Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 32 additions & 5 deletions docs/api-specs/battle-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,34 @@

기준 컨트롤러: `AdminBattleController`

### 2.1 배틀 생성
### 2.1 배틀 대본 붙여넣기 파싱 (미리보기용, 저장 안 함)
- `POST /api/v1/admin/battles/parse`
- 요청 본문(`AdminBattleParseRequest`):
- `rawText` (기획자가 구글독스에서 복사한 대본 원문 전체)
- 설명: 대본을 파싱해 배틀·시나리오 등록 폼을 자동으로 채워준다. **DB 저장은 하지 않는다.** 문서 포맷 규격/파싱 규칙은 `배틀_발행_시나리오_현행_vs_개선.md` 2.6, 실제 파싱 예시는 `배틀_대본_파싱_예시.md` 참고.
- 응답(`AdminBattleParseResponse`):
- `battlePayload` — 아래 2.2 배틀 생성 요청과 동일한 형태(`AdminBattleCreateRequest`). `status` 는 항상 `PENDING`. 문서에 없는 `thumbnailUrl`/`targetDate`/`publishAt`/`audioDuration` 은 `null`
- `scenarioPayload` — [시나리오 API](./scenario-api.md) 2.2 생성 요청과 동일한 형태(`AdminScenarioCreateRequest`). **`battleId` 는 항상 `null`** — 배틀이 아직 생성 전이라서다. 어드민이 `battlePayload` 로 배틀을 먼저 만들고, 응답으로 받은 `battleId` 를 채워 시나리오를 등록한다
- `speakerNames` — `{"A": "플라톤", "B": "마르크스"}` 형태로 화자 A/B 에 바인딩된 철학자 이름
- `warnings[]` — 아래 표. `blocking: true` 인 항목이 하나라도 있으면 미리보기에서 해결하기 전까지 발행하지 않는다

**warning 코드**

| code | 의미 | blocking |
|---|---|---|
| `MISSING_HEADER` | 첫 줄에서 `— 제목` 형식을 못 찾음 | no |
| `MISSING_METADATA` / `MISSING_CATEGORY` | 메타데이터 섹션/카테고리를 못 찾음 | no |
| `UNKNOWN_CATEGORY_TAG` | 그 카테고리 문자열의 CATEGORY 태그가 DB 에 없음 | no |
| `UNKNOWN_PHILOSOPHER_TAG` | 철학자 키워드가 철학자 유형 10인이 아님 | no |
| `MISSING_PHILOSOPHER_TAG` / `MISSING_VALUE_TAG` | 태그 문자열은 맞지만 DB row 가 없음 | no |
| `UNKNOWN_VALUE_TAG` | 성향 지표가 가치관 12축 문자열(원칙/결과/이성/감성/개인/관계/변화/전통/내면/구조/이상/현실)이 아님 | no |
| `MULTIPLE_TONE_TAGS` | 대사 한 줄에 톤 태그가 여러 개 → 첫 번째만 사용 | no |
| `SPEAKER_BINDING_FALLBACK` | 발화자↔A/B 매칭을 등장 순서로 임시 배정 | no |
| `LLM_CLASSIFY_FAILED` | 감정 자동분류 실패 → 톤이 전부 `NEUTRAL` | no |
| `MISSING_VOICE` | 발화자 보이스가 [철학자 보이스 매핑](./philosopher-voice-api.md) 에 없음 | **yes** |
| `INCOMPLETE_SPEAKER_BINDING` | A/B 발화자를 확정하지 못함 | **yes** |

### 2.2 배틀 생성
- `POST /api/v1/admin/battles`
- 요청 본문(`AdminBattleCreateRequest`) 주요 필드:
- `title`
Expand All @@ -47,21 +74,21 @@
- `imageUrl`
- `tagIds` (철학자/가치관 태그 ID 목록)

### 2.2 배틀 목록
### 2.3 배틀 목록
- `GET /api/v1/admin/battles`
- 쿼리 파라미터:
- `page` (기본값: `1`)
- `size` (기본값: `10`)
- `status` (선택)

### 2.3 배틀 상세
### 2.4 배틀 상세
- `GET /api/v1/admin/battles/{battleId}`

### 2.4 배틀 수정
### 2.5 배틀 수정
- `PATCH /api/v1/admin/battles/{battleId}`
- 요청 본문(`AdminBattleUpdateRequest`) 필드 구조는 생성과 동일

