From 71af0ae166c298733fc07fb321eb9c8a672fb752 Mon Sep 17 00:00:00 2001 From: seaislee1209 Date: Mon, 22 Jun 2026 20:12:11 +0800 Subject: [PATCH] =?UTF-8?q?fix(extract):=20=E5=AE=9E=E4=BD=93=E6=8F=90?= =?UTF-8?q?=E5=8F=96=E6=94=B9=E8=B5=B0=E6=B5=81=E5=BC=8F=E9=80=9A=E9=81=93?= =?UTF-8?q?=20+=20=E9=94=81=E5=AE=9A=E8=B1=86=E5=8C=852.0Pro,=E6=A0=B9?= =?UTF-8?q?=E6=B2=BB"=E6=9C=AA=E8=BF=94=E5=9B=9E=E6=9C=89=E6=95=88JSON"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 问题:进资产阶段点"提取人物/场景"间歇报「提取结果解析失败(模型未返回有效 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) --- core/backend/apps/ai/services.py | 54 ++++++++++++++++++++-- core/backend/apps/projects/tests.py | 70 +++++++++++++++++++++++++++++ 2 files changed, 121 insertions(+), 3 deletions(-) diff --git a/core/backend/apps/ai/services.py b/core/backend/apps/ai/services.py index ea9c5d7..dd57759 100644 --- a/core/backend/apps/ai/services.py +++ b/core/backend/apps/ai/services.py @@ -295,6 +295,55 @@ def _normalize_extracted_segment_refs(items: object, valid_ids: set[str]) -> lis return out +# 提取步固定锁定豆包 2.0 Pro(与脚本生成同款),不靠 get_default_model 的「最早创建」排序—— +# 避免不同环境 DB 创建序漂移把提取路由到别的(中转站)推理模型。取不到再回落默认文本模型。 +EXTRACT_TEXT_MODEL_NAME = "doubao-seed-2-0-pro-260215" + + +def _resolve_extract_model_config(): + """提取实体用的文本模型:优先豆包 2.0 Pro(active 且 provider active),否则回落默认文本模型。""" + pinned = ( + ModelConfig.objects.select_related("provider") + .filter( + name=EXTRACT_TEXT_MODEL_NAME, + capability=ModelConfig.Capability.TEXT, + status=ModelConfig.Status.ACTIVE, + provider__status="active", + ) + .order_by("created_at") + .first() + ) + return pinned or get_default_model(ModelConfig.Capability.TEXT) + + +def _collect_extract_text(provider, model_config, messages) -> tuple[str, dict]: + """走与「脚本生成」同一条已在生产验证稳定的流式通道把模型输出收全。 + + 豆包 seed-pro / GPT / Gemini 等思考模型,思考期只发 reasoning_content、正文期才发 content, + 流式分支(chat_completion_stream)已分别转发为 `reasoning` / `delta` 事件。这里**只收正文 delta**; + 仅当正文为空(整轮被思考占满的极端兜底)才回退已收到的 reasoning。如此从根上避免非流式只读 + content 拿到空串 → 解析不到 JSON 的老问题。返回 (text, 存档用精简 payload)。 + """ + content_parts: list[str] = [] + reasoning_parts: list[str] = [] + for ev in provider.chat_completion_stream( + model=model_config.name, + endpoint=model_config.endpoint, + messages=messages, + temperature=0.3, # 结构化抽取要稳:低温降低 JSON 漂移 + ): + etype = ev.get("type") + if etype == "delta": + content_parts.append(ev.get("text") or "") + elif etype == "reasoning": + reasoning_parts.append(ev.get("text") or "") + content = "".join(content_parts).strip() + reasoning = "".join(reasoning_parts).strip() + text = content or reasoning + payload = {"streamed": True, "model": model_config.name, "content": content, "had_reasoning": bool(reasoning)} + return text, payload + + def extract_entities_for_project(*, project, user) -> dict: """独立实体提取步(剧本定稿后、进资产阶段时跑)。读已定稿(或最新)脚本 → 调 ecommerce-entity-extract skill(一个文本模型)→ 产出角色 / 场景 entities + 每镜 entity_refs。**不提商品**(商品由参考图层用真实 @@ -315,7 +364,7 @@ def extract_entities_for_project(*, project, user) -> dict: segments = list(script.segments.order_by("sort_order")) if not segments: raise ValueError("脚本没有分镜,无法提取") - model_config = get_default_model(ModelConfig.Capability.TEXT) + model_config = _resolve_extract_model_config() if model_config is None: raise ValueError("没有可用的文本模型") @@ -352,8 +401,7 @@ def extract_entities_for_project(*, project, user) -> dict: task.submitted_at = timezone.now() task.save(update_fields=["status", "submitted_at", "updated_at"]) provider = build_provider(model_config) - response = provider.chat_completion(model=model_config.name, endpoint=model_config.endpoint, messages=messages) - text = provider.extract_text(response) + text, response = _collect_extract_text(provider, model_config, messages) with transaction.atomic(): task.status = AITask.Status.SUCCEEDED task.response_payload = response diff --git a/core/backend/apps/projects/tests.py b/core/backend/apps/projects/tests.py index ff202d2..9dc4796 100644 --- a/core/backend/apps/projects/tests.py +++ b/core/backend/apps/projects/tests.py @@ -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):