Add English level settings, improve content structuring and rendering

- Add english_level column to users table (CEFR with TOEIC mapping)
- Add UserController (GET/PATCH /api/users/me) and Settings page
- Enhance structuring prompts: sequential TOC, no summary sections,
  no content overlap, English expression extraction by CEFR level
- Remove sub-TOC analysis (caused content repetition), use simple
  per-section generation with truncation detection and continuation
- Fix CLOB truncation: explicit Clob-to-String conversion in repository
- Replace regex-based markdown rendering with react-markdown
- Add wallet renewal procedure to troubleshooting docs

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-12 23:48:38 +00:00
parent 4cde775809
commit f9f710ec90
12 changed files with 434 additions and 73 deletions

View File

@@ -92,7 +92,8 @@ public class KnowledgeController {
@PathVariable String id, @PathVariable String id,
@RequestBody(required = false) Map<String, String> body) { @RequestBody(required = false) Map<String, String> body) {
String modelId = body != null ? body.get("modelId") : null; String modelId = body != null ? body.get("modelId") : null;
return knowledgeService.structureContent(userId, id, modelId) String englishLevel = body != null ? body.get("englishLevel") : null;
return knowledgeService.structureContent(userId, id, modelId, englishLevel)
.map(ResponseEntity::ok); .map(ResponseEntity::ok);
} }

View File

@@ -0,0 +1,51 @@
package com.sundol.controller;
import com.sundol.repository.UserRepository;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;
import reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers;
import java.util.Map;
@RestController
@RequestMapping("/api/users")
public class UserController {
private final UserRepository userRepository;
public UserController(UserRepository userRepository) {
this.userRepository = userRepository;
}
@GetMapping("/me")
public Mono<ResponseEntity<Map<String, Object>>> me(@AuthenticationPrincipal String userId) {
return Mono.fromCallable(() -> {
Map<String, Object> user = userRepository.findById(userId);
if (user == null) {
return ResponseEntity.notFound().<Map<String, Object>>build();
}
// refresh_token은 응답에서 제외
user.remove("REFRESH_TOKEN");
return ResponseEntity.ok(user);
}).subscribeOn(Schedulers.boundedElastic());
}
@PatchMapping("/me")
public Mono<ResponseEntity<Map<String, Object>>> updateMe(
@AuthenticationPrincipal String userId,
@RequestBody Map<String, Object> updates) {
return Mono.fromCallable(() -> {
if (updates.containsKey("englishLevel")) {
String level = (String) updates.get("englishLevel");
if (level != null && level.matches("^(A1|A2|B1|B2|C1|C2)$")) {
userRepository.updateEnglishLevel(userId, level);
}
}
Map<String, Object> user = userRepository.findById(userId);
if (user != null) user.remove("REFRESH_TOKEN");
return ResponseEntity.ok(user);
}).subscribeOn(Schedulers.boundedElastic());
}
}

View File

@@ -1,3 +1,3 @@
package com.sundol.dto; package com.sundol.dto;
public record IngestRequest(String type, String url, String title, String rawText, String modelId) {} public record IngestRequest(String type, String url, String title, String rawText, String modelId, String englishLevel) {}

View File

