diff --git a/docs/api-specs/battle-api.md b/docs/api-specs/battle-api.md index d88cde2f..6d918e9a 100644 --- a/docs/api-specs/battle-api.md +++ b/docs/api-specs/battle-api.md @@ -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 코드** diff --git a/docs/api-specs/scenario-api.md b/docs/api-specs/scenario-api.md index da23adfb..b1b9d47a 100644 --- a/docs/api-specs/scenario-api.md +++ b/docs/api-specs/scenario-api.md @@ -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`, 값은 Fish Audio `reference_id`. 철학자 발화자(A/B)는 [철학자 보이스 매핑](./philosopher-voice-api.md)에서 조회해 채운다) diff --git a/docs/erd/scenario.puml b/docs/erd/scenario.puml index 10e653da..83094c9f 100644 --- a/docs/erd/scenario.puml +++ b/docs/erd/scenario.puml @@ -53,6 +53,7 @@ entity "scenario_options" as scenario_options { -- node_id : BIGINT <> label : VARCHAR(200) + title : VARCHAR(255) <> next_node_id : BIGINT option_order : INT created_at : TIMESTAMP @@ -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 diff --git a/src/main/java/com/swyp/picke/domain/admin/dto/scenario/request/AdminScenarioOptionRequest.java b/src/main/java/com/swyp/picke/domain/admin/dto/scenario/request/AdminScenarioOptionRequest.java index 4bf2988b..dca526b1 100644 --- a/src/main/java/com/swyp/picke/domain/admin/dto/scenario/request/AdminScenarioOptionRequest.java +++ b/src/main/java/com/swyp/picke/domain/admin/dto/scenario/request/AdminScenarioOptionRequest.java @@ -2,5 +2,6 @@ public record AdminScenarioOptionRequest( String label, + String title, String nextNodeName ) {} diff --git a/src/main/java/com/swyp/picke/domain/admin/dto/scenario/response/AdminScenarioOptionResponse.java b/src/main/java/com/swyp/picke/domain/admin/dto/scenario/response/AdminScenarioOptionResponse.java index 50bfc1c7..241c10d2 100644 --- a/src/main/java/com/swyp/picke/domain/admin/dto/scenario/response/AdminScenarioOptionResponse.java +++ b/src/main/java/com/swyp/picke/domain/admin/dto/scenario/response/AdminScenarioOptionResponse.java @@ -5,5 +5,6 @@ @Builder public record AdminScenarioOptionResponse( String label, + String title, Long nextNodeId ) {} diff --git a/src/main/java/com/swyp/picke/domain/admin/service/AdminScenarioService.java b/src/main/java/com/swyp/picke/domain/admin/service/AdminScenarioService.java index 242ea777..f071e4cb 100644 --- a/src/main/java/com/swyp/picke/domain/admin/service/AdminScenarioService.java +++ b/src/main/java/com/swyp/picke/domain/admin/service/AdminScenarioService.java @@ -96,6 +96,7 @@ private List toOptionRequests(List op return optionRequests.stream() .map(optionRequest -> new OptionRequest( optionRequest.label(), + optionRequest.title(), optionRequest.nextNodeName() )) .toList(); 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 a3c1830d..40f301c0 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 @@ -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; } 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 3d4915e6..53467ff8 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 @@ -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 meta, BattleScriptDocument doc) { 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 680924bc..5d48224c 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 @@ -287,7 +287,7 @@ private List buildNodes(BattleScriptDocument doc, Spea } List 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); diff --git a/src/main/java/com/swyp/picke/domain/scenario/converter/ScenarioConverter.java b/src/main/java/com/swyp/picke/domain/scenario/converter/ScenarioConverter.java index afaad889..ebecb95a 100644 --- a/src/main/java/com/swyp/picke/domain/scenario/converter/ScenarioConverter.java +++ b/src/main/java/com/swyp/picke/domain/scenario/converter/ScenarioConverter.java @@ -128,6 +128,7 @@ private AdminScenarioScriptResponse toAdminScriptResponse(Script script) { private OptionResponse toUserOptionResponse(InteractiveOption option) { return OptionResponse.builder() .label(option.getLabel()) + .title(option.getTitle()) .nextNodeId(option.getNextNodeId()) .build(); } @@ -135,6 +136,7 @@ private OptionResponse toUserOptionResponse(InteractiveOption option) { private AdminScenarioOptionResponse toAdminOptionResponse(InteractiveOption option) { return AdminScenarioOptionResponse.builder() .label(option.getLabel()) + .title(option.getTitle()) .nextNodeId(option.getNextNodeId()) .build(); } diff --git a/src/main/java/com/swyp/picke/domain/scenario/dto/request/OptionRequest.java b/src/main/java/com/swyp/picke/domain/scenario/dto/request/OptionRequest.java index bb95c110..7ba824a8 100644 --- a/src/main/java/com/swyp/picke/domain/scenario/dto/request/OptionRequest.java +++ b/src/main/java/com/swyp/picke/domain/scenario/dto/request/OptionRequest.java @@ -2,5 +2,6 @@ public record OptionRequest( String label, + String title, String nextNodeName -) {} \ No newline at end of file +) {} diff --git a/src/main/java/com/swyp/picke/domain/scenario/dto/response/OptionResponse.java b/src/main/java/com/swyp/picke/domain/scenario/dto/response/OptionResponse.java index 189d16c3..46b76a90 100644 --- a/src/main/java/com/swyp/picke/domain/scenario/dto/response/OptionResponse.java +++ b/src/main/java/com/swyp/picke/domain/scenario/dto/response/OptionResponse.java @@ -5,5 +5,6 @@ @Builder public record OptionResponse( String label, + String title, Long nextNodeId ) {} diff --git a/src/main/java/com/swyp/picke/domain/scenario/entity/InteractiveOption.java b/src/main/java/com/swyp/picke/domain/scenario/entity/InteractiveOption.java index 0b814800..3dea59cb 100644 --- a/src/main/java/com/swyp/picke/domain/scenario/entity/InteractiveOption.java +++ b/src/main/java/com/swyp/picke/domain/scenario/entity/InteractiveOption.java @@ -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; } diff --git a/src/main/java/com/swyp/picke/domain/scenario/service/ScenarioServiceImpl.java b/src/main/java/com/swyp/picke/domain/scenario/service/ScenarioServiceImpl.java index cd165aac..3229f935 100644 --- a/src/main/java/com/swyp/picke/domain/scenario/service/ScenarioServiceImpl.java +++ b/src/main/java/com/swyp/picke/domain/scenario/service/ScenarioServiceImpl.java @@ -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())); } diff --git a/src/test/java/com/swyp/picke/domain/battle/parser/BattleScriptDocumentParserTest.java b/src/test/java/com/swyp/picke/domain/battle/parser/BattleScriptDocumentParserTest.java index 4f8263b8..6d042c57 100644 --- a/src/test/java/com/swyp/picke/domain/battle/parser/BattleScriptDocumentParserTest.java +++ b/src/test/java/com/swyp/picke/domain/battle/parser/BattleScriptDocumentParserTest.java @@ -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 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 69ad90c6..128fdd36 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 @@ -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-플라톤")