### 2.5 배틀 삭제
### 2.6 배틀 삭제
- `DELETE /api/v1/admin/battles/{battleId}`

---
Expand Down
41 changes: 41 additions & 0 deletions docs/api-specs/philosopher-voice-api.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# 철학자 보이스 매핑(Philosopher Voice) API 명세

기준 코드:
`src/main/java/com/swyp/picke/domain/admin/controller/AdminPhilosopherVoiceController.java`
`src/main/java/com/swyp/picke/domain/scenario/service/PhilosopherVoiceService.java`

철학자 이름 → Fish Audio 보이스(`reference_id`) 매핑을 관리한다. [배틀 대본 붙여넣기 파서](./battle-api.md) 가
시나리오 발화자(옵션 A/B 철학자)에 맞는 보이스를 여기서 조회해 `scenario.voiceSettings` 를 채운다.
전부 관리자 전용 API. 사용자 API 없음.

## 1. 관리자 API

### 1.1 목록 조회
- `GET /api/v1/admin/philosopher-voices`
- 응답(`PhilosopherVoiceResponse[]`): `id`, `name`, `referenceId`, `voiceLabel`, `note`

### 1.2 생성
- `POST /api/v1/admin/philosopher-voices`
- 요청 본문(`PhilosopherVoiceRequest`):
- `name` (필수, 유니크)
- `referenceId` (필수, Fish Audio 보이스 모델 ID)
- `voiceLabel` (선택, 표시용 라벨. 예: `"미호크 장정진"`)
- `note` (선택)
- 이름 중복 시 `PHILOSOPHER_VOICE_409_DUP`

### 1.3 수정
- `PATCH /api/v1/admin/philosopher-voices/{id}`
- 요청 본문은 생성과 동일. `name` 은 응답에서 그대로 유지되고 `referenceId`/`voiceLabel`/`note` 만 갱신된다
- 대상이 없으면 `PHIL_VOICE_404`

### 1.4 삭제
- `DELETE /api/v1/admin/philosopher-voices/{id}`
- 대상이 없으면 `PHIL_VOICE_404`

## 2. 상태/동작 메모

- 테이블은 `spring.jpa.hibernate.ddl-auto=update` 가 자동 생성한다. 소프트 삭제 없음 (참조하는 FK가 없어 hard delete)
- 초기 시드: `docs/db/20260910_seed_philosopher_voice.sql` (`ON CONFLICT (name) DO NOTHING`) — 환경별 1회 수동 실행 필요. 재실행해도 안전하고 어드민에서 수정한 값을 덮지 않는다
- **NARRATOR / USER 고정 보이스는 이 테이블이 아니라 config** (`fishaudio.voice-id.narrator`, `fishaudio.voice-id.user`). 이 API 는 A/B(철학자) 보이스 전용
- 붙여넣기 파서가 발화자 이름으로 조회했는데 매핑이 없으면 `MISSING_VOICE` warning(blocking) 을 내고, 관리자가 미리보기에서 보이스를 고르거나 이 API 로 매핑을 추가한 뒤 다시 파싱해야 한다
- 배틀별로 다른 보이스를 쓰고 싶으면 이 테이블을 바꾸지 않고 시나리오의 `voiceSettings` 를 직접 오버라이드하면 된다(이 테이블은 파서가 채우는 기본값 소스일 뿐)
7 changes: 5 additions & 2 deletions docs/api-specs/scenario-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,12 @@
- `scripts[]`
- `speakerName`
- `speakerType`
- `text`
- `text` (오디오 효과 태그 `[sighing]` `[break]` 등은 인라인으로 그대로 둔다)
- `tone` (`Tone` enum, 생략 시 `NEUTRAL`: `NEUTRAL`/`ANGRY`/`SAD`/`EMBARRASSED`/`EMPHASIS`/`WHISPERING`/`SOFT_TONE`/`BREATHY`/`EXCITED`. 대사 한 줄의 감성적인 톤 — TTS 합성 시 `[tone]` 형태로 text 앞에 주입된다)
- `interactiveOptions[]`
- `label`
- `nextNodeName`
- `voiceSettings` (`Map<SpeakerType, String>`)
- `voiceSettings` (`Map<SpeakerType, String>`, 값은 Fish Audio `reference_id`. 철학자 발화자(A/B)는 [철학자 보이스 매핑](./philosopher-voice-api.md)에서 조회해 채운다)

