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:
@@ -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")
|
||||
|
||||
@@ -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"])
|
||||
|
||||
Reference in New Issue
Block a user