From 7fb727879f65b6b3e56911b13943101437874ddd Mon Sep 17 00:00:00 2001 From: si-zero Date: Sat, 12 Sep 2026 01:18:58 +0900 Subject: [PATCH 1/6] =?UTF-8?q?feat:=20=EC=9D=B8=ED=84=B0=EB=9E=99?= =?UTF-8?q?=ED=8B=B0=EB=B8=8C=20=EC=84=A0=ED=83=9D=EC=A7=80=EC=97=90=20?= =?UTF-8?q?=EC=84=A0=ED=83=9D=20=EC=8B=9C=20=EB=B3=B4=EC=97=AC=EC=A4=84=20?= =?UTF-8?q?=EC=84=A4=EB=AA=85(title)=20=ED=95=84=EB=93=9C=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../picke/domain/scenario/entity/InteractiveOption.java | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) 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; } From ddd4e58258388c9eab90af70c37ed2eea86609dd Mon Sep 17 00:00:00 2001 From: si-zero Date: Sat, 12 Sep 2026 01:18:58 +0900 Subject: [PATCH 2/6] =?UTF-8?q?feat:=20=EC=8B=9C=EB=82=98=EB=A6=AC?= =?UTF-8?q?=EC=98=A4=20=EC=98=B5=EC=85=98=20=EC=9A=94=EC=B2=AD=C2=B7?= =?UTF-8?q?=EC=9D=91=EB=8B=B5=20DTO=EC=97=90=20title=20=ED=95=84=EB=93=9C?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/dto/scenario/request/AdminScenarioOptionRequest.java | 1 + .../dto/scenario/response/AdminScenarioOptionResponse.java | 1 + .../swyp/picke/domain/scenario/dto/request/OptionRequest.java | 3 ++- .../picke/domain/scenario/dto/response/OptionResponse.java | 1 + 4 files changed, 5 insertions(+), 1 deletion(-) 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/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 ) {} From 1f9c21b549e90baf9f86884d73e3f656b4835152 Mon Sep 17 00:00:00 2001 From: si-zero Date: Sat, 12 Sep 2026 01:18:58 +0900 Subject: [PATCH 3/6] =?UTF-8?q?feat:=20=EC=8B=9C=EB=82=98=EB=A6=AC?= =?UTF-8?q?=EC=98=A4=20=EC=84=9C=EB=B9=84=EC=8A=A4=C2=B7=EC=BB=A8=EB=B2=84?= =?UTF-8?q?=ED=84=B0=EA=B0=80=20=EC=98=B5=EC=85=98=20title=EC=9D=84=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5=C2=B7=EB=B0=98=ED=99=98=ED=95=98=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../swyp/picke/domain/admin/service/AdminScenarioService.java | 1 + .../swyp/picke/domain/scenario/converter/ScenarioConverter.java | 2 ++ .../swyp/picke/domain/scenario/service/ScenarioServiceImpl.java | 1 + 3 files changed, 4 insertions(+) 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/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/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())); } From 4dffb353fb9cb7eb88e579aa78fcfd053c42a164 Mon Sep 17 00:00:00 2001 From: si-zero Date: Sat, 12 Sep 2026 01:18:58 +0900 Subject: [PATCH 4/6] =?UTF-8?q?feat:=20=ED=8C=8C=EC=84=9C=EA=B0=80=20?= =?UTF-8?q?=EC=84=A0=ED=83=9D=EC=9D=98=20=EC=8B=9C=EA=B0=84=20=EC=84=A4?= =?UTF-8?q?=EB=AA=85=20=ED=85=8D=EC=8A=A4=ED=8A=B8=EB=A5=BC=20=EC=98=B5?= =?UTF-8?q?=EC=85=98=20title=EB=A1=9C=20=EC=B6=94=EC=B6=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../swyp/picke/domain/battle/parser/BattleScriptDocument.java | 4 +++- .../domain/battle/parser/BattleScriptDocumentParser.java | 3 ++- .../picke/domain/battle/service/BattleScriptParseService.java | 2 +- 3 files changed, 6 insertions(+), 3 deletions(-) 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); From 45f3ef2271bcd91103956b24331707ebacd339dd Mon Sep 17 00:00:00 2001 From: si-zero Date: Sat, 12 Sep 2026 01:18:58 +0900 Subject: [PATCH 5/6] =?UTF-8?q?test:=20=EC=84=A0=ED=83=9D=EC=A7=80=20title?= =?UTF-8?q?=20=EC=B6=94=EC=B6=9C=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/battle/parser/BattleScriptDocumentParserTest.java | 3 +++ .../domain/battle/service/BattleScriptParseServiceTest.java | 5 +++++ 2 files changed, 8 insertions(+) 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-플라톤") From eecd8f2e360550650a0d489169db3fb2b62bc18e Mon Sep 17 00:00:00 2001 From: si-zero Date: Sat, 12 Sep 2026 01:18:58 +0900 Subject: [PATCH 6/6] =?UTF-8?q?docs:=20=EC=84=A0=ED=83=9D=EC=A7=80=20title?= =?UTF-8?q?=20=ED=95=84=EB=93=9C=20ERD=C2=B7API=20=EB=AA=85=EC=84=B8?= =?UTF-8?q?=EC=84=9C=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/api-specs/battle-api.md | 1 + docs/api-specs/scenario-api.md | 1 + docs/erd/scenario.puml | 7 +++++++ 3 files changed, 9 insertions(+) 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