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 @@ -44,6 +44,7 @@
- `speakerNames` — `{"A": "플라톤", "B": "마르크스"}` 형태로 화자 A/B 에 바인딩된 철학자 이름
- `warnings[]` — 아래 표. `blocking: true` 인 항목이 하나라도 있으면 미리보기에서 해결하기 전까지 발행하지 않는다
- **문장 단위 분리**: `scenarioPayload.nodes[].scripts[]` 는 원문의 한 발언(문단)을 그대로 담지 않고, 마침표/느낌표/물음표(`.` `!` `?`) 뒤 공백 기준으로 **문장마다 별도 script** 로 쪼갠다. 같은 발언에서 쪼개진 문장들은 `speakerName`/`speakerType`/`tone` 이 전부 동일하다. 오디오 파이프라인이 script 마다 무음(600ms)을 넣으므로 문장 단위 분리가 곧 자연스러운 끊어읽기가 된다. 종결부호 뒤에 닫는 따옴표(`"`/`'`)가 먼저 오면 그 지점은 안 끊긴다(알려진 제한사항).
- **선택지 설명(`interactiveOptions[].title`)**: `👉 [선택의 시간]` 아래 `A: {설명} (철학자)` / `B: {설명} (철학자)` 줄의 `{설명}` 부분이 그대로 들어간다. 인터랙티브(분기 O) 배틀에만 존재.

**warning 코드**

Expand Down
1 change: 1 addition & 0 deletions docs/api-specs/scenario-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
- `tone` (`Tone` enum, 생략 시 `NEUTRAL`: `NEUTRAL`/`ANGRY`/`SAD`/`EMBARRASSED`/`EMPHASIS`/`WHISPERING`/`SOFT_TONE`/`BREATHY`/`EXCITED`. 대사 한 줄의 감성적인 톤 — TTS 합성 시 `[tone]` 형태로 text 앞에 주입된다)
- `interactiveOptions[]`
- `label`
- `title` (선택 시 보여줄 선택지 설명, 생략 가능)
- `nextNodeName`
- `voiceSettings` (`Map<SpeakerType, String>`, 값은 Fish Audio `reference_id`. 철학자 발화자(A/B)는 [철학자 보이스 매핑](./philosopher-voice-api.md)에서 조회해 채운다)

