fix(core): 脚本 agent 兼容模型 ScriptDraft/shots 变体,修旁白/画面全空

豆包 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 <noreply@anthropic.com>
This commit is contained in:
zyc
2026-06-17 10:09:09 +08:00
co-authored by Claude Opus 4.8
parent 3d38c173e6
commit a7f1b0da1e
2 changed files with 95 additions and 10 deletions
+39 -10
View File
@@ -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,
+56
View File
@@ -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")