Files
Jellyfish/backend/app/chains/agents/script_processing_agents.py
T
extreme 752323510b Refactor script processing agents and update dependencies for improved functionality
- Renamed and updated agent classes for film processing, changing from FilmEntityExtractor and FilmShotlistStoryboarder to FilmEntityExtractorAgent and FilmShotlistStoryboarderAgent for consistency.
- Modified the dependencies in various API routes to utilize the new agent classes, ensuring proper integration with the updated architecture.
- Enhanced the README documentation to reflect changes in unit testing and integration testing for the updated agents.
- Streamlined the agent base class to improve the handling of prompts and outputs, facilitating better interaction with the language model.
- Removed deprecated skill loading methods and updated the output handling to align with the new agent structure.
2026-03-25 17:26:24 +08:00

660 lines
29 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""脚本处理 Agent:分镜分割、信息提取、实体合并、变体分析、一致性检查、输出编译。"""
from __future__ import annotations
import json
from typing import Any
from langchain_core.prompts import PromptTemplate
from app.chains.agents.base import AgentBase, _extract_json_from_text
from app.schemas.skills.script_processing import (
OutputCompileResult,
ScriptConsistencyCheckResult,
ScriptDivisionResult,
ScriptOptimizationResult,
ShotElementExtractionResult,
StudioScriptExtractionDraft,
EntityMergeResult,
VariantAnalysisResult,
ShotDivision,
)
# PromptTemplates(固化在本模块,供各 Agent 子类通过 @property 返回)
_SCRIPT_DIVIDER_SYSTEM_PROMPT = """\
你是\"剧本分镜师\"。将完整剧本分割为多个镜头。每个镜头应是完整的连贯场景。
为每个镜头提供:
- index(镜头序号,章节内唯一;从 1 开始)
- start_line、end_line
- shot_name(镜头名称/镜头标题,分镜名;一句话描述该镜头画面/动作;不要把它当作场景名)
- script_excerpt(镜头对应的剧本摘录/文本)
- scene_name(场景名称,必须与 shots 中 scene_name 的含义一致;不要把 shot_name 当成 scene_name)
- time_of_day
- character_names_in_text(角色名/称呼,弱信息;稳定ID会在后续合并阶段统一分配)
严格区分字段含义:
- shot_name = 分镜名/镜头标题
- scene_name = 场景名
只输出 JSON,符合 ScriptDivisionResult 结构。
"""
SCRIPT_DIVIDER_PROMPT = PromptTemplate(
input_variables=["script_text"],
template="## 输入脚本\n{script_text}\n\n## 输出\n",
)
_ELEMENT_EXTRACTOR_SYSTEM_PROMPT = """\
你是\"镜头元素提取员\"。从单个镜头提取关键信息:
- character_keys、scene_keys、costume_keys、prop_keys(弱ID/归一化名;稳定ID由后续合并阶段统一分配)
- characters_detailed(每个角色含 character_key/name_in_text/appearance/clothing/accessories/state + raw_* 溯源,可选 evidence)
- props_detailed(每个道具含 prop_key/name_in_text/description/state/interaction + raw_text,可选 evidence)
- scene_detailed(scene_key/name/location_detail/atmosphere/time_weather/raw_description_text,可选 evidence)
- dialogue_lines(结构化对白,字段与 schemas.DialogueLine 对齐)
- actions、shot_type_hints、confidence_breakdown
其中 dialogue_lines 每项必须包含 text/line_mode;建议包含 index/speaker_character_id/target_character_id(若可判定)。
严格按照原文,不要编造。只输出 JSON,符合 ShotElementExtractionResult 结构。
"""
ELEMENT_EXTRACTOR_PROMPT = PromptTemplate(
input_variables=["index", "shot_text", "context_summary", "shot_division_json"],
template="镜头号: {index}\n分镜元信息(来自上一步): {shot_division_json}\n上文: {context_summary}\n\n## 镜头文本\n{shot_text}\n\n## 输出\n",
)
_ENTITY_MERGER_SYSTEM_PROMPT = """\
你是\"实体合并师\"。合并多镜头提取结果,统一实体定义,为每个实体分配ID,识别变体和冲突。
请输出 EntityMergeResult,merged_library 中至少包含 characters/locations/scenes/props 四类。
每个实体条目(EntityEntry)需包含:
- 通用:id/name/type/description/aliases/normalized_name/confidence/first_appearance/evidence/first_shot/appearances/variants
- 角色(type=character):尽量补充 costume_note、traits
- 地点(type=location):尽量补充 location_type
- 道具(type=prop):尽量补充 category、owner_character_id
variants 使用 {variant_key, description, affected_shots, evidence} 的最小结构。
当提供 previous_merge_json 与 conflict_resolutions_json 时,表示这是一次“重试合并”:你必须参考上一次的合并结果与冲突解决建议,优先消解 conflicts;必要时可调整实体合并/拆分策略,但要保持 ID 尽量稳定(除非建议明确要求变更)。
只输出 JSON,符合 EntityMergeResult 结构。
"""
ENTITY_MERGER_PROMPT = PromptTemplate(
input_variables=[
"all_extractions_json",
"historical_library_json",
"script_division_json",
"previous_merge_json",
"conflict_resolutions_json",
],
template=(
"## 脚本分镜(来自上一步)\n{script_division_json}\n\n"
"## 所有镜头提取结果\n{all_extractions_json}\n\n"
"## 历史实体库\n{historical_library_json}\n\n"
"## 上一次合并结果(可选,用于重试)\n{previous_merge_json}\n\n"
"## 冲突解决建议(可选,用于重试)\n{conflict_resolutions_json}\n\n"
"## 输出\n"
),
)
_VARIANT_ANALYZER_SYSTEM_PROMPT = """\
你是\"变体分析师\"。分析实体变体(特别是角色服装变化),构建时间线,生成变体建议。
输出 VariantAnalysisResult:costume_timelines.timeline_entries 使用 {shot_index, scene_id, costume_note, changes, evidence};variant_suggestions 可带 evidence。
只输出 JSON,符合 VariantAnalysisResult 结构。
"""
VARIANT_ANALYZER_PROMPT = PromptTemplate(
input_variables=["merged_library_json", "all_extractions_json", "script_division_json"],
template="## 脚本分镜(来自上一步)\n{script_division_json}\n\n## 合并后的实体库\n{merged_library_json}\n\n## 所有镜头提取结果\n{all_extractions_json}\n\n## 输出\n",
)
_CONSISTENCY_CHECKER_SYSTEM_PROMPT = """\
你是\"一致性检查员\"。只做一件事:检测原文中是否把“同一个角色”在不同段落/镜头中赋予了不同的身份或行为主体,导致角色混淆(例如:同名不同人、代词指代混乱、行为归属错位)。
输出 ScriptConsistencyCheckResult:
- issues: 每条问题必须包含 character_candidates、description、suggestion;尽量给出 affected_lines(start_line/end_line)。
- has_issues: issues 非空则为 true
只输出 JSON。
"""
CONSISTENCY_CHECKER_PROMPT = PromptTemplate(
input_variables=["script_text"],
template="## 原文剧本\n{script_text}\n\n## 输出\n",
)
_SCRIPT_OPTIMIZER_SYSTEM_PROMPT = """\
你是\"剧本优化师\"。仅当一致性检查发现角色混淆问题时,对原文进行最小改写以消除混淆。
输入:
- script_text:原文
- consistency_json:一致性检查输出(ScriptConsistencyCheckResult)
输出 ScriptOptimizationResult:
- optimized_script_text:优化后的完整剧本文本(尽量少改,只改与 issues 相关的段落)
- change_summary:逐条对应 issues 的改动摘要
只输出 JSON。
"""
SCRIPT_OPTIMIZER_PROMPT = PromptTemplate(
input_variables=["script_text", "consistency_json"],
template="## 一致性检查结果\n{consistency_json}\n\n## 原文剧本\n{script_text}\n\n## 输出\n",
)
_SCRIPT_EXTRACTOR_SYSTEM_PROMPT = """\
你是\"Studio 信息提取员\"。你的任务是:基于剧本文本与分镜结果,输出可直接导入 Studio 的草稿结构 StudioScriptExtractionDraft(注意:ID 由导入 API 生成,因此这里全部使用 name 做引用键)。
输出 StudioScriptExtractionDraft:
- project_id(必填)
- chapter_id(必填)
- script_text(必填)
- characters: [{name, description, costume_name?, prop_names[], tags[]}]
- scenes/props/costumes: [{name, description, tags[], prompt_template_id?, view_count}]
- shots: [{index, title, script_excerpt, scene_name?, character_names[], prop_names[], costume_names[], dialogue_lines[], actions[]}]
- dialogue_lines: [{index, text, line_mode, speaker_name?, target_name?}]
强约束:
- 同名实体在输出中只出现一次(全局去重);shots 中引用必须使用同一名称
- shots.index 必须覆盖并对应输入分镜中的 index(不要跳号)
- 不要输出任何 id 字段(包括 char_001 等),由导入 API 生成
一致性强约束(必须严格遵守,否则导入会失败):
- 先输出全局 characters/scenes/props/costumes 列表,再输出 shots;并把它们视为“字典”。
- shots[*].character_names / prop_names / costume_names / scene_name 只能从对应全局列表的 name 中选择(完全一致的字符串),禁止生成任何未在全局列表中出现的新名字。
- 禁止“同义名/括号变体/临时称呼”漂移:例如禁止在 shots 中写「女子(群)」但在 characters 中没有该条目;禁止「仙女A」与「仙女 A」混用。
- 遇到群体角色/泛指角色(如“女子(群)”“群众”“村民们”):必须在 characters 列表中创建一条同名角色(name 完全一致),并在 shots 中引用该 name。
- 对于难以确定是否同一角色的称呼:宁可在 characters 里拆成两条不同 name,也不要在 shots 中凭空换名。
- 输出 shots 之前,必须做“全集校验”并补齐缺失:所有 shots[*] 中出现的 character_names/prop_names/costume_names/scene_name 的名字集合,必须都能在对应全局列表(characters/props/costumes/scenes)的 name 中找到;如果有缺失,必须在全局列表中补齐对应条目(描述可最小化,但 name 必须完全一致),禁止用别名替换来绕过。
- 角色名/场景名必须原样保留字符细节:包括全角/半角括号、空格、标点,不要自动做任何规范化或替换(例如不能把「女子(群)」改成「女子(群)」或「女子 (群)」)。
- 严格区分:shots[*].title 是“镜头标题”(一句话描述该镜头画面/动作),不要拿它当作 scenes 的 scene 名;shots[*].scene_name 才是场景名称,必须来自 scenes 全局列表的 name。
输入:
- project_id
- chapter_id
- script_text
- script_division_json(ScriptDivisionResult)
- consistency_json(可选)
只输出 JSON。
"""
SCRIPT_EXTRACTOR_PROMPT = PromptTemplate(
input_variables=["project_id", "chapter_id", "script_text", "script_division_json", "consistency_json"],
template=(
"## project_id\n{project_id}\n\n"
"## chapter_id\n{chapter_id}\n\n"
"## 一致性检查(可选)\n{consistency_json}\n\n"
"## 分镜结果\n{script_division_json}\n\n"
"## 剧本文本\n{script_text}\n\n"
"## 输出\n"
),
)
_OUTPUT_COMPILER_SYSTEM_PROMPT = """\
你是\"输出编译员\"。汇总所有Agent输出,生成完整项目JSON、可导出表格、项目总结。
输出 OutputCompileResult,其中 project_json 必须严格符合 ProjectCinematicBreakdown schema(至少包含 source_id/chunks/characters/locations/props/scenes/shots/transitions/notes/uncertainties)。
只输出 JSON。
"""
OUTPUT_COMPILER_PROMPT = PromptTemplate(
input_variables=["division_json", "all_extractions_json", "merge_json", "variant_json", "consistency_json"],
template="## 分镜结果\n{division_json}\n\n## 所有逐镜提取\n{all_extractions_json}\n\n## 实体合并\n{merge_json}\n\n## 变体分析\n{variant_json}\n\n## 一致性检查\n{consistency_json}\n\n## 输出\n",
)
# ============================================================================
# 1. ScriptDividerAgent - 剧本自动分镜
# ============================================================================
class ScriptDividerAgent(AgentBase[ScriptDivisionResult]):
"""剧本自动分镜:输入完整剧本文本,输出分镜列表。"""
@property
def system_prompt(self) -> str:
return _SCRIPT_DIVIDER_SYSTEM_PROMPT
@property
def prompt_template(self) -> PromptTemplate:
return SCRIPT_DIVIDER_PROMPT
@property
def output_model(self) -> type[ScriptDivisionResult]:
return ScriptDivisionResult
def format_output(self, raw: str) -> ScriptDivisionResult:
"""
更强的兜底解析:
LLM 可能输出:
- 正常结构:{shots:[...], total_shots:N}
- 包裹结构:{"ScriptDivisionResult": {...}}
- 直接列表:[{...}, {...}](视为 shots)
"""
json_str = _extract_json_from_text(raw)
data: Any = json.loads(json_str)
if isinstance(data, list):
data = {"shots": data}
elif isinstance(data, dict) and "ScriptDivisionResult" in data:
inner = data.get("ScriptDivisionResult")
if isinstance(inner, list):
data = {"shots": inner}
elif isinstance(inner, dict):
data = inner
else:
data = {"shots": []}
if isinstance(data, dict):
data = self._normalize(data)
return self.output_model.model_validate(data) # type: ignore[arg-type]
def divide_script(self, *, script_text: str) -> ScriptDivisionResult:
return self.extract(script_text=script_text)
async def adivide_script(self, *, script_text: str) -> ScriptDivisionResult:
return await self.aextract(script_text=script_text)
def _normalize(self, data: dict[str, Any]) -> dict[str, Any]:
"""规范化脚本分割结果。"""
data = dict(data)
# 兼容:LLM 可能输出 {"ScriptDivisionResult": {...}} 或 {"ScriptDivisionResult": [...]}
if "ScriptDivisionResult" in data:
inner = data.get("ScriptDivisionResult")
if isinstance(inner, list):
data = {"shots": inner}
elif isinstance(inner, dict):
data = dict(inner)
else:
data = {"shots": []}
if "shots" in data and isinstance(data["shots"], list):
shots = []
for idx, shot in enumerate(data["shots"]):
shot_dict: dict[str, Any] = (
dict(shot) if isinstance(shot, dict) else {"script_excerpt": str(shot), "shot_name": ""}
)
if "index" not in shot_dict:
shot_dict["index"] = idx + 1
# 兼容:LLM 可能用 title/shot_title 代替 shot_name
if "shot_name" not in shot_dict:
if "title" in shot_dict:
shot_dict["shot_name"] = str(shot_dict.pop("title"))
elif "shot_title" in shot_dict:
shot_dict["shot_name"] = str(shot_dict.pop("shot_title"))
shot_dict.setdefault("shot_name", "")
# 兼容旧字段:character_ids -> character_names_in_text(此阶段为弱信息)
if "character_ids" in shot_dict and "character_names_in_text" not in shot_dict:
val = shot_dict.get("character_ids")
if isinstance(val, list):
shot_dict["character_names_in_text"] = [str(x) for x in val]
shot_dict.pop("character_ids", None)
shots.append(shot_dict)
data["shots"] = shots
if "total_shots" not in data and "shots" in data:
data["total_shots"] = len(data["shots"])
return data
# ============================================================================
# 2. ShotElementExtractorAgent - 逐镜信息提取(兼容旧流程)
# ============================================================================
class ShotElementExtractorAgent(AgentBase[ShotElementExtractionResult]):
"""[兼容] 逐镜信息提取:输入单镜文本+上文摘要,输出该镜的结构化提取结果。"""
@property
def system_prompt(self) -> str:
return _ELEMENT_EXTRACTOR_SYSTEM_PROMPT
@property
def prompt_template(self) -> PromptTemplate:
return ELEMENT_EXTRACTOR_PROMPT
@property
def output_model(self) -> type[ShotElementExtractionResult]:
return ShotElementExtractionResult
def _normalize(self, data: dict[str, Any]) -> dict[str, Any]:
"""规范化元素提取结果(升级版结构)。"""
data = dict(data)
# 兼容:缺失 shot_division
if "shot_division" not in data:
data["shot_division"] = None
if "elements" not in data or not isinstance(data["elements"], dict):
data["elements"] = {}
elements = data["elements"]
# 兼容旧字段名:*_ids -> *_keys
legacy_key_map = {
"character_ids": "character_keys",
"scene_ids": "scene_keys",
"costume_ids": "costume_keys",
"prop_ids": "prop_keys",
}
for old, new in legacy_key_map.items():
if old in elements and new not in elements:
val = elements.get(old)
if isinstance(val, list):
elements[new] = [str(x) for x in val]
elements.pop(old, None)
# 兼容旧字段名:dialog_lines -> dialogue_lines
if "dialog_lines" in elements and "dialogue_lines" not in elements:
elements["dialogue_lines"] = elements.pop("dialog_lines")
for key in (
"character_keys",
"scene_keys",
"costume_keys",
"prop_keys",
"characters_detailed",
"props_detailed",
"dialogue_lines",
"actions",
"shot_type_hints",
):
if key not in elements or not isinstance(elements[key], list):
elements[key] = []
if "scene_detailed" not in elements:
elements["scene_detailed"] = None
elif elements["scene_detailed"] is not None and not isinstance(elements["scene_detailed"], dict):
elements["scene_detailed"] = None
if "confidence_breakdown" not in elements or not isinstance(elements["confidence_breakdown"], dict):
elements["confidence_breakdown"] = {}
# 兼容旧结构:characters_detailed/props_detailed/scene_detailed 里字段名 *_id -> *_key
for c in elements.get("characters_detailed", []) or []:
if isinstance(c, dict) and "character_id" in c and "character_key" not in c:
c["character_key"] = str(c.pop("character_id"))
if isinstance(c, dict) and "evidence" not in c:
c["evidence"] = []
for p in elements.get("props_detailed", []) or []:
if isinstance(p, dict) and "prop_id" in p and "prop_key" not in p:
p["prop_key"] = str(p.pop("prop_id"))
if isinstance(p, dict) and "evidence" not in p:
p["evidence"] = []
sd = elements.get("scene_detailed")
if isinstance(sd, dict) and "scene_id" in sd and "scene_key" not in sd:
sd["scene_key"] = str(sd.pop("scene_id"))
if isinstance(sd, dict) and "evidence" not in sd:
sd["evidence"] = []
# 兼容旧对白行结构:补齐 schemas.DialogueLine 的可选字段
dl = elements.get("dialogue_lines")
if isinstance(dl, list):
for line in dl:
if not isinstance(line, dict):
continue
if "index" not in line and "order" in line:
line["index"] = line.pop("order")
if "evidence" not in line:
line["evidence"] = []
# 确保 line_mode 合法字符串(由 schema Literal 校验)
if "line_mode" not in line:
line["line_mode"] = "DIALOGUE"
data["elements"] = elements
if "confidence" not in data:
data["confidence"] = None
return data
# ============================================================================
# 2b. ElementExtractorAgent - 项目级信息提取(新流程最终输出)
# ============================================================================
class ElementExtractorAgent(AgentBase[StudioScriptExtractionDraft]):
"""项目级信息提取(最终输出):输入剧本文本 + 分镜结果,产出全局实体表 + 逐镜关联。"""
@property
def system_prompt(self) -> str:
return _SCRIPT_EXTRACTOR_SYSTEM_PROMPT
@property
def prompt_template(self) -> PromptTemplate:
return SCRIPT_EXTRACTOR_PROMPT
@property
def output_model(self) -> type[StudioScriptExtractionDraft]:
return StudioScriptExtractionDraft
def _normalize(self, data: dict[str, Any]) -> dict[str, Any]:
data = dict(data)
data.setdefault("project_id", "")
data.setdefault("chapter_id", "")
data.setdefault("script_text", "")
for k in ("characters", "scenes", "props", "costumes", "shots"):
if k not in data or not isinstance(data[k], list):
data[k] = []
return data
# ============================================================================
# 3. EntityMergerAgent - 跨镜静态合并 + 基础画像生成
# ============================================================================
class EntityMergerAgent(AgentBase[EntityMergeResult]):
"""跨镜合并 + 基础画像生成:输入全部分镜提取结果+历史实体库,输出合并后的库。"""
@property
def system_prompt(self) -> str:
return _ENTITY_MERGER_SYSTEM_PROMPT
@property
def prompt_template(self) -> PromptTemplate:
return ENTITY_MERGER_PROMPT
@property
def output_model(self) -> type[EntityMergeResult]:
return EntityMergeResult
def _normalize(self, data: dict[str, Any]) -> dict[str, Any]:
"""规范化实体合并结果。"""
data = dict(data)
if "merged_library" not in data:
data["merged_library"] = {
"characters": [],
"locations": [],
"scenes": [],
"props": [],
"total_entries": 0,
}
lib = data["merged_library"]
# 兼容旧字段:缺 locations 时补空;旧结构可能没有 scenes
if "locations" not in lib:
lib["locations"] = []
if "scenes" not in lib:
lib["scenes"] = []
if "total_entries" not in lib:
lib["total_entries"] = sum(
len(lib.get(k, []) or [])
for k in ("characters", "locations", "scenes", "props")
)
# 兼容旧 variants 结构:dict[] -> EntityVariant[](最小补齐)
for bucket_name in ("characters", "locations", "scenes", "props"):
bucket = lib.get(bucket_name)
if not isinstance(bucket, list):
continue
for ent in bucket:
if not isinstance(ent, dict):
continue
if "variants" in ent and isinstance(ent["variants"], list):
new_vars = []
for v in ent["variants"]:
if isinstance(v, dict):
if "variant_key" not in v:
v["variant_key"] = v.get("id") or v.get("key") or "variant"
if "affected_shots" not in v:
v["affected_shots"] = []
if "evidence" not in v:
v["evidence"] = []
new_vars.append(v)
else:
new_vars.append(
{
"variant_key": "variant",
"description": str(v),
"affected_shots": [],
"evidence": [],
}
)
ent["variants"] = new_vars
if "merge_stats" not in data:
data["merge_stats"] = {}
if "conflicts" not in data or not isinstance(data["conflicts"], list):
data["conflicts"] = []
return data
# ============================================================================
# 4. VariantAnalyzerAgent - 服装/外形变体检测与建议
# ============================================================================
class VariantAnalyzerAgent(AgentBase[VariantAnalysisResult]):
"""服装/外形变体检测与建议:输入实体库+全镜提取,输出变体分析结果。"""
@property
def system_prompt(self) -> str:
return _VARIANT_ANALYZER_SYSTEM_PROMPT
@property
def prompt_template(self) -> PromptTemplate:
return VARIANT_ANALYZER_PROMPT
@property
def output_model(self) -> type[VariantAnalysisResult]:
return VariantAnalysisResult
def _normalize(self, data: dict[str, Any]) -> dict[str, Any]:
"""规范化变体分析结果。"""
data = dict(data)
if "costume_timelines" not in data or not isinstance(data["costume_timelines"], list):
data["costume_timelines"] = []
if "variant_suggestions" not in data or not isinstance(data["variant_suggestions"], list):
data["variant_suggestions"] = []
if "chapter_variants" not in data or not isinstance(data["chapter_variants"], dict):
data["chapter_variants"] = {}
# 补齐可选 evidence 字段,避免 strict schema 校验失败
for tl in data.get("costume_timelines", []) or []:
if not isinstance(tl, dict):
continue
entries = tl.get("timeline_entries")
if isinstance(entries, list):
for e in entries:
if isinstance(e, dict) and "evidence" not in e:
e["evidence"] = []
for s in data.get("variant_suggestions", []) or []:
if isinstance(s, dict) and "evidence" not in s:
s["evidence"] = []
return data
# ============================================================================
# 5. ConsistencyCheckerAgent - 文本一致性检查
# ============================================================================
class ConsistencyCheckerAgent(AgentBase[ScriptConsistencyCheckResult]):
"""一致性检查(角色混淆):输入原文,检测同一角色身份/行为混淆并给出修改建议。"""
@property
def system_prompt(self) -> str:
return _CONSISTENCY_CHECKER_SYSTEM_PROMPT
@property
def prompt_template(self) -> PromptTemplate:
return CONSISTENCY_CHECKER_PROMPT
@property
def output_model(self) -> type[ScriptConsistencyCheckResult]:
return ScriptConsistencyCheckResult
def _normalize(self, data: dict[str, Any]) -> dict[str, Any]:
"""规范化一致性检查结果(角色混淆)。"""
data = dict(data)
if "issues" not in data or not isinstance(data["issues"], list):
data["issues"] = []
for it in data["issues"]:
if isinstance(it, dict):
it.setdefault("issue_type", "character_confusion")
it.setdefault("character_candidates", [])
it.setdefault("affected_lines", None)
it.setdefault("evidence", [])
if "has_issues" not in data:
data["has_issues"] = len(data["issues"]) > 0
if "summary" not in data:
data["summary"] = None
return data
class ScriptOptimizerAgent(AgentBase[ScriptOptimizationResult]):
"""剧本优化 Agent:输入一致性检查输出 + 原文,输出优化后的剧本。"""
@property
def system_prompt(self) -> str:
return _SCRIPT_OPTIMIZER_SYSTEM_PROMPT
@property
def prompt_template(self) -> PromptTemplate:
return SCRIPT_OPTIMIZER_PROMPT
@property
def output_model(self) -> type[ScriptOptimizationResult]:
return ScriptOptimizationResult
def _normalize(self, data: dict[str, Any]) -> dict[str, Any]:
data = dict(data)
if "optimized_script_text" not in data:
data["optimized_script_text"] = ""
if "change_summary" not in data:
data["change_summary"] = ""
return data
# ============================================================================
# 6. OutputCompilerAgent - 最终输出打包
# ============================================================================
class OutputCompilerAgent(AgentBase[OutputCompileResult]):
"""最终输出打包:输入所有Agent状态,输出完整项目JSON + 表格数据。"""
@property
def system_prompt(self) -> str:
return _OUTPUT_COMPILER_SYSTEM_PROMPT
@property
def prompt_template(self) -> PromptTemplate:
return OUTPUT_COMPILER_PROMPT
@property
def output_model(self) -> type[OutputCompileResult]:
return OutputCompileResult
def _normalize(self, data: dict[str, Any]) -> dict[str, Any]:
"""规范化输出编译结果。"""
data = dict(data)
# 严格输出:project_json 必须能被 ProjectCinematicBreakdown 校验
if "project_json" not in data or not isinstance(data["project_json"], dict):
data["project_json"] = {}
pj = data["project_json"]
if isinstance(pj, dict):
# 补齐 ProjectCinematicBreakdown 必填字段
pj.setdefault("source_id", "unknown_source")
pj.setdefault("chunks", [])
pj.setdefault("characters", [])
pj.setdefault("locations", [])
pj.setdefault("props", [])
pj.setdefault("scenes", [])
pj.setdefault("shots", [])
pj.setdefault("transitions", [])
pj.setdefault("notes", [])
pj.setdefault("uncertainties", [])
if "tables" not in data or not isinstance(data["tables"], list):
data["tables"] = []
if "export_stats" not in data or not isinstance(data["export_stats"], dict):
data["export_stats"] = {
"total_tables": len(data["tables"]),
"total_rows": sum(t.get("row_count", 0) for t in data["tables"]),
}
return data