Skip to content

Commit 8efa2ad

Browse files
authored
#471 [Feat] 배틀 대본 파서 summary·description·선택지 title 매핑 수정
1 parent 3ed96b8 commit 8efa2ad

7 files changed

Lines changed: 114 additions & 7 deletions

File tree

‎docs/api-specs/battle-api.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,9 @@
3737
- 설명: 대본을 파싱해 배틀·시나리오 등록 폼을 자동으로 채워준다. **DB 저장은 하지 않는다.** 문서 포맷 규격/파싱 규칙은 `배틀_발행_시나리오_현행_vs_개선.md` 2.6, 실제 파싱 예시는 `배틀_대본_파싱_예시.md` 참고.
3838
- 응답(`AdminBattleParseResponse`):
3939
- `battlePayload` — 아래 2.2 배틀 생성 요청과 동일한 형태(`AdminBattleCreateRequest`). `status` 는 항상 `PENDING`. 문서에 없는 `thumbnailUrl`/`targetDate`/`publishAt`/`audioDuration` 은 `null`
40+
- `summary` = 오프닝 전문을 LLM 이 40자 이내로 한 줄 요약한 것. 요약 생성 실패 시 오프닝 원문 그대로 들어가고 `SUMMARY_GENERATION_FAILED` warning 이 붙는다
41+
- `description` = 오프닝 전문 원문 그대로
42+
- `options[].title` = **사전 투표 영역의 짧은 선택지명이 있으면 그걸 우선 사용**(예: `"유죄다" vs "무죄다"` → `유죄다`/`무죄다`), 사전 투표가 없으면 메타데이터 표의 "선택지 명칭"으로 대체
4043
- `scenarioPayload` — [시나리오 API](./scenario-api.md) 2.2 생성 요청과 동일한 형태(`AdminScenarioCreateRequest`). **`battleId` 는 항상 `null`** — 배틀이 아직 생성 전이라서다. 어드민이 `battlePayload` 로 배틀을 먼저 만들고, 응답으로 받은 `battleId` 를 채워 시나리오를 등록한다
4144
- `speakerNames` — `{"A": "플라톤", "B": "마르크스"}` 형태로 화자 A/B 에 바인딩된 철학자 이름
4245
- `warnings[]` — 아래 표. `blocking: true` 인 항목이 하나라도 있으면 미리보기에서 해결하기 전까지 발행하지 않는다
@@ -55,6 +58,7 @@
5558
| `MULTIPLE_TONE_TAGS` | 대사 한 줄에 톤 태그가 여러 개 → 첫 번째만 사용 | no |
5659
| `SPEAKER_BINDING_FALLBACK` | 발화자↔A/B 매칭을 등장 순서로 임시 배정 | no |
5760
| `LLM_CLASSIFY_FAILED` | 감정 자동분류 실패 → 톤이 전부 `NEUTRAL` | no |
61+
| `SUMMARY_GENERATION_FAILED` | 오프닝 한 줄 요약 생성 실패 → `summary` 에 오프닝 원문이 그대로 들어감 | no |
5862
| `MISSING_VOICE` | 발화자 보이스가 [철학자 보이스 매핑](./philosopher-voice-api.md) 에 없음 | **yes** |
5963
| `INCOMPLETE_SPEAKER_BINDING` | A/B 발화자를 확정하지 못함 | **yes** |
6064

