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
+65
View File
@@ -673,3 +673,68 @@ def _load_base_draft(project, base_version_id: str) -> dict | None:
return json.loads(version.content)
except (ValueError, TypeError):
return None
def regenerate_segment_via_agent(*, project, user, model_config: ModelConfig, segment, instruction: str = ""):
"""非流式·精准改一镜(「场次刷新」按钮复用 agent 单镜逻辑):读全脚本上下文,只重写该镜,落新 ScriptVersion。
与 stream_script_agent 的 target_index 分支同源,但同步返回(不走 SSE)。计费 reserve→charge/release 闭环。"""
from django.db import transaction
from apps.ai.services import build_provider, create_ai_task
from apps.billing.services.ledger import charge_reserved_credit
base_version = segment.script_version
base_draft = _load_base_draft(project, str(base_version.id))
if base_draft is None:
raise ValueError("基准脚本无法解析,无法精准改镜")
target_index = segment.sort_order
aspect_ratio = (base_draft.get("aspect_ratio") or "9:16").strip()
total_duration = base_draft.get("total_duration") or 60
seg_n = len(base_draft.get("segments", []))
if not (0 <= target_index < seg_n):
raise ValueError(f"镜号越界:第 {target_index + 1} 镜(共 {seg_n} 镜)")
messages = build_agent_messages(
project=project,
mode="revise",
user_prompt=instruction,
selling_point_ids=None,
base_draft=base_draft,
aspect_ratio=aspect_ratio,
total_duration=total_duration,
target_index=target_index,
)
task = create_ai_task(
project=project,
user=user,
task_type=AITask.Type.SCRIPT_OPTIMIZATION,
model_config=model_config,
request_payload={
"model": model_config.name,
"endpoint": model_config.endpoint,
"mode": "revise",
"target_index": target_index,
},
)
reservation = task.credit_reservation
try:
task.status = AITask.Status.SUBMITTED
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)
raw = provider.extract_text(response)
draft = normalize_draft(raw, aspect_ratio=aspect_ratio, total_duration=total_duration)
draft = _merge_single_segment(base_draft, draft, target_index, aspect_ratio, total_duration)
with transaction.atomic():
task.status = AITask.Status.SUCCEEDED
task.response_payload = {"raw": raw[:8000]}
task.actual_cost = task.estimated_cost
task.completed_at = timezone.now()
task.save(update_fields=["status", "response_payload", "actual_cost", "completed_at", "updated_at"])
charge_reserved_credit(reservation=reservation, actual_amount=task.actual_cost)
script = persist_script_draft(project=project, user=user, task=task, draft=draft, source="revise")
return script
except Exception:
_fail_task(task, reservation, "单镜重跑失败")
raise
+6 -201
View File
@@ -85,33 +85,6 @@ def estimate_cost(model_config: ModelConfig) -> Decimal:
return model_config.unit_price if model_config.unit_price > 0 else Decimal("1.0000")
def build_script_prompt(*, project, user_prompt: str, selling_point_ids: list[str] | None = None) -> list[dict[str, str]]:
product = project.product
selling_points = product.selling_points.all()
if selling_point_ids:
selling_points = selling_points.filter(id__in=selling_point_ids)
selling_text = "\n".join(f"- {item.title}: {item.detail}" for item in selling_points)
system = (
"你是电商短视频脚本导演。请为 9:16 竖屏带货短视频生成 60 秒脚本,"
"拆成 4 个 15 秒段落。严格按以下格式输出,段落之间空一行,不要输出其他内容:\n"
"镜头1\n旁白:这一镜要念出来的口播文案(一两句话)\n画面:这一镜的画面描述、商品露出方式和转场建议\n\n"
"镜头2\n旁白:…\n画面:…(依此类推到镜头4"
)
user = f"""
商品标题:{product.title}
品牌:{product.brand or "未填写"}
类目:{product.category or "未填写"}
目标人群:{product.target_audience or "未填写"}
商品描述:{product.description or "未填写"}
卖点:
{selling_text or "未选择卖点,请根据商品信息自行提炼。"}
用户补充需求:
{user_prompt or "生成一条结构完整、节奏清晰、适合投放的带货短视频脚本。"}
""".strip()
return [{"role": "system", "content": system}, {"role": "user", "content": user}]
def parse_segment_fields(block: str) -> tuple[str, str]:
"""从一镜文本里拆出(旁白, 画面)。
@@ -303,185 +276,17 @@ def create_ai_task(*, project, user, task_type: str, model_config: ModelConfig,
return task
def generate_project_script(*, project, user, user_prompt: str, selling_point_ids: list[str] | None = None, source: str = "ai") -> ScriptVersion:
model_config = get_default_model(ModelConfig.Capability.TEXT)
if model_config is None:
raise ValueError("no active text model configured")
messages = build_script_prompt(project=project, user_prompt=user_prompt, selling_point_ids=selling_point_ids)
payload = {"model": model_config.name, "endpoint": model_config.endpoint, "messages": messages}
task = create_ai_task(
project=project,
user=user,
task_type=AITask.Type.SCRIPT_GENERATION,
model_config=model_config,
request_payload=payload,
)
reservation = task.credit_reservation
try:
task.status = AITask.Status.SUBMITTED
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)
content = provider.extract_text(response)
# 出稿后自动提取人物 / 场景(best-effort,失败返回空,不挡主流程),供脚本页标签与基础资产 seed
extracted = extract_cast_and_scenes(project=project, user=user, content=content)
with transaction.atomic():
task.status = AITask.Status.SUCCEEDED
task.response_payload = response
task.actual_cost = task.estimated_cost
task.completed_at = timezone.now()
task.save(update_fields=["status", "response_payload", "actual_cost", "completed_at", "updated_at"])
charge_reserved_credit(reservation=reservation, actual_amount=task.actual_cost)
script = ScriptVersion.objects.create(
project=project,
task=task,
title="AI 脚本",
content=content,
source=source if source in ("ai", "theme", "manual") else "ai",
is_adopted=False,
)
for index, segment_text in enumerate(split_script_into_segments(content)):
narration, visual = parse_segment_fields(segment_text)
ScriptSegment.objects.create(
script_version=script,
sort_order=index,
duration_seconds=15,
narration=narration,
visual_prompt=visual,
)
# 把提取到的人物 / 场景(含每个标签的建议生图提示词)回填进 project.metadata:
# 脚本页标签自动读 cast/scenes,基础资产据 cast_prompts/scene_prompts seed 每张卡。
# 仅在提取到内容时覆盖,空结果不清掉用户已有标签。
if extracted["cast"] or extracted["scenes"]:
metadata = dict(project.metadata or {})
if extracted["cast"]:
metadata["cast"] = extracted["cast"]
metadata["cast_prompts"] = extracted["cast_prompts"]
if extracted["scenes"]:
metadata["scenes"] = extracted["scenes"]
metadata["scene_prompts"] = extracted["scene_prompts"]
project.metadata = metadata
project.save(update_fields=["metadata", "updated_at"])
stage, _ = ProjectStage.objects.get_or_create(project=project, stage=ProjectStage.Stage.SCRIPT)
stage.status = ProjectStage.Status.NEEDS_REVIEW
stage.save(update_fields=["status", "updated_at"])
return script
except Exception as exc:
with transaction.atomic():
task.status = AITask.Status.FAILED
task.error_message = str(exc)
task.completed_at = timezone.now()
task.save(update_fields=["status", "error_message", "completed_at", "updated_at"])
release_credit(reservation=reservation, reason=str(exc))
raise
def build_segment_rerun_prompt(*, project, segment, instruction: str = "") -> list[dict[str, str]]:
"""单镜重跑提示词:带商品/卖点上下文 + 该镜当前内容 + 前后镜上下文(保连贯)+ 用户修改意见。
只重写这一镜,严格输出「旁白:…/画面:…」两行,供 parse_segment_fields 精确解析。"""
product = project.product
selling_points = product.selling_points.all()
selling_text = "\n".join(f"- {item.title}: {item.detail}" for item in selling_points)
script = segment.script_version
siblings = list(script.segments.order_by("sort_order"))
prev_seg = next((s for s in reversed(siblings) if s.sort_order < segment.sort_order), None)
next_seg = next((s for s in siblings if s.sort_order > segment.sort_order), None)
def _brief(seg) -> str:
narration = (seg.narration or "").strip()
visual = (seg.visual_prompt or "").strip()
return f"旁白:{narration or ''};画面:{visual or ''}"
system = (
"你是电商短视频脚本导演。现在只需要**重写一条分镜**(其它分镜保持不变)。"
"结合商品卖点、该镜当前内容、前后镜上下文和用户的修改意见,重新生成这一镜的旁白口播与画面描述,"
"并保证与前后镜衔接连贯。严格按以下格式输出两行,不要输出镜头编号或任何其它内容:\n"
"旁白:这一镜要念出来的口播文案(一两句话)\n画面:这一镜的画面描述、商品露出方式和转场建议"
)
context_lines = [
f"商品标题:{product.title}",
f"品牌:{product.brand or '未填写'}",
f"类目:{product.category or '未填写'}",
f"目标人群:{product.target_audience or '未填写'}",
f"卖点:\n{selling_text or '未选择卖点,请根据商品信息自行提炼。'}",
"",
f"这是第 {segment.sort_order + 1} 镜(共 {len(siblings)} 镜),时长约 {segment.duration_seconds} 秒。",
f"该镜当前内容:{_brief(segment)}",
]
if prev_seg is not None:
context_lines.append(f"上一镜(保持不变,用于衔接){_brief(prev_seg)}")
if next_seg is not None:
context_lines.append(f"下一镜(保持不变,用于衔接){_brief(next_seg)}")
context_lines.append("")
context_lines.append(f"用户的修改意见:{instruction.strip() or '让这一镜更有吸引力、表达更清晰,并与前后镜自然衔接。'}")
user = "\n".join(context_lines).strip()
return [{"role": "system", "content": system}, {"role": "user", "content": user}]
def regenerate_script_segment(*, project, user, segment, instruction: str = "") -> ScriptVersion:
"""单镜 AI 重跑:只重新生成该 ScriptSegment 的 narration + visual_prompt(其它镜不动),
沿用 generate_project_script 的 AITask + 计费(reserve/charge/release)闭环,同步调 LLM。
返回该 segment 所属的 ScriptVersion。"""
"""单镜重跑(「场次刷新」按钮):复用脚本 agent 的精准改一镜——读全脚本上下文、只动该镜、保留其余镜,落新 ScriptVersion。
旧的「散文 prompt + 正则解析」整条已废,统一走 agent(结构化 + entity_refs 不丢)。"""
from apps.ai.script_agent import regenerate_segment_via_agent
model_config = get_default_model(ModelConfig.Capability.TEXT)
if model_config is None:
raise ValueError("no active text model configured")
messages = build_segment_rerun_prompt(project=project, segment=segment, instruction=instruction)
payload = {
"model": model_config.name,
"endpoint": model_config.endpoint,
"messages": messages,
"script_segment": str(segment.id),
}
task = create_ai_task(
project=project,
user=user,
task_type=AITask.Type.SCRIPT_OPTIMIZATION,
model_config=model_config,
request_payload=payload,
return regenerate_segment_via_agent(
project=project, user=user, model_config=model_config, segment=segment, instruction=instruction
)
reservation = task.credit_reservation
try:
task.status = AITask.Status.SUBMITTED
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)
content = provider.extract_text(response)
narration, visual = parse_segment_fields(content)
with transaction.atomic():
task.status = AITask.Status.SUCCEEDED
task.response_payload = response
task.actual_cost = task.estimated_cost
task.completed_at = timezone.now()
task.save(update_fields=["status", "response_payload", "actual_cost", "completed_at", "updated_at"])
charge_reserved_credit(reservation=reservation, actual_amount=task.actual_cost)
segment.narration = narration
segment.visual_prompt = visual
segment.save(update_fields=["narration", "visual_prompt", "updated_at"])
return segment.script_version
except Exception as exc:
with transaction.atomic():
task.status = AITask.Status.FAILED
task.error_message = str(exc)
task.completed_at = timezone.now()
task.save(update_fields=["status", "error_message", "completed_at", "updated_at"])
release_credit(reservation=reservation, reason=str(exc))
raise
def _generate_video_poster(*, video_bytes: bytes, team, project, asset_id) -> "StoredObject | None":
+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)。出稿 + 改稿一体,多模型可选。