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:
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) {}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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());
|
||||||
} catch (Exception e) {
|
|
||||||
log.warn("Failed to generate section {}: {}", i + 1, e.getMessage());
|
// 응답이 잘린 것 같으면 이어쓰기 (최대 2회)
|
||||||
fullResult.append("# ").append(i + 1).append(". ").append(tocItem).append("\n\n")
|
for (int cont = 0; cont < 2; cont++) {
|
||||||
.append("(정리 실패)\n\n");
|
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 process section {}: {}", sectionNum, e.getMessage());
|
||||||
|
fullResult.append("# ").append(sectionNum).append(". ").append(tocItem).append("\n\n(정리 실패)\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());
|
||||||
|
|||||||
@@ -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());
|
||||||
|
|||||||
2
sundol-frontend/package-lock.json
generated
2
sundol-frontend/package-lock.json
generated
@@ -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": {
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
123
sundol-frontend/src/app/settings/page.tsx
Normal file
123
sundol-frontend/src/app/settings/page.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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() {
|
||||||
|
|||||||
Reference in New Issue
Block a user