‎src/main/java/com/swyp/picke/domain/battle/parser/BattleScriptDocument.java‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,10 @@ public class BattleScriptDocument {
1818
public final List<ParseWarning> warnings = new ArrayList<>();
1919

2020
public static class OptionMeta {
21+
/** 사전 투표 줄의 짧은 선택지명(예: "유죄다"). 있으면 이게 최종 title 이 된다. */
2122
public String choiceName;
23+
/** 메타데이터 표 "선택지 명칭" 값. 사전 투표가 없을 때만 title 로 쓰는 폴백. */
24+
public String metadataChoiceName;
2225
/** 사전 투표 줄에 적힌 대표 발화자(예: "플라톤"). A/B 바인딩 힌트로 쓴다. */
2326
public String primarySpeaker;
2427
public final List<String> philosopherKeywords = new ArrayList<>();

‎src/main/java/com/swyp/picke/domain/battle/parser/BattleScriptDocumentParser.java‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,14 @@ public BattleScriptDocument parse(String rawText) {
7171
boolean bodyIsInteractive = doc.nodes.stream()
7272
.anyMatch(n -> n.name.equals("선택") || n.name.startsWith("분기_"));
7373
doc.interactive = doc.interactive || bodyIsInteractive;
74+
75+
// 선택지 명칭: 사전 투표의 짧은 이름이 우선, 없을 때만 메타데이터 표의 "선택지 명칭"으로 채운다
76+
if (doc.optionA.choiceName == null) {
77+
doc.optionA.choiceName = doc.optionA.metadataChoiceName;
78+
}
79+
if (doc.optionB.choiceName == null) {
80+
doc.optionB.choiceName = doc.optionB.metadataChoiceName;
81+
}
7482
return doc;
7583
}
7684

@@ -298,7 +306,7 @@ private void parseMetadata(List<String> meta, BattleScriptDocument doc) {
298306
} else if (line.startsWith("카테고리")) {
299307
doc.category = valueAfter(line, "카테고리", next);
300308
} else if (line.startsWith("선택지 명칭") && target != null) {
301-
target.choiceName = valueAfter(line, "선택지 명칭", next);
309+
target.metadataChoiceName = valueAfter(line, "선택지 명칭", next);
302310
} else if (line.startsWith("철학자 키워드") && target != null) {
303311
for (String kw : valueAfter(line, "철학자 키워드", next).split("[,、\\s]+")) {
304312
String cleaned = kw.replace("#", "").strip();

‎src/main/java/com/swyp/picke/domain/battle/parser/EmotionClassifier.java‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import com.swyp.picke.domain.scenario.enums.Tone;
44
import java.util.List;
55
import java.util.Map;
6+
import java.util.Optional;
67

78
/**
89
* 대본 대사에 LLM 으로 감정 톤과 효과 태그를 붙인다. 실패해도 파싱을 막지 않는다(빈 결과 반환).
@@ -19,4 +20,7 @@ record ScriptEmotion(Tone tone, String textWithEffects) {}
1920
/** 발화자 이름 -> "A" | "B". 선택의 시간에 명시가 없을 때 논지↔선택지명칭 매칭. 실패 시 빈 맵. */
2021
Map<String, String> bindSpeakers(String battleTitle, String optionATitle, String optionBTitle,
2122
List<String> speakers);
23+
24+
/** 오프닝 전문을 배틀 카드용 한 줄 요약으로 압축한다. 실패 시 empty. */
25+
Optional<String> summarizeOpening(String battleTitle, String openingText);
2226
}

‎src/main/java/com/swyp/picke/domain/battle/parser/OpenAiEmotionClassifier.java‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
import java.util.HashMap;
88
import java.util.List;
99
import java.util.Map;
10+
import java.util.Optional;
1011
import lombok.extern.slf4j.Slf4j;
1112
import org.springframework.beans.factory.annotation.Value;
1213
import org.springframework.http.client.SimpleClientHttpRequestFactory;
@@ -42,6 +43,13 @@ public class OpenAiEmotionClassifier implements EmotionClassifier {
4243
반드시 JSON 만 출력: {"bindings":{"발화자이름":"A" 또는 "B"}}
4344
""";
4445

46+
private static final String SUMMARY_SYSTEM = """
47+
너는 철학 토론 배틀 카드에 들어갈 한 줄 요약을 쓰는 도구다.
48+
오프닝 전문을 읽고, 배틀 카드에 노출할 흥미로운 한 줄 요약을 40자 이내 한국어로 작성해라.
49+
원문을 그대로 자르지 말고, 핵심 갈등/질문을 압축해라.
50+
반드시 JSON 만 출력: {"summary":"..."}
51+
""";
52+
4553
private final ObjectMapper objectMapper = new ObjectMapper();
4654

4755
@Value("${openai.api-key}")
@@ -115,6 +123,22 @@ public Map<String, String> bindSpeakers(String battleTitle, String optionATitle,
115123
}
116124
}
117125

126+
@Override
127+
public Optional<String> summarizeOpening(String battleTitle, String openingText) {
128+
if (openingText == null || openingText.isBlank()) {
129+
return Optional.empty();
130+
}
131+
try {
132+
String userPrompt = "배틀 제목: " + battleTitle + "\n오프닝 전문:\n" + openingText;
133+
JsonNode root = call(SUMMARY_SYSTEM, userPrompt);
134+
String summary = root.path("summary").asText(null);
135+
return (summary == null || summary.isBlank()) ? Optional.empty() : Optional.of(summary.trim());
136+
} catch (Exception e) {
137+
log.warn("[EmotionClassifier] 오프닝 요약 생성 실패", e);
138+
return Optional.empty();
139+
}
140+
}
141+
118142
private JsonNode call(String systemPrompt, String userPrompt) throws Exception {
119143
SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory();
120144
factory.setConnectTimeout(CONNECT_TIMEOUT_MS);

‎src/main/java/com/swyp/picke/domain/battle/service/BattleScriptParseService.java‎

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -85,12 +85,13 @@ public AdminBattleParseResponse parse(String rawText) {
8585

8686
SpeakerBinding binding = bindSpeakers(doc, warnings);
8787

88-
String openingText = firstNarration(doc, "오프닝");
88+
String description = allNarration(doc, "오프닝");
89+
String summary = summarizeOpening(doc.title, description, warnings);
8990

9091
AdminBattleCreateRequest battlePayload = new AdminBattleCreateRequest(
9192
doc.title,
92-
openingText,
93-
null,
93+
summary,
94+
description,
9495
null,
9596
null,
9697
null,
@@ -350,14 +351,30 @@ private void putVoice(Map<SpeakerType, String> voices, SpeakerType type, String
350351

351352
// ---- 유틸 ----
352353

353-
private String firstNarration(BattleScriptDocument doc, String nodeName) {
354-
return doc.nodes.stream()
354+
/** 오프닝 전문을 LLM으로 한 줄 요약한다(배틀 카드 summary 용). 실패하면 오프닝 원문으로 대신한다. */
355+
private String summarizeOpening(String title, String openingText, List<ParseWarning> warnings) {
356+
if (openingText == null || openingText.isBlank()) {
357+
return null;
358+
}
359+
Optional<String> summarized = emotionClassifier.summarizeOpening(title, openingText);
360+
if (summarized.isPresent() && !summarized.get().isBlank()) {
361+
return summarized.get();
362+
}
363+
warnings.add(ParseWarning.of("SUMMARY_GENERATION_FAILED",
364+
"오프닝 한 줄 요약 생성에 실패해 오프닝 원문을 그대로 넣었습니다. 미리보기에서 다듬어주세요.", null));
365+
return openingText;
366+
}
367+
368+
/** 오프닝 노드의 나레이션 문단을 전부 이어붙인다(배틀 description 용). */
369+
private String allNarration(BattleScriptDocument doc, String nodeName) {
370+
String joined = doc.nodes.stream()
355371
.filter(n -> n.name.equals(nodeName))
356372
.flatMap(n -> n.scripts.stream())
357373
.filter(s -> s.speaker == null)
358374
.map(s -> s.text)
359-
.findFirst()
375+
.reduce((a, b) -> a + " " + b)
360376
.orElse(null);
377+
return joined;
361378
}
362379

363380
/**

‎src/test/java/com/swyp/picke/domain/battle/service/BattleScriptParseServiceTest.java‎

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,53 @@ private AdminScenarioNodeRequest node(AdminBattleParseResponse res, String name)
192192
assertThat(res.battlePayload().options().get(0).tagIds()).hasSize(6);
193193
}
194194

195+
@Test
196+
void 선택지_title은_사전투표의_짧은_이름을_우선한다() throws IOException {
197+
// sample-real-1.txt: 사전 투표 = "유죄다"/"무죄다", 메타데이터 선택지 명칭 = 훨씬 긴 문장
198+
AdminBattleParseResponse res = service.parse(load("sample-real-1.txt"));
199+
200+
assertThat(res.battlePayload().options().get(0).title()).isEqualTo("유죄다");
201+
assertThat(res.battlePayload().options().get(1).title()).isEqualTo("무죄다");
202+
}
203+
204+
@Test
205+
void 사전투표가_없으면_메타데이터_선택지_명칭을_title로_쓴다() throws IOException {
206+
when(emotionClassifier.bindSpeakers(anyString(), anyString(), anyString(), any()))
207+
.thenReturn(Map.of("루소", "A", "홉스", "B"));
208+
209+
// sample-linear.txt 는 사전 투표 없음 → 메타데이터 "선택지 명칭"(선하다/악하다) 그대로
210+
AdminBattleParseResponse res = service.parse(load("sample-linear.txt"));
211+
212+
assertThat(res.battlePayload().options()).extracting(AdminBattleOptionRequest::title)
213+
.containsExactly("선하다", "악하다");
214+
}
215+
216+
@Test
217+
void summary는_LLM_한줄요약_description은_오프닝_전문을_담는다() throws IOException {
218+
when(emotionClassifier.bindSpeakers(anyString(), anyString(), anyString(), any()))
219+
.thenReturn(Map.of("루소", "A", "홉스", "B"));
220+
when(emotionClassifier.summarizeOpening(anyString(), anyString()))
221+
.thenReturn(Optional.of("인간은 선한가 악한가, 재난 앞에서 드러나는 본성"));
222+
223+
AdminBattleParseResponse res = service.parse(load("sample-linear.txt"));
224+
225+
assertThat(res.battlePayload().summary()).isEqualTo("인간은 선한가 악한가, 재난 앞에서 드러나는 본성");
226+
assertThat(res.battlePayload().description()).startsWith("재난이 일어났을 때");
227+
assertThat(res.warnings()).noneMatch(w -> w.code().equals("SUMMARY_GENERATION_FAILED"));
228+
}
229+
230+
@Test
231+
void 요약_생성_실패시_오프닝_원문으로_대체하고_경고를_남긴다() throws IOException {
232+
when(emotionClassifier.bindSpeakers(anyString(), anyString(), anyString(), any()))
233+
.thenReturn(Map.of("루소", "A", "홉스", "B"));
234+
when(emotionClassifier.summarizeOpening(anyString(), anyString())).thenReturn(Optional.empty());
235+
236+
AdminBattleParseResponse res = service.parse(load("sample-linear.txt"));
237+
238+
assertThat(res.battlePayload().summary()).isEqualTo(res.battlePayload().description());
239+
assertThat(res.warnings()).anyMatch(w -> w.code().equals("SUMMARY_GENERATION_FAILED"));
240+
}
241+
195242
@Test
196243
void 사전투표_대표발화자로_A_B를_바인딩하고_미지원_성향지표는_경고한다() throws IOException {
197244
AdminBattleParseResponse res = service.parse(load("sample-real-3.txt"));

0 commit comments

Comments
 (0)