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:
@@ -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,
|
||||||
|
|||||||
@@ -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({
|
||||||
|
|||||||
Reference in New Issue
Block a user