feat(脚本): 按流程文档重写脚本阶段(自动提取人物/场景 + 标签胶囊改写 + 基础资产按标签 seed)

后端:
- 出稿后新增轻量提取调用,从脚本抽取人物/场景标签及每个标签的建议生图提示词,
  写入 project.metadata(cast/scenes/cast_prompts/scene_prompts)
- 提取调用接入计费闭环(reserve→charge/release,记为 script_optimization);
  best-effort:无模型/预扣失败/调用失败/解析失败都吞掉返回空,绝不阻断出稿
- generate_base_asset 增加 label,落 group.metadata,把生成结果归回脚本标签卡

前端:
- 设定卡风格补齐到 5 个(真实测评/痛点种草/小红书种草/开箱测评/对比展示)
- 标签改为待确认胶囊流:增删不立即落库,排进队列在输入框上方显示可撤销胶囊,
  点发送才整体重写;待删划线、待加虚线
- 基础资产人物/场景按提取标签逐个 seed 卡,显示 AI 提示词、可改后生成

测试:新增提取+计费+release+label 共 4 个用例,全套 24 个通过(sqlite test 设置)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
zyc
2026-06-16 17:59:41 +08:00
co-authored by Claude Opus 4.8
parent 76b3ce94f9
commit 366e077c05
8 changed files with 370 additions and 46 deletions
+83 -1
View File
@@ -109,7 +109,8 @@ class ProjectApiTests(TestCase):
self.assertEqual(response.status_code, 201)
script = ScriptVersion.objects.get(project=project)
self.assertEqual(script.segments.count(), 4)
self.assertEqual(CreditLedger.objects.filter(team=self.team, ledger_type=CreditLedger.Type.CHARGE).count(), 1)
# 出稿(1)+ 人物/场景提取(1)各计费一次;此处 mock 提取返回非 JSON,提取仍按调用成功计费
self.assertEqual(CreditLedger.objects.filter(team=self.team, ledger_type=CreditLedger.Type.CHARGE).count(), 2)
@patch("apps.ai.services.VolcanoArkProvider")
def test_rerun_script_segment_updates_one_segment_and_charges_once(self, provider_cls):
@@ -152,6 +153,87 @@ class ProjectApiTests(TestCase):
)
self.assertEqual(response.status_code, 404)
# ── 流程文档 步骤③④:出稿后自动提取人物/场景(含建议提示词)并计费 ──
@patch("apps.ai.services.VolcanoArkProvider")
def test_generate_script_extracts_cast_scenes_and_bills(self, provider_cls):
provider = provider_cls.return_value
script_text = "镜头1\n旁白:开场\n画面:地铁里女主掏出面膜\n\n镜头2\n旁白:卖点\n画面:同事围观"
# 提取调用让模型回 JSON(允许外面裹解释文字,验证容错)
extract_text = '这是结果:{"cast":[{"name":"女主","prompt":"26岁都市女性,自然妆"},{"name":"同事","prompt":"职场女性"}],"scenes":[{"name":"地铁","prompt":"早高峰地铁车厢"}]}'
provider.chat_completion.side_effect = [{"r": 1}, {"r": 2}]
provider.extract_text.side_effect = [script_text, extract_text]
project = Project.objects.create(team=self.team, created_by=self.user, product=self.product, name="P")
ProjectStage.objects.create(project=project, stage=ProjectStage.Stage.SCRIPT)
response = self.client.post(f"/api/projects/{project.id}/generate-script/", {"prompt": "x"}, format="json")
self.assertEqual(response.status_code, 201)
project.refresh_from_db()
self.assertEqual(project.metadata.get("cast"), ["女主", "同事"])
self.assertEqual(project.metadata.get("scenes"), ["地铁"])
self.assertEqual(project.metadata.get("cast_prompts", {}).get("女主"), "26岁都市女性,自然妆")
self.assertEqual(project.metadata.get("scene_prompts", {}).get("地铁"), "早高峰地铁车厢")
# 出稿 + 提取,各计费一次
self.assertEqual(CreditLedger.objects.filter(team=self.team, ledger_type=CreditLedger.Type.CHARGE).count(), 2)
@patch("apps.ai.services.VolcanoArkProvider")
def test_extract_failure_releases_credit_and_keeps_script(self, provider_cls):
"""提取调用抛错:必须退掉提取的预扣(不计费),且不影响脚本已出稿(出稿那笔照扣)。"""
provider = provider_cls.return_value
provider.chat_completion.side_effect = [{"r": 1}, RuntimeError("ark down")]
provider.extract_text.side_effect = ["镜头1\n旁白:x\n画面:y", AssertionError("不应走到")]
project = Project.objects.create(team=self.team, created_by=self.user, product=self.product, name="P")
ProjectStage.objects.create(project=project, stage=ProjectStage.Stage.SCRIPT)
response = self.client.post(f"/api/projects/{project.id}/generate-script/", {"prompt": "x"}, format="json")
self.assertEqual(response.status_code, 201)
self.assertEqual(ScriptVersion.objects.filter(project=project).count(), 1)
# 出稿计费 1 笔;提取失败 → release,不产生第 2 笔 CHARGE
self.assertEqual(CreditLedger.objects.filter(team=self.team, ledger_type=CreditLedger.Type.CHARGE).count(), 1)
@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):
"""流程步骤④:按脚本标签生成基础资产时,label 落进 group.metadata,供前端把结果归回标签卡。"""
ModelConfig.objects.create(
provider=self.provider, name="img-model", display_name="Img",
capability=ModelConfig.Capability.IMAGE, endpoint="images/generations", unit_price="1.0000",
)
asset = Asset.objects.create(
team=self.team, created_by=self.user, name="女主",
asset_type=Asset.Type.IMAGE, source=Asset.Source.AI_GENERATED, category=Asset.Category.PERSON,
)
store_media.return_value = asset
provider = get_provider.return_value
provider.image_generation.return_value = {"data": [{"url": "http://x/img.png"}]}
provider.extract_first_media_url.return_value = "http://x/img.png"
project = Project.objects.create(team=self.team, created_by=self.user, product=self.product, name="P")
response = self.client.post(
f"/api/projects/{project.id}/generate-base-asset/",
{"kind": "person", "prompt": "26岁都市女性", "label": "女主"},
format="json",
)
self.assertEqual(response.status_code, 201)
from apps.projects.models import BaseAssetGroup
group = BaseAssetGroup.objects.get(id=response.data["id"])
self.assertEqual(group.metadata.get("label"), "女主")
def test_extract_cast_and_scenes_parsing_is_robust(self):
"""纯函数:_coerce_tag_entries 去重/容错;无可用模型时 extract 返回空且不抛。"""
from apps.ai.services import _coerce_tag_entries, extract_cast_and_scenes
names, prompts = _coerce_tag_entries(
[{"name": "女主", "prompt": "p1"}, {"name": "女主", "prompt": "dup"}, {"name": ""}, "同事"]
)
self.assertEqual(names, ["女主", "同事"]) # 去重 + 过滤占位「无」+ 容忍纯字符串项
self.assertEqual(prompts["女主"], "p1")
ModelConfig.objects.filter(capability=ModelConfig.Capability.TEXT).update(status=ModelConfig.Status.DISABLED)
project = Project.objects.create(team=self.team, created_by=self.user, product=self.product, name="P")
self.assertEqual(
extract_cast_and_scenes(project=project, user=self.user, content="脚本"),
{"cast": [], "scenes": [], "cast_prompts": {}, "scene_prompts": {}},
)
def test_adopt_video_version_remaps_timeline_draft(self):
"""切换采用版本后,时间线草稿里引用本场旧版本资产的片段必须跟随换成新资产(剪辑台/导出都读草稿)。"""
project = Project.objects.create(team=self.team, created_by=self.user, product=self.product, name="P")
+1 -1
View File
@@ -157,7 +157,7 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
kind = request.data.get("kind")
if kind not in BaseAssetGroup.Kind.values:
return Response({"detail": "invalid base asset kind"}, status=status.HTTP_400_BAD_REQUEST)
group = generate_base_asset(project=project, user=request.user, kind=kind, prompt=request.data.get("prompt", ""))
group = generate_base_asset(project=project, user=request.user, kind=kind, prompt=request.data.get("prompt", ""), label=request.data.get("label", ""))
stage, _ = ProjectStage.objects.get_or_create(project=project, stage=ProjectStage.Stage.BASE_ASSETS)
stage.status = ProjectStage.Status.NEEDS_REVIEW
stage.save(update_fields=["status", "updated_at"])