fix(extract): 实体提取改走流式通道 + 锁定豆包2.0Pro,根治"未返回有效JSON"

问题:进资产阶段点"提取人物/场景"间歇报「提取结果解析失败(模型未返回有效
JSON)」。根因——提取走非流式只读 content,而默认文本模型是会思考的推理模型
(豆包2.0Pro think:True),思考期常把内容留在 reasoning_content、content 返空 →
正则抽不到 JSON。脚本生成不犯此病是因其走流式、早已分离思考/正文。

改动:
- 锁模型:提取固定用 doubao-seed-2-0-pro-260215(_resolve_extract_model_config),
  取不到再回落默认,不再受各环境 DB 创建序漂移影响路由。
- 根治:提取复用与脚本生成同一条已验证稳定的流式通道(_collect_extract_text)——
  丢弃 reasoning 事件、只收正文 delta;正文为空时兜底回退 reasoning 里的 JSON。
  response_payload 改存精简流式存档(streamed/model/content/had_reasoning)。
- 测试:提取步原零测试,新增 2 个——流式正文JSON正确落库+计费一次、content空→
  回退reasoning。其余链路(脚本生成/单镜重跑)未动。

验收:本机 Python3.9 跑不了 Django5 全套,核心逻辑(事件过滤/回退/JSON抽取)已
独立验证通过;py_compile 绿;完整套件由 CI 跑。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
seaislee1209
2026-06-22 20:12:11 +08:00
co-authored by Claude Opus 4.8
parent 612ab6864d
commit 71af0ae166
2 changed files with 121 additions and 3 deletions
+70
View File
@@ -135,6 +135,76 @@ class ProjectApiTests(TestCase):
)
self.assertEqual(response.status_code, 404)
@patch("apps.ai.services.VolcanoArkProvider")
def test_extract_entities_streams_and_persists(self, provider_cls):
"""提取走流式通道(豆包 2.0 Pro 思考模型):思考事件丢弃、只收正文 JSON →
落库 cast/scenes + 每镜 entity_refs + entities_extracted 标记,计费一次。"""
import json as _json
extracted = {
"entities": [
{"id": "c1", "type": "character", "name": "女主", "visual_prompt": "26岁都市女性,利落短发"},
{"id": "s1", "type": "scene", "name": "出租屋客厅", "visual_prompt": "ins风小客厅,暖白自然光"},
],
"segments": [
{"index": 0, "entity_refs": ["c1", "s1"]},
{"index": 1, "entity_refs": ["c1", "s1"]},
],
}
provider = provider_cls.return_value
provider.chat_completion_stream.return_value = iter([
{"type": "reasoning", "text": "先通读分镜"}, # 思考事件:必须被丢弃,不进正文
{"type": "delta", "text": "```json\n" + _json.dumps(extracted, ensure_ascii=False)},
{"type": "delta", "text": "\n```"},
{"type": "done"},
])
project = Project.objects.create(team=self.team, created_by=self.user, product=self.product, name="P")
script = ScriptVersion.objects.create(
project=project, title="脚本", content="x", is_adopted=True, metadata={"entities": []},
)
ScriptSegment.objects.create(script_version=script, sort_order=0, narration="旧0", visual_prompt="画0")
ScriptSegment.objects.create(script_version=script, sort_order=1, narration="旧1", visual_prompt="画1")
response = self.client.post(f"/api/projects/{project.id}/extract-entities/", {}, format="json")
self.assertEqual(response.status_code, 200)
# 锁定豆包 2.0 Pro,且走的是流式通道(而非非流式 chat_completion)
self.assertEqual(provider.chat_completion_stream.call_args.kwargs["model"], "doubao-seed-2-0-pro-260215")
provider.chat_completion.assert_not_called()
# 实体落库:cast/scenes + entities_extracted 标记 + 每镜 entity_refs 回填
project.refresh_from_db()
self.assertIn("女主", project.metadata.get("cast", []))
self.assertIn("出租屋客厅", project.metadata.get("scenes", []))
self.assertTrue(project.metadata.get("entities_extracted"))
segs = list(script.segments.order_by("sort_order"))
self.assertEqual(segs[0].entity_refs, ["c1", "s1"])
self.assertEqual(CreditLedger.objects.filter(team=self.team, ledger_type=CreditLedger.Type.CHARGE).count(), 1)
@patch("apps.ai.services.VolcanoArkProvider")
def test_extract_entities_recovers_when_content_empty_uses_reasoning(self, provider_cls):
"""极端兜底:思考模型整轮只发了 reasoning、正文 content 为空 → 回退用 reasoning 里的 JSON,
不再像旧非流式那样拿到空串报「未返回有效 JSON」。"""
import json as _json
extracted = {
"entities": [{"id": "c1", "type": "character", "name": "男主", "visual_prompt": "28岁居家男性"}],
"segments": [{"index": 0, "entity_refs": ["c1"]}],
}
provider = provider_cls.return_value
provider.chat_completion_stream.return_value = iter([
{"type": "reasoning", "text": _json.dumps(extracted, ensure_ascii=False)},
{"type": "done"},
])
project = Project.objects.create(team=self.team, created_by=self.user, product=self.product, name="P2")
script = ScriptVersion.objects.create(
project=project, title="脚本", content="x", is_adopted=True, metadata={"entities": []},
)
ScriptSegment.objects.create(script_version=script, sort_order=0, narration="旧0", visual_prompt="画0")
response = self.client.post(f"/api/projects/{project.id}/extract-entities/", {}, format="json")
self.assertEqual(response.status_code, 200)
project.refresh_from_db()
self.assertIn("男主", project.metadata.get("cast", []))
@patch("apps.ai.services._store_generated_media")
@patch("apps.ai.services.get_image_provider")
def test_generate_base_asset_stores_tag_label(self, get_provider, store_media):