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)) 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: def normalize_draft(raw_text: str, *, aspect_ratio: str, total_duration: int) -> dict:
"""把模型输出抽成 JSON 并按铁律1契约规范化。宽容:小问题就地修,不轻易抛错。""" """把模型输出抽成 JSON 并按铁律1契约规范化。宽容:小问题就地修,不轻易抛错。"""
blob = _extract_json(raw_text) blob = _extract_json(raw_text)
@@ -301,16 +330,16 @@ def normalize_draft(raw_text: str, *, aspect_ratio: str, total_duration: int) ->
continue continue
sp = d.get("speaker") sp = d.get("speaker")
dialogue.append({"speaker": sp if sp in valid_ids else None, "line": line}) dialogue.append({"speaker": sp if sp in valid_ids else None, "line": line})
# 旁白:narration > 字符串 dialogue(整句口播)> 结构化对白/lines 拼接 > subtitle/caption(字幕兜底) # 旁白:结构化对白/lines 优先,其次整句字符串 dialogue,再退到通用字段解析(narration/voiceover/caption/字幕)
narration = (seg.get("narration") or seg.get("voiceover") or "").strip() narration = ""
if not narration and isinstance(raw_dialogue, str): if isinstance(raw_dialogue, str) and raw_dialogue.strip():
narration = raw_dialogue.strip() narration = raw_dialogue.strip()
if not narration and dialogue: elif dialogue:
narration = " ".join(d["line"] for d in dialogue) # 扁平拼接,兼容下游字幕/配音 narration = " ".join(d["line"] for d in dialogue) # 扁平拼接,兼容下游字幕/配音
if not narration: if not narration:
narration = (seg.get("subtitle") or seg.get("caption") or "").strip() narration = _pick_field(seg, _NARRATION_EXACT, _NARRATION_FUZZY)
# 画面:visual > visual_prompt > scene > shot/visual_description(模型变体常用 scene 描述画面) # 画面:通用解析(visual/scene/screenDescription/画面… 都能命中,跳过 shotNo/bgMusic 等)
visual = (seg.get("visual") or seg.get("visual_prompt") or seg.get("scene") or seg.get("visual_description") or "").strip() visual = _pick_field(seg, _VISUAL_EXACT, _VISUAL_FUZZY)
norm_segments.append( norm_segments.append(
{ {
"index": i, "index": i,
+19
View File
@@ -51,6 +51,25 @@ class NormalizeDraftTests(SimpleTestCase):
self.assertIn("防晒泛白太尴尬", draft["segments"][0]["narration"]) self.assertIn("防晒泛白太尴尬", draft["segments"][0]["narration"])
self.assertEqual(len(draft["segments"][0]["dialogue"]), 2) # lines→结构化对白 self.assertEqual(len(draft["segments"][0]["dialogue"]), 2) # lines→结构化对白
def test_generic_resolver_covers_unseen_field_names(self):
"""模型每次换字段名(scene/screenDescription/画面…、dialogue/lines/caption…)。
通用解析应「优先键 + 关键词模糊匹配」都能填,且跳过 bgMusic/note 等非内容键。"""
variants = {
"canonical": {"total_duration": 15, "segments": [{"role": "钩子", "narration": "口播A", "visual": "画面A"}]},
"scene+dialogue_str+subtitle": {"total_duration": 15, "shots": [{"scene": "画面B", "dialogue": "口播B", "subtitle": "字幕B"}]},
"scene+lines+caption": {"total_duration": 15, "shots": [{"scene": "画面C", "lines": [{"role": "女主", "content": "口播C"}], "caption": "字幕C"}]},
"screenDescription+dialogue+bgMusic+note": {"total_duration": 15, "shots": [{"screenDescription": "画面D", "dialogue": "口播D", "bgMusic": "音乐D", "note": "备注D"}]},
}
for name, raw in variants.items():
draft = normalize_draft(json.dumps(raw, ensure_ascii=False), aspect_ratio="9:16", total_duration=15)
seg = draft["segments"][0]
self.assertTrue(seg["narration"], f"{name}: 旁白为空")
self.assertTrue(seg["visual"], f"{name}: 画面为空")
# bgMusic/note 不得被误当画面/旁白
d = normalize_draft(json.dumps(variants["screenDescription+dialogue+bgMusic+note"], ensure_ascii=False), aspect_ratio="9:16", total_duration=15)
self.assertEqual(d["segments"][0]["visual"], "画面D")
self.assertEqual(d["segments"][0]["narration"], "口播D")
def test_string_dialogue_not_iterated_as_chars(self): def test_string_dialogue_not_iterated_as_chars(self):
"""dialogue 为整句字符串时,要当作旁白而不是逐字符遍历。""" """dialogue 为整句字符串时,要当作旁白而不是逐字符遍历。"""
raw = json.dumps({ raw = json.dumps({