154 lines
6.4 KiB
Python
154 lines
6.4 KiB
Python
import json
|
||
import logging
|
||
from typing import Optional
|
||
|
||
from app.services.hermes_client import hermes_client
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
class ScriptParser:
|
||
async def parse(self, content: str, file_type: str = "natural_language") -> dict:
|
||
if file_type == "json":
|
||
return self._parse_json(content)
|
||
return await self._parse_natural_language(content)
|
||
|
||
def _parse_json(self, content: str) -> dict:
|
||
try:
|
||
data = json.loads(content)
|
||
return {
|
||
"title": data.get("title", "未命名剧本"),
|
||
"background": data.get("background", ""),
|
||
"characters": data.get("characters", []),
|
||
"clues": data.get("clues", []),
|
||
"phases": data.get("phases", [
|
||
{"name": "intro", "order": 0, "duration": 120},
|
||
{"name": "round1_speak", "order": 1, "duration": 300},
|
||
{"name": "round1_search", "order": 2, "duration": 180},
|
||
{"name": "round2_speak", "order": 3, "duration": 300},
|
||
{"name": "round2_search", "order": 4, "duration": 180},
|
||
{"name": "final_discuss", "order": 5, "duration": 300},
|
||
{"name": "voting", "order": 6, "duration": 120},
|
||
{"name": "reveal", "order": 7, "duration": 120},
|
||
]),
|
||
"character_count": len(data.get("characters", [])),
|
||
}
|
||
except json.JSONDecodeError as e:
|
||
logger.error(f"JSON parse error: {e}")
|
||
return {"title": "未命名剧本", "background": "", "characters": [], "clues": [], "phases": [], "character_count": 0}
|
||
|
||
async def _parse_natural_language(self, content: str) -> dict:
|
||
prompt = f"""你是一个剧本杀解析器。请分析以下自然语言描述的剧本,提取结构化信息。
|
||
|
||
输出格式必须是合法的JSON:
|
||
{{
|
||
"title": "剧本名称(从内容推断)",
|
||
"background": "案件背景",
|
||
"characters": [
|
||
{{
|
||
"name": "角色名",
|
||
"personality": "性格特征",
|
||
"speaking_style": "说话风格",
|
||
"secret": "该角色隐藏的秘密",
|
||
"motive": "该角色的动机"
|
||
}}
|
||
],
|
||
"clues": [
|
||
{{
|
||
"id": "线索ID",
|
||
"content": "线索内容",
|
||
"owner": "线索属于哪个角色",
|
||
"phase": "线索在哪个阶段可用(round1_search/round2_search)"
|
||
}}
|
||
],
|
||
"phases": [
|
||
{{"name": "intro", "order": 0, "duration": 120}},
|
||
{{"name": "round1_speak", "order": 1, "duration": 300}},
|
||
{{"name": "round1_search", "order": 2, "duration": 180}},
|
||
{{"name": "round2_speak", "order": 3, "duration": 300}},
|
||
{{"name": "round2_search", "order": 4, "duration": 180}},
|
||
{{"name": "final_discuss", "order": 5, "duration": 300}},
|
||
{{"name": "voting", "order": 6, "duration": 120}},
|
||
{{"name": "reveal", "order": 7, "duration": 120}}
|
||
]
|
||
}}
|
||
|
||
剧本内容:
|
||
{content[:8000]}
|
||
|
||
请只返回JSON,不要包含其他解释文字。"""
|
||
|
||
try:
|
||
response = await hermes_client.chat(profile_name="default", message=prompt)
|
||
response = response.strip()
|
||
if response.startswith("```"):
|
||
lines = response.split("\n")
|
||
response = "\n".join(lines[1:-1]) if len(lines) >= 3 else response
|
||
data = json.loads(response)
|
||
data.setdefault("title", "未命名剧本")
|
||
data.setdefault("background", "")
|
||
data.setdefault("characters", [])
|
||
data.setdefault("clues", [])
|
||
data["character_count"] = len(data.get("characters", []))
|
||
if not data.get("phases"):
|
||
data["phases"] = [
|
||
{"name": "intro", "order": 0, "duration": 120},
|
||
{"name": "round1_speak", "order": 1, "duration": 300},
|
||
{"name": "round1_search", "order": 2, "duration": 180},
|
||
{"name": "round2_speak", "order": 3, "duration": 300},
|
||
{"name": "round2_search", "order": 4, "duration": 180},
|
||
{"name": "final_discuss", "order": 5, "duration": 300},
|
||
{"name": "voting", "order": 6, "duration": 120},
|
||
{"name": "reveal", "order": 7, "duration": 120},
|
||
]
|
||
return data
|
||
except (json.JSONDecodeError, Exception) as e:
|
||
logger.warning(f"LLM parse failed: {e}, using regex fallback")
|
||
return self._regex_fallback(content)
|
||
|
||
def _regex_fallback(self, content: str) -> dict:
|
||
import re
|
||
|
||
title = "未命名剧本"
|
||
first_line = content.strip().split("\n")[0].strip()
|
||
if len(first_line) <= 50:
|
||
title = first_line.lstrip("#").strip()
|
||
|
||
characters = []
|
||
char_pattern = re.compile(r"^[#\-\*]*\s*(.{1,10})(?:[::]\s*(.+))?$", re.MULTILINE)
|
||
name_keywords = re.compile(r"(角色|人物|嫌疑人|侦探|凶手|死者|被害人)", re.IGNORECASE)
|
||
|
||
char_section = False
|
||
for line in content.split("\n"):
|
||
line = line.strip()
|
||
if name_keywords.search(line):
|
||
char_section = True
|
||
continue
|
||
if char_section and line.startswith("#"):
|
||
break
|
||
if char_section and len(line) <= 30 and line:
|
||
name = re.sub(r"[-::\s].*$", "", line).strip()
|
||
if name and len(name) <= 10:
|
||
characters.append({"name": name, "personality": "", "speaking_style": "", "secret": "", "motive": ""})
|
||
|
||
return {
|
||
"title": title,
|
||
"background": content[:200],
|
||
"characters": characters,
|
||
"clues": [],
|
||
"phases": [
|
||
{"name": "intro", "order": 0, "duration": 120},
|
||
{"name": "round1_speak", "order": 1, "duration": 300},
|
||
{"name": "round1_search", "order": 2, "duration": 180},
|
||
{"name": "round2_speak", "order": 3, "duration": 300},
|
||
{"name": "round2_search", "order": 4, "duration": 180},
|
||
{"name": "final_discuss", "order": 5, "duration": 300},
|
||
{"name": "voting", "order": 6, "duration": 120},
|
||
{"name": "reveal", "order": 7, "duration": 120},
|
||
],
|
||
"character_count": len(characters),
|
||
}
|
||
|
||
|
||
script_parser = ScriptParser()
|