refactor(core): 脚本收口——只留 agent 出稿,删旧散文路 + 单镜重跑改走 agent

删 /generate-script/ 端点 + generate_project_script + build_script_prompt + 旧单镜 build_segment_rerun_prompt;前端删静默兜底(agent 失败明确报错,不再掉回旧路)+ onGenerateScript prop/api。rerun-script-segment 改用 agent 单镜逻辑(regenerate_segment_via_agent:读全脚本上下文/保留其余镜/落新版本)。根因:旧路不产 script_entities/entity_refs,下游故事板/视频参考图断线→图飘。测试:删 3 个旧 generate 用例 + 重写 rerun 为 agent 版,ai+projects 23 测试除 4 个既有 base-asset 失败外全绿。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
seaislee1209
2026-06-17 22:16:01 +08:00
co-authored by Claude Opus 4.8
parent 0695c36f4c
commit 1ecb186ddf
7 changed files with 102 additions and 316 deletions
+26 -86
View File
@@ -82,48 +82,30 @@ class ProjectApiTests(TestCase):
self.assertTrue(VideoSegment.objects.filter(project=project).exists())
@patch("apps.ai.services.VolcanoArkProvider")
def test_generate_script_creates_script_segments_and_charges_credit(self, provider_cls):
provider = provider_cls.return_value
provider.chat_completion.return_value = {
"choices": [
{
"message": {
"content": "1. 开场吸引\n2. 展示卖点\n3. 使用场景\n4. 促单转化",
}
}
]
def test_rerun_script_segment_via_agent_rewrites_one_and_charges_once(self, provider_cls):
"""单镜重跑改走 agent:读结构化全脚本,只重写目标镜、保留其余镜,落新版本,计费一次。"""
import json as _json
base_draft = {
"hook": "钩子", "tone": "种草", "aspect_ratio": "9:16", "total_duration": 30, "segment_count": 2,
"entities": [],
"segments": [
{"index": 0, "duration": 15, "role": "钩子", "narration": "旧0", "visual": "画0", "speaker": None, "product_exposure": "", "entity_refs": [], "dialogue": []},
{"index": 1, "duration": 15, "role": "CTA", "narration": "旧1", "visual": "画1", "speaker": None, "product_exposure": "", "entity_refs": [], "dialogue": []},
],
}
provider.extract_text.return_value = "1. 开场吸引\n2. 展示卖点\n3. 使用场景\n4. 促单转化"
project = Project.objects.create(team=self.team, created_by=self.user, product=self.product, name="Launch Video")
for stage in [
ProjectStage.Stage.SCRIPT,
ProjectStage.Stage.BASE_ASSETS,
ProjectStage.Stage.STORYBOARD,
ProjectStage.Stage.VIDEO,
ProjectStage.Stage.EXPORT,
]:
ProjectStage.objects.create(project=project, stage=stage)
response = self.client.post(
f"/api/projects/{project.id}/generate-script/",
{"prompt": "突出高转化"},
format="json",
)
self.assertEqual(response.status_code, 201)
script = ScriptVersion.objects.get(project=project)
self.assertEqual(script.segments.count(), 4)
# 出稿(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):
new_draft = _json.loads(_json.dumps(base_draft))
new_draft["segments"][1]["narration"] = "全新口播文案"
new_draft["segments"][1]["visual"] = "全新画面描述"
provider = provider_cls.return_value
provider.chat_completion.return_value = {"choices": [{"message": {"content": "x"}}]}
provider.extract_text.return_value = "旁白:全新口播文案\n画面:全新画面描述"
provider.extract_text.return_value = "```json\n" + _json.dumps(new_draft, ensure_ascii=False) + "\n```"
project = Project.objects.create(team=self.team, created_by=self.user, product=self.product, name="P")
script = ScriptVersion.objects.create(project=project, title="脚本", content="...", is_adopted=True)
seg0 = ScriptSegment.objects.create(script_version=script, sort_order=0, narration="旧0", visual_prompt="画0")
script = ScriptVersion.objects.create(
project=project, title="脚本", content=_json.dumps(base_draft, ensure_ascii=False),
is_adopted=True, metadata={"hook": "钩子", "entities": []},
)
ScriptSegment.objects.create(script_version=script, sort_order=0, narration="旧0", visual_prompt="画0")
seg1 = ScriptSegment.objects.create(script_version=script, sort_order=1, narration="旧1", visual_prompt="画1")
response = self.client.post(
@@ -131,17 +113,13 @@ class ProjectApiTests(TestCase):
{"segment_id": str(seg1.id), "instruction": "更俏皮"},
format="json",
)
self.assertEqual(response.status_code, 200)
seg0.refresh_from_db()
seg1.refresh_from_db()
self.assertEqual(seg1.narration, "全新口播文案")
self.assertEqual(seg1.visual_prompt, "全新画面描述")
# 其它分镜保持不动
self.assertEqual(seg0.narration, "旧0")
self.assertEqual(seg0.visual_prompt, "画0")
# 返回的是该镜所属 ScriptVersion
self.assertEqual(str(response.data["id"]), str(script.id))
# agent 落新 ScriptVersion:目标镜改了,其余镜保留
new_version = ScriptVersion.objects.filter(project=project).order_by("-created_at").first()
segs = list(new_version.segments.order_by("sort_order"))
self.assertEqual(segs[1].narration, "全新口播文案")
self.assertEqual(segs[1].visual_prompt, "全新画面描述")
self.assertEqual(segs[0].narration, "旧0")
self.assertEqual(CreditLedger.objects.filter(team=self.team, ledger_type=CreditLedger.Type.CHARGE).count(), 1)
def test_rerun_script_segment_rejects_foreign_segment(self):
@@ -157,44 +135,6 @@ 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):
-13
View File
@@ -21,7 +21,6 @@ from apps.ai.services import (
create_export_job,
generate_base_asset,
generate_person_triview,
generate_project_script,
generate_storyboard_frame,
get_default_model,
poll_video_segment,
@@ -169,18 +168,6 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
for index in range(4):
VideoSegment.objects.create(project=project, sort_order=index, target_duration_seconds=15)
@action(detail=True, methods=["post"], url_path="generate-script")
def generate_script(self, request, pk=None):
project = self.get_object()
script = generate_project_script(
project=project,
user=request.user,
user_prompt=request.data.get("prompt", ""),
selling_point_ids=request.data.get("selling_point_ids") or [],
source=request.data.get("source") or "ai",
)
return Response(ScriptVersionSerializer(script).data, status=status.HTTP_201_CREATED)
@action(detail=True, methods=["post"], url_path="script-agent-stream", renderer_classes=[ServerSentEventRenderer])
def script_agent_stream(self, request, pk=None):
"""对话式脚本 agent · 流式(SSE)。出稿 + 改稿一体,多模型可选。