fix(core): 脚本 agent 用通用字段解析,根治模型 schema 漂移致旁白/画面空

模型每次生成都换字段名(scene/screenDescription/画面…;dialogue 字符串 /
lines:[{role,content}] / caption…),逐一追变体是治标。改为通用解析:
- _pick_field:优先键命中 → 否则按关键词模糊匹配,跳过 bgMusic/note/shotNo 等非内容键
- 画面来源:visual/scene/screenDescription/画面… 任意命中
- 旁白来源:结构化 dialogue/lines 优先,其次整句 dialogue,再退 narration/voiceover/caption/字幕
实测覆盖 4 种真实/契约变体,全部正确填充;新增通用解析回归用例,23 测试通过。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
zyc
2026-06-17 10:38:23 +08:00
co-authored by Claude Opus 4.8
parent 0280c2e192
commit 521f5f4535
2 changed files with 55 additions and 7 deletions
+36 -7
View File
@@ -211,6 +211,35 @@ def _nearest_duration(value) -> int:
return min(DURATION_TIERS, key=lambda t: abs(t - value))
# 模型每次生成都可能换字段名(scene/screenDescription/visual…、dialogue/lines/caption…),
# 与其逐一追变体,不如「优先键命中 → 否则按关键词模糊匹配」通用解析。SKIP 掉明显的非内容键,
# 避免误抓(shotNo/duration/bgMusic/note 等)。
_PICK_SKIP_KEYS = {
"shotno", "shotsize", "shotsizetype", "duration", "starttime", "endtime", "timerange",
"bgmusic", "bg_music", "music", "sound", "sfx", "note", "notes", "tips", "index",
"role", "speaker", "entity_refs", "product_exposure", "id", "no", "transition",
}
_VISUAL_EXACT = ("visual", "visual_prompt", "visual_description", "scene", "screen_description", "screendescription", "screen", "picture", "shot_description")
_VISUAL_FUZZY = ("scene", "screen", "visual", "picture", "画面", "镜头描述", "分镜画面", "描述")
_NARRATION_EXACT = ("narration", "voiceover", "voice_over", "line", "caption", "subtitle", "speech", "口播", "旁白")
_NARRATION_FUZZY = ("narrat", "voice", "旁白", "口播", "台词", "dialog", "caption", "subtitle", "字幕", "speech", "monolog")
def _pick_field(seg: dict, exact: tuple[str, ...], fuzzy: tuple[str, ...]) -> str:
"""从一镜里取某类文本字段:先按优先键精确命中,再按关键词在剩余键里模糊匹配(跳过非内容键)。"""
for key in exact:
val = seg.get(key)
if isinstance(val, str) and val.strip():
return val.strip()
for key, val in seg.items():
kl = str(key).lower()
if kl in _PICK_SKIP_KEYS or not (isinstance(val, str) and val.strip()):
continue
if any(f in kl for f in fuzzy):
return val.strip()
return ""
def normalize_draft(raw_text: str, *, aspect_ratio: str, total_duration: int) -> dict:
"""把模型输出抽成 JSON 并按铁律1契约规范化。宽容:小问题就地修,不轻易抛错。"""
blob = _extract_json(raw_text)
@@ -301,16 +330,16 @@ def normalize_draft(raw_text: str, *, aspect_ratio: str, total_duration: int) ->
continue
sp = d.get("speaker")
dialogue.append({"speaker": sp if sp in valid_ids else None, "line": line})
# 旁白:narration > 字符串 dialogue(整句口播)> 结构化对白/lines 拼接 > subtitle/caption(字幕兜底)
narration = (seg.get("narration") or seg.get("voiceover") or "").strip()
if not narration and isinstance(raw_dialogue, str):
# 旁白:结构化对白/lines 优先,其次整句字符串 dialogue,再退到通用字段解析(narration/voiceover/caption/字幕)
narration = ""
if isinstance(raw_dialogue, str) and raw_dialogue.strip():
narration = raw_dialogue.strip()
if not narration and dialogue:
elif dialogue:
narration = " ".join(d["line"] for d in dialogue) # 扁平拼接,兼容下游字幕/配音
if not narration:
narration = (seg.get("subtitle") or seg.get("caption") or "").strip()
# 画面:visual > visual_prompt > scene > shot/visual_description(模型变体常用 scene 描述画面)
visual = (seg.get("visual") or seg.get("visual_prompt") or seg.get("scene") or seg.get("visual_description") or "").strip()
narration = _pick_field(seg, _NARRATION_EXACT, _NARRATION_FUZZY)
# 画面:通用解析(visual/scene/screenDescription/画面… 都能命中,跳过 shotNo/bgMusic 等)
visual = _pick_field(seg, _VISUAL_EXACT, _VISUAL_FUZZY)
norm_segments.append(
{
"index": i,