feat(core): 角色对白(剧情向按需)+ 聊天精准改一镜 + 模型下拉移位 + 全流程e2e + 交叉验证修复

新能力(均已端到端验证):
- 角色对白 dialogue:[{speaker,line}](speaker=null 即旁白)。默认口播,用户在聊天提要求才出多角色对白(剧情向);narration 保留扁平拼接兼容下游字幕/配音。skill 同步:默认不强制对白。
- 聊天精准改一镜:聊天说「第N镜改XX」→ agent 读全脚本上下文、只重写那一镜(target_index + 后端 _merge_single_segment 强制保其余镜原样),保衔接。
- 前端:分镜卡渲染对白/镜型/露出;模型下拉从顶部移到输入框下方小按钮(对齐 ChatGPT/Lovart)。

全流程 e2e 真跑通:商品→脚本→基础资产(gpt-image-2)→故事板(@图N 多图合成锁脸锁商品)→视频(Seedance 6.4分钟出片,带音效+人声)。

对抗式交叉验证(3 审查员)修复:
- 改稿用基准稿时长 effective_duration(prompt head + normalize + merge),避免请求默认 60 把 90s/6镜稿尾镜截掉/单镜改空转。
- target_index 越界服务端早返回报错、不计费;模型未产出目标镜时抛错释放额度(不静默空转)。
- 前端「第N镜」正则收窄为 镜|场(去掉会误判「第3个卖点」的 个|段)。
- 回归:测试 setUp 停用 seed 中转站文本模型,保证命中可 mock 的 provider(18/18 过)。

