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