diff --git a/core/backend/apps/ai/script_agent.py b/core/backend/apps/ai/script_agent.py index 21b5b7d..ef8c252 100644 --- a/core/backend/apps/ai/script_agent.py +++ b/core/backend/apps/ai/script_agent.py @@ -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, diff --git a/core/backend/apps/ai/tests.py b/core/backend/apps/ai/tests.py index c3ab3b9..bbd7f02 100644 --- a/core/backend/apps/ai/tests.py +++ b/core/backend/apps/ai/tests.py @@ -51,6 +51,25 @@ class NormalizeDraftTests(SimpleTestCase): self.assertIn("防晒泛白太尴尬", draft["segments"][0]["narration"]) 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): """dialogue 为整句字符串时,要当作旁白而不是逐字符遍历。""" raw = json.dumps({