CLAUDE.md 加「AI 生成 Agent 化架构」一节供后续维护。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
seaislee1209
2026-06-17 05:09:51 +08:00
co-authored by Claude Opus 4.8
parent 163a6627ba
commit 3d38c173e6
12 changed files with 186 additions and 29 deletions
+72 -5
View File
@@ -103,6 +103,7 @@ def build_agent_messages(
base_draft: dict | None,
aspect_ratio: str,
total_duration: int,
target_index: int | None = None,
) -> list[dict[str, str]]:
system = load_ecommerce_skill() + _OUTPUT_PROTOCOL
head = (
@@ -110,7 +111,17 @@ def build_agent_messages(
f"【总时长】{total_duration} 秒(每 15 秒一镜,共 {total_duration // 15} 镜)\n"
f"【商品信息】\n{_product_context(project, selling_point_ids)}"
)
if mode == "revise" and base_draft:
if mode == "revise" and base_draft and target_index is not None:
# 精准改一镜:读全脚本上下文,只重写第 N 镜,强制与前后镜衔接;其余镜后端会强制保持原样。
user = (
f"【任务】只重写第 {target_index + 1} 镜(共 {len(base_draft.get('segments', []))} 镜),其余镜保持不变。\n"
f"{head}\n\n"
f"【现有完整脚本 JSON(读它保证与前后镜衔接)】\n{json.dumps(base_draft, ensure_ascii=False)}\n\n"
f"【对第 {target_index + 1} 镜的修改意见】{user_prompt.strip() or '让这一镜更有吸引力、表达更清晰。'}\n\n"
"仍输出**完整** ScriptDraft(我只会采用第 "
f"{target_index + 1} 镜的改动);若意见涉及角色对白,就给这一镜填 dialogue。"
)
elif mode == "revise" and base_draft:
user = (
"【任务】改稿(模式③):在保留用户原意的前提下,增强钩子/节奏/卖点/CTA,并归一化到契约 JSON。\n"
f"{head}\n\n"
@@ -257,16 +268,30 @@ def normalize_draft(raw_text: str, *, aspect_ratio: str, total_duration: int) ->
speaker = seg.get("speaker")
speaker = speaker if (speaker in valid_ids) else None
refs = [r for r in (seg.get("entity_refs") or []) if r in valid_ids]
# 对白(剧情向):[{speaker(合法 entity id 或 null=旁白), line}];默认空 = 纯口播
dialogue = []
for d in (seg.get("dialogue") or []):
if not isinstance(d, dict):
continue
line = (d.get("line") or d.get("text") or "").strip()
if not line:
continue
sp = d.get("speaker")
dialogue.append({"speaker": sp if sp in valid_ids else None, "line": line})
narration = (seg.get("narration") or "").strip()
if not narration and dialogue:
narration = " ".join(d["line"] for d in dialogue) # 扁平拼接,兼容下游字幕/配音
norm_segments.append(
{
"index": i,
"duration": 15,
"role": role,
"narration": (seg.get("narration") or "").strip(),
"narration": narration,
"speaker": speaker,
"visual": (seg.get("visual") or seg.get("visual_prompt") or "").strip(),
"product_exposure": (seg.get("product_exposure") or "").strip(),
"entity_refs": refs,
"dialogue": dialogue,
}
)
# 不足镜数则补占位镜(极少发生,避免下游镜数对不上)
@@ -282,6 +307,7 @@ def normalize_draft(raw_text: str, *, aspect_ratio: str, total_duration: int) ->
"visual": "",
"product_exposure": "",
"entity_refs": [],
"dialogue": [],
}
)
if not norm_segments:
@@ -290,6 +316,30 @@ def normalize_draft(raw_text: str, *, aspect_ratio: str, total_duration: int) ->
return draft
def _merge_single_segment(base: dict, new: dict, idx: int, aspect_ratio: str, total_duration: int) -> dict:
"""精准改一镜:以基准稿为底,只用新稿的第 idx 镜替换,其余镜逐字保持;合并新稿引入的新 entity(对白可能加角色)。再整体规范化。"""
merged = json.loads(json.dumps(base)) # 深拷贝
base_ids = {e.get("id") for e in merged.get("entities", []) if isinstance(e, dict)}
for e in new.get("entities", []):
if isinstance(e, dict) and e.get("id") and e["id"] not in base_ids:
merged.setdefault("entities", []).append(e)
base_ids.add(e["id"])
new_segs = new.get("segments", [])
target = next((s for s in new_segs if isinstance(s, dict) and s.get("index") == idx), None)
if target is None and 0 <= idx < len(new_segs):
target = new_segs[idx]
if not isinstance(target, dict):
# 模型没产出目标镜(没按 N 镜输出)→ 抛错让上层释放额度+报错,而不是静默返回 base 空转计费
raise ValueError(f"模型未产出第 {idx + 1} 镜的改动,请重试")
segs = merged.get("segments", [])
if isinstance(target, dict) and 0 <= idx < len(segs):
target = dict(target)
target["index"] = idx
segs[idx] = target
merged["segments"] = segs
return normalize_draft(json.dumps(merged, ensure_ascii=False), aspect_ratio=aspect_ratio, total_duration=total_duration)
# --------------------------------------------------------------------------- #
# 落库
# --------------------------------------------------------------------------- #
@@ -346,6 +396,7 @@ def persist_script_draft(*, project, user, task, draft: dict, source: str):
speaker=seg.get("speaker") or "",
product_exposure=seg.get("product_exposure", ""),
entity_refs=seg.get("entity_refs") or [],
dialogue=seg.get("dialogue") or [],
product_points=[],
)
_map_entities_to_project_metadata(project, draft.get("entities", []))
@@ -383,8 +434,10 @@ def stream_script_agent(
base_version_id: str | None = None,
aspect_ratio: str = "9:16",
total_duration: int = 60,
target_index: int | None = None,
):
"""生成 SSE 帧字符串的同步生成器,供 StreamingHttpResponse 包裹。"""
"""生成 SSE 帧字符串的同步生成器,供 StreamingHttpResponse 包裹。
target_index 非空 = 精准只改第 N 镜(读全脚本上下文,后端强制保留其余镜原样)。"""
from apps.ai.services import build_provider, create_ai_task
yield _sse({"type": "tool", "id": "skill", "label": "加载电商脚本技能", "status": "running"})
@@ -395,6 +448,16 @@ def stream_script_agent(
base_draft = None
if mode == "revise" and base_version_id:
base_draft = _load_base_draft(project, base_version_id)
if base_draft is None:
target_index = None # 没有基准稿就退回整版生成,单镜改无从谈起
# 改稿以基准稿的时长/镜数为准,避免请求侧默认值(前端可能硬编码 60)把 90s/6镜稿的尾镜挤掉
effective_duration = (base_draft.get("total_duration") if base_draft else None) or total_duration
# 精准改一镜:镜号越界直接报错返回,绝不建任务/扣费(避免计费空转的静默 no-op)
if target_index is not None and base_draft is not None:
seg_n = len(base_draft.get("segments", []))
if not (0 <= target_index < seg_n):
yield _sse({"type": "error", "detail": f"镜号越界:第 {target_index + 1} 镜(共 {seg_n} 镜)"})
return
messages = build_agent_messages(
project=project,
mode=mode,
@@ -402,7 +465,8 @@ def stream_script_agent(
selling_point_ids=selling_point_ids,
base_draft=base_draft,
aspect_ratio=aspect_ratio,
total_duration=total_duration,
total_duration=effective_duration, # 改稿用基准稿时长,prompt head 才不会误导模型镜数(否则模型按60s只出4镜)
target_index=target_index,
)
yield _sse({"type": "tool", "id": "analyze", "status": "done"})
@@ -461,7 +525,10 @@ def stream_script_agent(
elif ev.get("type") == "done":
break
raw = "".join(full)
draft = normalize_draft(raw, aspect_ratio=aspect_ratio, total_duration=total_duration)
draft = normalize_draft(raw, aspect_ratio=aspect_ratio, total_duration=effective_duration)
if target_index is not None and base_draft:
# 精准改一镜:只采用新稿的第 target_index 镜,其余镜强制保持基准稿原样
draft = _merge_single_segment(base_draft, draft, target_index, aspect_ratio, effective_duration)
except Exception as exc: # noqa: BLE001
_fail_task(task, reservation, str(exc))
settled = True