From f9f710ec90eaa624d2266d03754864dee58fd417 Mon Sep 17 00:00:00 2001 From: joungmin Date: Sun, 12 Apr 2026 23:48:38 +0000 Subject: [PATCH] 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) --- .../controller/KnowledgeController.java | 3 +- .../com/sundol/controller/UserController.java | 51 +++++ .../java/com/sundol/dto/IngestRequest.java | 2 +- .../repository/KnowledgeRepository.java | 26 ++- .../com/sundol/repository/UserRepository.java | 11 +- .../sundol/service/IngestPipelineService.java | 215 ++++++++++++++---- .../com/sundol/service/KnowledgeService.java | 24 +- sundol-frontend/package-lock.json | 2 +- sundol-frontend/package.json | 16 +- .../src/app/knowledge/[id]/page.tsx | 33 +-- sundol-frontend/src/app/settings/page.tsx | 123 ++++++++++ sundol-frontend/src/components/nav-bar.tsx | 1 + 12 files changed, 434 insertions(+), 73 deletions(-) create mode 100644 sundol-backend/src/main/java/com/sundol/controller/UserController.java create mode 100644 sundol-frontend/src/app/settings/page.tsx diff --git a/sundol-backend/src/main/java/com/sundol/controller/KnowledgeController.java b/sundol-backend/src/main/java/com/sundol/controller/KnowledgeController.java index caebd7e..cd610fc 100644 --- a/sundol-backend/src/main/java/com/sundol/controller/KnowledgeController.java +++ b/sundol-backend/src/main/java/com/sundol/controller/KnowledgeController.java @@ -92,7 +92,8 @@ public class KnowledgeController { @PathVariable String id, @RequestBody(required = false) Map body) { 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); } diff --git a/sundol-backend/src/main/java/com/sundol/controller/UserController.java b/sundol-backend/src/main/java/com/sundol/controller/UserController.java new file mode 100644 index 0000000..6fd461e --- /dev/null +++ b/sundol-backend/src/main/java/com/sundol/controller/UserController.java @@ -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>> me(@AuthenticationPrincipal String userId) { + return Mono.fromCallable(() -> { + Map user = userRepository.findById(userId); + if (user == null) { + return ResponseEntity.notFound().>build(); + } + // refresh_token은 응답에서 제외 + user.remove("REFRESH_TOKEN"); + return ResponseEntity.ok(user); + }).subscribeOn(Schedulers.boundedElastic()); + } + + @PatchMapping("/me") + public Mono>> updateMe( + @AuthenticationPrincipal String userId, + @RequestBody Map 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 user = userRepository.findById(userId); + if (user != null) user.remove("REFRESH_TOKEN"); + return ResponseEntity.ok(user); + }).subscribeOn(Schedulers.boundedElastic()); + } +} diff --git a/sundol-backend/src/main/java/com/sundol/dto/IngestRequest.java b/sundol-backend/src/main/java/com/sundol/dto/IngestRequest.java index d514b9e..5b61b1b 100644 --- a/sundol-backend/src/main/java/com/sundol/dto/IngestRequest.java +++ b/sundol-backend/src/main/java/com/sundol/dto/IngestRequest.java @@ -1,3 +1,3 @@ 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) {} diff --git a/sundol-backend/src/main/java/com/sundol/repository/KnowledgeRepository.java b/sundol-backend/src/main/java/com/sundol/repository/KnowledgeRepository.java index 71488aa..d67282e 100644 --- a/sundol-backend/src/main/java/com/sundol/repository/KnowledgeRepository.java +++ b/sundol-backend/src/main/java/com/sundol/repository/KnowledgeRepository.java @@ -3,6 +3,7 @@ package com.sundol.repository; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.stereotype.Repository; +import java.sql.Clob; import java.util.List; import java.util.Map; @@ -59,7 +60,8 @@ public class KnowledgeRepository { "FROM knowledge_items WHERE RAWTOHEX(id) = ? AND user_id = HEXTORAW(?)", id, userId ); - return results.isEmpty() ? null : results.get(0); + if (results.isEmpty()) return null; + return convertClobFields(results.get(0)); } public Map findByIdInternal(String id) { @@ -68,7 +70,27 @@ public class KnowledgeRepository { "FROM knowledge_items WHERE RAWTOHEX(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 convertClobFields(Map 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) { diff --git a/sundol-backend/src/main/java/com/sundol/repository/UserRepository.java b/sundol-backend/src/main/java/com/sundol/repository/UserRepository.java index 61d4794..f86a91b 100644 --- a/sundol-backend/src/main/java/com/sundol/repository/UserRepository.java +++ b/sundol-backend/src/main/java/com/sundol/repository/UserRepository.java @@ -16,7 +16,7 @@ public class UserRepository { public Map findByGoogleSub(String googleSub) { 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 ); return results.isEmpty() ? null : results.get(0); @@ -48,9 +48,16 @@ public class UserRepository { public Map findById(String userId) { 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 ); 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 + ); + } } diff --git a/sundol-backend/src/main/java/com/sundol/service/IngestPipelineService.java b/sundol-backend/src/main/java/com/sundol/service/IngestPipelineService.java index ce11c99..3d25799 100644 --- a/sundol-backend/src/main/java/com/sundol/service/IngestPipelineService.java +++ b/sundol-backend/src/main/java/com/sundol/service/IngestPipelineService.java @@ -24,6 +24,7 @@ public class IngestPipelineService { private final KnowledgeChunkRepository chunkRepository; private final ChunkEmbeddingRepository embeddingRepository; private final CategoryRepository categoryRepository; + private final com.sundol.repository.UserRepository userRepository; private final ChunkingService chunkingService; private final WebCrawlerService webCrawlerService; private final OciGenAiService genAiService; @@ -34,6 +35,7 @@ public class IngestPipelineService { KnowledgeChunkRepository chunkRepository, ChunkEmbeddingRepository embeddingRepository, CategoryRepository categoryRepository, + com.sundol.repository.UserRepository userRepository, ChunkingService chunkingService, WebCrawlerService webCrawlerService, OciGenAiService genAiService, @@ -42,6 +44,7 @@ public class IngestPipelineService { this.chunkRepository = chunkRepository; this.embeddingRepository = embeddingRepository; this.categoryRepository = categoryRepository; + this.userRepository = userRepository; this.chunkingService = chunkingService; this.webCrawlerService = webCrawlerService; this.genAiService = genAiService; @@ -58,6 +61,10 @@ public class IngestPipelineService { * 1차 호출: Abstract + 목차 생성, 2차~ 호출: 목차별 상세 정리, 최종 조합. */ 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()) { log.info("OCI GenAI not configured, skipping structuring"); return null; @@ -70,27 +77,12 @@ public class IngestPipelineService { try { 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 + 목차 생성 === - String tocSystemMsg = - "당신은 콘텐츠 분석 전문가입니다. 주어진 원본 텍스트를 분석하여 요약과 목차만 생성해주세요.\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 tocSystemMsg = buildTocSystemMsg(isEnglish); String tocUserMsg = "아래 원본 텍스트의 요약과 목차를 생성해주세요:\n\n" + content; String tocResult = genAiService.chat(tocSystemMsg, tocUserMsg, modelId).strip(); log.info("Phase 1 - TOC generated: {} chars", tocResult.length()); @@ -108,38 +100,60 @@ public class IngestPipelineService { } log.info("Parsed {} TOC items: {}", tocItems.size(), tocItems); - // === 2차~ 호출: 목차별 상세 정리 === + // === 2차~ 호출: 목차별 상세 정리 (잘리면 이어쓰기) === StringBuilder fullResult = new StringBuilder(tocResult).append("\n\n"); - - String sectionSystemMsg = - "당신은 콘텐츠 정리 전문가입니다. 주어진 원본 텍스트에서 지정된 섹션에 해당하는 내용만 상세히 정리해주세요.\n\n" + - "## 규칙\n" + - "1. 원본의 의미를 절대 왜곡하거나 생략하지 마세요. 디테일을 최대한 살려주세요.\n" + - "2. 원본 언어와 같은 언어로 작성하세요.\n" + - "3. Markdown 형식으로 작성하세요.\n" + - "4. 불릿 포인트, 번호 매기기, 굵은 글씨 등을 활용하여 가독성을 높이세요.\n" + - "5. 원본에 없는 내용을 추가하지 마세요.\n" + - "6. 해당 섹션과 관련 없는 내용은 포함하지 마세요.\n" + - "7. 섹션 제목은 '# 번호. 제목' 형식으로 시작하세요."; + String sectionSystemMsg = buildSectionSystemMsg(isEnglish, level); for (int i = 0; i < tocItems.size(); i++) { String tocItem = tocItems.get(i); - String sectionUserMsg = "원본 텍스트에서 아래 섹션에 해당하는 내용을 상세히 정리해주세요.\n\n" + - "## 정리할 섹션\n" + - (i + 1) + ". " + tocItem + "\n\n" + - "## 원본 텍스트\n" + content; + int sectionNum = i + 1; + log.info("Phase 2 - Processing section {}: '{}'", sectionNum, tocItem); try { + String sectionUserMsg = "원본 텍스트에서 아래 섹션에 해당하는 내용을 상세히 정리해주세요.\n" + + "응답이 잘릴 경우 문장 중간에 끊지 말고, 완성된 문장까지만 작성하세요.\n\n" + + "## 정리할 섹션\n" + sectionNum + ". " + tocItem + "\n\n" + + "## 원본 텍스트\n" + content; + String sectionResult = genAiService.chat(sectionSystemMsg, sectionUserMsg, modelId).strip(); 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) { - log.warn("Failed to generate section {}: {}", i + 1, e.getMessage()); - fullResult.append("# ").append(i + 1).append(". ").append(tocItem).append("\n\n") - .append("(정리 실패)\n\n"); + log.warn("Failed to process section {}: {}", sectionNum, e.getMessage()); + fullResult.append("# ").append(sectionNum).append(". ").append(tocItem).append("\n\n(정리 실패)\n\n"); } - // 섹션 완료될 때마다 중간 저장 (프론트엔드 폴링용) + // 섹션 완료마다 중간 저장 if (knowledgeItemId != null) { 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. 첫 번째 주제" 형태의 줄을 추출. @@ -316,9 +435,9 @@ public class IngestPipelineService { * 수동 구조화 요청 (비동기). 프론트엔드 버튼에서 호출. */ @Async - public void runStructuring(String knowledgeItemId, String text, String modelId) { + public void runStructuring(String knowledgeItemId, String text, String modelId, String englishLevel) { try { - String structured = structureContent(text, modelId, knowledgeItemId); + String structured = structureContent(text, modelId, knowledgeItemId, englishLevel); if (structured != null && !structured.isBlank()) { knowledgeRepository.updateStructuredContent(knowledgeItemId, structured); } @@ -398,7 +517,17 @@ public class IngestPipelineService { // Step 2: Structure content (1000자 이상일 때만) knowledgeRepository.updateStatus(knowledgeItemId, "STRUCTURING"); try { - String structured = structureContent(extractedText, modelId, knowledgeItemId); + // 사용자의 영어 수준 가져오기 + String userId = (String) item.get("USER_ID"); + String englishLevel = "B2"; + try { + Map 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()) { knowledgeRepository.updateStructuredContent(knowledgeItemId, structured); log.info("Item {} structured: {} chars", knowledgeItemId, structured.length()); diff --git a/sundol-backend/src/main/java/com/sundol/service/KnowledgeService.java b/sundol-backend/src/main/java/com/sundol/service/KnowledgeService.java index d5600e4..3587d96 100644 --- a/sundol-backend/src/main/java/com/sundol/service/KnowledgeService.java +++ b/sundol-backend/src/main/java/com/sundol/service/KnowledgeService.java @@ -5,6 +5,7 @@ import com.sundol.exception.AppException; import com.sundol.repository.CategoryRepository; import com.sundol.repository.KnowledgeChunkRepository; import com.sundol.repository.KnowledgeRepository; +import com.sundol.repository.UserRepository; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; import reactor.core.publisher.Mono; @@ -19,19 +20,35 @@ public class KnowledgeService { private final KnowledgeRepository knowledgeRepository; private final KnowledgeChunkRepository chunkRepository; private final CategoryRepository categoryRepository; + private final UserRepository userRepository; private final IngestPipelineService pipelineService; public KnowledgeService( KnowledgeRepository knowledgeRepository, KnowledgeChunkRepository chunkRepository, CategoryRepository categoryRepository, + UserRepository userRepository, IngestPipelineService pipelineService) { this.knowledgeRepository = knowledgeRepository; this.chunkRepository = chunkRepository; this.categoryRepository = categoryRepository; + this.userRepository = userRepository; 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 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(String userId, String type, String status, String tag, String search) { return Mono.fromCallable(() -> knowledgeRepository.list(userId, type, status, search)) .subscribeOn(Schedulers.boundedElastic()); @@ -76,7 +93,7 @@ public class KnowledgeService { }).subscribeOn(Schedulers.boundedElastic()); } - public Mono> structureContent(String userId, String id, String modelId) { + public Mono> structureContent(String userId, String id, String modelId, String englishLevel) { return Mono.fromCallable(() -> { Map item = knowledgeRepository.findById(userId, id); if (item == null) { @@ -104,9 +121,12 @@ public class KnowledgeService { // STRUCTURING 상태로 변경 (프론트엔드 폴링에서 진행 중 표시) knowledgeRepository.updateStatus(id, "STRUCTURING"); + // 영어 수준 결정 (요청 값 > 사용자 프로필 > B2 기본) + String level = resolveEnglishLevel(userId, englishLevel); + // 비동기로 구조화 실행 (중간 결과는 pipelineService가 DB에 직접 저장) final String finalText = text; - pipelineService.runStructuring(id, finalText, modelId); + pipelineService.runStructuring(id, finalText, modelId, level); return knowledgeRepository.findById(userId, id); }).subscribeOn(Schedulers.boundedElastic()); diff --git a/sundol-frontend/package-lock.json b/sundol-frontend/package-lock.json index 1534fb5..e92d6ec 100644 --- a/sundol-frontend/package-lock.json +++ b/sundol-frontend/package-lock.json @@ -13,7 +13,7 @@ "next": "^15.3.1", "react": "^19.1.0", "react-dom": "^19.1.0", - "react-markdown": "^9.0.3", + "react-markdown": "^9.1.0", "zustand": "^5.0.3" }, "devDependencies": { diff --git a/sundol-frontend/package.json b/sundol-frontend/package.json index 1f350a6..feac81d 100644 --- a/sundol-frontend/package.json +++ b/sundol-frontend/package.json @@ -9,23 +9,23 @@ "lint": "next lint" }, "dependencies": { + "axios": "^1.7.9", + "lucide-react": "^0.469.0", "next": "^15.3.1", "react": "^19.1.0", "react-dom": "^19.1.0", - "axios": "^1.7.9", - "zustand": "^5.0.3", - "react-markdown": "^9.0.3", - "lucide-react": "^0.469.0" + "react-markdown": "^9.1.0", + "zustand": "^5.0.3" }, "devDependencies": { - "typescript": "^5.7.3", + "@eslint/eslintrc": "^3", + "@tailwindcss/postcss": "^4.1.0", "@types/node": "^22.10.0", "@types/react": "^19.1.0", "@types/react-dom": "^19.1.0", - "@tailwindcss/postcss": "^4.1.0", - "tailwindcss": "^4.1.0", "eslint": "^9.17.0", "eslint-config-next": "^15.3.1", - "@eslint/eslintrc": "^3" + "tailwindcss": "^4.1.0", + "typescript": "^5.7.3" } } diff --git a/sundol-frontend/src/app/knowledge/[id]/page.tsx b/sundol-frontend/src/app/knowledge/[id]/page.tsx index 6e4572e..1c98c55 100644 --- a/sundol-frontend/src/app/knowledge/[id]/page.tsx +++ b/sundol-frontend/src/app/knowledge/[id]/page.tsx @@ -5,6 +5,7 @@ import { useParams, useRouter } from "next/navigation"; import AuthGuard from "@/components/auth-guard"; import NavBar from "@/components/nav-bar"; import { useApi } from "@/lib/use-api"; +import ReactMarkdown from "react-markdown"; interface Category { ID: string; @@ -306,19 +307,25 @@ export default function KnowledgeDetailPage() { {showStructured ? "▼ 정리된 내용 숨기기" : "▶ 정리된 내용 보기"} {showStructured && ( -
-
$1') - .replace(/^## (.+)$/gm, '

$1

') - .replace(/^# (.+)$/gm, '

$1

') - .replace(/\*\*(.+?)\*\*/g, '$1') - .replace(/^- (.+)$/gm, '
  • $1
  • ') - .replace(/^(\d+)\. (.+)$/gm, '
  • $2
  • ') - }} - /> +
    +
    +

    {children}

    , + h2: ({children}) =>

    {children}

    , + h3: ({children}) =>

    {children}

    , + p: ({children}) =>

    {children}

    , + ul: ({children}) =>
      {children}
    , + ol: ({children}) =>
      {children}
    , + li: ({children}) =>
  • {children}
  • , + strong: ({children}) => {children}, + blockquote: ({children}) =>
    {children}
    , + code: ({children}) => {children}, + }} + > + {item.STRUCTURED_CONTENT} +
    +
    )}
    diff --git a/sundol-frontend/src/app/settings/page.tsx b/sundol-frontend/src/app/settings/page.tsx new file mode 100644 index 0000000..07eabb0 --- /dev/null +++ b/sundol-frontend/src/app/settings/page.tsx @@ -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(null); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [savedMsg, setSavedMsg] = useState(""); + + useEffect(() => { + (async () => { + try { + const data = await request({ 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({ + 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 ( + + +
    +

    Loading...

    +
    +
    + ); + } + + return ( + + +
    +

    설정

    + + {/* 프로필 정보 */} +
    +

    프로필

    +
    +
    + 이름 + {profile?.DISPLAY_NAME || "-"} +
    +
    + 이메일 + {profile?.EMAIL || "-"} +
    +
    +
    + + {/* 영어 학습 수준 */} +
    +

    영어 학습 수준

    +

    + 영어 컨텐츠를 정리할 때 추출할 학습 표현의 난이도를 결정합니다. +

    + + {savedMsg && ( +

    {savedMsg}

    + )} +
    +
    +
    + ); +} diff --git a/sundol-frontend/src/components/nav-bar.tsx b/sundol-frontend/src/components/nav-bar.tsx index 7f4df45..bcaa51a 100644 --- a/sundol-frontend/src/components/nav-bar.tsx +++ b/sundol-frontend/src/components/nav-bar.tsx @@ -11,6 +11,7 @@ const navItems = [ { href: "/study", label: "Study" }, { href: "/todos", label: "Todos" }, { href: "/habits", label: "Habits" }, + { href: "/settings", label: "Settings" }, ]; export default function NavBar() {