Expand Down
7 changes: 7 additions & 0 deletions docs/erd/scenario.puml
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ entity "scenario_options" as scenario_options {
--
node_id : BIGINT <<FK>>
label : VARCHAR(200)
title : VARCHAR(255) <<nullable>>
next_node_id : BIGINT
option_order : INT
created_at : TIMESTAMP
Expand Down Expand Up @@ -85,6 +86,12 @@ note right of scenarios
voice_settings는 화자별 보이스 코드 저장
end note

note bottom of scenario_options
title : 선택의 시간 화면에 보여줄 선택지 설명
(예: "첫 만남에는 그에 걸맞은 격조와
분위기가 필수다, 유죄!"). nullable.
end note

note bottom of scenario_scripts
tone : 대사 한 줄의 감성적인 톤 (Tone enum)
NEUTRAL | ANGRY | SAD | EMBARRASSED | EMPHASIS
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,6 @@

public record AdminScenarioOptionRequest(
String label,
String title,
String nextNodeName
) {}
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,6 @@
@Builder
public record AdminScenarioOptionResponse(
String label,
String title,
Long nextNodeId
) {}
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ private List<OptionRequest> toOptionRequests(List<AdminScenarioOptionRequest> op
return optionRequests.stream()
.map(optionRequest -> new OptionRequest(
optionRequest.label(),
optionRequest.title(),
optionRequest.nextNodeName()
))
.toList();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,11 +52,13 @@ public ParsedScript(String speaker, String text) {

public static class ParsedOption {
public String label; // "A" / "B"
public String title; // 선택의 시간 줄의 설명 텍스트 (예: "첫 만남에는 그에 걸맞은 격조와 분위기가 필수다, 유죄!")
public String nextNodeName; // "분기_A" 등
public String speaker; // 선택의 시간 줄에 표기된 발화자 (예: "칸트")

public ParsedOption(String label, String nextNodeName, String speaker) {
public ParsedOption(String label, String title, String nextNodeName, String speaker) {
this.label = label;
this.title = title;
this.nextNodeName = nextNodeName;
this.speaker = speaker;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -290,7 +290,8 @@ private void parseChoiceLine(String line, ParsedNode choiceNode) {
if (t.find()) {
speaker = t.group(1).strip();
}
choiceNode.options.add(new ParsedOption(label, "분기_" + label, speaker));
String title = stripQuotes(SPEAKER_ANNOTATION.matcher(rest).replaceAll("").strip());
choiceNode.options.add(new ParsedOption(label, title, "분기_" + label, speaker));
}

private void parseMetadata(List<String> meta, BattleScriptDocument doc) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -287,7 +287,7 @@ private List<AdminScenarioNodeRequest> buildNodes(BattleScriptDocument doc, Spea
}

List<AdminScenarioOptionRequest> options = node.options.stream()
.map(o -> new AdminScenarioOptionRequest(o.label, o.nextNodeName))
.map(o -> new AdminScenarioOptionRequest(o.label, o.title, o.nextNodeName))
.toList();

String autoNext = resolveAutoNext(order, ni, node, hasClosing);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -128,13 +128,15 @@ private AdminScenarioScriptResponse toAdminScriptResponse(Script script) {
private OptionResponse toUserOptionResponse(InteractiveOption option) {
return OptionResponse.builder()
.label(option.getLabel())
.title(option.getTitle())
.nextNodeId(option.getNextNodeId())
.build();
}

private AdminScenarioOptionResponse toAdminOptionResponse(InteractiveOption option) {
return AdminScenarioOptionResponse.builder()
.label(option.getLabel())
.title(option.getTitle())
.nextNodeId(option.getNextNodeId())
.build();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,6 @@

public record OptionRequest(
String label,
String title,
String nextNodeName
) {}
) {}
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,6 @@
@Builder
public record OptionResponse(
String label,
String title,
Long nextNodeId
) {}
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,17 @@ public class InteractiveOption extends BaseEntity {

private String label;

/** 선택의 시간 화면에 보여줄 선택지 설명(예: "첫 만남에는 그에 걸맞은 격조와 분위기가 필수다, 유죄!"). */
@Column(length = 255)
private String title;

@Column(name = "next_node_id")
private Long nextNodeId;

@Builder
public InteractiveOption(String label, Long nextNodeId) {
public InteractiveOption(String label, String title, Long nextNodeId) {
this.label = label;
this.title = title;
this.nextNodeId = nextNodeId;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,7 @@ private boolean smartUpdateNodesToScenario(Scenario scenario, ScenarioCreateRequ
Optional.ofNullable(updatedNodeMap.get(optReq.nextNodeName()))
.ifPresent(target -> parentNode.addOption(InteractiveOption.builder()
.label(optReq.label())
.title(optReq.title())
.nextNodeId(target.getId())
.build()));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,9 @@ private ParsedNode node(BattleScriptDocument doc, String name) {
assertThat(choice.options).extracting(o -> o.label).containsExactly("A", "B");
assertThat(choice.options).extracting(o -> o.nextNodeName).containsExactly("분기_A", "분기_B");
assertThat(choice.options).extracting(o -> o.speaker).containsExactly("칸트", "아리스토텔레스");
assertThat(choice.options).extracting(o -> o.title).containsExactly(
"AI 판사를 도입해야 한다. 일관된 원칙이 진정한 공정함이다.",
"AI 판사는 도입해선 안 된다. 맥락을 이해하는 인간의 판단이 필요하다.");
}

@Test
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -183,6 +183,11 @@ private AdminScenarioNodeRequest node(AdminBattleParseResponse res, String name)
.containsExactly("오프닝", "1라운드", "2라운드", "선택", "분기_A", "분기_B", "클로징");
assertThat(node(res, "선택").interactiveOptions()).extracting(o -> o.nextNodeName())
.containsExactly("분기_A", "분기_B");
// 선택의 시간 줄의 설명 텍스트("A: {title} (철학자)")가 title 로 담긴다
assertThat(node(res, "선택").interactiveOptions()).extracting(o -> o.title())
.containsExactly(
"첫 만남에는 그에 걸맞은 격조와 분위기가 필수다, 유죄!",
"가성비 좋고 든든하며, 솔직한 게 최고다, 무죄!");
assertThat(node(res, "분기_A").autoNextNode()).isEqualTo("클로징");
assertThat(res.scenarioPayload().voiceSettings())
.containsEntry(SpeakerType.A, "voice-플라톤")
Expand Down
Loading