Files
yingqing/core/backend/apps/ai/tests.py
T
zycandClaude Opus 4.8 521f5f4535 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>
2026-06-17 10:38:23 +08:00

96 lines
6.1 KiB
Python

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_lines_and_caption_variant_fills_narration(self):
"""另一种变体(Doubao-Seed-2.0-P):shots 用 lines:[{role,content}] 放口播、caption 放字幕。
必须把 lines 的 content 当对白/旁白,caption 兜底,而不是只填画面留旁白空。"""
raw = json.dumps({
"ScriptDraft": {
"basicInfo": {"totalDuration": 30, "aspectRatio": "9:16"},
"shots": [
{"shotNo": 1, "scene": "化妆台两闺蜜", "lines": [{"role": "女主", "content": "防晒泛白太尴尬!"}, {"role": "闺蜜", "content": "试试这个!"}], "caption": "防晒踩雷?"},
{"shotNo": 2, "scene": "手背挤膏体", "lines": [{"role": "闺蜜", "content": "质地清透不泛白"}], "caption": "清透质地"},
],
}
}, ensure_ascii=False)
draft = normalize_draft(raw, aspect_ratio="9:16", total_duration=30)
self.assertEqual(len(draft["segments"]), 2)
for seg in draft["segments"]:
self.assertTrue(seg["narration"], "旁白不应为空")
self.assertTrue(seg["visual"], "画面不应为空")
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({
"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")