diff --git a/docs/api-specs/battle-api.md b/docs/api-specs/battle-api.md index cfbcd2b3..d64bcf50 100644 --- a/docs/api-specs/battle-api.md +++ b/docs/api-specs/battle-api.md @@ -30,7 +30,34 @@ 기준 컨트롤러: `AdminBattleController` -### 2.1 배틀 생성 +### 2.1 배틀 대본 붙여넣기 파싱 (미리보기용, 저장 안 함) +- `POST /api/v1/admin/battles/parse` +- 요청 본문(`AdminBattleParseRequest`): + - `rawText` (기획자가 구글독스에서 복사한 대본 원문 전체) +- 설명: 대본을 파싱해 배틀·시나리오 등록 폼을 자동으로 채워준다. **DB 저장은 하지 않는다.** 문서 포맷 규격/파싱 규칙은 `배틀_발행_시나리오_현행_vs_개선.md` 2.6, 실제 파싱 예시는 `배틀_대본_파싱_예시.md` 참고. +- 응답(`AdminBattleParseResponse`): + - `battlePayload` — 아래 2.2 배틀 생성 요청과 동일한 형태(`AdminBattleCreateRequest`). `status` 는 항상 `PENDING`. 문서에 없는 `thumbnailUrl`/`targetDate`/`publishAt`/`audioDuration` 은 `null` + - `scenarioPayload` — [시나리오 API](./scenario-api.md) 2.2 생성 요청과 동일한 형태(`AdminScenarioCreateRequest`). **`battleId` 는 항상 `null`** — 배틀이 아직 생성 전이라서다. 어드민이 `battlePayload` 로 배틀을 먼저 만들고, 응답으로 받은 `battleId` 를 채워 시나리오를 등록한다 + - `speakerNames` — `{"A": "플라톤", "B": "마르크스"}` 형태로 화자 A/B 에 바인딩된 철학자 이름 + - `warnings[]` — 아래 표. `blocking: true` 인 항목이 하나라도 있으면 미리보기에서 해결하기 전까지 발행하지 않는다 + +**warning 코드** + +| code | 의미 | blocking | +|---|---|---| +| `MISSING_HEADER` | 첫 줄에서 `— 제목` 형식을 못 찾음 | no | +| `MISSING_METADATA` / `MISSING_CATEGORY` | 메타데이터 섹션/카테고리를 못 찾음 | no | +| `UNKNOWN_CATEGORY_TAG` | 그 카테고리 문자열의 CATEGORY 태그가 DB 에 없음 | no | +| `UNKNOWN_PHILOSOPHER_TAG` | 철학자 키워드가 철학자 유형 10인이 아님 | no | +| `MISSING_PHILOSOPHER_TAG` / `MISSING_VALUE_TAG` | 태그 문자열은 맞지만 DB row 가 없음 | no | +| `UNKNOWN_VALUE_TAG` | 성향 지표가 가치관 12축 문자열(원칙/결과/이성/감성/개인/관계/변화/전통/내면/구조/이상/현실)이 아님 | no | +| `MULTIPLE_TONE_TAGS` | 대사 한 줄에 톤 태그가 여러 개 → 첫 번째만 사용 | no | +| `SPEAKER_BINDING_FALLBACK` | 발화자↔A/B 매칭을 등장 순서로 임시 배정 | no | +| `LLM_CLASSIFY_FAILED` | 감정 자동분류 실패 → 톤이 전부 `NEUTRAL` | no | +| `MISSING_VOICE` | 발화자 보이스가 [철학자 보이스 매핑](./philosopher-voice-api.md) 에 없음 | **yes** | +| `INCOMPLETE_SPEAKER_BINDING` | A/B 발화자를 확정하지 못함 | **yes** | + +### 2.2 배틀 생성 - `POST /api/v1/admin/battles` - 요청 본문(`AdminBattleCreateRequest`) 주요 필드: - `title` @@ -47,21 +74,21 @@ - `imageUrl` - `tagIds` (철학자/가치관 태그 ID 목록) -### 2.2 배틀 목록 +### 2.3 배틀 목록 - `GET /api/v1/admin/battles` - 쿼리 파라미터: - `page` (기본값: `1`) - `size` (기본값: `10`) - `status` (선택) -### 2.3 배틀 상세 +### 2.4 배틀 상세 - `GET /api/v1/admin/battles/{battleId}` -### 2.4 배틀 수정 +### 2.5 배틀 수정 - `PATCH /api/v1/admin/battles/{battleId}` - 요청 본문(`AdminBattleUpdateRequest`) 필드 구조는 생성과 동일 -### 2.5 배틀 삭제 +### 2.6 배틀 삭제 - `DELETE /api/v1/admin/battles/{battleId}` --- diff --git a/docs/api-specs/philosopher-voice-api.md b/docs/api-specs/philosopher-voice-api.md new file mode 100644 index 00000000..4cff9003 --- /dev/null +++ b/docs/api-specs/philosopher-voice-api.md @@ -0,0 +1,41 @@ +# 철학자 보이스 매핑(Philosopher Voice) API 명세 + +기준 코드: +`src/main/java/com/swyp/picke/domain/admin/controller/AdminPhilosopherVoiceController.java` +`src/main/java/com/swyp/picke/domain/scenario/service/PhilosopherVoiceService.java` + +철학자 이름 → Fish Audio 보이스(`reference_id`) 매핑을 관리한다. [배틀 대본 붙여넣기 파서](./battle-api.md) 가 +시나리오 발화자(옵션 A/B 철학자)에 맞는 보이스를 여기서 조회해 `scenario.voiceSettings` 를 채운다. +전부 관리자 전용 API. 사용자 API 없음. + +## 1. 관리자 API + +### 1.1 목록 조회 +- `GET /api/v1/admin/philosopher-voices` +- 응답(`PhilosopherVoiceResponse[]`): `id`, `name`, `referenceId`, `voiceLabel`, `note` + +### 1.2 생성 +- `POST /api/v1/admin/philosopher-voices` +- 요청 본문(`PhilosopherVoiceRequest`): + - `name` (필수, 유니크) + - `referenceId` (필수, Fish Audio 보이스 모델 ID) + - `voiceLabel` (선택, 표시용 라벨. 예: `"미호크 장정진"`) + - `note` (선택) +- 이름 중복 시 `PHILOSOPHER_VOICE_409_DUP` + +### 1.3 수정 +- `PATCH /api/v1/admin/philosopher-voices/{id}` +- 요청 본문은 생성과 동일. `name` 은 응답에서 그대로 유지되고 `referenceId`/`voiceLabel`/`note` 만 갱신된다 +- 대상이 없으면 `PHIL_VOICE_404` + +### 1.4 삭제 +- `DELETE /api/v1/admin/philosopher-voices/{id}` +- 대상이 없으면 `PHIL_VOICE_404` + +## 2. 상태/동작 메모 + +- 테이블은 `spring.jpa.hibernate.ddl-auto=update` 가 자동 생성한다. 소프트 삭제 없음 (참조하는 FK가 없어 hard delete) +- 초기 시드: `docs/db/20260910_seed_philosopher_voice.sql` (`ON CONFLICT (name) DO NOTHING`) — 환경별 1회 수동 실행 필요. 재실행해도 안전하고 어드민에서 수정한 값을 덮지 않는다 +- **NARRATOR / USER 고정 보이스는 이 테이블이 아니라 config** (`fishaudio.voice-id.narrator`, `fishaudio.voice-id.user`). 이 API 는 A/B(철학자) 보이스 전용 +- 붙여넣기 파서가 발화자 이름으로 조회했는데 매핑이 없으면 `MISSING_VOICE` warning(blocking) 을 내고, 관리자가 미리보기에서 보이스를 고르거나 이 API 로 매핑을 추가한 뒤 다시 파싱해야 한다 +- 배틀별로 다른 보이스를 쓰고 싶으면 이 테이블을 바꾸지 않고 시나리오의 `voiceSettings` 를 직접 오버라이드하면 된다(이 테이블은 파서가 채우는 기본값 소스일 뿐) diff --git a/docs/api-specs/scenario-api.md b/docs/api-specs/scenario-api.md index 5d211914..da23adfb 100644 --- a/docs/api-specs/scenario-api.md +++ b/docs/api-specs/scenario-api.md @@ -30,11 +30,12 @@ - `scripts[]` - `speakerName` - `speakerType` - - `text` + - `text` (오디오 효과 태그 `[sighing]` `[break]` 등은 인라인으로 그대로 둔다) + - `tone` (`Tone` enum, 생략 시 `NEUTRAL`: `NEUTRAL`/`ANGRY`/`SAD`/`EMBARRASSED`/`EMPHASIS`/`WHISPERING`/`SOFT_TONE`/`BREATHY`/`EXCITED`. 대사 한 줄의 감성적인 톤 — TTS 합성 시 `[tone]` 형태로 text 앞에 주입된다) - `interactiveOptions[]` - `label` - `nextNodeName` - - `voiceSettings` (`Map`) + - `voiceSettings` (`Map`, 값은 Fish Audio `reference_id`. 철학자 발화자(A/B)는 [철학자 보이스 매핑](./philosopher-voice-api.md)에서 조회해 채운다) ### 2.3 시나리오 본문 수정 - `PUT /api/v1/admin/scenarios/{scenarioId}` @@ -58,3 +59,5 @@ - 임시저장(`DRAFT`) 상태에서는 대본/설정은 DB 저장, 발행(`PUBLISHED`) 시점에 TTS 파이프라인 수행 - 발행 후 수정 시에는 변경된 스크립트 조각만 재생성하고 병합 오디오를 갱신 +- `text` 또는 `tone` 이 바뀐 스크립트만 오디오를 재생성한다 (그 외 조각은 S3 캐시 재사용) +- TTS 는 Fish Audio S2.1 Pro 사용 (`fishaudio.tts.model`, 기본값 `s2.1-pro`). 톤 태그는 `[...]` 대괄호로 `text` 앞에 붙어 전송된다 diff --git a/src/main/java/com/swyp/picke/domain/admin/controller/AdminBattleController.java b/src/main/java/com/swyp/picke/domain/admin/controller/AdminBattleController.java index 327f4815..e24fc754 100644 --- a/src/main/java/com/swyp/picke/domain/admin/controller/AdminBattleController.java +++ b/src/main/java/com/swyp/picke/domain/admin/controller/AdminBattleController.java @@ -1,6 +1,8 @@ package com.swyp.picke.domain.admin.controller; import com.swyp.picke.domain.admin.dto.battle.request.AdminBattleCreateRequest; +import com.swyp.picke.domain.battle.dto.parse.AdminBattleParseRequest; +import com.swyp.picke.domain.battle.dto.parse.AdminBattleParseResponse; import com.swyp.picke.domain.admin.dto.battle.response.AdminBattleDeleteResponse; import com.swyp.picke.domain.admin.dto.battle.response.AdminBattleDetailResponse; import com.swyp.picke.domain.admin.dto.battle.request.AdminBattleUpdateRequest; @@ -31,6 +33,14 @@ public class AdminBattleController { private final AdminBattleService adminBattleService; + @Operation(summary = "배틀 대본 붙여넣기 파싱 (미리보기용, 저장 안 함)") + @PostMapping("/parse") + public ApiResponse parseBattleScript( + @RequestBody @Valid AdminBattleParseRequest request + ) { + return ApiResponse.onSuccess(adminBattleService.parseBattleScript(request.rawText())); + } + @Operation(summary = "배틀 생성") @PostMapping public ApiResponse createBattle( diff --git a/src/main/java/com/swyp/picke/domain/admin/service/AdminBattleService.java b/src/main/java/com/swyp/picke/domain/admin/service/AdminBattleService.java index a35cbc20..0e082f3b 100644 --- a/src/main/java/com/swyp/picke/domain/admin/service/AdminBattleService.java +++ b/src/main/java/com/swyp/picke/domain/admin/service/AdminBattleService.java @@ -4,6 +4,8 @@ import com.swyp.picke.domain.admin.dto.battle.request.AdminBattleUpdateRequest; import com.swyp.picke.domain.admin.dto.battle.response.AdminBattleDeleteResponse; import com.swyp.picke.domain.admin.dto.battle.response.AdminBattleDetailResponse; +import com.swyp.picke.domain.battle.dto.parse.AdminBattleParseResponse; +import com.swyp.picke.domain.battle.service.BattleScriptParseService; import com.swyp.picke.domain.battle.service.BattleService; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -13,6 +15,11 @@ public class AdminBattleService { private final BattleService battleService; + private final BattleScriptParseService battleScriptParseService; + + public AdminBattleParseResponse parseBattleScript(String rawText) { + return battleScriptParseService.parse(rawText); + } public AdminBattleDetailResponse createBattle(AdminBattleCreateRequest request, Long adminUserId) { return battleService.createBattle(request, adminUserId); diff --git a/src/main/java/com/swyp/picke/domain/battle/dto/parse/AdminBattleParseRequest.java b/src/main/java/com/swyp/picke/domain/battle/dto/parse/AdminBattleParseRequest.java new file mode 100644 index 00000000..b8d3e4a2 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/battle/dto/parse/AdminBattleParseRequest.java @@ -0,0 +1,7 @@ +package com.swyp.picke.domain.battle.dto.parse; + +import jakarta.validation.constraints.NotBlank; + +public record AdminBattleParseRequest( + @NotBlank String rawText +) {} diff --git a/src/main/java/com/swyp/picke/domain/battle/dto/parse/AdminBattleParseResponse.java b/src/main/java/com/swyp/picke/domain/battle/dto/parse/AdminBattleParseResponse.java new file mode 100644 index 00000000..0aa5b962 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/battle/dto/parse/AdminBattleParseResponse.java @@ -0,0 +1,21 @@ +package com.swyp.picke.domain.battle.dto.parse; + +import com.swyp.picke.domain.admin.dto.battle.request.AdminBattleCreateRequest; +import com.swyp.picke.domain.admin.dto.scenario.request.AdminScenarioCreateRequest; +import java.util.List; +import java.util.Map; + +/** + * 붙여넣기 파싱 결과. DB 에 저장하지 않고 미리보기로만 돌려준다. + * + *