@@ -3,6 +3,7 @@ package com.sundol.repository;
import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Repository; import org.springframework.stereotype.Repository;
import java.sql.Clob;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
@@ -59,7 +60,8 @@ public class KnowledgeRepository {
"FROM knowledge_items WHERE RAWTOHEX(id) = ? AND user_id = HEXTORAW(?)", "FROM knowledge_items WHERE RAWTOHEX(id) = ? AND user_id = HEXTORAW(?)",
id, userId id, userId
); );
return results.isEmpty() ? null : results.get(0); if (results.isEmpty()) return null;
return convertClobFields(results.get(0));
} }
public Map<String, Object> findByIdInternal(String id) { public Map<String, Object> findByIdInternal(String id) {
@@ -68,7 +70,27 @@ public class KnowledgeRepository {
"FROM knowledge_items WHERE RAWTOHEX(id) = ?", "FROM knowledge_items WHERE RAWTOHEX(id) = ?",
id id
); );
return results.isEmpty() ? null : results.get(0); if (results.isEmpty()) return null;
return convertClobFields(results.get(0));
}
/**
* CLOB 필드를 String으로 변환한다.
* Oracle JDBC는 4000바이트 이상의 CLOB을 java.sql.Clob 객체로 반환하므로
* API 응답 전에 String으로 변환해야 한다.
*/
private Map<String, Object> convertClobFields(Map<String, Object> row) {
for (var entry : row.entrySet()) {
Object val = entry.getValue();
if (val instanceof Clob clob) {
try {
entry.setValue(clob.getSubString(1, (int) clob.length()));
} catch (Exception e) {
entry.setValue(null);
}
}
}
return row;
} }
public void updateStatus(String id, String status) { public void updateStatus(String id, String status) {

View File

@@ -16,7 +16,7 @@ public class UserRepository {
public Map<String, Object> findByGoogleSub(String googleSub) { public Map<String, Object> findByGoogleSub(String googleSub) {
var results = jdbcTemplate.queryForList( var results = jdbcTemplate.queryForList(
"SELECT RAWTOHEX(id) AS id, email, display_name, avatar_url, google_sub FROM users WHERE google_sub = ?", "SELECT RAWTOHEX(id) AS id, email, display_name, avatar_url, google_sub, english_level FROM users WHERE google_sub = ?",
googleSub googleSub
); );
return results.isEmpty() ? null : results.get(0); return results.isEmpty() ? null : results.get(0);
@@ -48,9 +48,16 @@ public class UserRepository {
public Map<String, Object> findById(String userId) { public Map<String, Object> findById(String userId) {
var results = jdbcTemplate.queryForList( var results = jdbcTemplate.queryForList(
"SELECT RAWTOHEX(id) AS id, email, display_name, avatar_url, google_sub, refresh_token FROM users WHERE RAWTOHEX(id) = ?", "SELECT RAWTOHEX(id) AS id, email, display_name, avatar_url, google_sub, refresh_token, english_level FROM users WHERE RAWTOHEX(id) = ?",
userId userId
); );
return results.isEmpty() ? null : results.get(0); return results.isEmpty() ? null : results.get(0);
} }
public void updateEnglishLevel(String userId, String englishLevel) {
jdbcTemplate.update(
"UPDATE users SET english_level = ?, updated_at = SYSTIMESTAMP WHERE RAWTOHEX(id) = ?",
englishLevel, userId
);
}
} }

View File

@@ -24,6 +24,7 @@ public class IngestPipelineService {
private final KnowledgeChunkRepository chunkRepository; private final KnowledgeChunkRepository chunkRepository;
private final ChunkEmbeddingRepository embeddingRepository; private final ChunkEmbeddingRepository embeddingRepository;
private final CategoryRepository categoryRepository; private final CategoryRepository categoryRepository;
private final com.sundol.repository.UserRepository userRepository;
private final ChunkingService chunkingService; private final ChunkingService chunkingService;
private final WebCrawlerService webCrawlerService; private final WebCrawlerService webCrawlerService;
private final OciGenAiService genAiService; private final OciGenAiService genAiService;
@@ -34,6 +35,7 @@ public class IngestPipelineService {
KnowledgeChunkRepository chunkRepository, KnowledgeChunkRepository chunkRepository,
ChunkEmbeddingRepository embeddingRepository, ChunkEmbeddingRepository embeddingRepository,
CategoryRepository categoryRepository, CategoryRepository categoryRepository,
com.sundol.repository.UserRepository userRepository,
ChunkingService chunkingService, ChunkingService chunkingService,
WebCrawlerService webCrawlerService, WebCrawlerService webCrawlerService,
OciGenAiService genAiService, OciGenAiService genAiService,
@@ -42,6 +44,7 @@ public class IngestPipelineService {
this.chunkRepository = chunkRepository; this.chunkRepository = chunkRepository;
this.embeddingRepository = embeddingRepository; this.embeddingRepository = embeddingRepository;
this.categoryRepository = categoryRepository; this.categoryRepository = categoryRepository;
this.userRepository = userRepository;
this.chunkingService = chunkingService; this.chunkingService = chunkingService;
this.webCrawlerService = webCrawlerService; this.webCrawlerService = webCrawlerService;
this.genAiService = genAiService; this.genAiService = genAiService;
@@ -58,6 +61,10 @@ public class IngestPipelineService {
* 1차 호출: Abstract + 목차 생성, 2차~ 호출: 목차별 상세 정리, 최종 조합. * 1차 호출: Abstract + 목차 생성, 2차~ 호출: 목차별 상세 정리, 최종 조합.
*/ */
public String structureContent(String text, String modelId, String knowledgeItemId) { public String structureContent(String text, String modelId, String knowledgeItemId) {
return structureContent(text, modelId, knowledgeItemId, "B2");
}
public String structureContent(String text, String modelId, String knowledgeItemId, String englishLevel) {
if (!genAiService.isConfigured()) { if (!genAiService.isConfigured()) {
log.info("OCI GenAI not configured, skipping structuring"); log.info("OCI GenAI not configured, skipping structuring");
return null; return null;
@@ -70,27 +77,12 @@ public class IngestPipelineService {
try { try {
String content = text.length() > 30000 ? text.substring(0, 30000) : text; String content = text.length() > 30000 ? text.substring(0, 30000) : text;
boolean isEnglish = isEnglishContent(content);
String level = (englishLevel == null || englishLevel.isBlank()) ? "B2" : englishLevel;
log.info("Structuring content: isEnglish={}, englishLevel={}", isEnglish, level);
// === 1차 호출: Abstract + 목차 생성 === // === 1차 호출: Abstract + 목차 생성 ===
String tocSystemMsg = String tocSystemMsg = buildTocSystemMsg(isEnglish);
"당신은 콘텐츠 분석 전문가입니다. 주어진 원본 텍스트를 분석하여 요약과 목차만 생성해주세요.\n\n" +
"## 규칙\n" +
"1. 원본 언어와 같은 언어로 작성하세요.\n" +
"2. Markdown 형식으로 작성하세요.\n" +
"3. 아래 구조를 반드시 따르세요:\n\n" +
"```\n" +
"# 요약 (Abstract)\n" +
"(핵심 내용을 3~5문장으로 요약)\n\n" +
"# 목차\n" +
"1. 첫 번째 주제\n" +
"2. 두 번째 주제\n" +
"...\n" +
"```\n\n" +
"4. 목차 항목은 원본 내용의 흐름에 맞게 논리적으로 나누세요.\n" +
"5. 목차는 5~15개 사이로 적절히 나누세요.\n" +
"6. 목차에는 번호와 제목만 넣고, 상세 내용은 넣지 마세요.\n" +
"7. 원본에 없는 내용을 추가하지 마세요.";
String tocUserMsg = "아래 원본 텍스트의 요약과 목차를 생성해주세요:\n\n" + content; String tocUserMsg = "아래 원본 텍스트의 요약과 목차를 생성해주세요:\n\n" + content;
String tocResult = genAiService.chat(tocSystemMsg, tocUserMsg, modelId).strip(); String tocResult = genAiService.chat(tocSystemMsg, tocUserMsg, modelId).strip();
log.info("Phase 1 - TOC generated: {} chars", tocResult.length()); log.info("Phase 1 - TOC generated: {} chars", tocResult.length());
@@ -108,38 +100,60 @@ public class IngestPipelineService {
} }
log.info("Parsed {} TOC items: {}", tocItems.size(), tocItems); log.info("Parsed {} TOC items: {}", tocItems.size(), tocItems);
// === 2차~ 호출: 목차별 상세 정리 === // === 2차~ 호출: 목차별 상세 정리 (잘리면 이어쓰기) ===
StringBuilder fullResult = new StringBuilder(tocResult).append("\n\n"); StringBuilder fullResult = new StringBuilder(tocResult).append("\n\n");
String sectionSystemMsg = buildSectionSystemMsg(isEnglish, level);
String sectionSystemMsg =
"당신은 콘텐츠 정리 전문가입니다. 주어진 원본 텍스트에서 지정된 섹션에 해당하는 내용만 상세히 정리해주세요.\n\n" +
"## 규칙\n" +
"1. 원본의 의미를 절대 왜곡하거나 생략하지 마세요. 디테일을 최대한 살려주세요.\n" +
"2. 원본 언어와 같은 언어로 작성하세요.\n" +
"3. Markdown 형식으로 작성하세요.\n" +
"4. 불릿 포인트, 번호 매기기, 굵은 글씨 등을 활용하여 가독성을 높이세요.\n" +
"5. 원본에 없는 내용을 추가하지 마세요.\n" +
"6. 해당 섹션과 관련 없는 내용은 포함하지 마세요.\n" +
"7. 섹션 제목은 '# 번호. 제목' 형식으로 시작하세요.";
for (int i = 0; i < tocItems.size(); i++) { for (int i = 0; i < tocItems.size(); i++) {
String tocItem = tocItems.get(i); String tocItem = tocItems.get(i);
String sectionUserMsg = "원본 텍스트에서 아래 섹션에 해당하는 내용을 상세히 정리해주세요.\n\n" + int sectionNum = i + 1;
"## 정리할 섹션\n" + log.info("Phase 2 - Processing section {}: '{}'", sectionNum, tocItem);
(i + 1) + ". " + tocItem + "\n\n" +
"## 원본 텍스트\n" + content;
try { try {
String sectionUserMsg = "원본 텍스트에서 아래 섹션에 해당하는 내용을 상세히 정리해주세요.\n" +
"응답이 잘릴 경우 문장 중간에 끊지 말고, 완성된 문장까지만 작성하세요.\n\n" +
"## 정리할 섹션\n" + sectionNum + ". " + tocItem + "\n\n" +
"## 원본 텍스트\n" + content;
String sectionResult = genAiService.chat(sectionSystemMsg, sectionUserMsg, modelId).strip(); String sectionResult = genAiService.chat(sectionSystemMsg, sectionUserMsg, modelId).strip();
fullResult.append(sectionResult).append("\n\n"); fullResult.append(sectionResult).append("\n\n");
log.info("Phase 2 - Section {} '{}' generated: {} chars", i + 1, tocItem, sectionResult.length()); log.info("Phase 2 - Section {} '{}' generated: {} chars", sectionNum, tocItem, sectionResult.length());
// 응답이 잘린 것 같으면 이어쓰기 (최대 2회)
for (int cont = 0; cont < 2; cont++) {
if (sectionResult.length() < 3500) break;
String lastChars = sectionResult.substring(Math.max(0, sectionResult.length() - 50));
if (lastChars.endsWith(".") || lastChars.endsWith("다.") || lastChars.endsWith("니다.") ||
lastChars.endsWith("음.") || lastChars.endsWith("임.") || lastChars.endsWith("함.") ||
lastChars.endsWith("습니다.") || lastChars.endsWith("됩니다.")) {
break;
}
log.info("Phase 2 - Section {} may be truncated, continuation {}", sectionNum, cont + 1);
String contUserMsg = "이전 응답이 잘렸습니다. 아래 마지막 부분부터 이어서 작성해주세요.\n" +
"이미 작성된 내용을 반복하지 마세요.\n\n" +
"## 이전 응답의 마지막 200자\n" +
sectionResult.substring(Math.max(0, sectionResult.length() - 200)) + "\n\n" +
"## 정리할 섹션\n" + sectionNum + ". " + tocItem + "\n\n" +
"## 원본 텍스트\n" + content;
String contResult = genAiService.chat(sectionSystemMsg, contUserMsg, modelId).strip();
if (contResult.isBlank()) break;
fullResult.append(contResult).append("\n\n");
sectionResult = contResult;
log.info("Phase 2 - Section {} continuation {}: {} chars", sectionNum, cont + 1, contResult.length());
if (knowledgeItemId != null) {
knowledgeRepository.updateStructuredContent(knowledgeItemId, fullResult.toString().strip());
}
}
} catch (Exception e) { } catch (Exception e) {
log.warn("Failed to generate section {}: {}", i + 1, e.getMessage()); log.warn("Failed to process section {}: {}", sectionNum, e.getMessage());
fullResult.append("# ").append(i + 1).append(". ").append(tocItem).append("\n\n") fullResult.append("# ").append(sectionNum).append(". ").append(tocItem).append("\n\n(정리 실패)\n\n");
.append("(정리 실패)\n\n");
} }
// 섹션 완료될 때마다 중간 저장 (프론트엔드 폴링용) // 섹션 완료마다 중간 저장
if (knowledgeItemId != null) { if (knowledgeItemId != null) {
knowledgeRepository.updateStructuredContent(knowledgeItemId, fullResult.toString().strip()); knowledgeRepository.updateStructuredContent(knowledgeItemId, fullResult.toString().strip());
} }
@@ -154,6 +168,111 @@ public class IngestPipelineService {
} }
} }
/**
* 영어 컨텐츠인지 판단한다 (ASCII 알파벳 비중 60% 이상).
*/
private boolean isEnglishContent(String text) {
if (text == null || text.isBlank()) return false;
int alpha = 0, total = 0;
for (int i = 0; i < text.length(); i++) {
char c = text.charAt(i);
if (Character.isWhitespace(c)) continue;
total++;
if ((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')) alpha++;
}
if (total == 0) return false;
double ratio = (double) alpha / total;
return ratio >= 0.6;
}
/**
* CEFR 레벨에 대한 설명을 반환한다 (TOEIC 매핑 포함).
*/
private String cefrDescription(String level) {
return switch (level) {
case "A1" -> "A1 (TOEIC 120-225, 입문)";
case "A2" -> "A2 (TOEIC 225-550, 초급, 기초 회화)";
case "B1" -> "B1 (TOEIC 550-785, 중급, 일상 의사소통)";
case "B2" -> "B2 (TOEIC 785-945, 중상급, 업무 영어)";
case "C1" -> "C1 (TOEIC 945-990, 고급, 유창함)";
case "C2" -> "C2 (원어민 수준)";
default -> "B2 (TOEIC 785-945, 중상급)";
};
}
/**
* 1차 호출용 시스템 메시지 (Abstract + 목차).
*/
private String buildTocSystemMsg(boolean isEnglish) {
StringBuilder sb = new StringBuilder();
sb.append("당신은 콘텐츠 분석 전문가입니다. 주어진 원본 텍스트를 분석하여 요약과 목차만 생성해주세요.\n\n");
sb.append("## 출력 형식 규칙\n");
sb.append("1. 한국어로 작성하세요 (원본이 영어여도).\n");
sb.append("2. 순수 Markdown 형식으로 작성하세요. 코드 블록(```)으로 감싸지 마세요.\n");
sb.append("3. 아래 구조를 정확히 따르세요:\n\n");
sb.append("# 요약 (Abstract)\n");
sb.append("(핵심 내용을 3~5문장으로 요약)\n\n");
sb.append("# 목차\n");
sb.append("1. 첫 번째 주제\n");
sb.append("2. 두 번째 주제\n");
sb.append("...\n\n");
sb.append("## 작성 규칙\n");
sb.append("- 목차는 원본 텍스트의 **서술 순서(흐름)**에 따라 나누세요. 주제별 분류가 아닙니다.\n");
sb.append("- 원본에서 앞부분에 나오는 내용이 1번, 뒷부분이 마지막 번호가 되어야 합니다.\n");
sb.append("- 각 목차 항목이 원본의 서로 다른 구간을 담당해야 합니다. 같은 내용이 여러 항목에 걸치면 안 됩니다.\n");
sb.append("- 목차 개수는 원본 길이에 비례하세요: 3000자 미만이면 3~5개, 3000~10000자이면 5~10개, 10000자 이상이면 8~15개.\n");
sb.append("- 목차 항목은 번호와 제목만 포함하세요. 상세 설명은 넣지 마세요.\n");
sb.append("- 원본에 없는 내용을 추가하지 마세요.\n");
sb.append("- 목차 항목 앞에 불릿 마커(`-`, `*`)를 붙이지 마세요. 숫자만 사용하세요.\n");
sb.append("- **금지**: 전체 요약, 결론, 마무리, 종합 정리 같은 성격의 섹션을 만들지 마세요. 각 섹션은 원본의 고유한 구간만 담당해야 합니다.\n");
return sb.toString();
}
/**
* 2차+ 호출용 시스템 메시지 (목차별 상세 정리).
* 영어 컨텐츠인 경우 영어 표현 학습 섹션 추가.
*/
private String buildSectionSystemMsg(boolean isEnglish, String englishLevel) {
StringBuilder sb = new StringBuilder();
sb.append("당신은 콘텐츠 정리 전문가입니다. 주어진 원본 텍스트에서 지정된 섹션에 해당하는 내용만 상세히 정리해주세요.\n\n");
sb.append("## 출력 형식 규칙 (반드시 지킬 것)\n");
sb.append("1. 순수 Markdown 형식으로 작성하세요. 코드 블록(```)으로 감싸지 마세요.\n");
sb.append("2. 섹션 제목은 정확히 `# 번호. 제목` 형식으로 시작하세요. (예: `# 1. 첫 번째 주제`)\n");
sb.append("3. 하위 제목이 필요하면 `## 소제목` 또는 `### 세부제목`을 사용하세요.\n");
sb.append("4. 불릿 포인트는 `-`만 사용하세요. `*`는 사용하지 마세요.\n");
sb.append("5. 굵은 글씨는 `**텍스트**` 형식만 사용하세요.\n");
sb.append("6. 각 불릿 포인트는 한 줄로 시작하고, 들여쓰기는 2칸(공백 2개)으로 일관되게 하세요.\n");
sb.append("7. 빈 줄은 섹션/소제목 구분에만 사용하세요. 불릿 사이에 빈 줄을 넣지 마세요.\n");
sb.append("8. 한국어로 작성하세요 (원본이 영어여도).\n\n");
sb.append("## 내용 작성 규칙\n");
sb.append("- 원본 텍스트에 있는 내용만 정리하세요. 원본에 없는 내용을 절대 추가하지 마세요.\n");
sb.append("- 부연 설명, 해석, 의견을 추가하지 마세요. 요약과 정리만 하세요.\n");
sb.append("- 원본의 의미를 왜곡하지 마세요. 디테일을 살리되, 원본의 범위를 넘지 마세요.\n");
sb.append("- 해당 섹션이 담당하는 원본 구간의 내용만 정리하세요. 다른 섹션의 내용을 포함하지 마세요.\n");
sb.append("- 가독성을 위해 하위 항목, 굵은 글씨, 불릿 포인트를 적절히 활용하세요.\n");
sb.append("- 원본이 짧으면 정리도 짧게 하세요. 불필요하게 늘리지 마세요.\n");
if (isEnglish) {
sb.append("\n## 영어 학습 보조 (원본이 영어 컨텐츠임)\n");
sb.append("사용자의 영어 수준: **").append(cefrDescription(englishLevel)).append("**\n\n");
sb.append("이 섹션 정리 마지막에 다음 형식으로 영어 학습 박스를 추가하세요:\n\n");
sb.append("### 핵심 영어 표현\n");
sb.append("- **expression** (한글 번역) — 간단한 설명 또는 사용 맥락\n");
sb.append(" - 예문: \"Original English sentence from the text\" (한글 번역)\n\n");
sb.append("### 작성 규칙:\n");
sb.append("- 사용자의 수준(").append(englishLevel).append(")보다 약간 어려운 표현을 우선 추출하세요.\n");
sb.append("- 일상적인 단어(go, eat, see 등)는 추출하지 마세요.\n");
sb.append("- 학습할 가치가 있는 관용 표현, 숙어, 학술/전문 용어, 콜로케이션을 우선하세요.\n");
sb.append("- 섹션당 3~7개 표현을 추출하세요.\n");
sb.append("- 예문은 반드시 원본 텍스트에서 인용하세요.\n");
sb.append("- 추출할 만한 표현이 없으면 이 박스를 생략하세요.\n");
}
return sb.toString();
}
/** /**
* 목차 텍스트에서 항목들을 파싱한다. * 목차 텍스트에서 항목들을 파싱한다.
* "1. 첫 번째 주제" 형태의 줄을 추출. * "1. 첫 번째 주제" 형태의 줄을 추출.
@@ -316,9 +435,9 @@ public class IngestPipelineService {
* 수동 구조화 요청 (비동기). 프론트엔드 버튼에서 호출. * 수동 구조화 요청 (비동기). 프론트엔드 버튼에서 호출.
*/ */
@Async @Async
public void runStructuring(String knowledgeItemId, String text, String modelId) { public void runStructuring(String knowledgeItemId, String text, String modelId, String englishLevel) {
try { try {
String structured = structureContent(text, modelId, knowledgeItemId); String structured = structureContent(text, modelId, knowledgeItemId, englishLevel);
if (structured != null && !structured.isBlank()) { if (structured != null && !structured.isBlank()) {
knowledgeRepository.updateStructuredContent(knowledgeItemId, structured); knowledgeRepository.updateStructuredContent(knowledgeItemId, structured);
} }
@@ -398,7 +517,17 @@ public class IngestPipelineService {
// Step 2: Structure content (1000자 이상일 때만) // Step 2: Structure content (1000자 이상일 때만)
knowledgeRepository.updateStatus(knowledgeItemId, "STRUCTURING"); knowledgeRepository.updateStatus(knowledgeItemId, "STRUCTURING");
try { try {
String structured = structureContent(extractedText, modelId, knowledgeItemId); // 사용자의 영어 수준 가져오기
String userId = (String) item.get("USER_ID");
String englishLevel = "B2";
try {
Map<String, Object> user = userRepository.findById(userId);
if (user != null && user.get("ENGLISH_LEVEL") != null) {
englishLevel = (String) user.get("ENGLISH_LEVEL");
}
} catch (Exception ignored) {}
String structured = structureContent(extractedText, modelId, knowledgeItemId, englishLevel);
if (structured != null && !structured.isBlank()) { if (structured != null && !structured.isBlank()) {
knowledgeRepository.updateStructuredContent(knowledgeItemId, structured); knowledgeRepository.updateStructuredContent(knowledgeItemId, structured);
log.info("Item {} structured: {} chars", knowledgeItemId, structured.length()); log.info("Item {} structured: {} chars", knowledgeItemId, structured.length());

View File

@@ -5,6 +5,7 @@ import com.sundol.exception.AppException;
import com.sundol.repository.CategoryRepository; import com.sundol.repository.CategoryRepository;
import com.sundol.repository.KnowledgeChunkRepository; import com.sundol.repository.KnowledgeChunkRepository;
import com.sundol.repository.KnowledgeRepository; import com.sundol.repository.KnowledgeRepository;
import com.sundol.repository.UserRepository;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import reactor.core.publisher.Mono; import reactor.core.publisher.Mono;
@@ -19,19 +20,35 @@ public class KnowledgeService {
private final KnowledgeRepository knowledgeRepository; private final KnowledgeRepository knowledgeRepository;
private final KnowledgeChunkRepository chunkRepository; private final KnowledgeChunkRepository chunkRepository;
private final CategoryRepository categoryRepository; private final CategoryRepository categoryRepository;
private final UserRepository userRepository;
private final IngestPipelineService pipelineService; private final IngestPipelineService pipelineService;
public KnowledgeService( public KnowledgeService(
KnowledgeRepository knowledgeRepository, KnowledgeRepository knowledgeRepository,
KnowledgeChunkRepository chunkRepository, KnowledgeChunkRepository chunkRepository,
CategoryRepository categoryRepository, CategoryRepository categoryRepository,
UserRepository userRepository,
IngestPipelineService pipelineService) { IngestPipelineService pipelineService) {
this.knowledgeRepository = knowledgeRepository; this.knowledgeRepository = knowledgeRepository;
this.chunkRepository = chunkRepository; this.chunkRepository = chunkRepository;
this.categoryRepository = categoryRepository; this.categoryRepository = categoryRepository;
this.userRepository = userRepository;
this.pipelineService = pipelineService; this.pipelineService = pipelineService;
} }
private String resolveEnglishLevel(String userId, String requestLevel) {
if (requestLevel != null && requestLevel.matches("^(A1|A2|B1|B2|C1|C2)$")) {
return requestLevel;
}
try {
Map<String, Object> user = userRepository.findById(userId);
if (user != null && user.get("ENGLISH_LEVEL") != null) {
return (String) user.get("ENGLISH_LEVEL");
}
} catch (Exception ignored) {}
return "B2";
}
public Mono<List<Map<String, Object>>> list(String userId, String type, String status, String tag, String search) { public Mono<List<Map<String, Object>>> list(String userId, String type, String status, String tag, String search) {
return Mono.fromCallable(() -> knowledgeRepository.list(userId, type, status, search)) return Mono.fromCallable(() -> knowledgeRepository.list(userId, type, status, search))
.subscribeOn(Schedulers.boundedElastic()); .subscribeOn(Schedulers.boundedElastic());
@@ -76,7 +93,7 @@ public class KnowledgeService {
}).subscribeOn(Schedulers.boundedElastic()); }).subscribeOn(Schedulers.boundedElastic());
} }
public Mono<Map<String, Object>> structureContent(String userId, String id, String modelId) { public Mono<Map<String, Object>> structureContent(String userId, String id, String modelId, String englishLevel) {
return Mono.fromCallable(() -> { return Mono.fromCallable(() -> {
Map<String, Object> item = knowledgeRepository.findById(userId, id); Map<String, Object> item = knowledgeRepository.findById(userId, id);
if (item == null) { if (item == null) {
@@ -104,9 +121,12 @@ public class KnowledgeService {
// STRUCTURING 상태로 변경 (프론트엔드 폴링에서 진행 중 표시) // STRUCTURING 상태로 변경 (프론트엔드 폴링에서 진행 중 표시)
knowledgeRepository.updateStatus(id, "STRUCTURING"); knowledgeRepository.updateStatus(id, "STRUCTURING");
// 영어 수준 결정 (요청 값 > 사용자 프로필 > B2 기본)
String level = resolveEnglishLevel(userId, englishLevel);
// 비동기로 구조화 실행 (중간 결과는 pipelineService가 DB에 직접 저장) // 비동기로 구조화 실행 (중간 결과는 pipelineService가 DB에 직접 저장)
final String finalText = text; final String finalText = text;
pipelineService.runStructuring(id, finalText, modelId); pipelineService.runStructuring(id, finalText, modelId, level);
return knowledgeRepository.findById(userId, id); return knowledgeRepository.findById(userId, id);
}).subscribeOn(Schedulers.boundedElastic()); }).subscribeOn(Schedulers.boundedElastic());

View File

@@ -13,7 +13,7 @@
"next": "^15.3.1", "next": "^15.3.1",
"react": "^19.1.0", "react": "^19.1.0",
"react-dom": "^19.1.0", "react-dom": "^19.1.0",
"react-markdown": "^9.0.3", "react-markdown": "^9.1.0",
"zustand": "^5.0.3" "zustand": "^5.0.3"
}, },
"devDependencies": { "devDependencies": {

View File

@@ -9,23 +9,23 @@
"lint": "next lint" "lint": "next lint"
}, },
"dependencies": { "dependencies": {
"axios": "^1.7.9",
"lucide-react": "^0.469.0",
"next": "^15.3.1", "next": "^15.3.1",
"react": "^19.1.0", "react": "^19.1.0",
"react-dom": "^19.1.0", "react-dom": "^19.1.0",
"axios": "^1.7.9", "react-markdown": "^9.1.0",
"zustand": "^5.0.3", "zustand": "^5.0.3"
"react-markdown": "^9.0.3",
"lucide-react": "^0.469.0"
}, },
"devDependencies": { "devDependencies": {
"typescript": "^5.7.3", "@eslint/eslintrc": "^3",
"@tailwindcss/postcss": "^4.1.0",
"@types/node": "^22.10.0", "@types/node": "^22.10.0",
"@types/react": "^19.1.0", "@types/react": "^19.1.0",
"@types/react-dom": "^19.1.0", "@types/react-dom": "^19.1.0",
"@tailwindcss/postcss": "^4.1.0",
"tailwindcss": "^4.1.0",
"eslint": "^9.17.0", "eslint": "^9.17.0",
"eslint-config-next": "^15.3.1", "eslint-config-next": "^15.3.1",
"@eslint/eslintrc": "^3" "tailwindcss": "^4.1.0",
"typescript": "^5.7.3"
} }
} }

View File

@@ -5,6 +5,7 @@ import { useParams, useRouter } from "next/navigation";
import AuthGuard from "@/components/auth-guard"; import AuthGuard from "@/components/auth-guard";
import NavBar from "@/components/nav-bar"; import NavBar from "@/components/nav-bar";
import { useApi } from "@/lib/use-api"; import { useApi } from "@/lib/use-api";
import ReactMarkdown from "react-markdown";
interface Category { interface Category {
ID: string; ID: string;
@@ -306,19 +307,25 @@ export default function KnowledgeDetailPage() {
{showStructured ? "▼ 정리된 내용 숨기기" : "▶ 정리된 내용 보기"} {showStructured ? "▼ 정리된 내용 숨기기" : "▶ 정리된 내용 보기"}
</button> </button>
{showStructured && ( {showStructured && (
<div className="bg-[var(--color-bg-card)] rounded-xl p-6 border border-[var(--color-border)] prose prose-invert max-w-none"> <div className="bg-[var(--color-bg-card)] rounded-xl p-6 border border-[var(--color-border)] max-w-none">
<div <div className="structured-content text-sm leading-relaxed">
className="text-sm leading-relaxed whitespace-pre-wrap" <ReactMarkdown
dangerouslySetInnerHTML={{ components={{
__html: item.STRUCTURED_CONTENT h1: ({children}) => <h1 className="text-xl font-bold mt-6 mb-3">{children}</h1>,
.replace(/^### (.+)$/gm, '<h3 class="text-base font-bold mt-4 mb-2 text-[var(--color-text)]">$1</h3>') h2: ({children}) => <h2 className="text-lg font-bold mt-5 mb-2">{children}</h2>,
.replace(/^## (.+)$/gm, '<h2 class="text-lg font-bold mt-5 mb-2 text-[var(--color-text)]">$1</h2>') h3: ({children}) => <h3 className="text-base font-bold mt-4 mb-2">{children}</h3>,
.replace(/^# (.+)$/gm, '<h1 class="text-xl font-bold mt-6 mb-3 text-[var(--color-text)]">$1</h1>') p: ({children}) => <p className="mb-3">{children}</p>,
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>') ul: ({children}) => <ul className="list-disc ml-5 mb-3 space-y-1">{children}</ul>,
.replace(/^- (.+)$/gm, '<li class="ml-4 list-disc">$1</li>') ol: ({children}) => <ol className="list-decimal ml-5 mb-3 space-y-1">{children}</ol>,
.replace(/^(\d+)\. (.+)$/gm, '<li class="ml-4 list-decimal">$2</li>') li: ({children}) => <li className="leading-relaxed">{children}</li>,
}} strong: ({children}) => <strong className="font-bold">{children}</strong>,
/> blockquote: ({children}) => <blockquote className="border-l-2 border-[var(--color-primary)] pl-4 my-3 italic text-[var(--color-text-muted)]">{children}</blockquote>,
code: ({children}) => <code className="bg-[var(--color-bg-hover)] px-1.5 py-0.5 rounded text-xs">{children}</code>,
}}
>
{item.STRUCTURED_CONTENT}
</ReactMarkdown>
</div>
</div> </div>
)} )}
</div> </div>

View File

@@ -0,0 +1,123 @@
"use client";
import { useEffect, useState } from "react";
import AuthGuard from "@/components/auth-guard";
import NavBar from "@/components/nav-bar";
import { useApi } from "@/lib/use-api";
interface UserProfile {
ID: string;
EMAIL: string;
DISPLAY_NAME: string;
AVATAR_URL: string;
ENGLISH_LEVEL: string;
}
const ENGLISH_LEVELS = [
{ value: "A1", label: "A1 - 입문 (TOEIC 120~225)" },
{ value: "A2", label: "A2 - 초급 (TOEIC 225~550, 기초 회화)" },
{ value: "B1", label: "B1 - 중급 (TOEIC 550~785, 일상 의사소통)" },
{ value: "B2", label: "B2 - 중상급 (TOEIC 785~945, 업무 영어)" },
{ value: "C1", label: "C1 - 고급 (TOEIC 945~990, 유창함)" },
{ value: "C2", label: "C2 - 원어민 수준" },
];
export default function SettingsPage() {
const { request } = useApi();
const [profile, setProfile] = useState<UserProfile | null>(null);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [savedMsg, setSavedMsg] = useState("");
useEffect(() => {
(async () => {
try {
const data = await request<UserProfile>({ method: "GET", url: "/api/users/me" });
setProfile(data);
} catch (err) {
console.error("Failed to load profile:", err);
} finally {
setLoading(false);
}
})();
}, []);
const handleEnglishLevelChange = async (newLevel: string) => {
if (!profile) return;
setSaving(true);
setSavedMsg("");
try {
const updated = await request<UserProfile>({
method: "PATCH",
url: "/api/users/me",
data: { englishLevel: newLevel },
});
setProfile(updated);
setSavedMsg("저장되었습니다");
setTimeout(() => setSavedMsg(""), 2000);
} catch (err) {
console.error("Failed to update level:", err);
alert("저장에 실패했습니다");
} finally {
setSaving(false);
}
};
if (loading) {
return (
<AuthGuard>
<NavBar />
<main className="max-w-2xl mx-auto px-4 py-8">
<p className="text-[var(--color-text-muted)]">Loading...</p>
</main>
</AuthGuard>
);
}
return (
<AuthGuard>
<NavBar />
<main className="max-w-2xl mx-auto px-4 py-8">
<h1 className="text-2xl font-bold mb-6"></h1>
{/* 프로필 정보 */}
<section className="bg-[var(--color-bg-card)] rounded-xl p-6 border border-[var(--color-border)] mb-6">
<h2 className="text-lg font-semibold mb-4"></h2>
<div className="space-y-3 text-sm">
<div className="flex">
<span className="w-24 text-[var(--color-text-muted)]"></span>
<span>{profile?.DISPLAY_NAME || "-"}</span>
</div>
<div className="flex">
<span className="w-24 text-[var(--color-text-muted)]"></span>
<span>{profile?.EMAIL || "-"}</span>
</div>
</div>
</section>
{/* 영어 학습 수준 */}
<section className="bg-[var(--color-bg-card)] rounded-xl p-6 border border-[var(--color-border)]">
<h2 className="text-lg font-semibold mb-2"> </h2>
<p className="text-sm text-[var(--color-text-muted)] mb-4">
.
</p>
<select
value={profile?.ENGLISH_LEVEL || "B2"}
onChange={(e) => handleEnglishLevelChange(e.target.value)}
disabled={saving}
className="w-full px-3 py-2 rounded-lg bg-[var(--color-bg-hover)] border border-[var(--color-border)] focus:border-[var(--color-primary)] focus:outline-none disabled:opacity-50"
>
{ENGLISH_LEVELS.map((level) => (
<option key={level.value} value={level.value}>
{level.label}
</option>
))}
</select>
{savedMsg && (
<p className="text-sm text-green-400 mt-2">{savedMsg}</p>
)}
</section>
</main>
</AuthGuard>
);
}

View File

@@ -11,6 +11,7 @@ const navItems = [
{ href: "/study", label: "Study" }, { href: "/study", label: "Study" },
{ href: "/todos", label: "Todos" }, { href: "/todos", label: "Todos" },
{ href: "/habits", label: "Habits" }, { href: "/habits", label: "Habits" },
{ href: "/settings", label: "Settings" },
]; ];
export default function NavBar() { export default function NavBar() {