Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/api-specs/battle-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 코드**

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -276,7 +280,9 @@ private List<AdminScenarioNodeRequest> 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<AdminScenarioOptionRequest> options = node.options.stream()
Expand Down Expand Up @@ -354,6 +360,24 @@ private String firstNarration(BattleScriptDocument doc, String nodeName) {
.orElse(null);
}

/**
* 한 발언(text)을 문장 종결부호(. ! ?) 단위로 잘라 각각 별도 Script 로 만들기 위한 목록을 만든다.
* 오디오 파이프라인이 스크립트마다 무음(600ms)을 넣으므로, 문장 단위로 쪼개면 자연스러운 끊어읽기가 된다.
*/
private List<String> splitIntoSentences(String text) {
if (text == null || text.isBlank()) {
return List.of(text == null ? "" : text);
}
List<String> 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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"));
Expand Down
Loading