{@code scenarioPayload.battleId} 는 null 이다 — 배틀이 아직 없어서다. + * 어드민이 {@code battlePayload} 로 배틀을 먼저 만든 뒤, 받은 id 를 채워 시나리오를 등록한다. + * + *

{@code speakerNames} 는 화자(A/B)별 발화자 철학자 이름. voiceSettings 와 함께 미리보기에 표시한다. + */ +public record AdminBattleParseResponse( + AdminBattleCreateRequest battlePayload, + AdminScenarioCreateRequest scenarioPayload, + Map speakerNames, + List warnings +) {} diff --git a/src/main/java/com/swyp/picke/domain/battle/dto/parse/ParseWarning.java b/src/main/java/com/swyp/picke/domain/battle/dto/parse/ParseWarning.java new file mode 100644 index 00000000..a5ff6088 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/battle/dto/parse/ParseWarning.java @@ -0,0 +1,20 @@ +package com.swyp.picke.domain.battle.dto.parse; + +/** + * 붙여넣기 파싱 중 사람이 미리보기에서 확인/보정해야 하는 지점. + * blocking=true 면 그 상태로는 발행할 수 없다(보이스 미지정 등). + */ +public record ParseWarning( + String code, + String message, + String context, + boolean blocking +) { + public static ParseWarning of(String code, String message, String context) { + return new ParseWarning(code, message, context, false); + } + + public static ParseWarning blocking(String code, String message, String context) { + return new ParseWarning(code, message, context, true); + } +} 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 new file mode 100644 index 00000000..d1282fd1 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/battle/parser/BattleScriptDocument.java @@ -0,0 +1,61 @@ +package com.swyp.picke.domain.battle.parser; + +import com.swyp.picke.domain.battle.dto.parse.ParseWarning; +import java.util.ArrayList; +import java.util.List; + +/** + * 붙여넣은 대본을 정규식으로 1차 분해한 중간 표현. 태그 ID·보이스·감정 분류는 아직 안 붙었다. + */ +public class BattleScriptDocument { + + public boolean interactive; + public String title; + public String category; + public OptionMeta optionA = new OptionMeta(); + public OptionMeta optionB = new OptionMeta(); + public final List nodes = new ArrayList<>(); + public final List warnings = new ArrayList<>(); + + public static class OptionMeta { + public String choiceName; + /** 사전 투표 줄에 적힌 대표 발화자(예: "플라톤"). A/B 바인딩 힌트로 쓴다. */ + public String primarySpeaker; + public final List philosopherKeywords = new ArrayList<>(); + public final List valueTags = new ArrayList<>(); + } + + public static class ParsedNode { + public String name; + public final List scripts = new ArrayList<>(); + /** 선택의 시간 노드에서만: label(A/B) -> 다음 노드 이름 */ + public final List options = new ArrayList<>(); + + public ParsedNode(String name) { + this.name = name; + } + } + + public static class ParsedScript { + /** 철학자 이름. null 이면 나레이터. */ + public String speaker; + public String text; + + public ParsedScript(String speaker, String text) { + this.speaker = speaker; + this.text = text; + } + } + + public static class ParsedOption { + public String label; // "A" / "B" + public String nextNodeName; // "분기_A" 등 + public String speaker; // 선택의 시간 줄에 표기된 발화자 (예: "칸트") + + public ParsedOption(String label, String nextNodeName, String speaker) { + this.label = label; + 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 new file mode 100644 index 00000000..8b745ea8 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/battle/parser/BattleScriptDocumentParser.java @@ -0,0 +1,366 @@ +package com.swyp.picke.domain.battle.parser; + +import com.swyp.picke.domain.battle.dto.parse.ParseWarning; +import com.swyp.picke.domain.battle.parser.BattleScriptDocument.OptionMeta; +import com.swyp.picke.domain.battle.parser.BattleScriptDocument.ParsedNode; +import com.swyp.picke.domain.battle.parser.BattleScriptDocument.ParsedOption; +import com.swyp.picke.domain.battle.parser.BattleScriptDocument.ParsedScript; +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import org.springframework.stereotype.Component; + +/** + * 붙여넣은 대본 텍스트를 정규식으로 {@link BattleScriptDocument} 로 분해한다. + * 외부 의존성 없음(태그·보이스·LLM 은 상위 서비스가 붙인다). 포맷 규격: 배틀_발행_시나리오_현행_vs_개선.md 2.6 + */ +@Component +public class BattleScriptDocumentParser { + + private static final Pattern HEADER = + Pattern.compile("^\\s*(?:분기\\s*[OX]\\s*버전\\s*)?\\S+\\s*[—–-]\\s*(.+)$"); + private static final Pattern INTERACTIVE_HINT = Pattern.compile("분기\\s*([OX])"); + private static final Pattern PREVOTE = Pattern.compile( + "[\"'“]?\\s*([^\"'”()]+?)\\s*\\(([^)]+)\\)\\s*[\"'”]?\\s*(?:vs|VS|대)\\s*" + + "[\"'“]?\\s*([^\"'”()]+?)\\s*\\(([^)]+)\\)"); + private static final Pattern SECTION_OPENING = Pattern.compile("^\\[?\\s*오프닝\\s*\\]?\\s*[::]?\\s*(.*)$"); + private static final Pattern SECTION_ROUND = Pattern.compile("^\\[?\\s*(\\d+)\\s*라운드\\s*\\]?\\s*[::]?\\s*(.*)$"); + private static final Pattern SECTION_CLOSING = Pattern.compile("^\\[?\\s*클로징\\s*\\]?\\s*[::]?\\s*(.*)$"); + private static final Pattern SECTION_CHOICE = Pattern.compile(".*선택의\\s*시간.*"); + private static final Pattern OPTION_LINE = Pattern.compile("^\\s*([ABab])\\s*[::]\\s*(.+)$"); + private static final Pattern SECTION_BRANCH = Pattern.compile("^\\[\\s*분기\\s*([AB])\\b[^\\]]*\\]\\s*$"); + private static final Pattern INLINE_BRANCH = Pattern.compile("^\\[\\s*([AB])\\s*선택\\s*시\\s*\\]\\s*(.*)$"); + private static final Pattern BRANCH_CONTAINER = Pattern.compile("^\\[\\s*분기\\s*시나리오[^\\]]*\\]\\s*$"); + private static final Pattern SPEAKER_LINE = Pattern.compile("^([^::]{1,25})[::]\\s*(.+)$"); + private static final Pattern SPEAKER_ANNOTATION = Pattern.compile("\\s*\\([^)]*\\)\\s*$"); + private static final Pattern TRAILING_SPEAKER = Pattern.compile("\\(([^)]+)\\)\\s*$"); + private static final Pattern VALUE_TAG = Pattern.compile("\\[([^\\]]+)\\]"); + private static final Pattern BRANCH_MARKER = Pattern.compile("^\\(?\\s*분기\\s*버전\\s*\\)?$"); + private static final Pattern METADATA_MARKER = Pattern.compile("^\\s*메타데이터\\s*$"); + private static final Pattern PREVOTE_MARKER = Pattern.compile("^\\s*사전\\s*투표\\s*$"); + + public BattleScriptDocument parse(String rawText) { + BattleScriptDocument doc = new BattleScriptDocument(); + List lines = normalizeLines(rawText); + + int headerIdx = indexOfHeader(lines, doc); + int metadataIdx = firstMatch(lines, METADATA_MARKER, headerIdx + 1); + int branchIdx = firstMatch(lines, BRANCH_MARKER, headerIdx + 1); + + parsePreVote(lines, headerIdx, metadataIdx, doc); + + List bodyLines; + if (branchIdx >= 0) { + bodyLines = lines.subList(branchIdx + 1, lines.size()); + } else { + int bodyEnd = metadataIdx >= 0 ? metadataIdx : lines.size(); + bodyLines = lines.subList(headerIdx + 1, bodyEnd); + } + parseBody(bodyLines, doc); + + int metaEnd = branchIdx >= 0 && branchIdx > metadataIdx ? branchIdx : lines.size(); + if (metadataIdx >= 0) { + parseMetadata(lines.subList(metadataIdx + 1, metaEnd), doc); + } else { + doc.warnings.add(ParseWarning.of("MISSING_METADATA", + "메타데이터 섹션을 찾지 못했습니다. 카테고리/선택지/태그를 미리보기에서 직접 입력하세요.", null)); + } + + // 본문에 선택/분기가 있으면 인터랙티브로 승격 + boolean bodyIsInteractive = doc.nodes.stream() + .anyMatch(n -> n.name.equals("선택") || n.name.startsWith("분기_")); + doc.interactive = doc.interactive || bodyIsInteractive; + return doc; + } + + /** 각 줄을 trim 하고, 빈 줄(문단 구분)은 하나의 "" 로 남긴다. */ + private List normalizeLines(String rawText) { + List out = new ArrayList<>(); + if (rawText == null) { + return out; + } + for (String line : rawText.replace("\r\n", "\n").replace("\r", "\n").split("\n")) { + String trimmed = line.strip(); + if (trimmed.isEmpty()) { + if (!out.isEmpty() && !out.get(out.size() - 1).isEmpty()) { + out.add(""); + } + } else { + out.add(trimmed); + } + } + return out; + } + + private int indexOfHeader(List lines, BattleScriptDocument doc) { + for (int i = 0; i < lines.size(); i++) { + Matcher m = HEADER.matcher(lines.get(i)); + if (m.matches()) { + Matcher hint = INTERACTIVE_HINT.matcher(lines.get(i)); + doc.interactive = hint.find() && "O".equalsIgnoreCase(hint.group(1)); + doc.title = m.group(1).strip(); + return i; + } + } + doc.interactive = false; + doc.title = "(제목 미상)"; + doc.warnings.add(ParseWarning.of("MISSING_HEADER", + "'제목' 형식 헤더(맨 윗줄, '— 제목')를 찾지 못했습니다. 제목을 확인하세요.", null)); + return -1; + } + + private void parsePreVote(List lines, int headerIdx, int metadataIdx, BattleScriptDocument doc) { + int end = metadataIdx >= 0 ? metadataIdx : lines.size(); + for (int i = Math.max(headerIdx, 0); i < end; i++) { + if (PREVOTE_MARKER.matcher(lines.get(i)).matches() || lines.get(i).contains("사전 투표")) { + for (int j = i; j < Math.min(i + 3, end); j++) { + Matcher m = PREVOTE.matcher(lines.get(j)); + if (m.find()) { + doc.optionA.choiceName = m.group(1).strip(); + doc.optionA.primarySpeaker = m.group(2).strip(); + doc.optionB.choiceName = m.group(3).strip(); + doc.optionB.primarySpeaker = m.group(4).strip(); + return; + } + } + } + } + } + + private int firstMatch(List lines, Pattern pattern, int from) { + for (int i = Math.max(from, 0); i < lines.size(); i++) { + if (pattern.matcher(lines.get(i)).matches()) { + return i; + } + } + return -1; + } + + private void parseBody(List body, BattleScriptDocument doc) { + ParsedNode current = new ParsedNode("오프닝"); + boolean paragraphBreak = false; + + for (String line : body) { + if (line.isEmpty()) { + paragraphBreak = true; + continue; + } + if (PREVOTE_MARKER.matcher(line).matches() || PREVOTE.matcher(line).find() + || BRANCH_CONTAINER.matcher(line).matches()) { + continue; // 사전 투표 / 분기 컨테이너 헤더는 본문에서 무시 + } + + Matcher opening = SECTION_OPENING.matcher(line); + Matcher round = SECTION_ROUND.matcher(line); + Matcher closing = SECTION_CLOSING.matcher(line); + Matcher branch = SECTION_BRANCH.matcher(line); + Matcher inlineBranch = INLINE_BRANCH.matcher(line); + + if (opening.matches()) { + current = pushAndStart(doc, current, "오프닝"); + addDialogueLine(current, opening.group(1).strip(), true); // "[오프닝] 텍스트" 형태 대응 + continue; + } + if (SECTION_CHOICE.matcher(line).matches() && !OPTION_LINE.matcher(line).matches()) { + current = pushAndStart(doc, current, "선택"); + continue; + } + if (round.matches()) { + current = pushAndStart(doc, current, round.group(1) + "라운드"); + // 라운드 뒤 텍스트는 부제이므로 버린다 + continue; + } + if (branch.matches()) { + current = pushAndStart(doc, current, "분기_" + branch.group(1).toUpperCase()); + continue; + } + if (inlineBranch.matches()) { + current = pushAndStart(doc, current, "분기_" + inlineBranch.group(1).toUpperCase()); + addDialogueLine(current, inlineBranch.group(2).strip(), true); + continue; + } + if (closing.matches()) { + current = pushAndStart(doc, current, "클로징"); + addDialogueLine(current, closing.group(1).strip(), true); // "[클로징] 텍스트" 형태 대응 + continue; + } + + if ("선택".equals(current.name)) { + parseChoiceLine(line, current); + paragraphBreak = false; + continue; + } + addDialogueLine(current, line, paragraphBreak); + paragraphBreak = false; + } + pushIfNotEmpty(doc, current); + splitFlatBody(doc); + if (doc.nodes.isEmpty()) { + doc.warnings.add(ParseWarning.of("EMPTY_BODY", "본문에서 대사를 찾지 못했습니다.", null)); + } + } + + private void addDialogueLine(ParsedNode node, String line, boolean paragraphBreak) { + if (line.isEmpty()) { + return; + } + Matcher speaker = SPEAKER_LINE.matcher(line); + if (speaker.matches() && !isSentenceColon(speaker.group(1))) { + String name = SPEAKER_ANNOTATION.matcher(speaker.group(1).strip()).replaceAll("").strip(); + node.scripts.add(new ParsedScript(name, stripQuotes(speaker.group(2).strip()))); + return; + } + // 화자 표기 없는 줄: 문단 구분(빈 줄)이 없었고 직전이 특정 화자의 대사면 그 발언의 이어짐으로 합친다. + if (!paragraphBreak && !node.scripts.isEmpty() + && node.scripts.get(node.scripts.size() - 1).speaker != null) { + ParsedScript last = node.scripts.get(node.scripts.size() - 1); + last.text = (last.text + "\n" + stripQuotes(line)).strip(); + } else { + node.scripts.add(new ParsedScript(null, stripQuotes(line))); + } + } + + private String stripQuotes(String text) { + if (text.length() >= 2) { + char first = text.charAt(0); + char last = text.charAt(text.length() - 1); + if ((first == '"' && last == '"') || (first == '“' && last == '”') || (first == '\'' && last == '\'')) { + return text.substring(1, text.length() - 1).strip(); + } + } + return text; + } + + /** 라운드/분기 헤더 없이 오프닝 하나에 대사가 다 들어간 플랫 본문: 첫 나레이션=오프닝, 마지막 나레이션=클로징, 나머지=라운드. */ + private void splitFlatBody(BattleScriptDocument doc) { + if (doc.nodes.size() != 1 || !doc.nodes.get(0).name.equals("오프닝")) { + return; + } + List all = doc.nodes.get(0).scripts; + boolean hasSpeaker = all.stream().anyMatch(s -> s.speaker != null); + if (!hasSpeaker) { + return; + } + doc.nodes.clear(); + + ParsedNode opening = new ParsedNode("오프닝"); + ParsedNode round = new ParsedNode("라운드"); + ParsedNode closing = new ParsedNode("클로징"); + + int lastSpeaker = -1; + for (int i = 0; i < all.size(); i++) { + if (all.get(i).speaker != null) { + lastSpeaker = i; + } + } + for (int i = 0; i < all.size(); i++) { + ParsedScript s = all.get(i); + if (round.scripts.isEmpty() && s.speaker == null) { + opening.scripts.add(s); + } else if (lastSpeaker >= 0 && i > lastSpeaker && s.speaker == null) { + closing.scripts.add(s); + } else { + round.scripts.add(s); + } + } + pushIfNotEmpty(doc, opening); + pushIfNotEmpty(doc, round); + pushIfNotEmpty(doc, closing); + } + + private void parseChoiceLine(String line, ParsedNode choiceNode) { + Matcher m = OPTION_LINE.matcher(line); + if (!m.matches()) { + choiceNode.scripts.add(new ParsedScript(null, line)); + return; + } + String label = m.group(1).strip().toUpperCase(); + String rest = m.group(2).strip(); + String speaker = null; + Matcher t = TRAILING_SPEAKER.matcher(rest); + if (t.find()) { + speaker = t.group(1).strip(); + } + choiceNode.options.add(new ParsedOption(label, "분기_" + label, speaker)); + } + + private void parseMetadata(List meta, BattleScriptDocument doc) { + OptionMeta target = null; + for (int i = 0; i < meta.size(); i++) { + String line = meta.get(i); + String next = i + 1 < meta.size() ? meta.get(i + 1) : null; + + if (line.contains("A안")) { + target = doc.optionA; + } else if (line.contains("B안")) { + target = doc.optionB; + } else if (line.startsWith("카테고리")) { + doc.category = valueAfter(line, "카테고리", next); + } else if (line.startsWith("선택지 명칭") && target != null) { + target.choiceName = valueAfter(line, "선택지 명칭", next); + } else if (line.startsWith("철학자 키워드") && target != null) { + for (String kw : valueAfter(line, "철학자 키워드", next).split("[,、\\s]+")) { + String cleaned = kw.replace("#", "").strip(); + if (!cleaned.isEmpty()) { + target.philosopherKeywords.add(cleaned); + } + } + } else if (line.startsWith("성향 지표") && target != null) { + Matcher vm = VALUE_TAG.matcher(valueAfter(line, "성향 지표", next)); + while (vm.find()) { + target.valueTags.add(vm.group(1).strip()); + } + } + } + if (doc.category == null) { + doc.warnings.add(ParseWarning.of("MISSING_CATEGORY", "메타데이터에서 카테고리를 찾지 못했습니다.", null)); + } + } + + /** "카테고리: 철학" 처럼 같은 줄에 값이 있으면 그 값을, 없으면 다음 줄을 값으로 본다. */ + private String valueAfter(String line, String key, String nextLine) { + String inline = line.substring(line.indexOf(key) + key.length()) + .replaceFirst("^\\s*[::]?\\s*", "") + .replaceAll("\\(.*?\\)", "") + .strip(); + if (!inline.isEmpty()) { + return inline; + } + return nextLine == null ? "" : nextLine.strip(); + } + + private boolean isSentenceColon(String prefix) { + String p = prefix.strip(); + if (p.isBlank() || p.length() > 20) { + return true; + } + if (p.startsWith("[") || p.startsWith("👉") || p.contains("라운드")) { + return true; + } + String withoutAnnotation = SPEAKER_ANNOTATION.matcher(p).replaceAll("").strip(); + return withoutAnnotation.split("\\s+").length > 3; + } + + private ParsedNode pushAndStart(BattleScriptDocument doc, ParsedNode current, String newName) { + pushIfNotEmpty(doc, current); + return new ParsedNode(newName); + } + + private void pushIfNotEmpty(BattleScriptDocument doc, ParsedNode node) { + if (!node.scripts.isEmpty() || !node.options.isEmpty()) { + mergeOrAdd(doc, node); + } + } + + private void mergeOrAdd(BattleScriptDocument doc, ParsedNode node) { + for (ParsedNode existing : doc.nodes) { + if (existing.name.equals(node.name)) { + existing.scripts.addAll(node.scripts); + existing.options.addAll(node.options); + return; + } + } + doc.nodes.add(node); + } +} diff --git a/src/main/java/com/swyp/picke/domain/battle/parser/EmotionClassifier.java b/src/main/java/com/swyp/picke/domain/battle/parser/EmotionClassifier.java new file mode 100644 index 00000000..94aff5cf --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/battle/parser/EmotionClassifier.java @@ -0,0 +1,22 @@ +package com.swyp.picke.domain.battle.parser; + +import com.swyp.picke.domain.scenario.enums.Tone; +import java.util.List; +import java.util.Map; + +/** + * 대본 대사에 LLM 으로 감정 톤과 효과 태그를 붙인다. 실패해도 파싱을 막지 않는다(빈 결과 반환). + */ +public interface EmotionClassifier { + + record ScriptLine(int index, String speaker, String text) {} + + record ScriptEmotion(Tone tone, String textWithEffects) {} + + /** index -> 분류 결과. 호출 실패 시 빈 맵. */ + Map classify(String battleTitle, List lines); + + /** 발화자 이름 -> "A" | "B". 선택의 시간에 명시가 없을 때 논지↔선택지명칭 매칭. 실패 시 빈 맵. */ + Map bindSpeakers(String battleTitle, String optionATitle, String optionBTitle, + List speakers); +} diff --git a/src/main/java/com/swyp/picke/domain/battle/parser/OpenAiEmotionClassifier.java b/src/main/java/com/swyp/picke/domain/battle/parser/OpenAiEmotionClassifier.java new file mode 100644 index 00000000..1d054228 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/battle/parser/OpenAiEmotionClassifier.java @@ -0,0 +1,144 @@ +package com.swyp.picke.domain.battle.parser; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.swyp.picke.domain.scenario.enums.Tone; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.client.SimpleClientHttpRequestFactory; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestClient; + +/** + * OpenAI Chat Completions 로 감정 톤/효과 태그를 한 번에 배치 분류한다. + * {@code GptPerspectiveGenerationService} 와 같은 방식(RestClient + openai.* 설정)을 쓴다. + */ +@Slf4j +@Component +public class OpenAiEmotionClassifier implements EmotionClassifier { + + private static final int CONNECT_TIMEOUT_MS = 5000; + private static final int READ_TIMEOUT_MS = 30000; + + private static final String TONE_VALUES = + "NEUTRAL, ANGRY, SAD, EMBARRASSED, EMPHASIS, WHISPERING, SOFT_TONE, BREATHY, EXCITED"; + + private static final String CLASSIFY_SYSTEM = """ + 너는 철학 토론 대본의 대사에 감정 연출을 붙이는 도구다. + 각 줄에 대해: + - tone: 그 줄의 대표 감성 톤 하나. 반드시 다음 중 하나: %s. 담담하면 NEUTRAL. + - textWithEffects: 원문을 유지하되, 필요하면 오디오 효과 태그를 문장 안에 삽입한다. + 효과 태그 예: [sighing] [laughing] [chuckling] [clear throat] [break] [long-break]. + 이미 있는 대괄호 태그는 지우지 마라. 톤 태그([angry] 등)는 textWithEffects 에 넣지 마라. + 반드시 JSON 만 출력: {"results":[{"index":0,"tone":"...","textWithEffects":"..."}]} + """.formatted(TONE_VALUES); + + private static final String BIND_SYSTEM = """ + 철학 토론에서 각 발화자가 A안과 B안 중 어느 쪽을 지지하는지 논지로 판단한다. + 반드시 JSON 만 출력: {"bindings":{"발화자이름":"A" 또는 "B"}} + """; + + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Value("${openai.api-key}") + private String apiKey; + + @Value("${openai.url}") + private String openaiUrl; + + @Value("${openai.emotion.model:gpt-4o-mini}") + private String model; + + @Override + public Map classify(String battleTitle, List lines) { + if (lines == null || lines.isEmpty()) { + return Map.of(); + } + try { + List> payload = new ArrayList<>(); + for (ScriptLine line : lines) { + payload.add(Map.of( + "index", line.index(), + "speaker", line.speaker() == null ? "나레이터" : line.speaker(), + "text", line.text())); + } + String userPrompt = "배틀 제목: " + battleTitle + "\n대사 목록(JSON):\n" + + objectMapper.writeValueAsString(payload); + + JsonNode root = call(CLASSIFY_SYSTEM, userPrompt); + Map out = new HashMap<>(); + for (JsonNode item : root.path("results")) { + int index = item.path("index").asInt(-1); + if (index < 0) { + continue; + } + Tone tone = Tone.fromNameOrNeutral(item.path("tone").asText(null)); + String text = item.path("textWithEffects").asText(null); + out.put(index, new ScriptEmotion(tone, text)); + } + return out; + } catch (Exception e) { + log.warn("[EmotionClassifier] 감정 분류 실패 - 전부 NEUTRAL 로 진행", e); + return Map.of(); + } + } + + @Override + public Map bindSpeakers(String battleTitle, String optionATitle, String optionBTitle, + List speakers) { + if (speakers == null || speakers.isEmpty()) { + return Map.of(); + } + try { + String userPrompt = "배틀 제목: " + battleTitle + + "\nA안: " + optionATitle + + "\nB안: " + optionBTitle + + "\n발화자: " + String.join(", ", speakers); + + JsonNode root = call(BIND_SYSTEM, userPrompt); + Map out = new HashMap<>(); + JsonNode bindings = root.path("bindings"); + bindings.fieldNames().forEachRemaining(name -> { + String label = bindings.path(name).asText("").trim().toUpperCase(); + if (label.equals("A") || label.equals("B")) { + out.put(name, label); + } + }); + return out; + } catch (Exception e) { + log.warn("[EmotionClassifier] 발화자 바인딩 실패", e); + return Map.of(); + } + } + + private JsonNode call(String systemPrompt, String userPrompt) throws Exception { + SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory(); + factory.setConnectTimeout(CONNECT_TIMEOUT_MS); + factory.setReadTimeout(READ_TIMEOUT_MS); + RestClient restClient = RestClient.builder().requestFactory(factory).build(); + + Map requestBody = Map.of( + "model", model, + "response_format", Map.of("type", "json_object"), + "messages", List.of( + Map.of("role", "system", "content", systemPrompt), + Map.of("role", "user", "content", userPrompt))); + + Map response = restClient.post() + .uri(openaiUrl) + .header("Authorization", "Bearer " + apiKey) + .header("Content-Type", "application/json") + .body(requestBody) + .retrieve() + .body(Map.class); + + List choices = (List) response.get("choices"); + Map choice = (Map) choices.get(0); + Map message = (Map) choice.get("message"); + return objectMapper.readTree((String) message.get("content")); + } +} diff --git a/src/main/java/com/swyp/picke/domain/battle/parser/ScriptToneExtractor.java b/src/main/java/com/swyp/picke/domain/battle/parser/ScriptToneExtractor.java new file mode 100644 index 00000000..f1ee0792 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/battle/parser/ScriptToneExtractor.java @@ -0,0 +1,62 @@ +package com.swyp.picke.domain.battle.parser; + +import com.swyp.picke.domain.battle.dto.parse.ParseWarning; +import com.swyp.picke.domain.scenario.enums.Tone; +import com.swyp.picke.global.infra.tts.service.TtsTextNormalizer; +import java.util.ArrayList; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import org.springframework.stereotype.Component; + +/** + * 감정 큐 하이브리드 규칙(배틀_발행_시나리오_현행_vs_개선.md item 9)의 파서 쪽 처리. + * + *

    + *
  • 줄에 톤 태그({@code [angry]} 등)가 있으면 첫 번째를 {@link Tone} 으로 뽑고 text 에서 톤 태그를 모두 제거한다.
  • + *
  • 여러 톤 태그가 있으면 첫 번째만 쓰고 warning.
  • + *
  • 효과 태그({@code [sighing]} 등)는 그대로 두되 표기를 정규화한다.
  • + *
+ */ +@Component +public class ScriptToneExtractor { + + private static final Pattern BRACKET = Pattern.compile("\\[[^\\]]+\\]"); + + public record Result(Tone plannerTone, String cleanedText, List warnings) {} + + public Result extract(String rawText) { + List warnings = new ArrayList<>(); + String text = rawText == null ? "" : rawText; + + List toneTags = new ArrayList<>(); + Matcher m = BRACKET.matcher(text); + while (m.find()) { + Tone.fromInlineTag(m.group()).ifPresent(toneTags::add); + } + + Tone plannerTone = Tone.NEUTRAL; + if (!toneTags.isEmpty()) { + plannerTone = toneTags.get(0); + if (toneTags.size() > 1) { + warnings.add(ParseWarning.of("MULTIPLE_TONE_TAGS", + "톤 태그가 여러 개라 첫 번째(" + plannerTone + ")만 사용합니다.", rawText)); + } + // 톤 태그는 모두 제거(효과 태그는 남긴다) + StringBuilder sb = new StringBuilder(); + Matcher rm = BRACKET.matcher(text); + int last = 0; + while (rm.find()) { + if (Tone.fromInlineTag(rm.group()).isPresent()) { + sb.append(text, last, rm.start()); + last = rm.end(); + } + } + sb.append(text.substring(last)); + text = sb.toString(); + } + + String cleaned = TtsTextNormalizer.normalizeEffectTags(text).replaceAll("\\s{2,}", " ").trim(); + return new Result(plannerTone, cleaned, warnings); + } +} 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 new file mode 100644 index 00000000..5042a308 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/battle/service/BattleScriptParseService.java @@ -0,0 +1,360 @@ +package com.swyp.picke.domain.battle.service; + +import com.swyp.picke.domain.admin.dto.battle.request.AdminBattleCreateRequest; +import com.swyp.picke.domain.admin.dto.battle.request.AdminBattleOptionRequest; +import com.swyp.picke.domain.admin.dto.scenario.request.AdminScenarioCreateRequest; +import com.swyp.picke.domain.admin.dto.scenario.request.AdminScenarioNodeRequest; +import com.swyp.picke.domain.admin.dto.scenario.request.AdminScenarioOptionRequest; +import com.swyp.picke.domain.admin.dto.scenario.request.AdminScenarioScriptRequest; +import com.swyp.picke.domain.battle.dto.parse.AdminBattleParseResponse; +import com.swyp.picke.domain.battle.dto.parse.ParseWarning; +import com.swyp.picke.domain.battle.enums.BattleStatus; +import com.swyp.picke.domain.battle.parser.BattleScriptDocument; +import com.swyp.picke.domain.battle.parser.BattleScriptDocument.OptionMeta; +import com.swyp.picke.domain.battle.parser.BattleScriptDocument.ParsedNode; +import com.swyp.picke.domain.battle.parser.BattleScriptDocument.ParsedOption; +import com.swyp.picke.domain.battle.parser.BattleScriptDocument.ParsedScript; +import com.swyp.picke.domain.battle.parser.BattleScriptDocumentParser; +import com.swyp.picke.domain.battle.parser.EmotionClassifier; +import com.swyp.picke.domain.battle.parser.EmotionClassifier.ScriptEmotion; +import com.swyp.picke.domain.battle.parser.EmotionClassifier.ScriptLine; +import com.swyp.picke.domain.battle.parser.ScriptToneExtractor; +import com.swyp.picke.domain.scenario.enums.SpeakerType; +import com.swyp.picke.domain.scenario.enums.Tone; +import com.swyp.picke.domain.scenario.service.PhilosopherVoiceService; +import com.swyp.picke.domain.tag.entity.Tag; +import com.swyp.picke.domain.tag.enums.TagType; +import com.swyp.picke.domain.tag.repository.TagRepository; +import com.swyp.picke.domain.user.enums.PhilosopherType; +import com.swyp.picke.domain.user.enums.ValueAxis; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +/** + * 붙여넣은 대본을 파싱해 배틀/시나리오 등록 페이로드로 조립한다. DB 저장은 하지 않는다(미리보기용). + * 규격/규칙: 배틀_발행_시나리오_현행_vs_개선.md 2.4 / 2.5 / 2.6 / item 9. + */ +@Service +@RequiredArgsConstructor +public class BattleScriptParseService { + + private final BattleScriptDocumentParser documentParser; + private final ScriptToneExtractor toneExtractor; + private final EmotionClassifier emotionClassifier; + private final PhilosopherVoiceService philosopherVoiceService; + private final TagRepository tagRepository; + + @Value("${fishaudio.voice-id.narrator:}") + private String narratorVoice; + + @Value("${fishaudio.voice-id.user:}") + private String userVoice; + + public AdminBattleParseResponse parse(String rawText) { + BattleScriptDocument doc = documentParser.parse(rawText); + List warnings = new ArrayList<>(doc.warnings); + + Map categoryTags = tagNameToId(TagType.CATEGORY); + Map philosopherTags = tagNameToId(TagType.PHILOSOPHER); + Map valueTags = tagNameToId(TagType.VALUE); + + List battleTagIds = new ArrayList<>(); + if (doc.category != null) { + Long categoryId = categoryTags.get(doc.category); + if (categoryId != null) { + battleTagIds.add(categoryId); + } else { + warnings.add(ParseWarning.of("UNKNOWN_CATEGORY_TAG", + "'" + doc.category + "' 카테고리 태그가 없습니다.", doc.category)); + } + } + + List optionATagIds = resolveOptionTags(doc.optionA, "A", philosopherTags, valueTags, warnings); + List optionBTagIds = resolveOptionTags(doc.optionB, "B", philosopherTags, valueTags, warnings); + + SpeakerBinding binding = bindSpeakers(doc, warnings); + + String openingText = firstNarration(doc, "오프닝"); + + AdminBattleCreateRequest battlePayload = new AdminBattleCreateRequest( + doc.title, + openingText, + null, + null, + null, + null, + null, + BattleStatus.PENDING, + battleTagIds, + List.of( + new AdminBattleOptionRequest(nz(doc.optionA.choiceName), null, + binding.speakerA, null, 0, optionATagIds), + new AdminBattleOptionRequest(nz(doc.optionB.choiceName), null, + binding.speakerB, null, 1, optionBTagIds)), + 0); + + List nodes = buildNodes(doc, binding, warnings); + Map voiceSettings = buildVoiceSettings(binding, warnings); + + AdminScenarioCreateRequest scenarioPayload = new AdminScenarioCreateRequest( + null, doc.interactive, com.swyp.picke.domain.scenario.enums.ScenarioStatus.PENDING, + nodes, voiceSettings); + + Map speakerNames = new LinkedHashMap<>(); + speakerNames.put("A", binding.speakerA); + speakerNames.put("B", binding.speakerB); + + return new AdminBattleParseResponse(battlePayload, scenarioPayload, speakerNames, warnings); + } + + // ---- 태그 ---- + + private Map tagNameToId(TagType type) { + Map map = new LinkedHashMap<>(); + for (Tag tag : tagRepository.findAllByTypeAndDeletedAtIsNull(type)) { + map.put(tag.getName(), tag.getId()); + } + return map; + } + + private List resolveOptionTags(OptionMeta option, String label, + Map philosopherTags, Map valueTags, + List warnings) { + List ids = new ArrayList<>(); + for (String keyword : option.philosopherKeywords) { + if (PhilosopherType.fromLabel(keyword) == null) { + warnings.add(ParseWarning.of("UNKNOWN_PHILOSOPHER_TAG", + label + "안 철학자 키워드 '" + keyword + "'는 철학자 유형 10인에 없습니다.", keyword)); + continue; + } + Long id = philosopherTags.get(keyword); + if (id == null) { + warnings.add(ParseWarning.of("MISSING_PHILOSOPHER_TAG", + label + "안 철학자 태그 '" + keyword + "'가 DB에 없습니다.", keyword)); + } else { + ids.add(id); + } + } + for (String value : option.valueTags) { + if (ValueAxis.resolve(value).isEmpty()) { + warnings.add(ParseWarning.of("UNKNOWN_VALUE_TAG", + label + "안 성향 지표 '" + value + "'는 가치관 12개 문자열에 없습니다.", value)); + continue; + } + Long id = valueTags.get(value); + if (id == null) { + warnings.add(ParseWarning.of("MISSING_VALUE_TAG", + label + "안 성향 태그 '" + value + "'가 DB에 없습니다.", value)); + } else { + ids.add(id); + } + } + return ids; + } + + // ---- 발화자 A/B 바인딩 ---- + + private static class SpeakerBinding { + String speakerA; + String speakerB; + Map speakerToType = new LinkedHashMap<>(); + } + + private SpeakerBinding bindSpeakers(BattleScriptDocument doc, List warnings) { + SpeakerBinding binding = new SpeakerBinding(); + + List speakers = new ArrayList<>(new LinkedHashSet<>( + doc.nodes.stream() + .flatMap(n -> n.scripts.stream()) + .map(s -> s.speaker) + .filter(s -> s != null) + .toList())); + + // 1) 선택의 시간에 명시가 있으면 그대로 + Map explicit = new LinkedHashMap<>(); + for (ParsedNode node : doc.nodes) { + for (ParsedOption option : node.options) { + if (option.speaker != null) { + explicit.put(option.speaker.strip(), option.label); + } + } + } + + Map labelMap = explicit; + + // 2) 사전 투표 줄에 대표 발화자가 있으면 사용 + if (labelMap.size() < 2 && doc.optionA.primarySpeaker != null && doc.optionB.primarySpeaker != null) { + labelMap = new LinkedHashMap<>(); + labelMap.put(doc.optionA.primarySpeaker.strip(), "A"); + labelMap.put(doc.optionB.primarySpeaker.strip(), "B"); + } + + // 3) 그래도 부족하면 LLM, 그다음 등장 순서 + if (labelMap.size() < 2 && speakers.size() >= 2) { + Map llm = emotionClassifier.bindSpeakers( + doc.title, nz(doc.optionA.choiceName), nz(doc.optionB.choiceName), speakers); + if (llm.size() >= 2) { + labelMap = llm; + } else { + warnings.add(ParseWarning.of("SPEAKER_BINDING_FALLBACK", + "발화자↔A/B 매칭을 등장 순서로 임시 배정했습니다. 미리보기에서 확인하세요.", String.join(", ", speakers))); + labelMap = new LinkedHashMap<>(); + labelMap.put(speakers.get(0), "A"); + labelMap.put(speakers.get(1), "B"); + } + } + + for (Map.Entry e : labelMap.entrySet()) { + SpeakerType type = "A".equalsIgnoreCase(e.getValue()) ? SpeakerType.A : SpeakerType.B; + binding.speakerToType.put(e.getKey(), type); + if (type == SpeakerType.A) { + binding.speakerA = e.getKey(); + } else { + binding.speakerB = e.getKey(); + } + } + if (binding.speakerA == null || binding.speakerB == null) { + warnings.add(ParseWarning.blocking("INCOMPLETE_SPEAKER_BINDING", + "A/B 발화자를 확정하지 못했습니다. 미리보기에서 지정하세요.", null)); + } + return binding; + } + + // ---- 시나리오 노드 ---- + + private List buildNodes(BattleScriptDocument doc, SpeakerBinding binding, + List warnings) { + List order = doc.nodes.stream().map(n -> n.name).toList(); + boolean hasClosing = order.contains("클로징"); + + // 배치 감정 분류: 모든 대사 줄을 인덱스로 모아 한 번 호출 + List llmInput = new ArrayList<>(); + List indexMap = new ArrayList<>(); // [nodeIdx, scriptIdx] + List extracted = new ArrayList<>(); + int running = 0; + for (int ni = 0; ni < doc.nodes.size(); ni++) { + ParsedNode node = doc.nodes.get(ni); + for (int si = 0; si < node.scripts.size(); si++) { + ParsedScript s = node.scripts.get(si); + ScriptToneExtractor.Result r = toneExtractor.extract(s.text); + extracted.add(r); + warnings.addAll(r.warnings()); + llmInput.add(new ScriptLine(running, s.speaker, r.cleanedText())); + indexMap.add(new int[]{ni, si}); + running++; + } + } + Map emotions = emotionClassifier.classify(doc.title, llmInput); + if (emotions.isEmpty() && !llmInput.isEmpty()) { + warnings.add(ParseWarning.of("LLM_CLASSIFY_FAILED", + "감정 자동분류에 실패했습니다. 톤은 전부 NEUTRAL 입니다. 미리보기에서 지정하세요.", null)); + } + + List result = new ArrayList<>(); + int flat = 0; + for (int ni = 0; ni < doc.nodes.size(); ni++) { + ParsedNode node = doc.nodes.get(ni); + List scripts = new ArrayList<>(); + for (int si = 0; si < node.scripts.size(); si++) { + ParsedScript s = node.scripts.get(si); + ScriptToneExtractor.Result ex = extracted.get(flat); + ScriptEmotion emotion = emotions.get(flat); + flat++; + + Tone tone = ex.plannerTone() != Tone.NEUTRAL + ? ex.plannerTone() + : (emotion != null ? emotion.tone() : Tone.NEUTRAL); + String text = emotion != null && emotion.textWithEffects() != null + ? emotion.textWithEffects() + : ex.cleanedText(); + SpeakerType speakerType = resolveSpeakerType(s.speaker, binding); + String speakerName = s.speaker == null ? "나레이터" : s.speaker; + scripts.add(new AdminScenarioScriptRequest(speakerName, speakerType, text, tone)); + } + + List options = node.options.stream() + .map(o -> new AdminScenarioOptionRequest(o.label, o.nextNodeName)) + .toList(); + + String autoNext = resolveAutoNext(order, ni, node, hasClosing); + result.add(new AdminScenarioNodeRequest(node.name, ni == 0, autoNext, scripts, options)); + } + return result; + } + + private String resolveAutoNext(List order, int idx, ParsedNode node, boolean hasClosing) { + if (!node.options.isEmpty()) { + return null; // 선택 노드는 interactiveOptions 로 분기 + } + if (node.name.startsWith("분기_")) { + return hasClosing ? "클로징" : null; + } + for (int j = idx + 1; j < order.size(); j++) { + String next = order.get(j); + if (next.startsWith("분기_")) { + return null; // 다음이 분기 블록이면 이 노드가 선택 직전이 아니어야 정상 — 방어적으로 끊음 + } + return next; + } + return null; + } + + private SpeakerType resolveSpeakerType(String speaker, SpeakerBinding binding) { + if (speaker == null) { + return SpeakerType.NARRATOR; + } + return binding.speakerToType.getOrDefault(speaker.strip(), SpeakerType.A); + } + + // ---- 보이스 ---- + + private Map buildVoiceSettings(SpeakerBinding binding, List warnings) { + Map voices = new LinkedHashMap<>(); + putVoice(voices, SpeakerType.A, binding.speakerA, warnings); + putVoice(voices, SpeakerType.B, binding.speakerB, warnings); + if (narratorVoice != null && !narratorVoice.isBlank()) { + voices.put(SpeakerType.NARRATOR, narratorVoice); + } + if (userVoice != null && !userVoice.isBlank()) { + voices.put(SpeakerType.USER, userVoice); + } + return voices; + } + + private void putVoice(Map voices, SpeakerType type, String speaker, + List warnings) { + if (speaker == null) { + return; + } + Optional refId = philosopherVoiceService.findReferenceIdByName(speaker); + if (refId.isPresent()) { + voices.put(type, refId.get()); + } else { + warnings.add(ParseWarning.blocking("MISSING_VOICE", + "'" + speaker + "' 보이스가 philosopher_voice 에 없습니다. 미리보기에서 지정하거나 테이블에 추가하세요.", speaker)); + } + } + + // ---- 유틸 ---- + + private String firstNarration(BattleScriptDocument doc, String nodeName) { + return doc.nodes.stream() + .filter(n -> n.name.equals(nodeName)) + .flatMap(n -> n.scripts.stream()) + .filter(s -> s.speaker == null) + .map(s -> s.text) + .findFirst() + .orElse(null); + } + + private String nz(String value) { + return value == null ? "" : value; + } +} diff --git a/src/main/java/com/swyp/picke/global/infra/tts/service/TtsTextNormalizer.java b/src/main/java/com/swyp/picke/global/infra/tts/service/TtsTextNormalizer.java index bbcce623..0604daeb 100644 --- a/src/main/java/com/swyp/picke/global/infra/tts/service/TtsTextNormalizer.java +++ b/src/main/java/com/swyp/picke/global/infra/tts/service/TtsTextNormalizer.java @@ -25,15 +25,20 @@ private TtsTextNormalizer() { } public static String normalize(String rawText, Tone tone) { - String text = rawText == null ? "" : rawText; - - for (Map.Entry alias : EFFECT_ALIASES.entrySet()) { - text = text.replaceAll("(?i)" + java.util.regex.Pattern.quote(alias.getKey()), alias.getValue()); - } + String text = normalizeEffectTags(rawText); if (tone != null && tone.hasTag()) { text = tone.getTag() + " " + text.stripLeading(); } return text.trim(); } + + /** 효과 태그 표기만 Fish 정식 표기로 바꾼다(톤 태그는 안 붙임). 파서가 저장 텍스트를 정리할 때 쓴다. */ + public static String normalizeEffectTags(String rawText) { + String text = rawText == null ? "" : rawText; + for (Map.Entry alias : EFFECT_ALIASES.entrySet()) { + text = text.replaceAll("(?i)" + java.util.regex.Pattern.quote(alias.getKey()), alias.getValue()); + } + return text; + } } diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index c218cec2..5b8ca136 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -83,6 +83,8 @@ admob: android: ${ADMOB_REWARD_UNIT_ID_ANDROID} openai: + emotion: + model: ${OPENAI_EMOTION_MODEL:gpt-4o-mini} api-key: ${OPENAI_API_KEY} url: https://api.openai.com/v1/chat/completions model: gpt-4o-mini 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 new file mode 100644 index 00000000..4f8263b8 --- /dev/null +++ b/src/test/java/com/swyp/picke/domain/battle/parser/BattleScriptDocumentParserTest.java @@ -0,0 +1,162 @@ +package com.swyp.picke.domain.battle.parser; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.swyp.picke.domain.battle.parser.BattleScriptDocument.ParsedNode; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import org.junit.jupiter.api.Test; +import org.springframework.core.io.ClassPathResource; + +class BattleScriptDocumentParserTest { + + private final BattleScriptDocumentParser parser = new BattleScriptDocumentParser(); + + private String load(String name) throws IOException { + return new String(new ClassPathResource("battle-scripts/" + name).getInputStream().readAllBytes(), + StandardCharsets.UTF_8); + } + + private ParsedNode node(BattleScriptDocument doc, String name) { + return doc.nodes.stream().filter(n -> n.name.equals(name)).findFirst().orElse(null); + } + + @Test + void 선형_대본_헤더와_메타데이터를_파싱한다() throws IOException { + BattleScriptDocument doc = parser.parse(load("sample-linear.txt")); + + assertThat(doc.interactive).isFalse(); + assertThat(doc.title).isEqualTo("인간은 본래 선한가, 악한가?"); + assertThat(doc.category).isEqualTo("철학"); + assertThat(doc.optionA.choiceName).isEqualTo("선하다"); + assertThat(doc.optionA.philosopherKeywords).containsExactly("노자", "플라톤", "석가모니"); + assertThat(doc.optionA.valueTags).containsExactly("내면", "이상"); + assertThat(doc.optionB.choiceName).isEqualTo("악하다"); + assertThat(doc.optionB.valueTags).containsExactly("원칙", "이성"); + } + + @Test + void 선형_대본은_오프닝_라운드_클로징으로_나뉜다() throws IOException { + BattleScriptDocument doc = parser.parse(load("sample-linear.txt")); + + assertThat(doc.nodes).extracting(n -> n.name).containsExactly("오프닝", "라운드", "클로징"); + assertThat(node(doc, "오프닝").scripts).allMatch(s -> s.speaker == null); + assertThat(node(doc, "라운드").scripts).anyMatch(s -> "루소".equals(s.speaker)); + assertThat(node(doc, "라운드").scripts).anyMatch(s -> "홉스".equals(s.speaker)); + assertThat(node(doc, "클로징").scripts).hasSize(1); + assertThat(node(doc, "클로징").scripts.get(0).text).startsWith("루소는 말합니다"); + } + + @Test + void 인터랙티브_대본은_분기_버전을_노드_원본으로_쓴다() throws IOException { + BattleScriptDocument doc = parser.parse(load("sample-interactive.txt")); + + assertThat(doc.interactive).isTrue(); + assertThat(doc.title).isEqualTo("AI 판사는 도입해야 하는가?"); + assertThat(doc.nodes).extracting(n -> n.name) + .containsExactly("오프닝", "1라운드", "2라운드", "선택", "분기_A", "분기_B", "클로징"); + } + + @Test + void 선택의_시간_노드는_A_B_옵션과_발화자를_담는다() throws IOException { + BattleScriptDocument doc = parser.parse(load("sample-interactive.txt")); + ParsedNode choice = node(doc, "선택"); + + assertThat(choice.options).hasSize(2); + 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("칸트", "아리스토텔레스"); + } + + @Test + void 라운드_노드의_발화자_줄을_화자와_대사로_분리한다() throws IOException { + BattleScriptDocument doc = parser.parse(load("sample-interactive.txt")); + ParsedNode round1 = node(doc, "1라운드"); + + assertThat(round1.scripts).hasSize(2); + assertThat(round1.scripts.get(0).speaker).isEqualTo("칸트"); + assertThat(round1.scripts.get(0).text).startsWith("정의는 보편적 원칙"); + assertThat(round1.scripts.get(1).speaker).isEqualTo("아리스토텔레스"); + } + + @Test + void 실제_대본_버전헤더없음_라운드부제_따옴표_인라인분기를_처리한다() throws IOException { + BattleScriptDocument doc = parser.parse(load("sample-real-1.txt")); + + assertThat(doc.warnings).isEmpty(); + assertThat(doc.interactive).isTrue(); + assertThat(doc.title).isEqualTo("첫 데이트 국밥, 무죄인가 유죄인가?"); + assertThat(doc.category).isEqualTo("관계·사랑"); + assertThat(doc.nodes).extracting(n -> n.name) + .containsExactly("오프닝", "1라운드", "2라운드", "선택", "분기_A", "분기_B", "클로징"); + + // 발화자 주석 "(유죄)" 제거, 따옴표 제거 + assertThat(node(doc, "1라운드").scripts.get(0).speaker).isEqualTo("플라톤"); + assertThat(node(doc, "1라운드").scripts.get(0).text).startsWith("첫 데이트에서 국밥은"); + + // 사전 투표 줄에서 대표 발화자 + assertThat(doc.optionA.primarySpeaker).isEqualTo("플라톤"); + assertThat(doc.optionB.primarySpeaker).isEqualTo("마르크스"); + + // 공백 구분 철학자 키워드 + assertThat(doc.optionA.philosopherKeywords).containsExactly("플라톤", "칸트", "아리스토텔레스"); + assertThat(doc.optionB.philosopherKeywords).containsExactly("마르크스", "니체", "사르트르"); + assertThat(doc.optionA.valueTags).containsExactly("원칙", "이상", "내면"); + + // [A 선택 시] 인라인 분기 + assertThat(node(doc, "분기_A").scripts.get(0).speaker).isEqualTo("플라톤"); + assertThat(node(doc, "분기_B").scripts.get(0).speaker).isEqualTo("마르크스"); + assertThat(node(doc, "선택").options).extracting(o -> o.speaker).containsExactly("플라톤", "마르크스"); + } + + @Test + void 실제_선형대본_클로징_텍스트가_같은_줄에_있어도_노드로_잡는다() throws IOException { + BattleScriptDocument doc = parser.parse(load("sample-real-2.txt")); + + assertThat(doc.warnings).isEmpty(); + assertThat(doc.interactive).isFalse(); + assertThat(doc.title).isEqualTo("부모가 아이 이름에 종교적 의미를 담는 것은 정당한가?"); + assertThat(doc.nodes).extracting(n -> n.name) + .containsExactly("오프닝", "1라운드", "2라운드", "클로징"); + // "[클로징] 당신의 이름은..." 인라인 텍스트가 클로징 노드 나레이션으로 들어간다 + assertThat(node(doc, "클로징").scripts).hasSize(1); + assertThat(node(doc, "클로징").scripts.get(0).speaker).isNull(); + assertThat(node(doc, "클로징").scripts.get(0).text).startsWith("당신의 이름은 누가 결정했습니까?"); + // 음(−)의 극 성향 지표도 파싱 + assertThat(doc.optionA.valueTags).containsExactly("관계", "전통"); + assertThat(doc.optionB.philosopherKeywords).containsExactly("사르트르", "칸트", "니체"); + } + + @Test + void 여러_줄에_걸친_한_발언을_한_스크립트로_합친다() throws IOException { + BattleScriptDocument doc = parser.parse(load("sample-real-3.txt")); + + assertThat(doc.warnings).isEmpty(); + assertThat(doc.nodes).extracting(n -> n.name) + .containsExactly("오프닝", "1라운드", "2라운드", "클로징"); + assertThat(doc.optionA.primarySpeaker).isEqualTo("공자"); + assertThat(doc.optionB.primarySpeaker).isEqualTo("노자"); + + // "공자:" 뒤 콜론 공백 없음 + 이어지는 4줄이 한 발언 + ParsedNode round1 = node(doc, "1라운드"); + assertThat(round1.scripts).hasSize(2); + assertThat(round1.scripts.get(0).speaker).isEqualTo("공자"); + assertThat(round1.scripts.get(0).text) + .startsWith("생일 축하는 단순한 메시지가 아닙니다.") + .contains("무심함에 가깝습니다."); // 4번째 줄까지 합쳐짐 + assertThat(round1.scripts.get(1).speaker).isEqualTo("노자"); + + // 성향 지표에 12축 밖 문자열(자연)이 그대로 담긴다 — 검증은 조립 단계에서 + assertThat(doc.optionB.valueTags).containsExactly("개인", "자연", "내면"); + } + + @Test + void 인터랙티브_메타데이터도_양쪽_옵션을_파싱한다() throws IOException { + BattleScriptDocument doc = parser.parse(load("sample-interactive.txt")); + + assertThat(doc.optionA.choiceName).isEqualTo("AI 판사를 도입해야 한다"); + assertThat(doc.optionA.philosopherKeywords).containsExactly("칸트", "플라톤", "소크라테스"); + assertThat(doc.optionB.philosopherKeywords).containsExactly("아리스토텔레스", "공자", "사르트르"); + assertThat(doc.optionB.valueTags).containsExactly("개인", "변화"); + } +} 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 new file mode 100644 index 00000000..5e6e7773 --- /dev/null +++ b/src/test/java/com/swyp/picke/domain/battle/service/BattleScriptParseServiceTest.java @@ -0,0 +1,219 @@ +package com.swyp.picke.domain.battle.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.when; + +import com.swyp.picke.domain.admin.dto.battle.request.AdminBattleOptionRequest; +import com.swyp.picke.domain.admin.dto.scenario.request.AdminScenarioNodeRequest; +import com.swyp.picke.domain.battle.dto.parse.AdminBattleParseResponse; +import com.swyp.picke.domain.battle.enums.BattleStatus; +import com.swyp.picke.domain.battle.parser.BattleScriptDocumentParser; +import com.swyp.picke.domain.battle.parser.EmotionClassifier; +import com.swyp.picke.domain.battle.parser.ScriptToneExtractor; +import com.swyp.picke.domain.scenario.enums.SpeakerType; +import com.swyp.picke.domain.scenario.service.PhilosopherVoiceService; +import com.swyp.picke.domain.tag.entity.Tag; +import com.swyp.picke.domain.tag.enums.TagType; +import com.swyp.picke.domain.tag.repository.TagRepository; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicLong; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.core.io.ClassPathResource; +import org.springframework.test.util.ReflectionTestUtils; + +@ExtendWith(MockitoExtension.class) +class BattleScriptParseServiceTest { + + @Mock + private EmotionClassifier emotionClassifier; + @Mock + private PhilosopherVoiceService philosopherVoiceService; + @Mock + private TagRepository tagRepository; + + private BattleScriptParseService service; + + private final AtomicLong tagIdSeq = new AtomicLong(1); + + private Tag tag(String name, TagType type) { + Tag t = Tag.builder().name(name).type(type).build(); + ReflectionTestUtils.setField(t, "id", tagIdSeq.getAndIncrement()); + return t; + } + + @BeforeEach + void setUp() { + service = new BattleScriptParseService( + new BattleScriptDocumentParser(), new ScriptToneExtractor(), + emotionClassifier, philosopherVoiceService, tagRepository); + ReflectionTestUtils.setField(service, "narratorVoice", "voice-narrator"); + ReflectionTestUtils.setField(service, "userVoice", "voice-user"); + + lenient().when(tagRepository.findAllByTypeAndDeletedAtIsNull(TagType.CATEGORY)) + .thenReturn(List.of(tag("철학", TagType.CATEGORY), tag("관계·사랑", TagType.CATEGORY), + tag("관계", TagType.CATEGORY))); + lenient().when(tagRepository.findAllByTypeAndDeletedAtIsNull(TagType.PHILOSOPHER)) + .thenReturn(List.of( + tag("노자", TagType.PHILOSOPHER), tag("플라톤", TagType.PHILOSOPHER), + tag("석가모니", TagType.PHILOSOPHER), tag("칸트", TagType.PHILOSOPHER), + tag("아리스토텔레스", TagType.PHILOSOPHER), tag("마르크스", TagType.PHILOSOPHER), + tag("소크라테스", TagType.PHILOSOPHER), tag("공자", TagType.PHILOSOPHER), + tag("사르트르", TagType.PHILOSOPHER), tag("니체", TagType.PHILOSOPHER), + tag("노자", TagType.PHILOSOPHER))); + lenient().when(tagRepository.findAllByTypeAndDeletedAtIsNull(TagType.VALUE)) + .thenReturn(List.of( + tag("내면", TagType.VALUE), tag("이상", TagType.VALUE), + tag("원칙", TagType.VALUE), tag("이성", TagType.VALUE), + tag("개인", TagType.VALUE), tag("변화", TagType.VALUE))); + + lenient().when(philosopherVoiceService.findReferenceIdByName(anyString())) + .thenAnswer(inv -> Optional.of("voice-" + inv.getArgument(0, String.class).strip())); + lenient().when(emotionClassifier.classify(anyString(), any())).thenReturn(Map.of()); + } + + private String load(String name) throws IOException { + return new String(new ClassPathResource("battle-scripts/" + name).getInputStream().readAllBytes(), + StandardCharsets.UTF_8); + } + + private AdminScenarioNodeRequest node(AdminBattleParseResponse res, String name) { + return res.scenarioPayload().nodes().stream() + .filter(n -> n.nodeName().equals(name)).findFirst().orElseThrow(); + } + + @Test + void 선형_대본을_배틀_페이로드로_조립한다() throws IOException { + when(emotionClassifier.bindSpeakers(anyString(), anyString(), anyString(), any())) + .thenReturn(Map.of("루소", "A", "홉스", "B")); + + AdminBattleParseResponse res = service.parse(load("sample-linear.txt")); + + assertThat(res.battlePayload().title()).isEqualTo("인간은 본래 선한가, 악한가?"); + assertThat(res.battlePayload().status()).isEqualTo(BattleStatus.PENDING); + assertThat(res.battlePayload().summary()).startsWith("재난이 일어났을 때"); + assertThat(res.battlePayload().tagIds()).hasSize(1); // 철학 + assertThat(res.battlePayload().options()).extracting(AdminBattleOptionRequest::title) + .containsExactly("선하다", "악하다"); + assertThat(res.battlePayload().options().get(0).representative()).isEqualTo("루소"); + assertThat(res.battlePayload().options().get(1).representative()).isEqualTo("홉스"); + // A안: 철학자 3 + 성향 2 = 5개 태그 + assertThat(res.battlePayload().options().get(0).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")); + + assertThat(res.scenarioPayload().isInteractive()).isFalse(); + assertThat(res.scenarioPayload().nodes()).extracting(AdminScenarioNodeRequest::nodeName) + .containsExactly("오프닝", "라운드", "클로징"); + assertThat(node(res, "오프닝").isStartNode()).isTrue(); + assertThat(node(res, "오프닝").autoNextNode()).isEqualTo("라운드"); + assertThat(node(res, "라운드").autoNextNode()).isEqualTo("클로징"); + assertThat(node(res, "클로징").autoNextNode()).isNull(); + assertThat(node(res, "라운드").scripts().get(0).speakerType()).isEqualTo(SpeakerType.A); + assertThat(node(res, "오프닝").scripts().get(0).speakerType()).isEqualTo(SpeakerType.NARRATOR); + } + + @Test + void 선형_대본_보이스가_발화자별로_채워진다() throws IOException { + when(emotionClassifier.bindSpeakers(anyString(), anyString(), anyString(), any())) + .thenReturn(Map.of("루소", "A", "홉스", "B")); + + AdminBattleParseResponse res = service.parse(load("sample-linear.txt")); + + Map voices = res.scenarioPayload().voiceSettings(); + assertThat(voices).containsEntry(SpeakerType.A, "voice-루소") + .containsEntry(SpeakerType.B, "voice-홉스") + .containsEntry(SpeakerType.NARRATOR, "voice-narrator"); + assertThat(res.warnings()).noneMatch(w -> w.blocking()); + } + + @Test + void 인터랙티브_대본은_선택_노드와_분기_연결을_만든다() throws IOException { + AdminBattleParseResponse res = service.parse(load("sample-interactive.txt")); + + assertThat(res.scenarioPayload().isInteractive()).isTrue(); + assertThat(res.scenarioPayload().nodes()).extracting(AdminScenarioNodeRequest::nodeName) + .containsExactly("오프닝", "1라운드", "2라운드", "선택", "분기_A", "분기_B", "클로징"); + + AdminScenarioNodeRequest choice = node(res, "선택"); + assertThat(choice.autoNextNode()).isNull(); + assertThat(choice.interactiveOptions()).extracting(o -> o.label()).containsExactly("A", "B"); + assertThat(choice.interactiveOptions()).extracting(o -> o.nextNodeName()) + .containsExactly("분기_A", "분기_B"); + assertThat(node(res, "분기_A").autoNextNode()).isEqualTo("클로징"); + assertThat(node(res, "분기_B").autoNextNode()).isEqualTo("클로징"); + } + + @Test + void 인터랙티브_대본은_선택의_시간_명시로_A_B_발화자를_바인딩한다() throws IOException { + AdminBattleParseResponse res = service.parse(load("sample-interactive.txt")); + + 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); // 아리스토텔레스 + } + + @Test + void 실제_대본을_인터랙티브_페이로드로_조립한다() throws IOException { + AdminBattleParseResponse res = service.parse(load("sample-real-1.txt")); + + assertThat(res.battlePayload().title()).isEqualTo("첫 데이트 국밥, 무죄인가 유죄인가?"); + assertThat(res.battlePayload().tagIds()).hasSize(1); // 관계·사랑 + assertThat(res.speakerNames()).containsEntry("A", "플라톤").containsEntry("B", "마르크스"); + assertThat(res.scenarioPayload().isInteractive()).isTrue(); + assertThat(res.scenarioPayload().nodes()).extracting(AdminScenarioNodeRequest::nodeName) + .containsExactly("오프닝", "1라운드", "2라운드", "선택", "분기_A", "분기_B", "클로징"); + assertThat(node(res, "선택").interactiveOptions()).extracting(o -> o.nextNodeName()) + .containsExactly("분기_A", "분기_B"); + assertThat(node(res, "분기_A").autoNextNode()).isEqualTo("클로징"); + assertThat(res.scenarioPayload().voiceSettings()) + .containsEntry(SpeakerType.A, "voice-플라톤") + .containsEntry(SpeakerType.B, "voice-마르크스"); + assertThat(res.warnings()).noneMatch(w -> w.blocking()); + // 옵션 태그: 철학자 3 + 성향 3 = 6 + assertThat(res.battlePayload().options().get(0).tagIds()).hasSize(6); + } + + @Test + void 사전투표_대표발화자로_A_B를_바인딩하고_미지원_성향지표는_경고한다() throws IOException { + AdminBattleParseResponse res = service.parse(load("sample-real-3.txt")); + + // 사전 투표: 공자→A, 노자→B (LLM 호출 없이) + assertThat(res.speakerNames()).containsEntry("A", "공자").containsEntry("B", "노자"); + assertThat(res.scenarioPayload().isInteractive()).isFalse(); + assertThat(res.scenarioPayload().nodes()).extracting(AdminScenarioNodeRequest::nodeName) + .containsExactly("오프닝", "1라운드", "2라운드", "클로징"); + // 12축 밖 "자연" 은 태그로 안 들어가고 경고 + assertThat(res.warnings()).anyMatch(w -> w.code().equals("UNKNOWN_VALUE_TAG") + && "자연".equals(w.context())); + // B안 태그: 철학자 3 + 성향 2(개인,내면; 자연 제외) = 5 + assertThat(res.battlePayload().options().get(1).tagIds()).hasSize(5); + } + + @Test + void LLM_실패시_톤은_전부_NEUTRAL이고_경고를_남긴다() throws IOException { + AdminBattleParseResponse res = service.parse(load("sample-interactive.txt")); + + List tones = new ArrayList<>(); + res.scenarioPayload().nodes().forEach(n -> n.scripts().forEach(s -> tones.add(s.tone()))); + assertThat(tones).containsOnly(com.swyp.picke.domain.scenario.enums.Tone.NEUTRAL); + assertThat(res.warnings()).anyMatch(w -> w.code().equals("LLM_CLASSIFY_FAILED")); + } +} diff --git a/src/test/resources/battle-scripts/sample-interactive.txt b/src/test/resources/battle-scripts/sample-interactive.txt new file mode 100644 index 00000000..6f6760ed --- /dev/null +++ b/src/test/resources/battle-scripts/sample-interactive.txt @@ -0,0 +1,60 @@ +분기 O 버전 011 — AI 판사는 도입해야 하는가? +오프닝 +판사도 인간입니다. 배가 고프면 더 가혹해진다는 연구가 있습니다. AI가 판사를 대신한다면 어떨까요? + +칸트: 정의는 보편적 원칙 위에 서야 합니다. AI는 동일한 사안에 동일한 기준을 적용합니다. + +아리스토텔레스: 정의는 원칙만으로 완성되지 않습니다. 실천적 지혜가 필요합니다. + +칸트는 말합니다. 감정과 편견 없는 일관된 원칙이야말로 진정한 공정함이라고. 당신이 재판을 받아야 한다면, 어느 쪽 판사 앞에 서고 싶습니까? + +메타데이터 +구분 +상세 내용 +카테고리 +철학 +A안 선택 시 +선택지 명칭 +AI 판사를 도입해야 한다 +철학자 키워드 +#칸트, #플라톤, #소크라테스 +성향 지표 (Value) +[원칙], [이성] +B안 선택 시 +선택지 명칭 +AI 판사는 도입해선 안 된다 +철학자 키워드 +#아리스토텔레스, #공자, #사르트르 +성향 지표 (Value) +[개인], [변화] + +(분기 버전) +[오프닝] +판사도 인간입니다. 배가 고프면 더 가혹해진다는 연구가 있습니다. AI가 판사를 대신한다면 어떨까요? + +[1라운드] +칸트: 정의는 보편적 원칙 위에 서야 합니다. AI는 동일한 사안에 동일한 기준을 적용합니다. + +아리스토텔레스: 정의는 원칙만으로 완성되지 않습니다. 실천적 지혜가 필요합니다. + +[2라운드] +아리스토텔레스: AI는 과거 데이터로 학습합니다. 그 데이터 안에 이미 인간의 편견이 담겨 있습니다. + +칸트: 그건 데이터의 문제이지 AI 자체의 문제가 아닙니다. + +👉 [선택의 시간] +A: AI 판사를 도입해야 한다. 일관된 원칙이 진정한 공정함이다. (칸트) +B: AI 판사는 도입해선 안 된다. 맥락을 이해하는 인간의 판단이 필요하다. (아리스토텔레스) + +[분기 A — 도입을 선택했을 때] +칸트: 당신은 공정함의 본질을 파악했습니다. AI 판사는 모든 판결 과정을 투명하게 공개할 수 있습니다. + +아리스토텔레스: 재판은 단순한 계산이 아닙니다. 그 인간적인 무게를 감당하면서 판결을 내리는 것. + +[분기 B — 반대를 선택했을 때] +아리스토텔레스: 당신은 정의의 본질을 이해하고 있습니다. 법은 규칙이지만, 재판은 인간에 관한 것입니다. + +칸트: 그 마음은 이해합니다. 하지만 인간 판사의 편견으로 억울하게 처벌받은 사람들을 생각해보십시오. + +[클로징] +칸트는 말합니다. 감정과 편견 없는 일관된 원칙이야말로 진정한 공정함이라고. 당신이 재판을 받아야 한다면, 어느 쪽 판사 앞에 서고 싶습니까? diff --git a/src/test/resources/battle-scripts/sample-linear.txt b/src/test/resources/battle-scripts/sample-linear.txt new file mode 100644 index 00000000..e7247969 --- /dev/null +++ b/src/test/resources/battle-scripts/sample-linear.txt @@ -0,0 +1,39 @@ +분기 X 버전 001 — 인간은 본래 선한가, 악한가? +오프닝 +재난이 일어났을 때 사람들은 서로를 돕습니다. 재난이 길어지면 서로를 착취합니다. 어느 쪽이 인간의 본 모습일까요? + +루소: 인간은 자연 상태에서 평화로웠습니다. 경쟁도, 소유욕도, 증오도 — 이건 문명이 만든 겁니다. 아이를 보십시오. 태어난 아이는 나누고 웃고, 타인의 고통에 울음으로 반응합니다. 악은 가르쳐지는 것입니다. + +홉스: 역사상 국가 없이 평화로웠던 사회는 없습니다. 자연 상태의 인간의 삶은 고독하고 가난하고 불결하고 잔인하고 짧습니다. 선사 시대 유골에는 폭력의 흔적이 가득합니다. 문명은 타락이 아니라 구원입니다. + +루소: 그 폭력은 소유가 생긴 이후의 것입니다. 최초의 악은 '이건 내 것이다'라고 말한 순간 시작됐습니다. + +홉스: 소유욕은 어디서 왔습니까. 문명 이전에도 영역 싸움은 있었습니다. 침팬지도 영역 싸움을 합니다. 선함은 선택이고 교육이지, 기본값이 아닙니다. + +루소: 왜 인간은 아무 이익 없이 낯선 이를 돕습니까. 이익 계산으로는 설명되지 않는 선함이 존재합니다. + +홉스: 그런 선함은 감탄스럽지만 예외입니다. 시스템은 예외가 아닌 평균으로 설계해야 합니다. 인간 대부분이 선하다고 가정한 사회는 소수의 악한 자에게 무너집니다. 법과 제도는 인간의 선함을 믿어서가 아니라, 믿지 않기 때문에 만들어진 것입니다. + +루소는 말합니다. 악은 가르쳐지는 것이고, 인간의 기본값은 선이라고. 홉스는 말합니다. 선함은 제도가 만들어내는 것이고, 그것 없인 인간은 서로를 파괴한다고. 당신은 인간을 믿습니까? + +메타데이터 +구분 +상세 내용 +데이터 (Data) +기본 정보 +카테고리 +철학 +A안 선택 시 +선택지 명칭 +선하다 +철학자 키워드 +#노자, #플라톤, #석가모니 +성향 지표 (Value) +[내면], [이상] +B안 선택 시 +선택지 명칭 +악하다 +철학자 키워드 +#칸트, #아리스토텔레스, #마르크스 +성향 지표 (Value) +[원칙], [이성] diff --git a/src/test/resources/battle-scripts/sample-real-1.txt b/src/test/resources/battle-scripts/sample-real-1.txt new file mode 100644 index 00000000..15c47ed4 --- /dev/null +++ b/src/test/resources/battle-scripts/sample-real-1.txt @@ -0,0 +1,56 @@ +008 — 첫 데이트 국밥, 무죄인가 유죄인가? + +사전 투표 +"유죄다 (플라톤)" vs "무죄다 (마르크스)" + +[오프닝] +설레는 첫 데이트, 무슨 옷을 입을지 고민한 끝에 마주 앉은 자리. 그런데 눈앞에 뚝배기와 깍두기가 놓였습니다. "첫 만남엔 무조건 분위기 좋은 곳"이라는 격조파와 "처음부터 내숭 없이 든든하게"라는 실속파의 대결. 인생의 가장 아름다운 이상을 추구하는 플라톤과 현실의 실리를 날카롭게 따지는 마르크스가 보글보글 끓는 국밥 앞에서 마주 앉았습니다. + +1라운드: "아름다운 첫인상" vs "실속 있는 노동의 대가" +플라톤 (유죄): "첫 데이트에서 국밥은 명백한 '유죄'입니다! 첫 만남이란 서로에게 가장 아름다운 모습을 보여주고, 설레는 감정을 정성스럽게 가꾸어가는 신성한 시간입니다. 땀을 뻘뻘 흘리며 마늘과 다대기를 섞는 모습은, 첫 데이트가 가져야 할 최소한의 낭만과 격조를 깨뜨리는 파괴 행위입니다." +마르크스 (무죄): "플라톤 선생, 낭만이라는 허울로 자본주의의 사치를 정당화하지 마십시오! 피땀 흘려 번 돈으로 먹는 첫 식사입니다. 거품 낀 비싼 파스타 대신, 뜨끈하고 영양가 가득한 국밥이야말로 노동의 대가를 가장 가치 있게 쓰는 '합리적인 선택'입니다. 허례허식을 걷어내고 처음부터 서로의 솔직한 모습을 보여주는 것이 진짜 올바른 소통입니다." + +2라운드: "배려의 미학" vs "계급장 뗀 소통" +플라톤: "음식을 먹는 환경도 중요합니다. 조용한 조명 아래서 서로의 목소리에 집중하는 것이 상대방에 대한 예의이자 '배려'입니다. 시끄러운 국밥집에서 "이모, 여기 깍두기 더 주세요!"를 외치며 밥을 마는 환경 속에서는, 깊이 있는 영혼의 대화가 피어나기 어렵습니다." +마르크스: "오히려 좋습니다! 비싼 레스토랑의 어색한 공기 속에서 가짜 미소를 짓는 것이야말로 위선입니다. 서민들의 활기가 넘치는 국밥집에서 숟가락을 부딪치며 먹을 때, 서로의 계급장을 떼고 가장 인간적이고 친밀한 연대감이 형성되는 법입니다. 국밥을 함께 말 수 있는 사람이야말로 거친 세상을 함께 살아갈 진정한 동반자입니다." + +👉 [선택의 시간: 당신의 데이트 철학은?] +A: 첫 만남에는 그에 걸맞은 격조와 분위기가 필수다, 유죄! (플라톤) +B: 가성비 좋고 든든하며, 솔직한 게 최고다, 무죄! (마르크스) + +[분기 시나리오 A / B] +[A 선택 시] 플라톤: "현명하군요. 당신은 눈앞의 실리를 넘어, 인생에서 아름다움과 예의를 가꿀 줄 아는 고상한 영혼을 가졌습니다. 당신의 다음 데이트는 로맨틱한 빛으로 가득할 것입니다." +[B 선택 시] 마르크스: "동지! 당신은 자본이 만든 가짜 환상에 속지 않는 냉철한 눈을 가졌군요. 가식 없는 든든함으로 다가가는 당신의 솔직함이 진짜 배필을 찾아낼 것입니다." + +[클로징] +플라톤은 설렘의 순간을 완성하는 아름다운 격조를 지키려 하고, 마르크스는 거품을 뺀 솔직하고 든든한 현실을 옹호합니다. +이번 주말 당신의 첫 데이트, 당신은 은은한 조명의 이탈리안 레스토랑 벨을 누르시겠습니까, 아니면 뽀얀 국물이 우러나는 국밥집 문을 여시겠습니까? +메타데이터 +구분 +상세 내용 +데이터 (Data) +기본 정보 +카테고리 +관계·사랑 +A안 선택 시 +선택지 명칭 +첫 만남에는 그에 걸맞은 격조와 분위기가 필수다, 유죄! + + +철학자 키워드 +#플라톤 #칸트 #아리스토텔레스 + + +성향 지표 (Value) +[원칙] [이상] [내면] +B안 선택 시 +선택지 명칭 +가성비 좋고 든든하며, 솔직한 게 최고다, 무죄! + + +철학자 키워드 +#마르크스 #니체 #사르트르 + + +성향 지표 (Value) +[이성] [개인] [변화] diff --git a/src/test/resources/battle-scripts/sample-real-2.txt b/src/test/resources/battle-scripts/sample-real-2.txt new file mode 100644 index 00000000..793ea4e8 --- /dev/null +++ b/src/test/resources/battle-scripts/sample-real-2.txt @@ -0,0 +1,39 @@ +004 — 부모가 아이 이름에 종교적 의미를 담는 것은 정당한가? +[오프닝] +하나님의 은혜, 예수님 안에. 한국에서 흔히 볼 수 있는 이름들입니다. 부모는 가장 소중한 것을 담아 이름을 지었습니다. 그런데 그 아이는 자신의 이름을 선택하지 않았습니다. 태어나는 순간 종교적 정체성을 부여받은 아이 — 이것은 사랑입니까, 아니면 침해입니까? +[1라운드] +공자: 이름은 단순한 기호가 아닙니다. 이름을 짓는 행위는 그 존재에 대한 부모의 가장 깊은 바람과 사랑을 담는 의식입니다. 부모는 자녀에게 언어를 가르치고, 문화를 전수하고, 가치관을 심습니다. 종교적 의미를 담은 이름도 그 연장선입니다. 아이는 자라며 그 이름의 의미를 알게 되고, 받아들이거나 스스로의 방식으로 재해석할 것입니다. 이것이 세대를 잇는 문화 전승입니다. +밀: 자유론에서 저는 말했습니다. 인간은 자신에게 관한 일에서 주권자입니다. 종교는 가장 개인적인 영역입니다. 아이가 동의하지 않은 종교적 정체성을 이름이라는 형태로 평생 짊어지게 하는 것 — 이것은 아이의 자율성을 태어나는 순간부터 선점하는 일입니다. 이름은 그 사람이 세상에서 불리는 방식입니다. 그것을 타인이 결정한다는 것, 특히 종교적 의미로 결정한다는 것은 가볍게 볼 문제가 아닙니다. +[2라운드] +공자: 밀, 그 논리라면 부모는 자녀에게 아무것도 전달하면 안 됩니까. 모국어도, 국적도, 문화도 — 아이가 동의하지 않은 것들입니다. 아이는 완전한 진공 상태에서 태어날 수 없습니다. 부모의 세계관 안에서 태어납니다. 중요한 것은 그 이름을 강요로 만드는 것이 아니라, 아이가 자라며 자신의 정체성을 자유롭게 탐색할 수 있는 환경을 만드는 것입니다. +밀: 언어와 문화 전수는 사회적 기능입니다. 하지만 종교적 이름은 다릅니다. 그것은 특정 신앙 공동체에의 귀속을 이름 자체에 각인시킵니다. 아이가 커서 다른 신앙을 갖거나 무신론자가 된다면 — 그는 평생 자신이 선택하지 않은 신앙을 이름으로 달고 살아야 합니다. 그 불일치가 가져오는 부담은 작지 않습니다. +[클로징] 당신의 이름은 누가 결정했습니까? 그리고 그것은 당신의 것입니까? +메타데이터 +구분 +상세 내용 +데이터 (Data) +기본 정보 +카테고리 +철학 +A안 선택 시 +선택지 명칭 +정당하다 + + +철학자 키워드 +#공자 #아리스토텔레스 #플라톤 + + +성향 지표 (Value) +[관계] [전통] +B안 선택 시 +선택지 명칭 +정당하지 않다 + + +철학자 키워드 +#사르트르 #칸트 #니체 + + +성향 지표 (Value) +[개인] [원칙] diff --git a/src/test/resources/battle-scripts/sample-real-3.txt b/src/test/resources/battle-scripts/sample-real-3.txt new file mode 100644 index 00000000..d3ce8455 --- /dev/null +++ b/src/test/resources/battle-scripts/sample-real-3.txt @@ -0,0 +1,64 @@ +013 — 단톡방 생일 축하,”다 같이 축하” vs 조용히 넘어가기 + +사전 투표 +“다 같이 축하 (공자)” vs “조용히 넘기기 (노자)” + +[오프닝] +단톡방에 생일 알림이 떴습니다. +누군가는 바로 “생일 축하해!”를 보내고, 그 뒤로 축하 메시지가 줄줄이 이어집니다.그런데 당사자는 조용히 지나가고 싶었을지도 모릅니다. 관계의 예의를 중시하는 공자와 불필요한 개입을 줄이려는 노자가 단톡방 알림 앞에서 마주 앉았습니다. + +1라운드 +공자:생일 축하는 단순한 메시지가 아닙니다. +그 사람을 기억하고 있다는 작은 표시입니다. +사람은 관계 속에서 살아가며, 관계는 이런 사소한 예의로 유지됩니다. +단톡방에서 누군가의 생일을 알고도 아무 말 없이 지나가는 것은, 무례는 아닐지 몰라도 무심함에 가깝습니다. +노자:하지만 모든 관심이 좋은 것은 아닙니다. +누군가는 자신의 생일이 공개적으로 언급되는 것을 부담스러워할 수 있습니다. +억지로 축하의 흐름을 만드는 순간, 축하는 마음이 아니라 분위기에 맞춘 의무가 됩니다. +조용히 지나가고 싶은 사람에게는 아무 말 하지 않는 것이 오히려 배려일 수 있습니다. + + +2라운드: +공자:물론 부담스러운 방식의 축하는 조심해야 합니다. + 그러나 축하 자체를 피하는 것은 관계를 너무 차갑게 만드는 일입니다. + “축하해”라는 한마디는 상대에게 내가 너를 잊지 않았다는 신호입니다. + 관계의 온도는 거창한 행동이 아니라 이런 작은 말에서 만들어집니다. +노자:진심 없는 축하는 오히려 관계를 어색하게 만듭니다. +모두가 하니까 따라 하는 축하는 형식일 뿐입니다. +상대가 원하지 않는 관심이라면, 그것은 예의가 아니라 개입이 됩니다. +때로는 말하지 않고 지나가는 것이 가장 자연스러운 배려입니다. + +[클로징] +단톡방 생일 축하는 관계를 지키는 예의일까요, +아니면 원치 않는 관심을 만드는 부담일까요? + + +메타데이터 +구분 +상세 내용 +데이터 (Data) +기본 정보 +카테고리 +관계 +A안 선택 시 +선택지 명칭 +다 같이 축하 + + +철학자 키워드 +#공자 #아리스토텔레스 #칸트 + + +성향 지표 (Value) +[관계] [전통] [감성] +B안 선택 시 +선택지 명칭 +조용히 넘기기 + + +철학자 키워드 +#노자 #사르트르 #니체 + + +성향 지표 (Value) +[개인] [자연] [내면]