diff --git a/docs/api-specs/battle-api.md b/docs/api-specs/battle-api.md index d64bcf5..5b4263c 100644 --- a/docs/api-specs/battle-api.md +++ b/docs/api-specs/battle-api.md @@ -40,6 +40,7 @@ - `scenarioPayload` — [시나리오 API](./scenario-api.md) 2.2 생성 요청과 동일한 형태(`AdminScenarioCreateRequest`). **`battleId` 는 항상 `null`** — 배틀이 아직 생성 전이라서다. 어드민이 `battlePayload` 로 배틀을 먼저 만들고, 응답으로 받은 `battleId` 를 채워 시나리오를 등록한다 - `speakerNames` — `{"A": "플라톤", "B": "마르크스"}` 형태로 화자 A/B 에 바인딩된 철학자 이름 - `warnings[]` — 아래 표. `blocking: true` 인 항목이 하나라도 있으면 미리보기에서 해결하기 전까지 발행하지 않는다 +- **문장 단위 분리**: `scenarioPayload.nodes[].scripts[]` 는 원문의 한 발언(문단)을 그대로 담지 않고, 마침표/느낌표/물음표(`.` `!` `?`) 뒤 공백 기준으로 **문장마다 별도 script** 로 쪼갠다. 같은 발언에서 쪼개진 문장들은 `speakerName`/`speakerType`/`tone` 이 전부 동일하다. 오디오 파이프라인이 script 마다 무음(600ms)을 넣으므로 문장 단위 분리가 곧 자연스러운 끊어읽기가 된다. 종결부호 뒤에 닫는 따옴표(`"`/`'`)가 먼저 오면 그 지점은 안 끊긴다(알려진 제한사항). **warning 코드** 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 5042a30..3f53250 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 @@ -33,6 +33,7 @@ import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.regex.Pattern; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; @@ -45,6 +46,9 @@ @RequiredArgsConstructor public class BattleScriptParseService { + /** 문장 종결부호(. ! ?) 뒤 공백 기준으로 자른다. 말줄임표(...) 뒤에도 공백이 없으면 안 끊긴다. */ + private static final Pattern SENTENCE_BOUNDARY = Pattern.compile("(?<=[.!?])\\s+(?=\\S)"); + private final BattleScriptDocumentParser documentParser; private final ScriptToneExtractor toneExtractor; private final EmotionClassifier emotionClassifier; @@ -276,7 +280,9 @@ private List buildNodes(BattleScriptDocument doc, Spea : ex.cleanedText(); SpeakerType speakerType = resolveSpeakerType(s.speaker, binding); String speakerName = s.speaker == null ? "나레이터" : s.speaker; - scripts.add(new AdminScenarioScriptRequest(speakerName, speakerType, text, tone)); + for (String sentence : splitIntoSentences(text)) { + scripts.add(new AdminScenarioScriptRequest(speakerName, speakerType, sentence, tone)); + } } List options = node.options.stream() @@ -354,6 +360,24 @@ private String firstNarration(BattleScriptDocument doc, String nodeName) { .orElse(null); } + /** + * 한 발언(text)을 문장 종결부호(. ! ?) 단위로 잘라 각각 별도 Script 로 만들기 위한 목록을 만든다. + * 오디오 파이프라인이 스크립트마다 무음(600ms)을 넣으므로, 문장 단위로 쪼개면 자연스러운 끊어읽기가 된다. + */ + private List splitIntoSentences(String text) { + if (text == null || text.isBlank()) { + return List.of(text == null ? "" : text); + } + List sentences = new ArrayList<>(); + for (String piece : SENTENCE_BOUNDARY.split(text.strip())) { + String trimmed = piece.strip(); + if (!trimmed.isEmpty()) { + sentences.add(trimmed); + } + } + return sentences.isEmpty() ? List.of(text.strip()) : sentences; + } + private String nz(String value) { return value == null ? "" : value; } 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 5e6e777..1be15f0 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 @@ -167,7 +167,8 @@ private AdminScenarioNodeRequest node(AdminBattleParseResponse res, String name) assertThat(res.speakerNames()).containsEntry("A", "칸트").containsEntry("B", "아리스토텔레스"); assertThat(node(res, "1라운드").scripts().get(0).speakerType()).isEqualTo(SpeakerType.A); // 칸트 - assertThat(node(res, "1라운드").scripts().get(1).speakerType()).isEqualTo(SpeakerType.B); // 아리스토텔레스 + assertThat(node(res, "1라운드").scripts()).last().extracting(s -> s.speakerType()) + .isEqualTo(SpeakerType.B); // 아리스토텔레스 (문장 단위로 쪼개져 있을 수 있음) } @Test @@ -207,6 +208,30 @@ private AdminScenarioNodeRequest node(AdminBattleParseResponse res, String name) assertThat(res.battlePayload().options().get(1).tagIds()).hasSize(5); } + @Test + void 한_발언은_문장_종결부호_기준으로_여러_스크립트로_쪼개진다() throws IOException { + when(emotionClassifier.bindSpeakers(anyString(), anyString(), anyString(), any())) + .thenReturn(Map.of("루소", "A", "홉스", "B")); + + AdminBattleParseResponse res = service.parse(load("sample-linear.txt")); + + // 루소의 첫 발언: "인간은 자연 상태에서 평화로웠습니다. 경쟁도, 소유욕도, 증오도 — 이건 문명이 만든 겁니다. + // 아이를 보십시오. 태어난 아이는 나누고 웃고, 타인의 고통에 울음으로 반응합니다. 악은 가르쳐지는 것입니다." + // → 문장 5개로 쪼개져야 한다. + var scripts = node(res, "라운드").scripts(); + long firstSpeakerSentences = scripts.stream() + .takeWhile(s -> "루소".equals(s.speakerName())) + .count(); + + assertThat(firstSpeakerSentences).isEqualTo(5); + assertThat(scripts.get(0).text()).isEqualTo("인간은 자연 상태에서 평화로웠습니다."); + assertThat(scripts.get(1).text()).startsWith("경쟁도, 소유욕도, 증오도"); + assertThat(scripts.get(2).text()).isEqualTo("아이를 보십시오."); + assertThat(scripts.get(4).text()).isEqualTo("악은 가르쳐지는 것입니다."); + // 같은 발언에서 쪼개진 문장들은 같은 화자·같은 톤을 유지한다 + assertThat(scripts.subList(0, 5)).allMatch(s -> "루소".equals(s.speakerName())); + } + @Test void LLM_실패시_톤은_전부_NEUTRAL이고_경고를_남긴다() throws IOException { AdminBattleParseResponse res = service.parse(load("sample-interactive.txt"));