diff --git a/docs/api-specs/battle-api.md b/docs/api-specs/battle-api.md index cfbcd2b3..6d918e9a 100644 --- a/docs/api-specs/battle-api.md +++ b/docs/api-specs/battle-api.md @@ -30,7 +30,40 @@ 기준 컨트롤러: `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` + - `summary` = 오프닝 전문을 LLM 이 40자 이내로 한 줄 요약한 것. 요약 생성 실패 시 오프닝 원문 그대로 들어가고 `SUMMARY_GENERATION_FAILED` warning 이 붙는다 + - `description` = 오프닝 전문 원문 그대로 + - `options[].title` = **사전 투표 영역의 짧은 선택지명이 있으면 그걸 우선 사용**(예: `"유죄다" vs "무죄다"` → `유죄다`/`무죄다`), 사전 투표가 없으면 메타데이터 표의 "선택지 명칭"으로 대체 + - `scenarioPayload` — [시나리오 API](./scenario-api.md) 2.2 생성 요청과 동일한 형태(`AdminScenarioCreateRequest`). **`battleId` 는 항상 `null`** — 배틀이 아직 생성 전이라서다. 어드민이 `battlePayload` 로 배틀을 먼저 만들고, 응답으로 받은 `battleId` 를 채워 시나리오를 등록한다 + - `speakerNames` — `{"A": "플라톤", "B": "마르크스"}` 형태로 화자 A/B 에 바인딩된 철학자 이름 + - `warnings[]` — 아래 표. `blocking: true` 인 항목이 하나라도 있으면 미리보기에서 해결하기 전까지 발행하지 않는다 +- **문장 단위 분리**: `scenarioPayload.nodes[].scripts[]` 는 원문의 한 발언(문단)을 그대로 담지 않고, 마침표/느낌표/물음표(`.` `!` `?`) 뒤 공백 기준으로 **문장마다 별도 script** 로 쪼갠다. 같은 발언에서 쪼개진 문장들은 `speakerName`/`speakerType`/`tone` 이 전부 동일하다. 오디오 파이프라인이 script 마다 무음(600ms)을 넣으므로 문장 단위 분리가 곧 자연스러운 끊어읽기가 된다. 종결부호 뒤에 닫는 따옴표(`"`/`'`)가 먼저 오면 그 지점은 안 끊긴다(알려진 제한사항). +- **선택지 설명(`interactiveOptions[].title`)**: `👉 [선택의 시간]` 아래 `A: {설명} (철학자)` / `B: {설명} (철학자)` 줄의 `{설명}` 부분이 그대로 들어간다. 인터랙티브(분기 O) 배틀에만 존재. + +**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 | +| `SUMMARY_GENERATION_FAILED` | 오프닝 한 줄 요약 생성 실패 → `summary` 에 오프닝 원문이 그대로 들어감 | 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 +80,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..648948e5 --- /dev/null +++ b/docs/api-specs/philosopher-voice-api.md @@ -0,0 +1,44 @@ +# 철학자 보이스 매핑(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`, `imageKey`, `note` + - **`imageKey`는 DB의 raw 저장 키가 아니라 `ResourceUrlProvider`로 변환된 호출 가능한 경로다** (다른 이미지 필드인 배틀 썸네일·옵션 이미지와 동일 규칙). 값이 있으면 그대로 ``에 써도 된다. `null`이면 이미지가 등록 안 된 것 — 프론트 기본 이미지로 대체 + +### 1.2 생성 +- `POST /api/v1/admin/philosopher-voices` +- 요청 본문(`PhilosopherVoiceRequest`): + - `name` (필수, 유니크) + - `referenceId` (필수, Fish Audio 보이스 모델 ID) + - `voiceLabel` (선택, 표시용 라벨. 예: `"미호크 장정진"`) + - `imageKey` (선택, 철학자 이미지 저장 키. 예: `"images/philosophers/rousseau.png"`) + - `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` 를 직접 오버라이드하면 된다(이 테이블은 파서가 채우는 기본값 소스일 뿐) +- **`imageKey`**: `PhilosopherType` enum(철학자 유형 10인)만 실제 이미지가 있고, 그 밖의 철학자(예: 루소, 홉스, 쇼펜하우어)는 원래 이름을 해시해서 10개 이미지 중 하나를 무작위로 배정하는 폴백이 있었다(`PhilosopherType.resolveImageKey`) — 엉뚱한 철학자 이미지가 뜨는 문제가 있었음. 프론트는 배틀 생성 화면에서 이 API의 `imageKey` 로 정확한 이미지를 조회해 쓰고, 값이 없으면(아직 등록 안 된 철학자) 기본 이미지로 대체해야 한다. `imageKey` 는 `null` 이어도 되며, 랜덤 배정 로직으로 대신하지 않는다 diff --git a/docs/api-specs/scenario-api.md b/docs/api-specs/scenario-api.md index 5d211914..b1b9d47a 100644 --- a/docs/api-specs/scenario-api.md +++ b/docs/api-specs/scenario-api.md @@ -30,11 +30,13 @@ - `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` + - `title` (선택 시 보여줄 선택지 설명, 생략 가능) - `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 +60,5 @@ - 임시저장(`DRAFT`) 상태에서는 대본/설정은 DB 저장, 발행(`PUBLISHED`) 시점에 TTS 파이프라인 수행 - 발행 후 수정 시에는 변경된 스크립트 조각만 재생성하고 병합 오디오를 갱신 +- `text` 또는 `tone` 이 바뀐 스크립트만 오디오를 재생성한다 (그 외 조각은 S3 캐시 재사용) +- TTS 는 Fish Audio S2.1 Pro 사용 (`fishaudio.tts.model`, 기본값 `s2.1-pro`). 톤 태그는 `[...]` 대괄호로 `text` 앞에 붙어 전송된다 diff --git a/docs/db/20260910_seed_philosopher_voice.sql b/docs/db/20260910_seed_philosopher_voice.sql new file mode 100644 index 00000000..ebf6f695 --- /dev/null +++ b/docs/db/20260910_seed_philosopher_voice.sql @@ -0,0 +1,72 @@ +-- 철학자 → Fish Audio 보이스(reference_id) 초기 매핑 시드 +-- 관련 설계: 배틀_발행_시나리오_현행_vs_개선.md 2.5 +-- +-- 테이블 자체는 spring.jpa.hibernate.ddl-auto=update 가 PhilosopherVoice 엔티티를 보고 생성한다. +-- 이 스크립트는 초기 데이터만 넣는다. 환경별(dev / prod / local)로 1회 실행. +-- ON CONFLICT DO NOTHING 이라 재실행해도 안전하고, 어드민에서 수정한 값을 덮지 않는다. +-- 이후 매핑 변경/추가는 어드민 CRUD(/api/v1/admin/philosopher-voices)로 한다. + +INSERT INTO philosopher_voice (name, reference_id, voice_label, created_at, updated_at) VALUES + ('소크라테스', '02f1c443c12b4f1fa0777983d07e64af', '정감 있는 이야기꾼(남자 노인)', now(), now()), + ('스마일즈', '02f1c443c12b4f1fa0777983d07e64af', '정감 있는 이야기꾼(남자 노인)', now(), now()), + ('플라톤', '02f1c443c12b4f1fa0777983d07e64af', '정감 있는 이야기꾼(남자 노인)', now(), now()), + ('톨스토이', '02f1c443c12b4f1fa0777983d07e64af', '정감 있는 이야기꾼(남자 노인)', now(), now()), + ('맹자', '02f1c443c12b4f1fa0777983d07e64af', '정감 있는 이야기꾼(남자 노인)', now(), now()), + ('공자', '02f1c443c12b4f1fa0777983d07e64af', '정감 있는 이야기꾼(남자 노인)', now(), now()), + ('칙센트미하이', '02f1c443c12b4f1fa0777983d07e64af', '정감 있는 이야기꾼(남자 노인)', now(), now()), + ('히포크라테스', '02f1c443c12b4f1fa0777983d07e64af', '정감 있는 이야기꾼(남자 노인)', now(), now()), + + ('아리스토텔레스', '87d3970929bf47e19cf38472eda5764d', '한국 성우', now(), now()), + ('벤담', '87d3970929bf47e19cf38472eda5764d', '한국 성우', now(), now()), + ('한비자', '87d3970929bf47e19cf38472eda5764d', '한국 성우', now(), now()), + ('피터싱어', '87d3970929bf47e19cf38472eda5764d', '한국 성우', now(), now()), + ('볼테르', '87d3970929bf47e19cf38472eda5764d', '한국 성우', now(), now()), + ('푸코', '87d3970929bf47e19cf38472eda5764d', '한국 성우', now(), now()), + ('토크빌', '87d3970929bf47e19cf38472eda5764d', '한국 성우', now(), now()), + ('베카리아', '87d3970929bf47e19cf38472eda5764d', '한국 성우', now(), now()), + ('스피노자', '87d3970929bf47e19cf38472eda5764d', '한국 성우', now(), now()), + ('애덤 그랜트', '87d3970929bf47e19cf38472eda5764d', '한국 성우', now(), now()), + ('버크', '87d3970929bf47e19cf38472eda5764d', '한국 성우', now(), now()), + ('쇼펜하우어', '87d3970929bf47e19cf38472eda5764d', '한국 성우', now(), now()), + ('노자', '87d3970929bf47e19cf38472eda5764d', '한국 성우', now(), now()), + ('보르헤스', '87d3970929bf47e19cf38472eda5764d', '한국 성우', now(), now()), + ('월드런', '87d3970929bf47e19cf38472eda5764d', '한국 성우', now(), now()), + + ('홉스', '14da644e89dc4ab18a3c066788b3f2c5', '미호크 장정진', now(), now()), + ('비트겐슈타인', '14da644e89dc4ab18a3c066788b3f2c5', '미호크 장정진', now(), now()), + ('도스토옙스키', '14da644e89dc4ab18a3c066788b3f2c5', '미호크 장정진', now(), now()), + ('니체', '14da644e89dc4ab18a3c066788b3f2c5', '미호크 장정진', now(), now()), + ('밀', '14da644e89dc4ab18a3c066788b3f2c5', '미호크 장정진', now(), now()), + ('뒤르켐', '14da644e89dc4ab18a3c066788b3f2c5', '미호크 장정진', now(), now()), + ('베이컨', '14da644e89dc4ab18a3c066788b3f2c5', '미호크 장정진', now(), now()), + ('존 로크', '14da644e89dc4ab18a3c066788b3f2c5', '미호크 장정진', now(), now()), + ('루소', '14da644e89dc4ab18a3c066788b3f2c5', '미호크 장정진', now(), now()), + ('에피쿠로스', '14da644e89dc4ab18a3c066788b3f2c5', '미호크 장정진', now(), now()), + ('사르트르', '14da644e89dc4ab18a3c066788b3f2c5', '미호크 장정진', now(), now()), + ('간디', '14da644e89dc4ab18a3c066788b3f2c5', '미호크 장정진', now(), now()), + ('사마천', '14da644e89dc4ab18a3c066788b3f2c5', '미호크 장정진', now(), now()), + ('프로이트', '14da644e89dc4ab18a3c066788b3f2c5', '미호크 장정진', now(), now()), + ('카뮈', '14da644e89dc4ab18a3c066788b3f2c5', '미호크 장정진', now(), now()), + ('데카르트', '14da644e89dc4ab18a3c066788b3f2c5', '미호크 장정진', now(), now()), + + ('칸트', '21c5fb9d1000425c9a74536691c46ce4', 'Meursault(남자 중저음)', now(), now()), + ('애들러', '21c5fb9d1000425c9a74536691c46ce4', 'Meursault(남자 중저음)', now(), now()), + ('마키아벨리', '21c5fb9d1000425c9a74536691c46ce4', 'Meursault(남자 중저음)', now(), now()), + ('엘리아데', '21c5fb9d1000425c9a74536691c46ce4', 'Meursault(남자 중저음)', now(), now()), + ('프롬', '21c5fb9d1000425c9a74536691c46ce4', 'Meursault(남자 중저음)', now(), now()), + ('융', '21c5fb9d1000425c9a74536691c46ce4', 'Meursault(남자 중저음)', now(), now()), + ('롤스', '21c5fb9d1000425c9a74536691c46ce4', 'Meursault(남자 중저음)', now(), now()), + ('왈처', '21c5fb9d1000425c9a74536691c46ce4', 'Meursault(남자 중저음)', now(), now()), + ('암베드카르', '21c5fb9d1000425c9a74536691c46ce4', 'Meursault(남자 중저음)', now(), now()), + ('라이프니츠', '21c5fb9d1000425c9a74536691c46ce4', 'Meursault(남자 중저음)', now(), now()), + ('카너먼', '21c5fb9d1000425c9a74536691c46ce4', 'Meursault(남자 중저음)', now(), now()), + ('도킨스', '21c5fb9d1000425c9a74536691c46ce4', 'Meursault(남자 중저음)', now(), now()), + ('흄', '21c5fb9d1000425c9a74536691c46ce4', 'Meursault(남자 중저음)', now(), now()), + ('보드리야르', '21c5fb9d1000425c9a74536691c46ce4', 'Meursault(남자 중저음)', now(), now()), + ('몽테스키외', '21c5fb9d1000425c9a74536691c46ce4', 'Meursault(남자 중저음)', now(), now()), + ('마이어스', '21c5fb9d1000425c9a74536691c46ce4', 'Meursault(남자 중저음)', now(), now()), + ('마르크스', '21c5fb9d1000425c9a74536691c46ce4', 'Meursault(남자 중저음)', now(), now()), + ('바흐', '21c5fb9d1000425c9a74536691c46ce4', 'Meursault(남자 중저음)', now(), now()), + + ('아렌트', '90597c459f7a4438801589a5268c1628', '차분하고 지적인(여성)', now(), now()) +ON CONFLICT (name) DO NOTHING; diff --git a/docs/db/20260912_update_philosopher_voice_image_key.sql b/docs/db/20260912_update_philosopher_voice_image_key.sql new file mode 100644 index 00000000..2e3a8c21 --- /dev/null +++ b/docs/db/20260912_update_philosopher_voice_image_key.sql @@ -0,0 +1,35 @@ +-- philosopher_voice.image_key 채우기 +-- 관련 이슈: #475 (PR #476) +-- +-- Railway Bucket images/philosophers/ 에 실제 업로드된 파일과 이름이 일치하는 +-- 22명만 채운다. 나머지 36명(voice는 있지만 이미지 없음)은 image_key = NULL 유지 — +-- 프론트가 null 이면 기본 플레이스홀더 이미지로 대체해야 한다(philosopher-voice-api.md 참고). +-- +-- images/philosophers/ 에는 이 22명 외에 6개 파일이 더 있다 +-- (aquinas, choe_hangi, jeong_yakyong, xunzi, yi_hwang, yi_i) — +-- 이 6명은 philosopher_voice 에 row 자체가 없다(Fish 보이스 미정이라 별도 처리 필요). +-- +-- 환경별(dev/prod) 1회 실행. 재실행해도 안전(멱등 UPDATE). + +UPDATE philosopher_voice SET image_key = 'images/philosophers/aristotle.png', updated_at = now() WHERE name = '아리스토텔레스'; +UPDATE philosopher_voice SET image_key = 'images/philosophers/buddha.png', updated_at = now() WHERE name = '석가모니'; +UPDATE philosopher_voice SET image_key = 'images/philosophers/camus.png', updated_at = now() WHERE name = '카뮈'; +UPDATE philosopher_voice SET image_key = 'images/philosophers/confucius.png', updated_at = now() WHERE name = '공자'; +UPDATE philosopher_voice SET image_key = 'images/philosophers/descartes.png', updated_at = now() WHERE name = '데카르트'; +UPDATE philosopher_voice SET image_key = 'images/philosophers/epicurus.png', updated_at = now() WHERE name = '에피쿠로스'; +UPDATE philosopher_voice SET image_key = 'images/philosophers/fromm.png', updated_at = now() WHERE name = '프롬'; +UPDATE philosopher_voice SET image_key = 'images/philosophers/hobbes.png', updated_at = now() WHERE name = '홉스'; +UPDATE philosopher_voice SET image_key = 'images/philosophers/hume.png', updated_at = now() WHERE name = '흄'; +UPDATE philosopher_voice SET image_key = 'images/philosophers/jung.png', updated_at = now() WHERE name = '융'; +UPDATE philosopher_voice SET image_key = 'images/philosophers/kant.png', updated_at = now() WHERE name = '칸트'; +UPDATE philosopher_voice SET image_key = 'images/philosophers/laozi.png', updated_at = now() WHERE name = '노자'; +UPDATE philosopher_voice SET image_key = 'images/philosophers/leibniz.png', updated_at = now() WHERE name = '라이프니츠'; +UPDATE philosopher_voice SET image_key = 'images/philosophers/marx.png', updated_at = now() WHERE name = '마르크스'; +UPDATE philosopher_voice SET image_key = 'images/philosophers/mencius.png', updated_at = now() WHERE name = '맹자'; +UPDATE philosopher_voice SET image_key = 'images/philosophers/mill.png', updated_at = now() WHERE name = '밀'; +UPDATE philosopher_voice SET image_key = 'images/philosophers/nietzsche.png', updated_at = now() WHERE name = '니체'; +UPDATE philosopher_voice SET image_key = 'images/philosophers/plato.png', updated_at = now() WHERE name = '플라톤'; +UPDATE philosopher_voice SET image_key = 'images/philosophers/rawls.png', updated_at = now() WHERE name = '롤스'; +UPDATE philosopher_voice SET image_key = 'images/philosophers/sartre.png', updated_at = now() WHERE name = '사르트르'; +UPDATE philosopher_voice SET image_key = 'images/philosophers/schopenhauer.png', updated_at = now() WHERE name = '쇼펜하우어'; +UPDATE philosopher_voice SET image_key = 'images/philosophers/socrates.png', updated_at = now() WHERE name = '소크라테스'; diff --git a/docs/erd/philosopher-voice.puml b/docs/erd/philosopher-voice.puml new file mode 100644 index 00000000..fc66a171 --- /dev/null +++ b/docs/erd/philosopher-voice.puml @@ -0,0 +1,50 @@ +@startuml philosopher-voice +hide circle +hide methods +skinparam linetype ortho + +entity "philosopher_voice" as philosopher_voice { + * id : BIGINT <> + -- + name : VARCHAR(50) <> + reference_id : VARCHAR(64) + voice_label : VARCHAR(100) <> + image_key : VARCHAR(500) <> + note : VARCHAR(255) <> + created_at : TIMESTAMP + updated_at : TIMESTAMP +} + +entity "scenarios" as scenarios { + * id : BIGINT <> +} + +entity "scenario_voice_settings" as scenario_voice_settings { + * scenario_id : BIGINT <> + * speaker_type : VARCHAR(20) + -- + voice_code : VARCHAR(200) +} + +scenarios ||--o{ scenario_voice_settings : has +philosopher_voice ..> scenario_voice_settings : 파서가 voice_code 채움 + +note bottom of philosopher_voice + 철학자 이름 → Fish Audio 보이스(reference_id) 매핑. + 붙여넣기 파서가 시나리오 발화자(옵션 A/B 철학자)에 맞는 + reference_id 를 조회해 scenario_voice_settings.voice_code 에 채운다. + NARRATOR / USER 고정 보이스는 이 테이블이 아니라 config. + + FK 없음: scenario_voice_settings 가 name 이 아닌 완성된 + voice_code(reference_id) 를 저장하므로 논리적 참조만 존재. + 매핑 변경/추가는 어드민(/api/v1/admin/philosopher-voices)에서 한다. + 초기 데이터는 docs/db/20260910_seed_philosopher_voice.sql. + + image_key : 철학자 이미지 저장 키(S3/Railway Bucket). + PhilosopherType enum(철학자 유형 10인) 밖 철학자는 원래 + 이름 해시로 10개 이미지 중 하나를 무작위 배정했는데, + 그 대신 프론트가 이 값으로 정확한 이미지를 쓰게 하려고 추가. + 없으면 프론트가 기본 이미지로 대체해야 한다. +end note + +@enduml diff --git a/docs/erd/scenario.puml b/docs/erd/scenario.puml index 0e5d2e38..83094c9f 100644 --- a/docs/erd/scenario.puml +++ b/docs/erd/scenario.puml @@ -41,6 +41,7 @@ entity "scenario_scripts" as scenario_scripts { speaker_type : VARCHAR(20) speaker_name : VARCHAR(100) text : TEXT + tone : VARCHAR(20) audio_url : VARCHAR(500) script_order : INT created_at : TIMESTAMP @@ -52,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 @@ -84,4 +86,19 @@ 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 + | WHISPERING | SOFT_TONE | BREATHY | EXCITED + NEUTRAL 이면 TTS 전송 시 톤 태그를 붙이지 않음. + 오디오 효과 태그([sighing] 등)는 text 인라인에 그대로 둔다. + tone 이 바뀌면 audio_url 을 비워 재합성한다. +end note + @enduml 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/controller/AdminPhilosopherVoiceController.java b/src/main/java/com/swyp/picke/domain/admin/controller/AdminPhilosopherVoiceController.java new file mode 100644 index 00000000..6019f230 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/controller/AdminPhilosopherVoiceController.java @@ -0,0 +1,58 @@ +package com.swyp.picke.domain.admin.controller; + +import com.swyp.picke.domain.admin.dto.philosophervoice.request.PhilosopherVoiceRequest; +import com.swyp.picke.domain.admin.dto.philosophervoice.response.PhilosopherVoiceResponse; +import com.swyp.picke.domain.scenario.service.PhilosopherVoiceService; +import com.swyp.picke.global.common.response.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "관리자 철학자 보이스 API", description = "철학자 이름 → Fish Audio 보이스(reference_id) 매핑 관리") +@RestController +@RequestMapping("/api/v1/admin/philosopher-voices") +@RequiredArgsConstructor +@PreAuthorize("hasRole('ADMIN')") +public class AdminPhilosopherVoiceController { + + private final PhilosopherVoiceService philosopherVoiceService; + + @Operation(summary = "철학자 보이스 매핑 목록") + @GetMapping + public ApiResponse> getAll() { + return ApiResponse.onSuccess(philosopherVoiceService.getAll()); + } + + @Operation(summary = "철학자 보이스 매핑 생성") + @PostMapping + public ApiResponse create(@Valid @RequestBody PhilosopherVoiceRequest request) { + return ApiResponse.onSuccess(philosopherVoiceService.create(request)); + } + + @Operation(summary = "철학자 보이스 매핑 수정") + @PatchMapping("/{id}") + public ApiResponse update( + @PathVariable Long id, + @Valid @RequestBody PhilosopherVoiceRequest request + ) { + return ApiResponse.onSuccess(philosopherVoiceService.update(id, request)); + } + + @Operation(summary = "철학자 보이스 매핑 삭제") + @DeleteMapping("/{id}") + public ApiResponse delete(@PathVariable Long id) { + philosopherVoiceService.delete(id); + return ApiResponse.onSuccess(null); + } +} diff --git a/src/main/java/com/swyp/picke/domain/admin/dto/philosophervoice/request/PhilosopherVoiceRequest.java b/src/main/java/com/swyp/picke/domain/admin/dto/philosophervoice/request/PhilosopherVoiceRequest.java new file mode 100644 index 00000000..10be818c --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/dto/philosophervoice/request/PhilosopherVoiceRequest.java @@ -0,0 +1,11 @@ +package com.swyp.picke.domain.admin.dto.philosophervoice.request; + +import jakarta.validation.constraints.NotBlank; + +public record PhilosopherVoiceRequest( + @NotBlank String name, + @NotBlank String referenceId, + String voiceLabel, + String imageKey, + String note +) {} diff --git a/src/main/java/com/swyp/picke/domain/admin/dto/philosophervoice/response/PhilosopherVoiceResponse.java b/src/main/java/com/swyp/picke/domain/admin/dto/philosophervoice/response/PhilosopherVoiceResponse.java new file mode 100644 index 00000000..e51907e1 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/dto/philosophervoice/response/PhilosopherVoiceResponse.java @@ -0,0 +1,27 @@ +package com.swyp.picke.domain.admin.dto.philosophervoice.response; + +import com.swyp.picke.domain.scenario.entity.PhilosopherVoice; + +public record PhilosopherVoiceResponse( + Long id, + String name, + String referenceId, + String voiceLabel, + String imageKey, + String note +) { + /** + * @param imageUrl {@code entity.getImageKey()} 를 {@code ResourceUrlProvider.getImageUrl(FileCategory.PHILOSOPHER, ...)} + * 로 변환한 호출 가능한 경로. raw key 를 그대로 내려주지 않는다(다른 이미지 필드와 동일한 규칙). + */ + public static PhilosopherVoiceResponse from(PhilosopherVoice entity, String imageUrl) { + return new PhilosopherVoiceResponse( + entity.getId(), + entity.getName(), + entity.getReferenceId(), + entity.getVoiceLabel(), + imageUrl, + entity.getNote() + ); + } +} 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/request/AdminScenarioScriptRequest.java b/src/main/java/com/swyp/picke/domain/admin/dto/scenario/request/AdminScenarioScriptRequest.java index 60563860..cf302c82 100644 --- a/src/main/java/com/swyp/picke/domain/admin/dto/scenario/request/AdminScenarioScriptRequest.java +++ b/src/main/java/com/swyp/picke/domain/admin/dto/scenario/request/AdminScenarioScriptRequest.java @@ -1,9 +1,11 @@ package com.swyp.picke.domain.admin.dto.scenario.request; import com.swyp.picke.domain.scenario.enums.SpeakerType; +import com.swyp.picke.domain.scenario.enums.Tone; public record AdminScenarioScriptRequest( String speakerName, SpeakerType speakerType, - String text + String text, + Tone tone ) {} 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/dto/scenario/response/AdminScenarioScriptResponse.java b/src/main/java/com/swyp/picke/domain/admin/dto/scenario/response/AdminScenarioScriptResponse.java index 2fd0f253..91cb3c43 100644 --- a/src/main/java/com/swyp/picke/domain/admin/dto/scenario/response/AdminScenarioScriptResponse.java +++ b/src/main/java/com/swyp/picke/domain/admin/dto/scenario/response/AdminScenarioScriptResponse.java @@ -1,6 +1,7 @@ package com.swyp.picke.domain.admin.dto.scenario.response; import com.swyp.picke.domain.scenario.enums.SpeakerType; +import com.swyp.picke.domain.scenario.enums.Tone; import lombok.Builder; @Builder @@ -9,5 +10,6 @@ public record AdminScenarioScriptResponse( Integer startTimeMs, SpeakerType speakerType, String speakerName, - String text + String text, + Tone tone ) {} 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/admin/service/AdminScenarioService.java b/src/main/java/com/swyp/picke/domain/admin/service/AdminScenarioService.java index 50a62517..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 @@ -83,7 +83,8 @@ private List toScriptRequests(List sc .map(scriptRequest -> new ScriptRequest( scriptRequest.speakerName(), scriptRequest.speakerType(), - scriptRequest.text() + scriptRequest.text(), + scriptRequest.tone() )) .toList(); } @@ -95,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/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..40f301c0 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/battle/parser/BattleScriptDocument.java @@ -0,0 +1,66 @@ +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 { + /** 사전 투표 줄의 짧은 선택지명(예: "유죄다"). 있으면 이게 최종 title 이 된다. */ + public String choiceName; + /** 메타데이터 표 "선택지 명칭" 값. 사전 투표가 없을 때만 title 로 쓰는 폴백. */ + public String metadataChoiceName; + /** 사전 투표 줄에 적힌 대표 발화자(예: "플라톤"). 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 title; // 선택의 시간 줄의 설명 텍스트 (예: "첫 만남에는 그에 걸맞은 격조와 분위기가 필수다, 유죄!") + public String nextNodeName; // "분기_A" 등 + public 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 new file mode 100644 index 00000000..53467ff8 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/battle/parser/BattleScriptDocumentParser.java @@ -0,0 +1,375 @@ +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; + + // 선택지 명칭: 사전 투표의 짧은 이름이 우선, 없을 때만 메타데이터 표의 "선택지 명칭"으로 채운다 + if (doc.optionA.choiceName == null) { + doc.optionA.choiceName = doc.optionA.metadataChoiceName; + } + if (doc.optionB.choiceName == null) { + doc.optionB.choiceName = doc.optionB.metadataChoiceName; + } + 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(); + } + 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) { + 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.metadataChoiceName = 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..4815ac57 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/battle/parser/EmotionClassifier.java @@ -0,0 +1,26 @@ +package com.swyp.picke.domain.battle.parser; + +import com.swyp.picke.domain.scenario.enums.Tone; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +/** + * 대본 대사에 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); + + /** 오프닝 전문을 배틀 카드용 한 줄 요약으로 압축한다. 실패 시 empty. */ + Optional summarizeOpening(String battleTitle, String openingText); +} 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..86f35f4d --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/battle/parser/OpenAiEmotionClassifier.java @@ -0,0 +1,168 @@ +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 java.util.Optional; +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 static final String SUMMARY_SYSTEM = """ + 너는 철학 토론 배틀 카드에 들어갈 한 줄 요약을 쓰는 도구다. + 오프닝 전문을 읽고, 배틀 카드에 노출할 흥미로운 한 줄 요약을 40자 이내 한국어로 작성해라. + 원문을 그대로 자르지 말고, 핵심 갈등/질문을 압축해라. + 반드시 JSON 만 출력: {"summary":"..."} + """; + + 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(); + } + } + + @Override + public Optional summarizeOpening(String battleTitle, String openingText) { + if (openingText == null || openingText.isBlank()) { + return Optional.empty(); + } + try { + String userPrompt = "배틀 제목: " + battleTitle + "\n오프닝 전문:\n" + openingText; + JsonNode root = call(SUMMARY_SYSTEM, userPrompt); + String summary = root.path("summary").asText(null); + return (summary == null || summary.isBlank()) ? Optional.empty() : Optional.of(summary.trim()); + } catch (Exception e) { + log.warn("[EmotionClassifier] 오프닝 요약 생성 실패", e); + return Optional.empty(); + } + } + + 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..5d48224c --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/battle/service/BattleScriptParseService.java @@ -0,0 +1,401 @@ +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 java.util.regex.Pattern; +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 static final Pattern SENTENCE_BOUNDARY = Pattern.compile("(?<=[.!?])\\s+(?=\\S)"); + + 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 description = allNarration(doc, "오프닝"); + String summary = summarizeOpening(doc.title, description, warnings); + + AdminBattleCreateRequest battlePayload = new AdminBattleCreateRequest( + doc.title, + summary, + description, + 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; + for (String sentence : splitIntoSentences(text)) { + scripts.add(new AdminScenarioScriptRequest(speakerName, speakerType, sentence, tone)); + } + } + + List options = node.options.stream() + .map(o -> new AdminScenarioOptionRequest(o.label, o.title, 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)); + } + } + + // ---- 유틸 ---- + + /** 오프닝 전문을 LLM으로 한 줄 요약한다(배틀 카드 summary 용). 실패하면 오프닝 원문으로 대신한다. */ + private String summarizeOpening(String title, String openingText, List warnings) { + if (openingText == null || openingText.isBlank()) { + return null; + } + Optional summarized = emotionClassifier.summarizeOpening(title, openingText); + if (summarized.isPresent() && !summarized.get().isBlank()) { + return summarized.get(); + } + warnings.add(ParseWarning.of("SUMMARY_GENERATION_FAILED", + "오프닝 한 줄 요약 생성에 실패해 오프닝 원문을 그대로 넣었습니다. 미리보기에서 다듬어주세요.", null)); + return openingText; + } + + /** 오프닝 노드의 나레이션 문단을 전부 이어붙인다(배틀 description 용). */ + private String allNarration(BattleScriptDocument doc, String nodeName) { + String joined = doc.nodes.stream() + .filter(n -> n.name.equals(nodeName)) + .flatMap(n -> n.scripts.stream()) + .filter(s -> s.speaker == null) + .map(s -> s.text) + .reduce((a, b) -> a + " " + b) + .orElse(null); + return joined; + } + + /** + * 한 발언(text)을 문장 종결부호(. ! ?) 단위로 잘라 각각 별도 Script 로 만들기 위한 목록을 만든다. + * 오디오 파이프라인이 스크립트마다 무음(600ms)을 넣으므로, 문장 단위로 쪼개면 자연스러운 끊어읽기가 된다. + */ + private List splitIntoSentences(String text) { + if (text == null || text.isBlank()) { + return List.of(text == null ? "" : text); + } + List sentences = new ArrayList<>(); + for (String piece : SENTENCE_BOUNDARY.split(text.strip())) { + String trimmed = piece.strip(); + if (!trimmed.isEmpty()) { + sentences.add(trimmed); + } + } + return sentences.isEmpty() ? List.of(text.strip()) : sentences; + } + + private String nz(String value) { + return value == null ? "" : value; + } +} diff --git a/src/main/java/com/swyp/picke/domain/battle/service/BattleServiceImpl.java b/src/main/java/com/swyp/picke/domain/battle/service/BattleServiceImpl.java index bc43e7b0..38dd2757 100644 --- a/src/main/java/com/swyp/picke/domain/battle/service/BattleServiceImpl.java +++ b/src/main/java/com/swyp/picke/domain/battle/service/BattleServiceImpl.java @@ -662,6 +662,7 @@ private void replaceBattleOptionTags(BattleOption option, List tagIds) { List existingTags = battleOptionTagRepository.findByBattleOption(option); if (!existingTags.isEmpty()) { battleOptionTagRepository.deleteAll(existingTags); + battleOptionTagRepository.flush(); } saveBattleOptionTags(option, tagIds.stream().distinct().toList()); @@ -679,11 +680,30 @@ private String resolveStoredImageKey(String rawReference, BattleStatus targetSta return null; } if (targetStatus == BattleStatus.PUBLISHED && localDraftFileStorageService.isLocalDraftReference(normalized)) { - return localDraftFileStorageService.promoteLocalDraftToS3(normalized, fallbackCategory, s3UploadService); + // 실제 S3 업로드/로컬 draft 삭제는 커밋 이후로 미룬다. + // 여기서 바로 승격시키면, 이후 같은 트랜잭션에서 DB 저장이 실패해 롤백돼도 + // 이미 올라간 S3 파일과 지워진 draft는 되돌릴 수 없어 orphan이 된다. + String s3Key = localDraftFileStorageService.resolveS3Key(normalized, fallbackCategory); + promoteDraftAfterCommit(normalized, s3Key); + return s3Key; } return normalized; } + private void promoteDraftAfterCommit(String localReference, String s3Key) { + if (TransactionSynchronizationManager.isSynchronizationActive()) { + TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() { + @Override + public void afterCommit() { + localDraftFileStorageService.promoteToS3(localReference, s3Key, s3UploadService); + } + }); + return; + } + + localDraftFileStorageService.promoteToS3(localReference, s3Key, s3UploadService); + } + private String normalizeStoredImageReference(String rawReference, FileCategory fallbackCategory) { if (rawReference == null || rawReference.isBlank()) { return null; 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 a5b73b06..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 @@ -95,6 +95,7 @@ private ScriptResponse toUserScriptResponse(Script script) { .speakerType(script.getSpeakerType()) .speakerName(script.getSpeakerName()) .text(cleanText) + .tone(script.getTone()) .build(); } @@ -120,12 +121,14 @@ private AdminScenarioScriptResponse toAdminScriptResponse(Script script) { .speakerType(script.getSpeakerType()) .speakerName(script.getSpeakerName()) .text(script.getText()) + .tone(script.getTone()) .build(); } private OptionResponse toUserOptionResponse(InteractiveOption option) { return OptionResponse.builder() .label(option.getLabel()) + .title(option.getTitle()) .nextNodeId(option.getNextNodeId()) .build(); } @@ -133,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/request/ScriptRequest.java b/src/main/java/com/swyp/picke/domain/scenario/dto/request/ScriptRequest.java index 36984364..a3236262 100644 --- a/src/main/java/com/swyp/picke/domain/scenario/dto/request/ScriptRequest.java +++ b/src/main/java/com/swyp/picke/domain/scenario/dto/request/ScriptRequest.java @@ -1,9 +1,11 @@ package com.swyp.picke.domain.scenario.dto.request; import com.swyp.picke.domain.scenario.enums.SpeakerType; +import com.swyp.picke.domain.scenario.enums.Tone; public record ScriptRequest( String speakerName, SpeakerType speakerType, - String text -) {} \ No newline at end of file + String text, + Tone tone +) {} 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/dto/response/ScriptResponse.java b/src/main/java/com/swyp/picke/domain/scenario/dto/response/ScriptResponse.java index 7833ed20..060bad05 100644 --- a/src/main/java/com/swyp/picke/domain/scenario/dto/response/ScriptResponse.java +++ b/src/main/java/com/swyp/picke/domain/scenario/dto/response/ScriptResponse.java @@ -1,6 +1,7 @@ package com.swyp.picke.domain.scenario.dto.response; import com.swyp.picke.domain.scenario.enums.SpeakerType; +import com.swyp.picke.domain.scenario.enums.Tone; import lombok.Builder; @Builder @@ -9,5 +10,6 @@ public record ScriptResponse( Integer startTimeMs, SpeakerType speakerType, String speakerName, - String text -) {} \ No newline at end of file + String text, + Tone tone +) {} 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/entity/PhilosopherVoice.java b/src/main/java/com/swyp/picke/domain/scenario/entity/PhilosopherVoice.java new file mode 100644 index 00000000..83fc07cb --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/scenario/entity/PhilosopherVoice.java @@ -0,0 +1,61 @@ +package com.swyp.picke.domain.scenario.entity; + +import com.swyp.picke.global.common.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** + * 철학자 이름 → Fish Audio 보이스(reference_id) 매핑. + * 붙여넣기 파서가 시나리오 발화자에 맞는 보이스를 채울 때 조회한다. + */ +@Getter +@Entity +@Table(name = "philosopher_voice") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class PhilosopherVoice extends BaseEntity { + + @Column(nullable = false, unique = true, length = 50) + private String name; + + @Column(name = "reference_id", nullable = false, length = 64) + private String referenceId; + + @Column(name = "voice_label", length = 100) + private String voiceLabel; + + /** 철학자 이미지 저장 키(S3/Railway Bucket). 없으면 프론트가 기본 이미지로 대체해야 한다. */ + @Column(name = "image_key", length = 500) + private String imageKey; + + @Column(length = 255) + private String note; + + @Builder + public PhilosopherVoice(String name, String referenceId, String voiceLabel, String imageKey, String note) { + this.name = name; + this.referenceId = referenceId; + this.voiceLabel = voiceLabel; + this.imageKey = imageKey; + this.note = note; + } + + public void update(String referenceId, String voiceLabel, String imageKey, String note) { + if (referenceId != null && !referenceId.isBlank()) { + this.referenceId = referenceId; + } + if (voiceLabel != null) { + this.voiceLabel = voiceLabel; + } + if (imageKey != null) { + this.imageKey = imageKey; + } + if (note != null) { + this.note = note; + } + } +} diff --git a/src/main/java/com/swyp/picke/domain/scenario/entity/Script.java b/src/main/java/com/swyp/picke/domain/scenario/entity/Script.java index 27e8b6e7..31b7022e 100644 --- a/src/main/java/com/swyp/picke/domain/scenario/entity/Script.java +++ b/src/main/java/com/swyp/picke/domain/scenario/entity/Script.java @@ -1,6 +1,7 @@ package com.swyp.picke.domain.scenario.entity; import com.swyp.picke.domain.scenario.enums.SpeakerType; +import com.swyp.picke.domain.scenario.enums.Tone; import com.swyp.picke.global.common.BaseEntity; import jakarta.persistence.*; import lombok.AccessLevel; @@ -29,27 +30,33 @@ public class Script extends BaseEntity { private String speakerName; @Column(columnDefinition = "TEXT") - private String text; // SSML 태그가 포함된 텍스트 + private String text; // 오디오 효과 태그([sighing] 등)가 인라인으로 포함된 텍스트 + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private Tone tone = Tone.NEUTRAL; @Column(name = "audio_url") private String audioUrl; @Builder - public Script(Integer startTimeMs, SpeakerType speakerType, String speakerName, String text) { + public Script(Integer startTimeMs, SpeakerType speakerType, String speakerName, String text, Tone tone) { this.startTimeMs = startTimeMs; this.speakerType = speakerType; this.speakerName = speakerName; this.text = text; + this.tone = tone == null ? Tone.NEUTRAL : tone; } public void updateAudioUrl(String audioUrl) { this.audioUrl = audioUrl; } - public void updateContent(SpeakerType speakerType, String speakerName, String newText) { + public void updateContent(SpeakerType speakerType, String speakerName, String newText, Tone newTone) { this.speakerType = speakerType; this.speakerName = speakerName; this.text = newText; + this.tone = newTone == null ? Tone.NEUTRAL : newTone; this.audioUrl = null; } diff --git a/src/main/java/com/swyp/picke/domain/scenario/enums/Tone.java b/src/main/java/com/swyp/picke/domain/scenario/enums/Tone.java new file mode 100644 index 00000000..6623ded0 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/scenario/enums/Tone.java @@ -0,0 +1,68 @@ +package com.swyp.picke.domain.scenario.enums; + +import java.util.Arrays; +import java.util.Optional; +import lombok.Getter; + +/** + * 대사 한 줄의 감성적인 톤. 줄당 하나. 오디오 효과 태그(문장 중간 삽입, 다중)는 이 enum이 아니라 + * Script.text 인라인에 그대로 둔다. + * + *

