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
4 changes: 4 additions & 0 deletions docs/api-specs/battle-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@
- 설명: 대본을 파싱해 배틀·시나리오 등록 폼을 자동으로 채워준다. **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` 인 항목이 하나라도 있으면 미리보기에서 해결하기 전까지 발행하지 않는다
Expand All @@ -55,6 +58,7 @@
| `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** |

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,10 @@ public class BattleScriptDocument {
public final List<ParseWarning> warnings = new ArrayList<>();

public static class OptionMeta {
/** 사전 투표 줄의 짧은 선택지명(예: "유죄다"). 있으면 이게 최종 title 이 된다. */
public String choiceName;
/** 메타데이터 표 "선택지 명칭" 값. 사전 투표가 없을 때만 title 로 쓰는 폴백. */
public String metadataChoiceName;
/** 사전 투표 줄에 적힌 대표 발화자(예: "플라톤"). A/B 바인딩 힌트로 쓴다. */
public String primarySpeaker;
public final List<String> philosopherKeywords = new ArrayList<>();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,14 @@ public BattleScriptDocument parse(String rawText) {
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;
}

Expand Down Expand Up @@ -298,7 +306,7 @@ private void parseMetadata(List<String> meta, BattleScriptDocument doc) {
} else if (line.startsWith("카테고리")) {
doc.category = valueAfter(line, "카테고리", next);
} else if (line.startsWith("선택지 명칭") && target != null) {
target.choiceName = valueAfter(line, "선택지 명칭", next);
target.metadataChoiceName = valueAfter(line, "선택지 명칭", next);
} else if (line.startsWith("철학자 키워드") && target != null) {
for (String kw : valueAfter(line, "철학자 키워드", next).split("[,、\\s]+")) {
String cleaned = kw.replace("#", "").strip();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import com.swyp.picke.domain.scenario.enums.Tone;
import java.util.List;
import java.util.Map;
import java.util.Optional;

/**
* 대본 대사에 LLM 으로 감정 톤과 효과 태그를 붙인다. 실패해도 파싱을 막지 않는다(빈 결과 반환).
Expand All @@ -19,4 +20,7 @@ record ScriptEmotion(Tone tone, String textWithEffects) {}
/** 발화자 이름 -> "A" | "B". 선택의 시간에 명시가 없을 때 논지↔선택지명칭 매칭. 실패 시 빈 맵. */
Map<String, String> bindSpeakers(String battleTitle, String optionATitle, String optionBTitle,
List<String> speakers);

/** 오프닝 전문을 배틀 카드용 한 줄 요약으로 압축한다. 실패 시 empty. */
Optional<String> summarizeOpening(String battleTitle, String openingText);
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
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;
Expand Down Expand Up @@ -42,6 +43,13 @@ public class OpenAiEmotionClassifier implements EmotionClassifier {
반드시 JSON 만 출력: {"bindings":{"발화자이름":"A" 또는 "B"}}
""";

private static final String SUMMARY_SYSTEM = """
너는 철학 토론 배틀 카드에 들어갈 한 줄 요약을 쓰는 도구다.
오프닝 전문을 읽고, 배틀 카드에 노출할 흥미로운 한 줄 요약을 40자 이내 한국어로 작성해라.
원문을 그대로 자르지 말고, 핵심 갈등/질문을 압축해라.
반드시 JSON 만 출력: {"summary":"..."}
""";

private final ObjectMapper objectMapper = new ObjectMapper();

@Value("${openai.api-key}")
Expand Down Expand Up @@ -115,6 +123,22 @@ public Map<String, String> bindSpeakers(String battleTitle, String optionATitle,
}
}

@Override
public Optional<String> 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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,12 +85,13 @@ public AdminBattleParseResponse parse(String rawText) {

SpeakerBinding binding = bindSpeakers(doc, warnings);

String openingText = firstNarration(doc, "오프닝");
String description = allNarration(doc, "오프닝");
String summary = summarizeOpening(doc.title, description, warnings);

AdminBattleCreateRequest battlePayload = new AdminBattleCreateRequest(
doc.title,
openingText,
null,
summary,
description,
null,
null,
null,
Expand Down Expand Up @@ -350,14 +351,30 @@ private void putVoice(Map<SpeakerType, String> voices, SpeakerType type, String

// ---- 유틸 ----

private String firstNarration(BattleScriptDocument doc, String nodeName) {
return doc.nodes.stream()
/** 오프닝 전문을 LLM으로 한 줄 요약한다(배틀 카드 summary 용). 실패하면 오프닝 원문으로 대신한다. */
private String summarizeOpening(String title, String openingText, List<ParseWarning> warnings) {
if (openingText == null || openingText.isBlank()) {
return null;
}
Optional<String> 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)
.findFirst()
.reduce((a, b) -> a + " " + b)
.orElse(null);
return joined;
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -192,6 +192,53 @@ private AdminScenarioNodeRequest node(AdminBattleParseResponse res, String name)
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"));
Expand Down
Loading