diff --git a/docs/api-specs/battle-api.md b/docs/api-specs/battle-api.md index 5b4263c..d88cde2 100644 --- a/docs/api-specs/battle-api.md +++ b/docs/api-specs/battle-api.md @@ -37,6 +37,9 @@ - 설명: 대본을 파싱해 배틀·시나리오 등록 폼을 자동으로 채워준다. **DB 저장은 하지 않는다.** 문서 포맷 규격/파싱 규칙은 `배틀_발행_시나리오_현행_vs_개선.md` 2.6, 실제 파싱 예시는 `배틀_대본_파싱_예시.md` 참고. - 응답(`AdminBattleParseResponse`): - `battlePayload` — 아래 2.2 배틀 생성 요청과 동일한 형태(`AdminBattleCreateRequest`). `status` 는 항상 `PENDING`. 문서에 없는 `thumbnailUrl`/`targetDate`/`publishAt`/`audioDuration` 은 `null` + - `summary` = 오프닝 전문을 LLM 이 40자 이내로 한 줄 요약한 것. 요약 생성 실패 시 오프닝 원문 그대로 들어가고 `SUMMARY_GENERATION_FAILED` warning 이 붙는다 + - `description` = 오프닝 전문 원문 그대로 + - `options[].title` = **사전 투표 영역의 짧은 선택지명이 있으면 그걸 우선 사용**(예: `"유죄다" vs "무죄다"` → `유죄다`/`무죄다`), 사전 투표가 없으면 메타데이터 표의 "선택지 명칭"으로 대체 - `scenarioPayload` — [시나리오 API](./scenario-api.md) 2.2 생성 요청과 동일한 형태(`AdminScenarioCreateRequest`). **`battleId` 는 항상 `null`** — 배틀이 아직 생성 전이라서다. 어드민이 `battlePayload` 로 배틀을 먼저 만들고, 응답으로 받은 `battleId` 를 채워 시나리오를 등록한다 - `speakerNames` — `{"A": "플라톤", "B": "마르크스"}` 형태로 화자 A/B 에 바인딩된 철학자 이름 - `warnings[]` — 아래 표. `blocking: true` 인 항목이 하나라도 있으면 미리보기에서 해결하기 전까지 발행하지 않는다 @@ -55,6 +58,7 @@ | `MULTIPLE_TONE_TAGS` | 대사 한 줄에 톤 태그가 여러 개 → 첫 번째만 사용 | no | | `SPEAKER_BINDING_FALLBACK` | 발화자↔A/B 매칭을 등장 순서로 임시 배정 | no | | `LLM_CLASSIFY_FAILED` | 감정 자동분류 실패 → 톤이 전부 `NEUTRAL` | no | +| `SUMMARY_GENERATION_FAILED` | 오프닝 한 줄 요약 생성 실패 → `summary` 에 오프닝 원문이 그대로 들어감 | no | | `MISSING_VOICE` | 발화자 보이스가 [철학자 보이스 매핑](./philosopher-voice-api.md) 에 없음 | **yes** | | `INCOMPLETE_SPEAKER_BINDING` | A/B 발화자를 확정하지 못함 | **yes** | diff --git a/src/main/java/com/swyp/picke/domain/battle/parser/BattleScriptDocument.java b/src/main/java/com/swyp/picke/domain/battle/parser/BattleScriptDocument.java index d1282fd..a3c1830 100644 --- a/src/main/java/com/swyp/picke/domain/battle/parser/BattleScriptDocument.java +++ b/src/main/java/com/swyp/picke/domain/battle/parser/BattleScriptDocument.java @@ -18,7 +18,10 @@ public class BattleScriptDocument { public final List warnings = new ArrayList<>(); public static class OptionMeta { + /** 사전 투표 줄의 짧은 선택지명(예: "유죄다"). 있으면 이게 최종 title 이 된다. */ public String choiceName; + /** 메타데이터 표 "선택지 명칭" 값. 사전 투표가 없을 때만 title 로 쓰는 폴백. */ + public String metadataChoiceName; /** 사전 투표 줄에 적힌 대표 발화자(예: "플라톤"). A/B 바인딩 힌트로 쓴다. */ public String primarySpeaker; public final List philosopherKeywords = new ArrayList<>(); diff --git a/src/main/java/com/swyp/picke/domain/battle/parser/BattleScriptDocumentParser.java b/src/main/java/com/swyp/picke/domain/battle/parser/BattleScriptDocumentParser.java index 8b745ea..3d4915e 100644 --- a/src/main/java/com/swyp/picke/domain/battle/parser/BattleScriptDocumentParser.java +++ b/src/main/java/com/swyp/picke/domain/battle/parser/BattleScriptDocumentParser.java @@ -71,6 +71,14 @@ public BattleScriptDocument parse(String rawText) { boolean bodyIsInteractive = doc.nodes.stream() .anyMatch(n -> n.name.equals("선택") || n.name.startsWith("분기_")); doc.interactive = doc.interactive || bodyIsInteractive; + + // 선택지 명칭: 사전 투표의 짧은 이름이 우선, 없을 때만 메타데이터 표의 "선택지 명칭"으로 채운다 + if (doc.optionA.choiceName == null) { + doc.optionA.choiceName = doc.optionA.metadataChoiceName; + } + if (doc.optionB.choiceName == null) { + doc.optionB.choiceName = doc.optionB.metadataChoiceName; + } return doc; } @@ -298,7 +306,7 @@ private void parseMetadata(List meta, BattleScriptDocument doc) { } else if (line.startsWith("카테고리")) { doc.category = valueAfter(line, "카테고리", next); } else if (line.startsWith("선택지 명칭") && target != null) { - target.choiceName = valueAfter(line, "선택지 명칭", next); + target.metadataChoiceName = valueAfter(line, "선택지 명칭", next); } else if (line.startsWith("철학자 키워드") && target != null) { for (String kw : valueAfter(line, "철학자 키워드", next).split("[,、\\s]+")) { String cleaned = kw.replace("#", "").strip(); diff --git a/src/main/java/com/swyp/picke/domain/battle/parser/EmotionClassifier.java b/src/main/java/com/swyp/picke/domain/battle/parser/EmotionClassifier.java index 94aff5c..4815ac5 100644 --- a/src/main/java/com/swyp/picke/domain/battle/parser/EmotionClassifier.java +++ b/src/main/java/com/swyp/picke/domain/battle/parser/EmotionClassifier.java @@ -3,6 +3,7 @@ import com.swyp.picke.domain.scenario.enums.Tone; import java.util.List; import java.util.Map; +import java.util.Optional; /** * 대본 대사에 LLM 으로 감정 톤과 효과 태그를 붙인다. 실패해도 파싱을 막지 않는다(빈 결과 반환). @@ -19,4 +20,7 @@ record ScriptEmotion(Tone tone, String textWithEffects) {} /** 발화자 이름 -> "A" | "B". 선택의 시간에 명시가 없을 때 논지↔선택지명칭 매칭. 실패 시 빈 맵. */ Map bindSpeakers(String battleTitle, String optionATitle, String optionBTitle, List speakers); + + /** 오프닝 전문을 배틀 카드용 한 줄 요약으로 압축한다. 실패 시 empty. */ + Optional summarizeOpening(String battleTitle, String openingText); } diff --git a/src/main/java/com/swyp/picke/domain/battle/parser/OpenAiEmotionClassifier.java b/src/main/java/com/swyp/picke/domain/battle/parser/OpenAiEmotionClassifier.java index 1d05422..86f35f4 100644 --- a/src/main/java/com/swyp/picke/domain/battle/parser/OpenAiEmotionClassifier.java +++ b/src/main/java/com/swyp/picke/domain/battle/parser/OpenAiEmotionClassifier.java @@ -7,6 +7,7 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Optional; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.client.SimpleClientHttpRequestFactory; @@ -42,6 +43,13 @@ public class OpenAiEmotionClassifier implements EmotionClassifier { 반드시 JSON 만 출력: {"bindings":{"발화자이름":"A" 또는 "B"}} """; + private static final String SUMMARY_SYSTEM = """ + 너는 철학 토론 배틀 카드에 들어갈 한 줄 요약을 쓰는 도구다. + 오프닝 전문을 읽고, 배틀 카드에 노출할 흥미로운 한 줄 요약을 40자 이내 한국어로 작성해라. + 원문을 그대로 자르지 말고, 핵심 갈등/질문을 압축해라. + 반드시 JSON 만 출력: {"summary":"..."} + """; + private final ObjectMapper objectMapper = new ObjectMapper(); @Value("${openai.api-key}") @@ -115,6 +123,22 @@ public Map bindSpeakers(String battleTitle, String optionATitle, } } + @Override + public Optional summarizeOpening(String battleTitle, String openingText) { + if (openingText == null || openingText.isBlank()) { + return Optional.empty(); + } + try { + String userPrompt = "배틀 제목: " + battleTitle + "\n오프닝 전문:\n" + openingText; + JsonNode root = call(SUMMARY_SYSTEM, userPrompt); + String summary = root.path("summary").asText(null); + return (summary == null || summary.isBlank()) ? Optional.empty() : Optional.of(summary.trim()); + } catch (Exception e) { + log.warn("[EmotionClassifier] 오프닝 요약 생성 실패", e); + return Optional.empty(); + } + } + private JsonNode call(String systemPrompt, String userPrompt) throws Exception { SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory(); factory.setConnectTimeout(CONNECT_TIMEOUT_MS); diff --git a/src/main/java/com/swyp/picke/domain/battle/service/BattleScriptParseService.java b/src/main/java/com/swyp/picke/domain/battle/service/BattleScriptParseService.java index 3f53250..680924b 100644 --- a/src/main/java/com/swyp/picke/domain/battle/service/BattleScriptParseService.java +++ b/src/main/java/com/swyp/picke/domain/battle/service/BattleScriptParseService.java @@ -85,12 +85,13 @@ public AdminBattleParseResponse parse(String rawText) { SpeakerBinding binding = bindSpeakers(doc, warnings); - String openingText = firstNarration(doc, "오프닝"); + String description = allNarration(doc, "오프닝"); + String summary = summarizeOpening(doc.title, description, warnings); AdminBattleCreateRequest battlePayload = new AdminBattleCreateRequest( doc.title, - openingText, - null, + summary, + description, null, null, null, @@ -350,14 +351,30 @@ private void putVoice(Map voices, SpeakerType type, String // ---- 유틸 ---- - private String firstNarration(BattleScriptDocument doc, String nodeName) { - return doc.nodes.stream() + /** 오프닝 전문을 LLM으로 한 줄 요약한다(배틀 카드 summary 용). 실패하면 오프닝 원문으로 대신한다. */ + private String summarizeOpening(String title, String openingText, List warnings) { + if (openingText == null || openingText.isBlank()) { + return null; + } + Optional summarized = emotionClassifier.summarizeOpening(title, openingText); + if (summarized.isPresent() && !summarized.get().isBlank()) { + return summarized.get(); + } + warnings.add(ParseWarning.of("SUMMARY_GENERATION_FAILED", + "오프닝 한 줄 요약 생성에 실패해 오프닝 원문을 그대로 넣었습니다. 미리보기에서 다듬어주세요.", null)); + return openingText; + } + + /** 오프닝 노드의 나레이션 문단을 전부 이어붙인다(배틀 description 용). */ + private String allNarration(BattleScriptDocument doc, String nodeName) { + String joined = doc.nodes.stream() .filter(n -> n.name.equals(nodeName)) .flatMap(n -> n.scripts.stream()) .filter(s -> s.speaker == null) .map(s -> s.text) - .findFirst() + .reduce((a, b) -> a + " " + b) .orElse(null); + return joined; } /** diff --git a/src/test/java/com/swyp/picke/domain/battle/service/BattleScriptParseServiceTest.java b/src/test/java/com/swyp/picke/domain/battle/service/BattleScriptParseServiceTest.java index 1be15f0..69ad90c 100644 --- a/src/test/java/com/swyp/picke/domain/battle/service/BattleScriptParseServiceTest.java +++ b/src/test/java/com/swyp/picke/domain/battle/service/BattleScriptParseServiceTest.java @@ -192,6 +192,53 @@ private AdminScenarioNodeRequest node(AdminBattleParseResponse res, String name) assertThat(res.battlePayload().options().get(0).tagIds()).hasSize(6); } + @Test + void 선택지_title은_사전투표의_짧은_이름을_우선한다() throws IOException { + // sample-real-1.txt: 사전 투표 = "유죄다"/"무죄다", 메타데이터 선택지 명칭 = 훨씬 긴 문장 + AdminBattleParseResponse res = service.parse(load("sample-real-1.txt")); + + assertThat(res.battlePayload().options().get(0).title()).isEqualTo("유죄다"); + assertThat(res.battlePayload().options().get(1).title()).isEqualTo("무죄다"); + } + + @Test + void 사전투표가_없으면_메타데이터_선택지_명칭을_title로_쓴다() throws IOException { + when(emotionClassifier.bindSpeakers(anyString(), anyString(), anyString(), any())) + .thenReturn(Map.of("루소", "A", "홉스", "B")); + + // sample-linear.txt 는 사전 투표 없음 → 메타데이터 "선택지 명칭"(선하다/악하다) 그대로 + AdminBattleParseResponse res = service.parse(load("sample-linear.txt")); + + assertThat(res.battlePayload().options()).extracting(AdminBattleOptionRequest::title) + .containsExactly("선하다", "악하다"); + } + + @Test + void summary는_LLM_한줄요약_description은_오프닝_전문을_담는다() throws IOException { + when(emotionClassifier.bindSpeakers(anyString(), anyString(), anyString(), any())) + .thenReturn(Map.of("루소", "A", "홉스", "B")); + when(emotionClassifier.summarizeOpening(anyString(), anyString())) + .thenReturn(Optional.of("인간은 선한가 악한가, 재난 앞에서 드러나는 본성")); + + AdminBattleParseResponse res = service.parse(load("sample-linear.txt")); + + assertThat(res.battlePayload().summary()).isEqualTo("인간은 선한가 악한가, 재난 앞에서 드러나는 본성"); + assertThat(res.battlePayload().description()).startsWith("재난이 일어났을 때"); + assertThat(res.warnings()).noneMatch(w -> w.code().equals("SUMMARY_GENERATION_FAILED")); + } + + @Test + void 요약_생성_실패시_오프닝_원문으로_대체하고_경고를_남긴다() throws IOException { + when(emotionClassifier.bindSpeakers(anyString(), anyString(), anyString(), any())) + .thenReturn(Map.of("루소", "A", "홉스", "B")); + when(emotionClassifier.summarizeOpening(anyString(), anyString())).thenReturn(Optional.empty()); + + AdminBattleParseResponse res = service.parse(load("sample-linear.txt")); + + assertThat(res.battlePayload().summary()).isEqualTo(res.battlePayload().description()); + assertThat(res.warnings()).anyMatch(w -> w.code().equals("SUMMARY_GENERATION_FAILED")); + } + @Test void 사전투표_대표발화자로_A_B를_바인딩하고_미지원_성향지표는_경고한다() throws IOException { AdminBattleParseResponse res = service.parse(load("sample-real-3.txt"));