### 2.3 시나리오 본문 수정
- `PUT /api/v1/admin/scenarios/{scenarioId}`
Expand All @@ -58,3 +59,5 @@

- 임시저장(`DRAFT`) 상태에서는 대본/설정은 DB 저장, 발행(`PUBLISHED`) 시점에 TTS 파이프라인 수행
- 발행 후 수정 시에는 변경된 스크립트 조각만 재생성하고 병합 오디오를 갱신
- `text` 또는 `tone` 이 바뀐 스크립트만 오디오를 재생성한다 (그 외 조각은 S3 캐시 재사용)
- TTS 는 Fish Audio S2.1 Pro 사용 (`fishaudio.tts.model`, 기본값 `s2.1-pro`). 톤 태그는 `[...]` 대괄호로 `text` 앞에 붙어 전송된다
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -31,6 +33,14 @@ public class AdminBattleController {

private final AdminBattleService adminBattleService;

@Operation(summary = "배틀 대본 붙여넣기 파싱 (미리보기용, 저장 안 함)")
@PostMapping("/parse")
public ApiResponse<AdminBattleParseResponse> parseBattleScript(
@RequestBody @Valid AdminBattleParseRequest request
) {
return ApiResponse.onSuccess(adminBattleService.parseBattleScript(request.rawText()));
}

@Operation(summary = "배틀 생성")
@PostMapping
public ApiResponse<AdminBattleDetailResponse> createBattle(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package com.swyp.picke.domain.battle.dto.parse;

import jakarta.validation.constraints.NotBlank;

public record AdminBattleParseRequest(
@NotBlank String rawText
) {}
Original file line number Diff line number Diff line change
@@ -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 에 저장하지 않고 미리보기로만 돌려준다.
*
* <p>{@code scenarioPayload.battleId} 는 null 이다 — 배틀이 아직 없어서다.
* 어드민이 {@code battlePayload} 로 배틀을 먼저 만든 뒤, 받은 id 를 채워 시나리오를 등록한다.
*
* <p>{@code speakerNames} 는 화자(A/B)별 발화자 철학자 이름. voiceSettings 와 함께 미리보기에 표시한다.
*/
public record AdminBattleParseResponse(
AdminBattleCreateRequest battlePayload,
AdminScenarioCreateRequest scenarioPayload,
Map<String, String> speakerNames,
List<ParseWarning> warnings
) {}
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package com.swyp.picke.domain.battle.parser;

import com.swyp.picke.domain.battle.dto.parse.ParseWarning;
import java.util.ArrayList;
import java.util.List;

/**
* 붙여넣은 대본을 정규식으로 1차 분해한 중간 표현. 태그 ID·보이스·감정 분류는 아직 안 붙었다.
*/
public class BattleScriptDocument {

public boolean interactive;
public String title;
public String category;
public OptionMeta optionA = new OptionMeta();
public OptionMeta optionB = new OptionMeta();
public final List<ParsedNode> nodes = new ArrayList<>();
public final List<ParseWarning> warnings = new ArrayList<>();

public static class OptionMeta {
public String choiceName;
/** 사전 투표 줄에 적힌 대표 발화자(예: "플라톤"). A/B 바인딩 힌트로 쓴다. */
public String primarySpeaker;
public final List<String> philosopherKeywords = new ArrayList<>();
public final List<String> valueTags = new ArrayList<>();
}

public static class ParsedNode {
public String name;
public final List<ParsedScript> scripts = new ArrayList<>();
/** 선택의 시간 노드에서만: label(A/B) -> 다음 노드 이름 */
public final List<ParsedOption> options = new ArrayList<>();

public ParsedNode(String name) {
this.name = name;
}
}

public static class ParsedScript {
/** 철학자 이름. null 이면 나레이터. */
public String speaker;
public String text;

public ParsedScript(String speaker, String text) {
this.speaker = speaker;
this.text = text;
}
}

public static class ParsedOption {
public String label; // "A" / "B"
public String nextNodeName; // "분기_A" 등
public String speaker; // 선택의 시간 줄에 표기된 발화자 (예: "칸트")

public ParsedOption(String label, String nextNodeName, String speaker) {
this.label = label;
this.nextNodeName = nextNodeName;
this.speaker = speaker;
}
}
}
Loading
Loading