{@link #tag} 는 Fish Audio S2/S2.1 이 인식하는 인라인 대괄호 표기. TTS 합성 직전 text 앞에 붙인다. + * {@link #NEUTRAL} 은 아무 태그도 붙이지 않는다. + */ +@Getter +public enum Tone { + NEUTRAL(null), + ANGRY("[angry]"), + SAD("[sad]"), + EMBARRASSED("[embarrassed]"), + EMPHASIS("[emphasis]"), + WHISPERING("[whispering]"), + SOFT_TONE("[soft tone]"), + BREATHY("[breathy]"), + EXCITED("[excited]"); + + private final String tag; + + Tone(String tag) { + this.tag = tag; + } + + public boolean hasTag() { + return tag != null; + } + + /** + * 기획자가 대본에 인라인으로 써넣은 톤 태그 문자열(예: {@code [angry]}, {@code [soft]})을 enum 으로 해석. + * 표기 흔들림({@code [soft]} → SOFT_TONE)도 흡수한다. 톤이 아닌 태그면 empty. + */ + public static Optional fromInlineTag(String rawTag) { + if (rawTag == null) { + return Optional.empty(); + } + String normalized = rawTag.trim().toLowerCase() + .replace("[", "").replace("]", "").trim(); + return switch (normalized) { + case "angry" -> Optional.of(ANGRY); + case "sad" -> Optional.of(SAD); + case "embarrassed" -> Optional.of(EMBARRASSED); + case "emphasis" -> Optional.of(EMPHASIS); + case "whispering", "whisper" -> Optional.of(WHISPERING); + case "soft", "soft tone" -> Optional.of(SOFT_TONE); + case "breathy" -> Optional.of(BREATHY); + case "excited" -> Optional.of(EXCITED); + default -> Optional.empty(); + }; + } + + public static Tone fromNameOrNeutral(String name) { + if (name == null || name.isBlank()) { + return NEUTRAL; + } + return Arrays.stream(values()) + .filter(tone -> tone.name().equalsIgnoreCase(name.trim())) + .findFirst() + .orElse(NEUTRAL); + } +} diff --git a/src/main/java/com/swyp/picke/domain/scenario/repository/PhilosopherVoiceRepository.java b/src/main/java/com/swyp/picke/domain/scenario/repository/PhilosopherVoiceRepository.java new file mode 100644 index 00000000..1f978314 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/scenario/repository/PhilosopherVoiceRepository.java @@ -0,0 +1,10 @@ +package com.swyp.picke.domain.scenario.repository; + +import com.swyp.picke.domain.scenario.entity.PhilosopherVoice; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface PhilosopherVoiceRepository extends JpaRepository { + Optional findByName(String name); + boolean existsByName(String name); +} diff --git a/src/main/java/com/swyp/picke/domain/scenario/service/PhilosopherVoiceService.java b/src/main/java/com/swyp/picke/domain/scenario/service/PhilosopherVoiceService.java new file mode 100644 index 00000000..37420b05 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/scenario/service/PhilosopherVoiceService.java @@ -0,0 +1,80 @@ +package com.swyp.picke.domain.scenario.service; + +import com.swyp.picke.domain.admin.dto.philosophervoice.request.PhilosopherVoiceRequest; +import com.swyp.picke.domain.admin.dto.philosophervoice.response.PhilosopherVoiceResponse; +import com.swyp.picke.domain.scenario.entity.PhilosopherVoice; +import com.swyp.picke.domain.scenario.repository.PhilosopherVoiceRepository; +import com.swyp.picke.global.common.exception.CustomException; +import com.swyp.picke.global.common.exception.ErrorCode; +import com.swyp.picke.global.infra.s3.enums.FileCategory; +import com.swyp.picke.global.infra.s3.util.ResourceUrlProvider; +import java.util.List; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class PhilosopherVoiceService { + + private final PhilosopherVoiceRepository philosopherVoiceRepository; + private final ResourceUrlProvider resourceUrlProvider; + + @Transactional(readOnly = true) + public List getAll() { + return philosopherVoiceRepository.findAll().stream() + .map(this::toResponse) + .toList(); + } + + /** 파서가 발화자 철학자에 맞는 보이스를 채울 때 사용. 없으면 empty. */ + @Transactional(readOnly = true) + public Optional findReferenceIdByName(String philosopherName) { + if (philosopherName == null || philosopherName.isBlank()) { + return Optional.empty(); + } + return philosopherVoiceRepository.findByName(philosopherName.trim()) + .map(PhilosopherVoice::getReferenceId); + } + + @Transactional + public PhilosopherVoiceResponse create(PhilosopherVoiceRequest request) { + if (philosopherVoiceRepository.existsByName(request.name().trim())) { + throw new CustomException(ErrorCode.PHILOSOPHER_VOICE_DUPLICATED); + } + PhilosopherVoice saved = philosopherVoiceRepository.save(PhilosopherVoice.builder() + .name(request.name().trim()) + .referenceId(request.referenceId().trim()) + .voiceLabel(request.voiceLabel()) + .imageKey(request.imageKey()) + .note(request.note()) + .build()); + return toResponse(saved); + } + + @Transactional + public PhilosopherVoiceResponse update(Long id, PhilosopherVoiceRequest request) { + PhilosopherVoice entity = philosopherVoiceRepository.findById(id) + .orElseThrow(() -> new CustomException(ErrorCode.PHILOSOPHER_VOICE_NOT_FOUND)); + entity.update(request.referenceId(), request.voiceLabel(), request.imageKey(), request.note()); + return toResponse(entity); + } + + @Transactional + public void delete(Long id) { + if (!philosopherVoiceRepository.existsById(id)) { + throw new CustomException(ErrorCode.PHILOSOPHER_VOICE_NOT_FOUND); + } + philosopherVoiceRepository.deleteById(id); + } + + /** + * imageKey는 DB에 raw 저장 키로 있다가, 응답 시 다른 이미지 필드(썸네일/옵션 이미지)와 + * 동일하게 호출 가능한 경로로 변환해서 나간다({@link ResourceUrlProvider}). + */ + private PhilosopherVoiceResponse toResponse(PhilosopherVoice entity) { + String imageUrl = resourceUrlProvider.getImageUrl(FileCategory.PHILOSOPHER, entity.getImageKey()); + return PhilosopherVoiceResponse.from(entity, imageUrl); + } +} diff --git a/src/main/java/com/swyp/picke/domain/scenario/service/ScenarioAudioPipelineService.java b/src/main/java/com/swyp/picke/domain/scenario/service/ScenarioAudioPipelineService.java index 029a0a9c..26a3e94b 100644 --- a/src/main/java/com/swyp/picke/domain/scenario/service/ScenarioAudioPipelineService.java +++ b/src/main/java/com/swyp/picke/domain/scenario/service/ScenarioAudioPipelineService.java @@ -63,7 +63,8 @@ public void generateAndMergeAudioAsync(Long scenarioId) { audioFile = ttsService.generateTtsWithSsml( script.getText(), script.getSpeakerType(), - scenario.getVoiceCode(script.getSpeakerType()) + scenario.getVoiceCode(script.getSpeakerType()), + script.getTone() ); // 새로 만든 조각 파일을 다음 수정을 위해 S3에 업로드 (chunks 폴더) 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 243015fd..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 @@ -21,6 +21,7 @@ import com.swyp.picke.domain.scenario.enums.CreatorType; import com.swyp.picke.domain.scenario.enums.ScenarioStatus; import com.swyp.picke.domain.scenario.enums.SpeakerType; +import com.swyp.picke.domain.scenario.enums.Tone; import com.swyp.picke.domain.scenario.repository.ScenarioRepository; import com.swyp.picke.domain.vote.entity.BattleVote; import com.swyp.picke.domain.vote.repository.BattleVoteRepository; @@ -221,6 +222,7 @@ private boolean smartUpdateNodesToScenario(Scenario scenario, ScenarioCreateRequ .speakerType(scriptReq.speakerType()) .speakerName(autoSpeakerName) .text(scriptReq.text()) + .tone(scriptReq.tone()) .build()); } } @@ -255,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())); } @@ -291,9 +294,11 @@ private boolean updateScriptsSmartly(ScenarioNode existingNode, java.util.List]*>", "").trim(); + public File generateTtsWithSsml(String rawText, SpeakerType speakerType, String customVoice, Tone tone) throws Exception { + String actingText = TtsTextNormalizer.normalize(rawText, tone).replaceAll("<[^>]*>", "").trim(); String voiceId = (customVoice != null && !customVoice.isBlank()) ? customVoice.trim() : getElevenLabsVoiceId(speakerType); diff --git a/src/main/java/com/swyp/picke/global/infra/tts/service/FishAudioTtsServiceImpl.java b/src/main/java/com/swyp/picke/global/infra/tts/service/FishAudioTtsServiceImpl.java index 8376e0f1..1cb1bb14 100644 --- a/src/main/java/com/swyp/picke/global/infra/tts/service/FishAudioTtsServiceImpl.java +++ b/src/main/java/com/swyp/picke/global/infra/tts/service/FishAudioTtsServiceImpl.java @@ -1,6 +1,7 @@ package com.swyp.picke.global.infra.tts.service; import com.swyp.picke.domain.scenario.enums.SpeakerType; +import com.swyp.picke.domain.scenario.enums.Tone; import com.swyp.picke.global.common.exception.CustomException; import com.swyp.picke.global.common.exception.ErrorCode; import lombok.extern.slf4j.Slf4j; @@ -31,15 +32,19 @@ public class FishAudioTtsServiceImpl implements TtsService { @Value("${fishaudio.tts.url:https://api.fish.audio/v1/tts}") private String ttsUrl; + @Value("${fishaudio.tts.model:s2.1-pro}") + private String ttsModel; + @Override - public File generateTtsWithSsml(String rawText, SpeakerType speakerType, String customVoice) throws Exception { - String actingText = cleanTextForNaturalFlow(rawText); + public File generateTtsWithSsml(String rawText, SpeakerType speakerType, String customVoice, Tone tone) throws Exception { + String actingText = cleanTextForNaturalFlow(TtsTextNormalizer.normalize(rawText, tone)); String referenceId = resolveVoiceId(speakerType, customVoice); RestTemplate restTemplate = new RestTemplate(); HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_JSON); headers.setBearerAuth(fishAudioApiKey); + headers.set("model", ttsModel); Map requestBody = new HashMap<>(); requestBody.put("text", actingText); diff --git a/src/main/java/com/swyp/picke/global/infra/tts/service/TtsService.java b/src/main/java/com/swyp/picke/global/infra/tts/service/TtsService.java index d4332b4f..3fc0aa9a 100644 --- a/src/main/java/com/swyp/picke/global/infra/tts/service/TtsService.java +++ b/src/main/java/com/swyp/picke/global/infra/tts/service/TtsService.java @@ -1,8 +1,9 @@ package com.swyp.picke.global.infra.tts.service; import com.swyp.picke.domain.scenario.enums.SpeakerType; +import com.swyp.picke.domain.scenario.enums.Tone; import java.io.File; public interface TtsService { - File generateTtsWithSsml(String text, SpeakerType speakerType, String customVoice) throws Exception; -} \ No newline at end of file + File generateTtsWithSsml(String text, SpeakerType speakerType, String customVoice, Tone tone) throws Exception; +} 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 new file mode 100644 index 00000000..0604daeb --- /dev/null +++ b/src/main/java/com/swyp/picke/global/infra/tts/service/TtsTextNormalizer.java @@ -0,0 +1,44 @@ +package com.swyp.picke.global.infra.tts.service; + +import com.swyp.picke.domain.scenario.enums.Tone; +import java.util.Map; + +/** + * TTS 전송 직전 대사 텍스트를 정리한다. + * + *

    + *
  • 줄의 대표 톤({@link Tone})을 Fish Audio S2/S2.1 인라인 표기로 맨 앞에 붙인다. {@code NEUTRAL} 은 아무것도 안 붙임.
  • + *
  • 기획자가 관용적으로 쓰는 효과 태그 표기를 Fish 정식 표기로 정규화한다({@code [pause]} → {@code [break]} 등).
  • + *
  • 대괄호 태그 자체는 제거하지 않는다 — Fish S2.1 이 인라인으로 해석한다.
  • + *
+ */ +public final class TtsTextNormalizer { + + private static final Map EFFECT_ALIASES = Map.of( + "[soft]", "[soft tone]", + "[pause]", "[break]", + "[long pause]", "[long-break]", + "[long-pause]", "[long-break]" + ); + + private TtsTextNormalizer() { + } + + public static String normalize(String rawText, Tone tone) { + 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 9d197bac..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 @@ -94,6 +96,7 @@ fishaudio: api-key: ${FISHAUDIO_API_KEY} tts: url: https://api.fish.audio/v1/tts + model: ${FISHAUDIO_TTS_MODEL:s2.1-pro} voice-id: a: ${FISHAUDIO_VOICE_A} b: ${FISHAUDIO_VOICE_B} diff --git a/src/test/java/com/swyp/picke/domain/admin/controller/AdminContentCreationIntegrationTest.java b/src/test/java/com/swyp/picke/domain/admin/controller/AdminContentCreationIntegrationTest.java index a51cf36c..0998a24e 100644 --- a/src/test/java/com/swyp/picke/domain/admin/controller/AdminContentCreationIntegrationTest.java +++ b/src/test/java/com/swyp/picke/domain/admin/controller/AdminContentCreationIntegrationTest.java @@ -29,9 +29,7 @@ import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MvcResult; import org.springframework.transaction.annotation.Transactional; -import software.amazon.awssdk.core.sync.RequestBody; import software.amazon.awssdk.services.s3.S3Client; -import software.amazon.awssdk.services.s3.model.PutObjectRequest; import java.time.LocalDate; import java.util.LinkedHashMap; @@ -40,9 +38,6 @@ import java.util.UUID; import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.ArgumentMatchers.any; -import static org.mockito.Mockito.atLeastOnce; -import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart; @@ -349,12 +344,16 @@ void pending_local_images_are_promoted_to_s3_on_publish() throws Exception { .content(objectMapper.writeValueAsString(publishPayload))) .andExpect(status().isOk()); + // 실제 S3 업로드/로컬 draft 삭제는 DB 트랜잭션 커밋 이후로 미뤄진다 + // (트랜잭션 중간에 실패해도 이미 올라간 파일이 orphan되는 걸 막기 위함 — [[s3_railway_bucket_migration]] 참고). + // 이 테스트는 클래스 레벨 @Transactional로 실제 커밋 없이 끝에 롤백되므로, + // "커밋 이후에만 실제로 승격됨"을 여기서 직접 증명하지는 않는다. + // 그 배선(afterCommit 등록 여부)은 LocalDraftFileStorageServiceTest/단위테스트에서 검증한다. + // 여기서는 요청 처리 중 동기적으로 결정되는 최종 저장 키(목적지 경로)만 확인한다. Battle publishedBattle = battleRepository.findById(battleId).orElseThrow(); assertThat(publishedBattle.getThumbnailUrl()).startsWith("images/battles/"); List publishedOptions = battleOptionRepository.findByBattle(publishedBattle); assertThat(publishedOptions).allMatch(option -> option.getImageUrl().startsWith("images/philosophers/")); - - verify(s3Client, atLeastOnce()).putObject(any(PutObjectRequest.class), any(RequestBody.class)); } private String uploadLocalDraftKey(String adminToken, String fileName, String content) throws Exception { 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..6d042c57 --- /dev/null +++ b/src/test/java/com/swyp/picke/domain/battle/parser/BattleScriptDocumentParserTest.java @@ -0,0 +1,165 @@ +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("칸트", "아리스토텔레스"); + assertThat(choice.options).extracting(o -> o.title).containsExactly( + "AI 판사를 도입해야 한다. 일관된 원칙이 진정한 공정함이다.", + "AI 판사는 도입해선 안 된다. 맥락을 이해하는 인간의 판단이 필요하다."); + } + + @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..128fdd36 --- /dev/null +++ b/src/test/java/com/swyp/picke/domain/battle/service/BattleScriptParseServiceTest.java @@ -0,0 +1,296 @@ +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()).last().extracting(s -> s.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"); + // 선택의 시간 줄의 설명 텍스트("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-플라톤") + .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 선택지_title은_사전투표의_짧은_이름을_우선한다() throws IOException { + // sample-real-1.txt: 사전 투표 = "유죄다"/"무죄다", 메타데이터 선택지 명칭 = 훨씬 긴 문장 + AdminBattleParseResponse res = service.parse(load("sample-real-1.txt")); + + assertThat(res.battlePayload().options().get(0).title()).isEqualTo("유죄다"); + assertThat(res.battlePayload().options().get(1).title()).isEqualTo("무죄다"); + } + + @Test + void 사전투표가_없으면_메타데이터_선택지_명칭을_title로_쓴다() throws IOException { + when(emotionClassifier.bindSpeakers(anyString(), anyString(), anyString(), any())) + .thenReturn(Map.of("루소", "A", "홉스", "B")); + + // sample-linear.txt 는 사전 투표 없음 → 메타데이터 "선택지 명칭"(선하다/악하다) 그대로 + AdminBattleParseResponse res = service.parse(load("sample-linear.txt")); + + assertThat(res.battlePayload().options()).extracting(AdminBattleOptionRequest::title) + .containsExactly("선하다", "악하다"); + } + + @Test + void summary는_LLM_한줄요약_description은_오프닝_전문을_담는다() throws IOException { + when(emotionClassifier.bindSpeakers(anyString(), anyString(), anyString(), any())) + .thenReturn(Map.of("루소", "A", "홉스", "B")); + when(emotionClassifier.summarizeOpening(anyString(), anyString())) + .thenReturn(Optional.of("인간은 선한가 악한가, 재난 앞에서 드러나는 본성")); + + AdminBattleParseResponse res = service.parse(load("sample-linear.txt")); + + assertThat(res.battlePayload().summary()).isEqualTo("인간은 선한가 악한가, 재난 앞에서 드러나는 본성"); + assertThat(res.battlePayload().description()).startsWith("재난이 일어났을 때"); + assertThat(res.warnings()).noneMatch(w -> w.code().equals("SUMMARY_GENERATION_FAILED")); + } + + @Test + void 요약_생성_실패시_오프닝_원문으로_대체하고_경고를_남긴다() throws IOException { + when(emotionClassifier.bindSpeakers(anyString(), anyString(), anyString(), any())) + .thenReturn(Map.of("루소", "A", "홉스", "B")); + when(emotionClassifier.summarizeOpening(anyString(), anyString())).thenReturn(Optional.empty()); + + AdminBattleParseResponse res = service.parse(load("sample-linear.txt")); + + assertThat(res.battlePayload().summary()).isEqualTo(res.battlePayload().description()); + assertThat(res.warnings()).anyMatch(w -> w.code().equals("SUMMARY_GENERATION_FAILED")); + } + + @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 한_발언은_문장_종결부호_기준으로_여러_스크립트로_쪼개진다() throws IOException { + when(emotionClassifier.bindSpeakers(anyString(), anyString(), anyString(), any())) + .thenReturn(Map.of("루소", "A", "홉스", "B")); + + AdminBattleParseResponse res = service.parse(load("sample-linear.txt")); + + // 루소의 첫 발언: "인간은 자연 상태에서 평화로웠습니다. 경쟁도, 소유욕도, 증오도 — 이건 문명이 만든 겁니다. + // 아이를 보십시오. 태어난 아이는 나누고 웃고, 타인의 고통에 울음으로 반응합니다. 악은 가르쳐지는 것입니다." + // → 문장 5개로 쪼개져야 한다. + var scripts = node(res, "라운드").scripts(); + long firstSpeakerSentences = scripts.stream() + .takeWhile(s -> "루소".equals(s.speakerName())) + .count(); + + assertThat(firstSpeakerSentences).isEqualTo(5); + assertThat(scripts.get(0).text()).isEqualTo("인간은 자연 상태에서 평화로웠습니다."); + assertThat(scripts.get(1).text()).startsWith("경쟁도, 소유욕도, 증오도"); + assertThat(scripts.get(2).text()).isEqualTo("아이를 보십시오."); + assertThat(scripts.get(4).text()).isEqualTo("악은 가르쳐지는 것입니다."); + // 같은 발언에서 쪼개진 문장들은 같은 화자·같은 톤을 유지한다 + assertThat(scripts.subList(0, 5)).allMatch(s -> "루소".equals(s.speakerName())); + } + + @Test + void LLM_실패시_톤은_전부_NEUTRAL이고_경고를_남긴다() throws IOException { + AdminBattleParseResponse res = service.parse(load("sample-interactive.txt")); + + 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/java/com/swyp/picke/domain/battle/service/BattleServiceImplTest.java b/src/test/java/com/swyp/picke/domain/battle/service/BattleServiceImplTest.java new file mode 100644 index 00000000..61b61f68 --- /dev/null +++ b/src/test/java/com/swyp/picke/domain/battle/service/BattleServiceImplTest.java @@ -0,0 +1,176 @@ +package com.swyp.picke.domain.battle.service; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.swyp.picke.domain.admin.dto.battle.request.AdminBattleOptionRequest; +import com.swyp.picke.domain.admin.dto.battle.request.AdminBattleUpdateRequest; +import com.swyp.picke.domain.battle.converter.BattleConverter; +import com.swyp.picke.domain.battle.entity.Battle; +import com.swyp.picke.domain.battle.entity.BattleOption; +import com.swyp.picke.domain.battle.entity.BattleOptionTag; +import com.swyp.picke.domain.battle.enums.BattleCreatorType; +import com.swyp.picke.domain.battle.enums.BattleStatus; +import com.swyp.picke.domain.battle.repository.BattleOptionRepository; +import com.swyp.picke.domain.battle.repository.BattleOptionTagRepository; +import com.swyp.picke.domain.battle.repository.BattleRepository; +import com.swyp.picke.domain.battle.repository.BattleTagRepository; +import com.swyp.picke.domain.notification.service.NotificationDispatchService; +import com.swyp.picke.domain.scenario.repository.ScenarioRepository; +import com.swyp.picke.domain.scenario.service.ScenarioAudioPipelineService; +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.service.UserBattleService; +import com.swyp.picke.domain.vote.repository.BattleVoteRepository; +import com.swyp.picke.global.infra.local.service.LocalDraftFileStorageService; +import com.swyp.picke.global.infra.s3.enums.FileCategory; +import com.swyp.picke.global.infra.s3.service.S3UploadService; +import com.swyp.picke.domain.user.repository.UserRepository; +import java.util.List; +import java.util.Optional; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InOrder; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.transaction.support.TransactionSynchronizationManager; + +@ExtendWith(MockitoExtension.class) +class BattleServiceImplTest { + + @Mock private BattleRepository battleRepository; + @Mock private BattleOptionRepository battleOptionRepository; + @Mock private BattleTagRepository battleTagRepository; + @Mock private BattleOptionTagRepository battleOptionTagRepository; + @Mock private TagRepository tagRepository; + @Mock private UserRepository userRepository; + @Mock private BattleVoteRepository battleVoteRepository; + @Mock private BattleConverter battleConverter; + @Mock private S3UploadService s3UploadService; + @Mock private LocalDraftFileStorageService localDraftFileStorageService; + @Mock private UserBattleService userBattleService; + @Mock private BattleAutoSeedService battleAutoSeedService; + @Mock private ScenarioRepository scenarioRepository; + @Mock private ScenarioAudioPipelineService scenarioAudioPipelineService; + @Mock private NotificationDispatchService notificationDispatchService; + + private BattleServiceImpl service; + + @BeforeEach + void setUp() { + service = new BattleServiceImpl( + battleRepository, battleOptionRepository, battleTagRepository, battleOptionTagRepository, + tagRepository, userRepository, battleVoteRepository, battleConverter, s3UploadService, + localDraftFileStorageService, userBattleService, battleAutoSeedService, scenarioRepository, + scenarioAudioPipelineService, notificationDispatchService); + } + + private Battle battle(Long id) { + Battle battle = Battle.builder() + .title("제목").status(BattleStatus.PENDING) + .creatorType(BattleCreatorType.ADMIN) + .build(); + ReflectionTestUtils.setField(battle, "id", id); + return battle; + } + + private BattleOption option(Long id, Battle battle) { + BattleOption option = BattleOption.builder() + .battle(battle).title("기존 옵션").displayOrder(1).build(); + ReflectionTestUtils.setField(option, "id", id); + return option; + } + + @Test + void 옵션_태그를_교체할때_기존_태그_삭제를_먼저_flush한_뒤_새_태그를_저장한다() { + Battle battle = battle(22L); + BattleOption existingOption = option(42L, battle); + Tag tag = Tag.builder().name("칸트").type(TagType.PHILOSOPHER).build(); + ReflectionTestUtils.setField(tag, "id", 6L); + BattleOptionTag existingOptionTag = BattleOptionTag.builder() + .battleOption(existingOption).tag(tag).build(); + + when(battleRepository.findById(22L)).thenReturn(Optional.of(battle)); + when(battleOptionRepository.findByBattle(battle)).thenReturn(List.of(existingOption)); + when(battleOptionTagRepository.findByBattleOption(existingOption)).thenReturn(List.of(existingOptionTag)); + when(tagRepository.findAllById(anyList())).thenReturn(List.of(tag)); + lenient().when(battleOptionTagRepository.findByBattleWithTags(battle)).thenReturn(List.of()); + lenient().when(battleOptionRepository.save(any(BattleOption.class))) + .thenAnswer(inv -> inv.getArgument(0)); + + AdminBattleOptionRequest optionRequest = new AdminBattleOptionRequest( + "기존 옵션", null, null, null, 1, List.of(6L)); + AdminBattleUpdateRequest request = new AdminBattleUpdateRequest( + "제목", null, null, null, null, null, null, BattleStatus.PENDING, + null, List.of(optionRequest, optionRequest2())); + + service.updateBattle(22L, request); + + // 같은 (battleOption, tag) 조합을 다시 저장하는 경우, delete가 flush로 먼저 반영된 뒤에 + // insert(save)가 나가야 유니크 제약 위반(23505)이 안 난다. + InOrder inOrder = inOrder(battleOptionTagRepository); + inOrder.verify(battleOptionTagRepository).deleteAll(List.of(existingOptionTag)); + inOrder.verify(battleOptionTagRepository).flush(); + inOrder.verify(battleOptionTagRepository).save(any(BattleOptionTag.class)); + } + + // updateBattle은 옵션 2~4개를 요구한다(validateBattleOptionCount) — 최소 개수 충족용 더미 옵션 + private AdminBattleOptionRequest optionRequest2() { + return new AdminBattleOptionRequest("두번째 옵션", null, null, null, 2, List.of()); + } + + @AfterEach + void tearDownSynchronization() { + if (TransactionSynchronizationManager.isSynchronizationActive()) { + TransactionSynchronizationManager.clearSynchronization(); + } + } + + @Test + void 발행_상태로_바뀌며_local_draft_썸네일이면_실제_S3_승격은_커밋_이후로_미룬다() { + Battle battle = battle(1L); + String localDraftKey = "local/drafts/uuid_thumb.png"; + String s3Key = "images/battles/uuid_thumb.png"; + + when(battleRepository.findById(1L)).thenReturn(Optional.of(battle)); + when(battleOptionRepository.findByBattle(battle)).thenReturn(List.of()); + lenient().when(localDraftFileStorageService.normalizeLocalDraftKey(localDraftKey)).thenReturn(localDraftKey); + when(localDraftFileStorageService.isLocalDraftReference(localDraftKey)).thenReturn(true); + when(localDraftFileStorageService.resolveS3Key(localDraftKey, FileCategory.BATTLE)).thenReturn(s3Key); + lenient().when(battleOptionRepository.save(any(BattleOption.class))) + .thenAnswer(inv -> inv.getArgument(0)); + lenient().when(battleOptionTagRepository.findByBattleWithTags(battle)).thenReturn(List.of()); + + AdminBattleUpdateRequest request = new AdminBattleUpdateRequest( + "제목", null, null, localDraftKey, null, null, null, BattleStatus.PUBLISHED, + null, List.of( + new AdminBattleOptionRequest("옵션1", null, null, null, 1, null), + new AdminBattleOptionRequest("옵션2", null, null, null, 2, null))); + + TransactionSynchronizationManager.initSynchronization(); + try { + service.updateBattle(1L, request); + + // 요청 처리 도중(커밋 전)에는 실제 업로드/삭제가 절대 일어나면 안 된다. + verify(localDraftFileStorageService, never()).promoteToS3(anyString(), anyString(), any()); + org.assertj.core.api.Assertions.assertThat(battle.getThumbnailUrl()).isEqualTo(s3Key); + + // 커밋 이후에야 실제 승격이 일어난다. + TransactionSynchronizationManager.getSynchronizations() + .forEach(org.springframework.transaction.support.TransactionSynchronization::afterCommit); + verify(localDraftFileStorageService).promoteToS3(localDraftKey, s3Key, s3UploadService); + } finally { + TransactionSynchronizationManager.clearSynchronization(); + } + } +} diff --git a/src/test/java/com/swyp/picke/domain/scenario/enums/ToneTest.java b/src/test/java/com/swyp/picke/domain/scenario/enums/ToneTest.java new file mode 100644 index 00000000..500c34a6 --- /dev/null +++ b/src/test/java/com/swyp/picke/domain/scenario/enums/ToneTest.java @@ -0,0 +1,54 @@ +package com.swyp.picke.domain.scenario.enums; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.Optional; +import org.junit.jupiter.api.Test; + +class ToneTest { + + @Test + void fromInlineTag_기본_톤_태그를_해석한다() { + assertThat(Tone.fromInlineTag("[angry]")).contains(Tone.ANGRY); + assertThat(Tone.fromInlineTag("[excited]")).contains(Tone.EXCITED); + } + + @Test + void fromInlineTag_축약_표기도_흡수한다() { + assertThat(Tone.fromInlineTag("[soft]")).contains(Tone.SOFT_TONE); + assertThat(Tone.fromInlineTag("[soft tone]")).contains(Tone.SOFT_TONE); + assertThat(Tone.fromInlineTag("[whisper]")).contains(Tone.WHISPERING); + } + + @Test + void fromInlineTag_대괄호_없이_대소문자_섞여도_해석한다() { + assertThat(Tone.fromInlineTag(" Angry ")).contains(Tone.ANGRY); + } + + @Test + void fromInlineTag_효과_태그나_미지정은_empty() { + assertThat(Tone.fromInlineTag("[sighing]")).isEmpty(); + assertThat(Tone.fromInlineTag(null)).isEmpty(); + assertThat(Tone.fromInlineTag("[break]")).isEmpty(); + } + + @Test + void fromNameOrNeutral_null이거나_모르는_값이면_NEUTRAL() { + assertThat(Tone.fromNameOrNeutral(null)).isEqualTo(Tone.NEUTRAL); + assertThat(Tone.fromNameOrNeutral("")).isEqualTo(Tone.NEUTRAL); + assertThat(Tone.fromNameOrNeutral("ANGRY")).isEqualTo(Tone.ANGRY); + assertThat(Tone.fromNameOrNeutral("something-else")).isEqualTo(Tone.NEUTRAL); + } + + @Test + void NEUTRAL만_태그가_없다() { + assertThat(Tone.NEUTRAL.hasTag()).isFalse(); + for (Tone tone : Tone.values()) { + if (tone != Tone.NEUTRAL) { + assertThat(tone.hasTag()).isTrue(); + assertThat(tone.getTag()).startsWith("[").endsWith("]"); + } + } + assertThat(Optional.ofNullable(Tone.NEUTRAL.getTag())).isEmpty(); + } +} diff --git a/src/test/java/com/swyp/picke/domain/scenario/service/PhilosopherVoiceServiceTest.java b/src/test/java/com/swyp/picke/domain/scenario/service/PhilosopherVoiceServiceTest.java new file mode 100644 index 00000000..c198a288 --- /dev/null +++ b/src/test/java/com/swyp/picke/domain/scenario/service/PhilosopherVoiceServiceTest.java @@ -0,0 +1,166 @@ +package com.swyp.picke.domain.scenario.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.swyp.picke.domain.admin.dto.philosophervoice.request.PhilosopherVoiceRequest; +import com.swyp.picke.domain.admin.dto.philosophervoice.response.PhilosopherVoiceResponse; +import com.swyp.picke.domain.scenario.entity.PhilosopherVoice; +import com.swyp.picke.domain.scenario.repository.PhilosopherVoiceRepository; +import com.swyp.picke.global.common.exception.CustomException; +import com.swyp.picke.global.common.exception.ErrorCode; +import com.swyp.picke.global.infra.s3.enums.FileCategory; +import com.swyp.picke.global.infra.s3.util.ResourceUrlProvider; +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class PhilosopherVoiceServiceTest { + + @Mock + private PhilosopherVoiceRepository philosopherVoiceRepository; + + @Mock + private ResourceUrlProvider resourceUrlProvider; + + @InjectMocks + private PhilosopherVoiceService philosopherVoiceService; + + @BeforeEach + void setUp() { + // raw key를 그대로 돌려주는 기본 스텁(변환 여부 자체를 검증하는 테스트만 다르게 재정의) + lenient().when(resourceUrlProvider.getImageUrl(any(), any())) + .thenAnswer(invocation -> invocation.getArgument(1)); + } + + private PhilosopherVoice entity(String name, String referenceId) { + return PhilosopherVoice.builder() + .name(name) + .referenceId(referenceId) + .voiceLabel("label") + .build(); + } + + @Test + void findReferenceIdByName_등록된_철학자면_reference_id_반환() { + when(philosopherVoiceRepository.findByName("소크라테스")) + .thenReturn(Optional.of(entity("소크라테스", "voice-a"))); + + assertThat(philosopherVoiceService.findReferenceIdByName(" 소크라테스 ")) + .contains("voice-a"); + } + + @Test + void findReferenceIdByName_미등록이거나_빈값이면_empty() { + when(philosopherVoiceRepository.findByName("루소")).thenReturn(Optional.empty()); + + assertThat(philosopherVoiceService.findReferenceIdByName("루소")).isEmpty(); + assertThat(philosopherVoiceService.findReferenceIdByName(null)).isEmpty(); + assertThat(philosopherVoiceService.findReferenceIdByName(" ")).isEmpty(); + } + + @Test + void create_이름이_중복이면_예외() { + when(philosopherVoiceRepository.existsByName("칸트")).thenReturn(true); + + assertThatThrownBy(() -> philosopherVoiceService.create( + new PhilosopherVoiceRequest("칸트", "voice-x", null, null, null))) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.PHILOSOPHER_VOICE_DUPLICATED); + + verify(philosopherVoiceRepository, never()).save(any()); + } + + @Test + void create_정상이면_저장하고_응답_반환() { + when(philosopherVoiceRepository.existsByName("칸트")).thenReturn(false); + when(philosopherVoiceRepository.save(any(PhilosopherVoice.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + PhilosopherVoiceResponse response = philosopherVoiceService.create( + new PhilosopherVoiceRequest(" 칸트 ", " voice-x ", "Meursault", null, null)); + + assertThat(response.name()).isEqualTo("칸트"); + assertThat(response.referenceId()).isEqualTo("voice-x"); + verify(philosopherVoiceRepository).save(any(PhilosopherVoice.class)); + } + + @Test + void update_대상이_없으면_예외() { + when(philosopherVoiceRepository.findById(99L)).thenReturn(Optional.empty()); + + assertThatThrownBy(() -> philosopherVoiceService.update(99L, + new PhilosopherVoiceRequest("칸트", "voice-x", null, null, null))) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.PHILOSOPHER_VOICE_NOT_FOUND); + } + + @Test + void update_reference_id를_교체한다() { + PhilosopherVoice found = entity("칸트", "voice-old"); + when(philosopherVoiceRepository.findById(1L)).thenReturn(Optional.of(found)); + + PhilosopherVoiceResponse response = philosopherVoiceService.update(1L, + new PhilosopherVoiceRequest("칸트", "voice-new", "새 라벨", null, "메모")); + + assertThat(response.referenceId()).isEqualTo("voice-new"); + assertThat(found.getReferenceId()).isEqualTo("voice-new"); + } + + @Test + void 응답의_imageKey는_raw_저장키가_아니라_ResourceUrlProvider가_변환한_값이다() { + when(philosopherVoiceRepository.existsByName("루소")).thenReturn(false); + when(philosopherVoiceRepository.save(any(PhilosopherVoice.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + when(resourceUrlProvider.getImageUrl(FileCategory.PHILOSOPHER, "images/philosophers/rousseau.png")) + .thenReturn("https://dev.picke.store/api/v1/resources/images/PHILOSOPHER/rousseau.png"); + + PhilosopherVoiceResponse response = philosopherVoiceService.create( + new PhilosopherVoiceRequest("루소", "voice-x", null, "images/philosophers/rousseau.png", null)); + + assertThat(response.imageKey()) + .isEqualTo("https://dev.picke.store/api/v1/resources/images/PHILOSOPHER/rousseau.png") + .isNotEqualTo("images/philosophers/rousseau.png"); + } + + @Test + void create_및_update가_imageKey를_저장한다() { + when(philosopherVoiceRepository.existsByName("루소")).thenReturn(false); + when(philosopherVoiceRepository.save(any(PhilosopherVoice.class))) + .thenAnswer(invocation -> invocation.getArgument(0)); + + PhilosopherVoiceResponse created = philosopherVoiceService.create( + new PhilosopherVoiceRequest("루소", "voice-x", "미호크 장정진", + "images/philosophers/rousseau.png", null)); + assertThat(created.imageKey()).isEqualTo("images/philosophers/rousseau.png"); + + PhilosopherVoice found = entity("루소", "voice-x"); + when(philosopherVoiceRepository.findById(2L)).thenReturn(Optional.of(found)); + + PhilosopherVoiceResponse updated = philosopherVoiceService.update(2L, + new PhilosopherVoiceRequest("루소", "voice-x", null, + "images/philosophers/rousseau-v2.png", null)); + assertThat(updated.imageKey()).isEqualTo("images/philosophers/rousseau-v2.png"); + } + + @Test + void delete_대상이_없으면_예외() { + when(philosopherVoiceRepository.existsById(99L)).thenReturn(false); + + assertThatThrownBy(() -> philosopherVoiceService.delete(99L)) + .isInstanceOf(CustomException.class) + .hasFieldOrPropertyWithValue("errorCode", ErrorCode.PHILOSOPHER_VOICE_NOT_FOUND); + + verify(philosopherVoiceRepository, never()).deleteById(any()); + } +} diff --git a/src/test/java/com/swyp/picke/domain/scenario/service/ScenarioAudioPipelineServiceTest.java b/src/test/java/com/swyp/picke/domain/scenario/service/ScenarioAudioPipelineServiceTest.java index 6e0515e9..781f0ced 100644 --- a/src/test/java/com/swyp/picke/domain/scenario/service/ScenarioAudioPipelineServiceTest.java +++ b/src/test/java/com/swyp/picke/domain/scenario/service/ScenarioAudioPipelineServiceTest.java @@ -11,6 +11,7 @@ import com.swyp.picke.domain.scenario.enums.CreatorType; import com.swyp.picke.domain.scenario.enums.ScenarioStatus; import com.swyp.picke.domain.scenario.enums.SpeakerType; +import com.swyp.picke.domain.scenario.enums.Tone; import com.swyp.picke.domain.scenario.repository.ScenarioRepository; import com.swyp.picke.global.infra.media.service.FFmpegService; import com.swyp.picke.global.infra.s3.service.S3UploadService; @@ -124,9 +125,9 @@ void generateAndMergeAudioAsync_usesFallbackExecutableAndCompletesUploadFlow() t scenario.replaceVoiceSettings(Map.of(SpeakerType.NARRATOR, "voice-narrator")); when(scenarioRepository.findById(1L)).thenReturn(Optional.of(scenario)); - when(ttsService.generateTtsWithSsml(eq("First line"), eq(SpeakerType.NARRATOR), eq("voice-narrator"))) + when(ttsService.generateTtsWithSsml(eq("First line"), eq(SpeakerType.NARRATOR), eq("voice-narrator"), eq(Tone.NEUTRAL))) .thenReturn(ffmpegService.createSilenceFile(1000)); - when(ttsService.generateTtsWithSsml(eq("Second line"), eq(SpeakerType.NARRATOR), eq("voice-narrator"))) + when(ttsService.generateTtsWithSsml(eq("Second line"), eq(SpeakerType.NARRATOR), eq("voice-narrator"), eq(Tone.NEUTRAL))) .thenReturn(ffmpegService.createSilenceFile(1000)); AtomicInteger chunkIndex = new AtomicInteger(); @@ -149,7 +150,7 @@ void generateAndMergeAudioAsync_usesFallbackExecutableAndCompletesUploadFlow() t assertThat(scenario.getAudios()).containsEntry(AudioPathType.COMMON, "s3://scenario/merged/common.mp3"); assertThat(battle.getAudioDuration()).isNotNull().isGreaterThan(0); - verify(ttsService, times(2)).generateTtsWithSsml(anyString(), eq(SpeakerType.NARRATOR), eq("voice-narrator")); + verify(ttsService, times(2)).generateTtsWithSsml(anyString(), eq(SpeakerType.NARRATOR), eq("voice-narrator"), any(Tone.class)); verify(s3UploadService, times(3)).uploadFile(anyString(), any(File.class)); verify(battleRepository).save(battle); verify(scenarioRepository).saveAndFlush(scenario); diff --git a/src/test/java/com/swyp/picke/domain/scenario/service/ScenarioServiceImplTest.java b/src/test/java/com/swyp/picke/domain/scenario/service/ScenarioServiceImplTest.java index 2d409aaa..d3687ada 100644 --- a/src/test/java/com/swyp/picke/domain/scenario/service/ScenarioServiceImplTest.java +++ b/src/test/java/com/swyp/picke/domain/scenario/service/ScenarioServiceImplTest.java @@ -90,8 +90,8 @@ void updateScenarioContent_textChanged_invalidatesOnlyChangedScriptChunk_andClea true, "", List.of( - new ScriptRequest("NARRATOR", SpeakerType.NARRATOR, "line-1"), - new ScriptRequest("NARRATOR", SpeakerType.NARRATOR, "line-2-new") + new ScriptRequest("NARRATOR", SpeakerType.NARRATOR, "line-1", null), + new ScriptRequest("NARRATOR", SpeakerType.NARRATOR, "line-2-new", null) ), List.of() ) @@ -138,8 +138,8 @@ void updateScenarioContent_voiceChanged_invalidatesOnlyAffectedSpeakerChunks_and true, "", List.of( - new ScriptRequest("NARRATOR", SpeakerType.NARRATOR, "same-narrator"), - new ScriptRequest("A", SpeakerType.A, "same-a") + new ScriptRequest("NARRATOR", SpeakerType.NARRATOR, "same-narrator", null), + new ScriptRequest("A", SpeakerType.A, "same-a", null) ), List.of() ) @@ -184,7 +184,7 @@ void updateScenarioContent_whenPublishedAndModified_triggersAudioPipeline() { "START", true, "", - List.of(new ScriptRequest("NARRATOR", SpeakerType.NARRATOR, "new-line")), + List.of(new ScriptRequest("NARRATOR", SpeakerType.NARRATOR, "new-line", null)), List.of() ) ), diff --git a/src/test/java/com/swyp/picke/global/infra/local/service/LocalDraftFileStorageServiceTest.java b/src/test/java/com/swyp/picke/global/infra/local/service/LocalDraftFileStorageServiceTest.java new file mode 100644 index 00000000..6827c011 --- /dev/null +++ b/src/test/java/com/swyp/picke/global/infra/local/service/LocalDraftFileStorageServiceTest.java @@ -0,0 +1,83 @@ +package com.swyp.picke.global.infra.local.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.swyp.picke.global.common.exception.CustomException; +import com.swyp.picke.global.infra.s3.enums.FileCategory; +import com.swyp.picke.global.infra.s3.service.S3UploadService; +import java.io.IOException; +import java.nio.file.Path; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.test.util.ReflectionTestUtils; + +class LocalDraftFileStorageServiceTest { + + private LocalDraftFileStorageService service; + private S3UploadService s3UploadService; + + @BeforeEach + void setUp(@TempDir Path tempDir) { + service = new LocalDraftFileStorageService(); + ReflectionTestUtils.setField(service, "localStorageRoot", tempDir.toString()); + ReflectionTestUtils.setField(service, "baseUrl", "https://dev.picke.store"); + s3UploadService = mock(S3UploadService.class); + } + + private String saveDraft() throws IOException { + MockMultipartFile file = new MockMultipartFile("file", "rousseau.png", "image/png", new byte[]{1, 2, 3}); + return service.saveDraftFile(file); + } + + @Test + void resolveS3Key는_실제_업로드_없이_목적지_key만_계산한다() throws IOException { + String localKey = saveDraft(); + + String s3Key = service.resolveS3Key(localKey, FileCategory.PHILOSOPHER); + + assertThat(s3Key).startsWith("images/philosophers/").endsWith("rousseau.png"); + // 부수효과 없음: 로컬 파일 그대로 남아있고 S3 호출 없음 + verify(s3UploadService, never()).uploadFile(anyString(), any()); + } + + @Test + void resolveS3Key는_draft_파일이_없으면_예외를_던진다() { + assertThatThrownBy(() -> service.resolveS3Key("local/drafts/missing_file.png", FileCategory.PHILOSOPHER)) + .isInstanceOf(CustomException.class); + } + + @Test + void promoteToS3는_업로드하고_로컬_draft를_삭제한다() throws IOException { + String localKey = saveDraft(); + String s3Key = service.resolveS3Key(localKey, FileCategory.PHILOSOPHER); + when(s3UploadService.uploadFile(eq(s3Key), any())) + .thenReturn(s3Key); + + service.promoteToS3(localKey, s3Key, s3UploadService); + + verify(s3UploadService).uploadFile(eq(s3Key), any()); + assertThat(service.isLocalDraftReference(localKey)).isTrue(); // 여전히 local draft 형식의 키지만 + // 실제 파일은 삭제됐어야 한다 -> 다시 promote 시도해도 재업로드 없이 조용히 스킵 + service.promoteToS3(localKey, s3Key, s3UploadService); + verify(s3UploadService, times(1)) + .uploadFile(eq(s3Key), any()); + } + + @Test + void promoteToS3는_local_draft가_아니면_아무일도_하지_않는다() { + service.promoteToS3("images/philosophers/already-s3.png", "images/philosophers/already-s3.png", s3UploadService); + + verify(s3UploadService, never()).uploadFile(anyString(), any()); + } +} diff --git a/src/test/java/com/swyp/picke/global/infra/tts/service/TtsTextNormalizerTest.java b/src/test/java/com/swyp/picke/global/infra/tts/service/TtsTextNormalizerTest.java new file mode 100644 index 00000000..d983c433 --- /dev/null +++ b/src/test/java/com/swyp/picke/global/infra/tts/service/TtsTextNormalizerTest.java @@ -0,0 +1,66 @@ +package com.swyp.picke.global.infra.tts.service; + +import static org.assertj.core.api.Assertions.assertThat; + +import com.swyp.picke.domain.scenario.enums.Tone; +import org.junit.jupiter.api.Test; + +class TtsTextNormalizerTest { + + @Test + void NEUTRAL_톤은_태그를_붙이지_않는다() { + String result = TtsTextNormalizer.normalize("그건 궤변이다.", Tone.NEUTRAL); + + assertThat(result).isEqualTo("그건 궤변이다."); + } + + @Test + void 톤이_있으면_Fish_인라인_표기를_맨_앞에_붙인다() { + String result = TtsTextNormalizer.normalize("그건 궤변이다.", Tone.ANGRY); + + assertThat(result).isEqualTo("[angry] 그건 궤변이다."); + } + + @Test + void SOFT_TONE은_soft_tone_표기로_붙는다() { + String result = TtsTextNormalizer.normalize("괜찮아요.", Tone.SOFT_TONE); + + assertThat(result).isEqualTo("[soft tone] 괜찮아요."); + } + + @Test + void 관용_효과_표기를_Fish_정식_표기로_정규화한다() { + String result = TtsTextNormalizer.normalize( + "그래서 [pause] 나는 [soft] 포기했다 [long pause] 결국.", Tone.NEUTRAL); + + assertThat(result).isEqualTo("그래서 [break] 나는 [soft tone] 포기했다 [long-break] 결국."); + } + + @Test + void 효과_정규화는_대소문자를_무시한다() { + String result = TtsTextNormalizer.normalize("음 [PAUSE] 그리고 [Long Pause] 끝.", Tone.NEUTRAL); + + assertThat(result).isEqualTo("음 [break] 그리고 [long-break] 끝."); + } + + @Test + void 문서에_없는_효과_태그는_그대로_둔다() { + String result = TtsTextNormalizer.normalize("[sighing] 나는 지쳤다.", Tone.NEUTRAL); + + assertThat(result).isEqualTo("[sighing] 나는 지쳤다."); + } + + @Test + void 톤과_효과가_함께_있으면_톤은_앞_효과는_원위치() { + String result = TtsTextNormalizer.normalize("나는 [sighing] 포기했다.", Tone.SAD); + + assertThat(result).isEqualTo("[sad] 나는 [sighing] 포기했다."); + } + + @Test + void null_텍스트도_톤_태그만_남기고_안전하게_처리한다() { + String result = TtsTextNormalizer.normalize(null, Tone.EXCITED); + + assertThat(result).isEqualTo("[excited]"); + } +} 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) +[개인] [자연] [내면]