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,
@RequestBody(required = false) Map<String, String> 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);
}

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;
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.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<String, Object> 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<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) {

View File

@@ -16,7 +16,7 @@ public class UserRepository {
public Map<String, Object> 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<String, Object> 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
);
}
}

View File

@@ -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<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()) {
knowledgeRepository.updateStructuredContent(knowledgeItemId, structured);
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.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<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) {
return Mono.fromCallable(() -> knowledgeRepository.list(userId, type, status, search))
.subscribeOn(Schedulers.boundedElastic());
@@ -76,7 +93,7 @@ public class KnowledgeService {
}).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(() -> {
Map<String, Object> 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());