From a7f1b0da1e5873bf646d461a20a6d8c12ca6b682 Mon Sep 17 00:00:00 2001 From: zyc <1439655764@qq.com> Date: Wed, 17 Jun 2026 10:09:09 +0800 Subject: [PATCH] =?UTF-8?q?fix(core):=20=E8=84=9A=E6=9C=AC=20agent=20?= =?UTF-8?q?=E5=85=BC=E5=AE=B9=E6=A8=A1=E5=9E=8B=20ScriptDraft/shots=20?= =?UTF-8?q?=E5=8F=98=E4=BD=93,=E4=BF=AE=E6=97=81=E7=99=BD/=E7=94=BB?= =?UTF-8?q?=E9=9D=A2=E5=85=A8=E7=A9=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 豆包 Doubao-Seed-2.0-Lite 常不按扁平契约输出,而是回 {"ScriptDraft":{"basicInfo":...,"shots":[{scene,dialogue,subtitle}]}}。 normalize_draft 在顶层找不到 segments → 走补占位镜兜底 → 旁白/画面全空(只剩 role)。 normalize_draft 增加容错: - 解开 {"ScriptDraft":{...}} 外壳;basicInfo 的 camelCase 时长/比例拍平 - shots → segments;每镜 scene→画面、dialogue/subtitle→旁白 - 防 dialogue 为整句字符串时被逐字符遍历 原扁平契约路径不受影响。新增 apps/ai/tests.py 3 个回归用例,全套 21 测试通过。 Co-Authored-By: Claude Opus 4.8 --- core/backend/apps/ai/script_agent.py | 49 +++++++++++++++++++----- core/backend/apps/ai/tests.py | 56 ++++++++++++++++++++++++++++ 2 files changed, 95 insertions(+), 10 deletions(-) create mode 100644 core/backend/apps/ai/tests.py diff --git a/core/backend/apps/ai/script_agent.py b/core/backend/apps/ai/script_agent.py index 646d410..7f4161e 100644 --- a/core/backend/apps/ai/script_agent.py +++ b/core/backend/apps/ai/script_agent.py @@ -220,6 +220,25 @@ def normalize_draft(raw_text: str, *, aspect_ratio: str, total_duration: int) -> if not isinstance(draft, dict): raise ValueError("脚本 JSON 顶层不是对象") + # 兼容模型不按契约的常见变体:① 把内容裹进 {"ScriptDraft": {...}} 外壳; + # ② 用 basicInfo(camelCase)放时长/比例;③ 用 shots 代替 segments。 + # 解开外壳并把别名拍平到契约字段,避免「找不到 segments → 全填空占位镜」(旁白/画面全空)。 + for wrapper in ("ScriptDraft", "script_draft", "scriptDraft", "draft"): + inner = draft.get(wrapper) + if isinstance(inner, dict): + draft = {**inner, **{k: v for k, v in draft.items() if k != wrapper}} + break + basic = draft.get("basicInfo") if isinstance(draft.get("basicInfo"), dict) else {} + if basic: + draft.setdefault("total_duration", basic.get("totalDuration") or basic.get("total_duration")) + draft.setdefault("aspect_ratio", basic.get("aspectRatio") or basic.get("aspect_ratio")) + draft.setdefault("theme", basic.get("theme")) + if not isinstance(draft.get("segments"), list): + for alias in ("shots", "scenes_list", "shotList"): + if isinstance(draft.get(alias), list): + draft["segments"] = draft[alias] + break + draft["aspect_ratio"] = (draft.get("aspect_ratio") or aspect_ratio or "9:16").strip() dur = _nearest_duration(draft.get("total_duration") or total_duration) draft["total_duration"] = dur @@ -268,19 +287,29 @@ def normalize_draft(raw_text: str, *, aspect_ratio: str, total_duration: int) -> speaker = seg.get("speaker") speaker = speaker if (speaker in valid_ids) else None refs = [r for r in (seg.get("entity_refs") or []) if r in valid_ids] - # 对白(剧情向):[{speaker(合法 entity id 或 null=旁白), line}];默认空 = 纯口播 + # 对白(剧情向):[{speaker(合法 entity id 或 null=旁白), line}];默认空 = 纯口播。 + # 模型变体里 dialogue 可能是字符串(整句口播)而非数组——只在数组时按结构解析。 + raw_dialogue = seg.get("dialogue") dialogue = [] - for d in (seg.get("dialogue") or []): - if not isinstance(d, dict): - continue - line = (d.get("line") or d.get("text") or "").strip() - if not line: - continue - sp = d.get("speaker") - dialogue.append({"speaker": sp if sp in valid_ids else None, "line": line}) + if isinstance(raw_dialogue, list): + for d in raw_dialogue: + if not isinstance(d, dict): + continue + line = (d.get("line") or d.get("text") or "").strip() + if not line: + continue + sp = d.get("speaker") + dialogue.append({"speaker": sp if sp in valid_ids else None, "line": line}) + # 旁白:narration > 字符串 dialogue(整句口播)> subtitle(字幕)> 结构化对白拼接 narration = (seg.get("narration") or "").strip() + if not narration and isinstance(raw_dialogue, str): + narration = raw_dialogue.strip() + if not narration: + narration = (seg.get("subtitle") or "").strip() if not narration and dialogue: narration = " ".join(d["line"] for d in dialogue) # 扁平拼接,兼容下游字幕/配音 + # 画面:visual > visual_prompt > scene(模型变体常用 scene 描述画面) + visual = (seg.get("visual") or seg.get("visual_prompt") or seg.get("scene") or "").strip() norm_segments.append( { "index": i, @@ -288,7 +317,7 @@ def normalize_draft(raw_text: str, *, aspect_ratio: str, total_duration: int) -> "role": role, "narration": narration, "speaker": speaker, - "visual": (seg.get("visual") or seg.get("visual_prompt") or "").strip(), + "visual": visual, "product_exposure": (seg.get("product_exposure") or "").strip(), "entity_refs": refs, "dialogue": dialogue, diff --git a/core/backend/apps/ai/tests.py b/core/backend/apps/ai/tests.py new file mode 100644 index 0000000..186f24b --- /dev/null +++ b/core/backend/apps/ai/tests.py @@ -0,0 +1,56 @@ +import json + +from django.test import SimpleTestCase + +from apps.ai.script_agent import normalize_draft + + +class NormalizeDraftTests(SimpleTestCase): + """normalize_draft 对模型不按契约输出的容错(防「旁白/画面全空」回归)。""" + + def test_scriptdraft_shots_variant_fills_narration_and_visual(self): + """模型常见变体:{"ScriptDraft":{"basicInfo":..,"shots":[{scene,dialogue,subtitle}]}}。 + 必须解开外壳 + 把 shots→segments、scene→画面、dialogue/subtitle→旁白,而非全填空占位镜。""" + raw = json.dumps({ + "ScriptDraft": { + "basicInfo": {"totalDuration": 60, "aspectRatio": "9:16", "totalShots": 4}, + "shots": [ + {"shotNo": 1, "duration": 15, "scene": "更衣室扯卡裆旧裤", "dialogue": "卡裆太社死!", "subtitle": "还在卡裆?"}, + {"shotNo": 2, "duration": 15, "scene": "特写拉扯面料回弹", "dialogue": "高弹不变形!", "subtitle": "裸感面料"}, + {"shotNo": 3, "duration": 15, "scene": "健身切通勤", "dialogue": "都能穿!", "subtitle": "一裤多穿"}, + {"shotNo": 4, "duration": 15, "scene": "对镜弹小黄车", "dialogue": "点小黄车抢!", "subtitle": "点击入手"}, + ], + } + }, ensure_ascii=False) + draft = normalize_draft(raw, aspect_ratio="9:16", total_duration=60) + self.assertEqual(len(draft["segments"]), 4) + self.assertEqual([s["role"] for s in draft["segments"]], ["钩子", "痛点", "卖点", "CTA"]) + for seg in draft["segments"]: + self.assertTrue(seg["narration"], "旁白不应为空") + self.assertTrue(seg["visual"], "画面不应为空") + self.assertEqual(draft["segments"][0]["narration"], "卡裆太社死!") + self.assertEqual(draft["segments"][0]["visual"], "更衣室扯卡裆旧裤") + + def test_string_dialogue_not_iterated_as_chars(self): + """dialogue 为整句字符串时,要当作旁白而不是逐字符遍历。""" + raw = json.dumps({ + "segments": [{"role": "钩子", "dialogue": "一句完整口播", "visual": "画面"}], + "total_duration": 15, + }, ensure_ascii=False) + draft = normalize_draft(raw, aspect_ratio="9:16", total_duration=15) + self.assertEqual(draft["segments"][0]["narration"], "一句完整口播") + self.assertEqual(draft["segments"][0]["dialogue"], []) # 字符串不进结构化对白 + + def test_canonical_flat_schema_still_works(self): + """契约内的扁平 schema(segments/narration/visual)不受兼容改动影响。""" + raw = json.dumps({ + "total_duration": 30, + "segments": [ + {"role": "钩子", "narration": "口播1", "visual": "画面1"}, + {"role": "CTA", "narration": "口播2", "visual": "画面2"}, + ], + }, ensure_ascii=False) + draft = normalize_draft(raw, aspect_ratio="9:16", total_duration=30) + self.assertEqual(len(draft["segments"]), 2) + self.assertEqual(draft["segments"][0]["narration"], "口播1") + self.assertEqual(draft["segments"][1]["visual"], "画面2")