Files
yingqing/core/backend/apps/ai/script_agent.py
T

1750 lines
80 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""对话式脚本生成 agent(出稿 + 改稿一体,多模型可选,流式 SSE)。
设计:
- 加载电商脚本 skill(SKILL.md + references)作为领域知识系统提示词;模型无关。
- 3 种输入模式(全自动 / 一句话 / 改稿)收敛到同一份结构化 ScriptDraft(铁律1契约)。
- 流式:边生成边吐「工具卡 + 思考」事件,给前端真 agent 体感;JSON 由后端可靠抽取,不靠模型排版。
- 计费走现有 AITask + 额度预扣(reserve→charge/release),与 generate_project_script 一致。
SSE 事件(每帧 `data: {json}\n\n`,json 带 type):
tool {id,label?,status:running|done|error} —— 工具卡(加载skill/分析商品/生成分镜/提取实体/自检)
reasoning {text} —— 推理模型思考流(逐字,纯展示,不进答案)
delta {text} —— 模型自然语言前言(JSON 部分不外露)
draft {draft} —— 规范化后的 ScriptDraft(前端结构化渲染)
saved {script_version_id, version} —— 已落库的 ScriptVersion(含 segments/metadata)
summary {text} —— 模型自己写的收尾交付语(当 AI 回复气泡,替代写死的「已生成」)
done {} —— 结束
error {detail,error} —— 失败(已回滚额度;error 为安全业务错误对象)
"""
from __future__ import annotations
import json
import re
import uuid
from decimal import Decimal
from functools import lru_cache
from pathlib import Path
from django.conf import settings
from django.utils import timezone
from apps.ai.generation_errors import classify_generation_error
from apps.ai.models import AITask, ModelConfig
from apps.billing.services.ledger import charge_reserved_credit, release_credit
VALID_TONES = ["种草", "测评", "剧情", "痛点"]
VALID_ROLES = ["钩子", "痛点", "卖点", "CTA"]
VALID_ENTITY_TYPES = ["character", "scene", "product"]
# 时长:主流程每镜固定 15 秒;总时长只能是 15/30/45/60。
# 4–15 仍是出片模型合法区间,手改单镜 / 精准改一镜(旧稿)才用。
TOTAL_DURATION_MIN = 15
TOTAL_DURATION_MAX = 60
TOTAL_DURATION_STEP = 15
SEGMENT_DURATION_MIN = 4
SEGMENT_DURATION_MAX = 15
DEFAULT_TOTAL_DURATION = 30
# 表现形式 × 视频结构(二期)。key 用 ASCII 找套路文件,label 是给模型和用户看的中文。
PRESENTATION_FORMATS: dict[str, str] = {"oral": "口播", "drama": "短剧", "vlog": "Vlog"}
VIDEO_STRUCTURES: dict[str, str] = {
"pain": "痛点解决",
"contrast": "前后对比",
"review": "测评验证",
"scene": "场景种草",
"promo": "促销抢购",
"knowledge": "知识分享",
}
DEFAULT_PRESENTATION_FORMAT = "oral"
DEFAULT_VIDEO_STRUCTURE = "pain"
# 唯一禁用组合:短剧 × 测评验证。演出来的实测没有可信度,详见 playbooks/combo-matrix.md。
FORBIDDEN_COMBOS: set[tuple[str, str]] = {("drama", "review")}
# 表现形式推荐的默认总时长:口播短平快,短剧要装下三幕,Vlog 要铺氛围。
FORMAT_DEFAULT_DURATION: dict[str, int] = {"oral": 30, "drama": 45, "vlog": 30}
# 各结构能压到的最短总时长(须落在 15 秒步进上),见 playbooks/combo-matrix.md。
STRUCTURE_MIN_DURATION: dict[str, int] = {
"pain": 15, "contrast": 15, "review": 30, "scene": 30, "promo": 15, "knowledge": 30,
}
# 设定卡人物 key → 中文(与前端 WIZ_PERSONA_LABEL / 模板 coerce_persona 对齐)。
PERSONA_LABELS: dict[str, str] = {
"urban": "都市白领女性",
"bestie": "闺蜜种草",
"ceo": "总裁亲选",
"reviewer": "专业测评师",
"mom": "实用宝妈",
"genz": "学生党",
}
PERSONA_BRIEFS: dict[str, str] = {
"urban": "25–32岁都市白领,工位或下班回家,说话像跟同事吐槽,不要主播腔",
"bestie": "闺蜜分享口吻,带点兴奋,爱用「你懂的」「我跟你讲」",
"ceo": "利落、判断句、少形容词,像拍板不是带货",
"reviewer": "先讲怎么试的再给结论,允许提一个小缺点才可信",
"mom": "带娃/家务间隙,讲省事,孩子或家人能沾边",
"genz": "宿舍或通勤,短句,像给朋友发语音条",
}
_PERSONA_KEY_BY_LABEL = {label: key for key, label in PERSONA_LABELS.items()}
# 电商口播需要同时承载观点、证据和转折;15 秒以 4.3–5.2 字/秒为可用区间。
# 这比日常闲聊快,但仍给句间停顿和镜头切换留出空间。
NARRATION_CHARS_PER_SECOND = 5.2
NARRATION_CHARS_PER_SECOND_MIN = 4.3
NARRATION_CHARS_HARD_CAP = 78
VISUAL_CHARS_MIN = 72
SHOT_BEATS_MIN = 3
BEAT_SPAN_RE = re.compile(
r"(?P<start>\d{1,2})\s*[-–—~到至]\s*(?P<end>\d{1,2})\s*(?:s|秒)?\s*[:]",
re.IGNORECASE,
)
_SHOT_SIZE_MARKERS = (
"特写", "近景", "中近景", "中景", "全景", "远景", "胸上", "过肩",
"手持", "跟拍", "俯拍", "仰拍", "推近", "拉远", "摇",
)
_FORMAT_KEY_BY_LABEL = {label: key for key, label in PRESENTATION_FORMATS.items()}
_STRUCTURE_KEY_BY_LABEL = {label: key for key, label in VIDEO_STRUCTURES.items()}
def coerce_persona(value: str | None) -> str:
"""把设定卡 key 或中文标签收成 key;空/未知原样(未知时当补充描述用)。"""
raw = (value or "").strip()
if not raw:
return ""
if raw in PERSONA_LABELS:
return raw
return _PERSONA_KEY_BY_LABEL.get(raw, raw)
def persona_label(value: str | None) -> str:
key = coerce_persona(value)
return PERSONA_LABELS.get(key, key)
def _wizard_meta(project) -> dict:
meta = getattr(project, "metadata", None) or {}
wizard = meta.get("wizard") if isinstance(meta, dict) else None
return wizard if isinstance(wizard, dict) else {}
def _resolve_selling_point_ids(project, selling_point_ids: list | None) -> list[str]:
if selling_point_ids:
return [str(item) for item in selling_point_ids if item]
raw = _wizard_meta(project).get("selling_point_ids") or []
if not isinstance(raw, list):
return []
return [str(item) for item in raw if item]
def _resolve_persona(project, persona: str | None) -> str:
return coerce_persona(persona) or coerce_persona(_wizard_meta(project).get("persona"))
def combo_keys(value_format, value_structure) -> tuple[str, str]:
"""把「中文标签或 ASCII key」都归一成 key。落库存的是中文,请求传的是 key,两边都要认。"""
fmt = _FORMAT_KEY_BY_LABEL.get(value_format, value_format)
structure = _STRUCTURE_KEY_BY_LABEL.get(value_structure, value_structure)
return coerce_combo(fmt, structure)
def allowed_structures(fmt: str) -> list[str]:
"""某表现形式下可选的视频结构 key(1.8 组合联动:换表现形式,结构列表跟着变)。"""
fmt = fmt if fmt in PRESENTATION_FORMATS else DEFAULT_PRESENTATION_FORMAT
return [key for key in VIDEO_STRUCTURES if (fmt, key) not in FORBIDDEN_COMBOS]
def coerce_combo(fmt: str | None, structure: str | None) -> tuple[str, str]:
"""把任意输入夹成一组合法的(表现形式, 视频结构)。禁用组合回落到该形式的第一个合法结构。"""
fmt = fmt if fmt in PRESENTATION_FORMATS else DEFAULT_PRESENTATION_FORMAT
structure = structure if structure in VIDEO_STRUCTURES else DEFAULT_VIDEO_STRUCTURE
if (fmt, structure) in FORBIDDEN_COMBOS:
structure = allowed_structures(fmt)[0]
return fmt, structure
def narration_limit(duration: int) -> int:
"""这一镜旁白的字数上限:秒数 × 5.2,且不超过硬上限 78。"""
return max(1, min(NARRATION_CHARS_HARD_CAP, int(duration * NARRATION_CHARS_PER_SECOND)))
def narration_floor(duration: int) -> int:
"""15 秒口播至少要说到这个字数,再短就撑不满镜头。"""
cap = narration_limit(duration)
return max(1, min(cap - 6, int(duration * NARRATION_CHARS_PER_SECOND_MIN)))
def _compact_len(text: str) -> int:
return len(re.sub(r"\s+", "", text or ""))
def _speech_text(seg: dict) -> str:
dialogue = seg.get("dialogue") if isinstance(seg.get("dialogue"), list) else []
lines = [
str(item.get("line") or "")
for item in dialogue
if isinstance(item, dict) and (item.get("line") or "").strip()
]
if lines:
return "".join(lines)
return str(seg.get("narration") or "")
def min_beats_for_duration(duration: int) -> int:
if duration >= 12:
return SHOT_BEATS_MIN
if duration >= 8:
return 2
return 0
def parse_visual_beats(text: str) -> list[tuple[int, int, str]]:
"""从 visual 文本里抽出「0-3s:…」分镜。接得上才能喂下游视频。"""
raw = (text or "").strip()
if not raw:
return []
matches = list(BEAT_SPAN_RE.finditer(raw))
if not matches:
return []
beats: list[tuple[int, int, str]] = []
for index, match in enumerate(matches):
start, end = int(match.group("start")), int(match.group("end"))
if end < start:
start, end = end, start
body_to = matches[index + 1].start() if index + 1 < len(matches) else len(raw)
body = re.sub(r"[\n\r]+", " ", raw[match.end():body_to]).strip(" ;,、")
if start == end or not body:
continue
beats.append((start, end, body))
return beats
def format_visual_beats(beats: list[tuple[int, int, str]]) -> str:
return "\n".join(f"{start}-{end}s{body}" for start, end, body in beats)
# --------------------------------------------------------------------------- #
# skill 加载(缓存)
# --------------------------------------------------------------------------- #
def _skill_dir() -> Path:
override = getattr(settings, "ECOMMERCE_SKILL_DIR", None)
if override:
return Path(override)
# skills 已随后端打进镜像(core/backend/skills);优先 BASE_DIR/skills,回落仓库根(本地/旧布局)。
# 旧版只看仓库根 → 镜像里没有(构建上下文是 ./core/backend)→ 提示词为空、退化兜底。详见 services._skills_root。
base = Path(settings.BASE_DIR)
for cand in (base / "skills", base.parent.parent / "skills"):
if (cand / "ecommerce-video-script").is_dir():
return cand / "ecommerce-video-script"
return base / "skills" / "ecommerce-video-script"
def _read_ref(path: Path, label: str) -> str:
if not path.exists():
return ""
return f"\n\n===== {label} =====\n\n{path.read_text(encoding='utf-8')}"
@lru_cache(maxsize=1)
def _load_skill_base() -> str:
"""SKILL.md + references 根目录下的通用资料(方法论/钩子库/品类/平台/自检),每次都要。
playbooks/ 是子目录,glob("*.md") 不会递归到,套路由 load_ecommerce_skill 按组合单独挑。
"""
skill_dir = _skill_dir()
parts: list[str] = []
main = skill_dir / "SKILL.md"
if main.exists():
parts.append(main.read_text(encoding="utf-8"))
ref_dir = skill_dir / "references"
if ref_dir.exists():
for ref in sorted(ref_dir.glob("*.md")):
parts.append(_read_ref(ref, f"references/{ref.name}"))
return "".join(parts)
@lru_cache(maxsize=32)
def load_ecommerce_skill(
presentation_format: str = DEFAULT_PRESENTATION_FORMAT,
video_structure: str = DEFAULT_VIDEO_STRUCTURE,
) -> str:
"""通用资料 + 组合矩阵 + **只挑被选中的那一份表现形式和那一份视频结构**。
套路全量灌进去会让系统提示词翻倍(每份 2-3K 字),而且模型会在 11 套互相矛盾的
镜头语言里挑花眼。只给当前这一组,提示词更短、约束更硬。
"""
fmt, structure = coerce_combo(presentation_format, video_structure)
playbooks = _skill_dir() / "references" / "playbooks"
parts = [
_load_skill_base(),
_read_ref(playbooks / "combo-matrix.md", "references/playbooks/combo-matrix.md"),
_read_ref(playbooks / f"format-{fmt}.md", f"references/playbooks/format-{fmt}.md"),
_read_ref(playbooks / f"structure-{structure}.md", f"references/playbooks/structure-{structure}.md"),
]
joined = "".join(parts)
if not joined.strip():
# 兜底:skill 文件缺失也能退化生成(交接文档会提示补 skills 目录)
return "你是电商带货短视频脚本生成 agent,输出结构化 ScriptDraft JSON。"
return joined
# 运行时输出协议:优先级高于 skill 里的「只输出 JSON / 不展示思考」,只为流式体感放开一句前言。
_OUTPUT_PROTOCOL = """
---
## 运行时输出协议(AirShelf 流式展示专用,优先级高于技能正文的「只输出 JSON」)
严格按以下顺序输出,不要有别的内容:
1. 先用 **1 句中文口语**告诉用户你正在做什么(≤40 字,例:「在为这款保温杯生成 2 镜痛点脚本…」),让用户看到进展;
2. 紧接着输出**且仅输出一个** ```json 代码块,内容为符合技能契约(铁律1)的 ScriptDraft 对象;
3. json 代码块**收尾之后另起一行**,用 **1–2 句中文口语**跟用户交付这一版:做了什么、为什么这么改、可以怎么接着调(像同事汇报,**别复述 JSON 字段、别再写代码块**)。这句会作为你的回复气泡展示给用户。
### 字段名锚定(硬性 · 下游靠它取数,跑偏即数据全空)
- 分镜数组的键名**必须**叫 `segments`(禁止用 scenes / script / shots / 分镜 等同义词)。
- 每镜口播键名**必须**叫 `narration`(禁止用 voiceover / audio / line)。
- 每镜画面键名**必须**叫 `visual`(禁止用 scene / screen / 画面 当键名)。
- `visual` 是**多行字符串**,每行一条秒级分镜:`0-3s:近景,……`(禁止只写一句静态动作,也禁止只给 setting/camera 对象而不给 visual)。
- 允许额外给 `beats`/`shots` 数组,后端会折进 visual;有数组也必须能折成「起-止秒」格式。
- 即使你额外附带了 scenes / shots 等创作结构,也**必须同时**给出标准 `segments` 数组,并把口播填进 `narration`、画面填进 `visual`,否则视为不合格。
"""
# 创作方向不放在前端文案里,而是和输出协议一起作为运行时最高优先级约束。
# 它解决的不是 JSON 合不合法,而是「脚本合格却像商品详情页朗读」的问题。
_CREATIVE_DIRECTION = """
---
## 出片感与转化感(硬性创作方向)
你的脚本不能像商品详情页、说明书或主播念稿;它必须让用户在第一秒看到一个
**正在发生的具体瞬间**。先选一个最能代表目标人群的「时间 + 地点 + 小麻烦/小欲望」,
全片只围绕这一个情境推进。不要在一条短视频里罗列所有卖点。
### 每一镜都必须有推进,禁止平铺直叙
- 钩子镜:前 15 个字就抛出反常、尴尬、选择或结果;禁止「大家好」「今天分享」
「给你们推荐」「这款很好用」「值得买」等任何寒暄或泛推荐开场。
- 痛点镜:拍得到的细节,而非抽象感受。例如「开会前刘海粘成一绺」而不是「头发很油」。
- 卖点镜:只证明一个卖点,必须写清「原本卡在哪里 → 手怎么使用商品 → 眼前有什么变化」。
不准只报参数、堆形容词或重复商品名。
- CTA 镜:回到前面那个具体情境,给一个自然的选择/动作;不要硬喊「赶紧下单」。
### 画面与台词必须互相提供新信息
- 每个 15 秒场只安排**一个连续的核心动作链**,例如「拆开 → 倒入 → 颜色变化」,
不要在同一段塞进无关剧情、三个卖点和多次场景跳转。
- 台词说人物的判断、感受或转折;画面证明这句话。台词不要逐字复述画面。
- 至少有一个可见证据:包装/质地/声音/前后状态/动作结果。没有可见证据的卖点不写。
- 用像真实人在当下会说的短句,可以有停顿、转折和个人立场;禁止「不仅…而且…」
「全面升级」「高品质」「性价比很高」「闭眼入」等详情页腔。
输出前自问:遮住商品名后,这是不是仍然像一个真实的人在讲自己刚遇到的一件事?
如果像广告口号,重写得更具体、更有立场。
### visual 必须写成「导演说明书」,不是一句画面摘要
每个 segment 的 `visual` 采用下面的层级;这不是给用户看的散文,而是会原样交给故事板和视频模型的执行指令:
```
【本镜任务】这一段要让观众看懂的变化或悬念。
【声音】台词/旁白状态;音效;背景音乐;字幕。没有就写「无」;没有配乐写「无配乐,仅同期声」。
【画面内容】
0-3s:景别;机位;运镜;人物/商品动作;情绪或信息变化。
3-8s:景别;机位;运镜;手与商品的空间关系;可见细节。
8-12s:景别;机位;运镜;动作结果或卖点证据。
12-15s:景别;机位;运镜;反应/悬念/自然收束。
```
每条秒级分镜都要明确写出 **景别、机位、运镜、动作、信息变化** 五项中的至少四项。
输入如果是 `【镜头 01】` 导演分镜稿,把原稿的景别、机位、运镜、动作、表情、音效、背景音乐、字幕、备注折进对应栏,不要压成一句画面摘要。
同一角色、商品、场景的外观一律引用 entities 里的既定设定,不在每一镜随意换发型、服装、包装、光线或地点。
"""
# 这些句式几乎总会让首屏像模板广告。只作为生成后的最后一道门,不替代模型的创作判断。
_GENERIC_HOOK_PHRASES = (
"大家好", "今天", "给大家", "给你们", "给姐妹", "分享一下", "推荐一下",
"这款", "好物分享", "真的好用", "值得买", "闭眼入", "宝子们",
)
# --------------------------------------------------------------------------- #
# 提示词构建(3 模式)
# --------------------------------------------------------------------------- #
def _specs_lines(specs) -> str:
if not isinstance(specs, dict) or not specs:
return ""
skip = {"source"}
labels = {"price": "价格"}
lines: list[str] = []
for key, val in specs.items():
if key in skip or val in (None, "", [], {}):
continue
if isinstance(val, (dict, list)):
rendered = json.dumps(val, ensure_ascii=False)
else:
rendered = str(val).strip()
if not rendered:
continue
lines.append(f"- {labels.get(key, key)}:{rendered}")
return "\n".join(lines)
def _product_facts(project, selling_point_ids: list[str] | None):
"""商品事实 + 本次勾选的卖点(供提示词和落库前原词校验共用)。
新建向导历史上把卖点**标题**(如「茶」)写进 metadata.wizard.selling_point_ids,
字段名叫 ids 但不是 UUID。按 id__in 过滤会直接 ValidationError 把 SSE 打崩。
这里 UUID 和标题都认;对不上就回落全部卖点,绝不抛。
"""
product = project.product
keys = [str(item).strip() for item in _resolve_selling_point_ids(project, selling_point_ids) if str(item).strip()]
all_points = list(product.selling_points.all())
if not keys:
return product, all_points
uuid_keys: set[str] = set()
title_keys: set[str] = set()
for key in keys:
try:
uuid_keys.add(str(uuid.UUID(key)))
except (ValueError, AttributeError, TypeError):
title_keys.add(key)
matched = [
point
for point in all_points
if str(getattr(point, "id", "") or "") in uuid_keys
or (getattr(point, "title", "") or "").strip() in title_keys
]
return product, matched or all_points
def _product_context(project, selling_point_ids: list[str] | None, persona: str | None = None) -> str:
product, selling_points = _product_facts(project, selling_point_ids)
selling_text = "\n".join(f"- {sp.title}:{sp.detail or sp.title}" for sp in selling_points)
business_type = getattr(product, "business_type", "") or "ecommerce"
if business_type == "local_life":
type_line = "业务类型:本地生活(团购/到店核销,无实物,按虚拟商品走主流程 SOP)\n"
else:
type_line = "业务类型:电商(实物商品)\n"
specs_text = _specs_lines(getattr(product, "specs", None) or {})
persona_key = _resolve_persona(project, persona)
persona_text = persona_label(persona_key)
persona_brief = PERSONA_BRIEFS.get(persona_key, "")
must = [f"商品名「{product.title}」"] if product.title else []
if product.brand:
must.append(f"品牌「{product.brand}」")
must.extend(f"卖点「{sp.title.strip()}」" for sp in selling_points if (sp.title or "").strip())
must_line = "、".join(must) if must else "无(根据商品描述自行提炼,禁止空话套话)"
specs_block = f"\n{specs_text}" if specs_text else "未填写"
desc = (product.description or "").strip() or "未填写"
return (
f"商品标题:{product.title}\n"
f"品牌:{product.brand or '未填写'}\n"
f"{type_line}"
f"品类:{product.category or '未填写'}\n"
f"目标人群:{product.target_audience or '未填写'}\n"
f"人物设定:{persona_text or '未指定,按目标人群自行定一个具体身份'}\n"
f"人物口吻:{persona_brief or '按人物设定自己定一个具体身份,不要用万能主播腔'}\n"
f"商品描述:{desc}\n"
f"规格:{specs_block}\n"
f"本次必须用上的卖点:\n{selling_text or '未勾选卖点,请根据商品信息自行提炼,禁止空话。'}\n"
f"【必须原词用上】旁白/对白里要出现:{must_line}。"
f"禁止用「补水/好用/值得买/宝藏」这类空卖点替换上面的原词。"
f"商品名全片点名 1–2 次即可,其余镜用卖点原词和可感知细节(口感/气味/动作/使用场景),不要每句重复商品名。"
f"描述若未填写,就从标题+卖点把使用感写具体,禁止只喊商品名。"
)
def build_agent_messages(
*,
project,
mode: str,
user_prompt: str,
selling_point_ids: list[str] | None,
base_draft: dict | None,
aspect_ratio: str,
total_duration: int,
presentation_format: str = DEFAULT_PRESENTATION_FORMAT,
video_structure: str = DEFAULT_VIDEO_STRUCTURE,
target_index: int | None = None,
persona: str | None = None,
) -> list[dict[str, str]]:
fmt, structure = coerce_combo(presentation_format, video_structure)
if target_index is not None:
try:
total = int(total_duration)
except (TypeError, ValueError):
total = DEFAULT_TOTAL_DURATION
if total <= 0:
total = DEFAULT_TOTAL_DURATION
else:
total = coerce_total_duration(total_duration)
system = load_ecommerce_skill(fmt, structure) + _CREATIVE_DIRECTION + _OUTPUT_PROTOCOL
suggested = plan_segment_durations(total, fmt)
shot_n = len(suggested)
extra = (user_prompt or "").strip()
if target_index is not None and base_draft:
existing_n = len(base_draft.get("segments") or [])
duration_line = (
f"【分镜时长】保持现有 {existing_n} 镜和每镜秒数,"
f"只改第 {target_index + 1} 镜的文案与画面,不要加减镜、不要改时长。\n"
)
else:
duration_line = (
f"【分镜时长】每镜必须 {SEGMENT_DURATION_MAX} 秒,一共 {shot_n} 镜;"
f"禁止写成 8/10/12 这种不等长,也禁止加减镜数。\n"
)
speech_floor = narration_floor(SEGMENT_DURATION_MAX)
speech_cap = narration_limit(SEGMENT_DURATION_MAX)
beats_line = (
f"【秒级分镜】每个 {SEGMENT_DURATION_MAX} 秒场必须拆成 3–5 个分镜,visual 必须按「导演说明书」写成多行:"
"先写【本镜任务】、【声音】、【画面内容】,再写秒级分镜;"
f"每条格式`0-3s:景别;机位;运镜;谁在做什么;信息变化`,最后一条接到 {SEGMENT_DURATION_MAX}s。"
"每条写清手/商品/容器的空间关系和真实用法"
"(茶=热水+蒸汽+茶汤变色,禁止茶包丢进冷白开;手从真实方向入画,禁止悬浮肢体)。"
"禁止一句空画面撑满 15 秒。允许另给 beats 数组,后端会折进 visual。\n"
)
if fmt == "oral":
writing_line = (
f"【写法硬约束】口播每镜必须说满 {speech_floor}{speech_cap} 字(24 句短句,"
f"禁止一句 20 字收工);visual 至少 {VISUAL_CHARS_MIN} 字。"
"第一镜用人物口吻交代身份,不要万能主播腔。\n"
)
elif fmt == "drama":
writing_line = (
f"【写法硬约束】对白每镜合计 {speech_floor}{speech_cap} 字;"
f"visual 至少 {VISUAL_CHARS_MIN} 字。\n"
)
else:
writing_line = (
f"【写法硬约束】有人声的镜口播/对白 {speech_floor}{speech_cap} 字,允许个别镜纯画面;"
f"visual 至少 {VISUAL_CHARS_MIN} 字。\n"
)
structure_line = ""
if structure == "scene":
structure_line = (
"【场景种草骨架】按镜走完:场景建立(商品先不出现)→商品被使用着入场→场景因它更好/软CTA。"
"每镜写出具体时刻+地点+光/声音/物件。口播时每镜仍要说满字数,留白靠停顿不是靠少写字。\n"
)
elif structure == "promo":
structure_line = (
"【促销抢购合规】只可使用商品资料中明确给出的价格、优惠、库存、赠品和活动时间;"
"没有明确数据时,绝不写原价、最低价、限时、限量、领券或赠品。"
"结尾只给一个清楚的购买入口和操作动作,不制造虚假的紧迫感。\n"
)
elif structure == "knowledge":
structure_line = (
"【知识分享骨架】先提出一个与商品相关的选购/使用误区或判断方法,再用实物与步骤证明,"
"最后把商品作为符合该判断的具体实例。只能讲商品资料可支持的事实,"
"禁止编造专业身份、数据、标准或功效承诺。\n"
)
combo_line = (
f"严格按已加载的「{PRESENTATION_FORMATS[fmt]} × {VIDEO_STRUCTURES[structure]}」套路写,"
f"不要串成别的结构的套话。\n"
)
head = (
f"【画幅】{aspect_ratio}\n"
f"【表现形式】{PRESENTATION_FORMATS[fmt]}(套路见 playbooks/format-{fmt}.md,已加载)\n"
f"【视频结构】{VIDEO_STRUCTURES[structure]}(套路见 playbooks/structure-{structure}.md,已加载)\n"
f"【总时长】{total}\n"
f"{duration_line}"
f"{writing_line}"
f"{beats_line}"
f"{structure_line}"
f"{combo_line}"
f"【商品信息】\n{_product_context(project, selling_point_ids, persona)}"
)
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} 镜的修改意见】{extra or '让这一镜更有吸引力、表达更清晰。'}\n\n"
"仍输出**完整** ScriptDraft(我只会采用第 "
f"{target_index + 1} 镜的改动);若意见涉及角色对白,就给这一镜填 dialogue。"
)
elif mode == "revise" and base_draft:
user = (
"【任务】改稿(模式③):在保留用户原意的前提下,增强钩子/节奏/卖点/CTA,并归一化到契约 JSON。\n"
f"{head}\n\n"
f"【现有脚本 JSON】\n{json.dumps(base_draft, ensure_ascii=False)}\n\n"
f"【用户修改意见】{extra or '让整体更有吸引力、转化感更强,并保持各镜衔接连贯。'}\n\n"
"请输出修订后的**完整** ScriptDraft。"
)
elif mode == "theme":
user = (
"【任务】一句话主题扩写(模式②):以用户主题为脚本主轴,但商品事实和必须原词仍要全部用上。\n"
f"{head}\n\n"
f"【用户主题】{extra or '按商品最强卖点选题'}\n\n"
"请按技能流程一次性产出 ScriptDraft。"
)
else:
extra_block = f"\n\n【补充要求】{extra}\n" if extra else "\n"
if extra and _looks_like_shot_digest(extra):
user = (
"【任务】参考视频改写:用户给了一份逐镜拆解稿。"
"照搬它的镜头顺序、每镜时长比例、景别、机位、运镜、人物动作和声音层次,"
"把人物、商品、品牌、台词全部换成当前商品。"
"写 visual 时把拆解稿里的景别/机位/运镜/人物动作/表情/音效/背景音乐/字幕/备注"
"折进【声音】和【画面内容】的秒级分镜,不要压成一句画面摘要。"
"原稿写「无 / 不可见 / 听不清」的栏不要编造。"
"钩子、痛点、卖点必须能对上这份商品。"
"每镜旁白要能撑满指定时长;画面必须按秒拆分镜,够导演在 15 秒里切 3–5 刀。\n"
f"{head}"
f"{extra_block}\n"
"请按技能流程一次性产出 ScriptDraft。"
)
else:
user = (
"【任务】全自动(模式①):仅凭上面的商品事实与前置条件,按指定的表现形式与视频结构套路"
"自动定镜/选 tone/造 entity/填结构骨架。"
"不要另起一个空主题;钩子、痛点、卖点必须能对上这份商品,而不是品类套话。"
"每镜旁白要能撑满 15 秒;画面必须按秒拆分镜,够导演在 15 秒里切 3–5 刀。\n"
f"{head}"
f"{extra_block}\n"
"请按技能流程一次性产出 ScriptDraft。"
)
return [{"role": "system", "content": system}, {"role": "user", "content": user}]
# --------------------------------------------------------------------------- #
# JSON 抽取 + 契约规范化(模型无关,后端兜底)
# --------------------------------------------------------------------------- #
def _balanced_object(text: str) -> str | None:
"""从首个 '{' 起按括号深度扫描,返回第一个配平的 {...}(忽略字符串内的括号)。
避免 rfind('}') 在 JSON 后还有含花括号的散文时越界截出非法片段。"""
start = text.find("{")
if start == -1:
return None
depth = 0
in_str = False
esc = False
for i in range(start, len(text)):
c = text[i]
if in_str:
if esc:
esc = False
elif c == "\\":
esc = True
elif c == '"':
in_str = False
continue
if c == '"':
in_str = True
elif c == "{":
depth += 1
elif c == "}":
depth -= 1
if depth == 0:
return text[start : i + 1]
return None
def _looks_like_shot_digest(text: str) -> bool:
"""用户贴进来的是视频提炼分镜稿,不是一句补充要求。"""
blob = text or ""
has_shot = "【镜头" in blob or ("【第" in blob and "镜】" in blob)
has_picture = "画面:" in blob or "画面:" in blob
return has_shot and has_picture
def _looks_like_draft(blob: str) -> bool:
try:
d = json.loads(blob)
except (ValueError, TypeError):
return False
return isinstance(d, dict) and ("segments" in d or "hook" in d)
def _extract_json(text: str) -> str | None:
"""抽取 ScriptDraft JSON。容错:模型可能先给示例 ```json 块再给正式块,
故取**最后一个**含 segments/hook 的合法围栏块;都不像草稿再退而取末个配平对象;无围栏再裸扫。"""
fences = re.findall(r"```(?:json)?\s*(.+?)```", text, re.DOTALL)
for block in reversed(fences):
obj = _balanced_object(block)
if obj and _looks_like_draft(obj):
return obj
for block in reversed(fences):
obj = _balanced_object(block)
if obj:
return obj
return _balanced_object(text)
def coerce_total_duration(value) -> int:
"""总时长夹到 1560 秒、15 秒步进(15/30/45/60)。空值/0/非法输入一律回落默认 30。"""
if value in (None, "", 0):
return DEFAULT_TOTAL_DURATION
try:
value = int(value)
except (TypeError, ValueError):
return DEFAULT_TOTAL_DURATION
if value <= 0:
return DEFAULT_TOTAL_DURATION
value = max(TOTAL_DURATION_MIN, min(TOTAL_DURATION_MAX, value))
stepped = int(round(value / TOTAL_DURATION_STEP) * TOTAL_DURATION_STEP)
return max(TOTAL_DURATION_MIN, min(TOTAL_DURATION_MAX, stepped))
def plan_segment_durations(total_duration: int, presentation_format: str | None = None) -> list[int]:
"""主流程每镜固定 15 秒。镜数 = 总时长 / 15。presentation_format 保留签名以免调用方改动。"""
total = coerce_total_duration(total_duration)
count = max(1, total // SEGMENT_DURATION_MAX)
return [SEGMENT_DURATION_MAX] * count
def plan_roles(count: int) -> list[str]:
"""镜数 → role 序列。通用规则:首钩子、次痛点、末 CTA,中间全是卖点。"""
if count <= 1:
return ["钩子"]
if count == 2:
return ["钩子", "卖点"]
if count == 3:
return ["钩子", "卖点", "CTA"]
return ["钩子", "痛点"] + ["卖点"] * (count - 3) + ["CTA"]
def _fit_segment_durations(raw: list, total: int, presentation_format: str) -> list[int]:
"""主流程强制每镜 15 秒。模型给多少秒都丢掉,按总时长切成 N 个 15。"""
return plan_segment_durations(total, presentation_format)
def _layout_durations(preserve_layout: dict | None, count: int) -> list[int] | None:
"""精准改一镜:保住原稿每镜秒数,避免 3 镜旧稿被压成 2×15。"""
if not preserve_layout:
return None
raw = preserve_layout.get("durations")
if not isinstance(raw, list) or len(raw) != count:
return None
durations: list[int] = []
for value in raw:
try:
seconds = int(value)
except (TypeError, ValueError):
seconds = 0
durations.append(max(SEGMENT_DURATION_MIN, min(SEGMENT_DURATION_MAX, seconds or SEGMENT_DURATION_MAX)))
return durations
def _script_plaintext(draft: dict) -> str:
parts = [str(draft.get("hook") or "")]
for seg in draft.get("segments") or []:
if not isinstance(seg, dict):
continue
parts.append(str(seg.get("narration") or ""))
parts.append(str(seg.get("visual") or ""))
parts.append(str(seg.get("product_exposure") or ""))
for item in seg.get("dialogue") or []:
if isinstance(item, dict):
parts.append(str(item.get("line") or ""))
return "".join(parts)
def _fact_mentioned(text: str, fact: str) -> bool:
fact = (fact or "").strip()
if not fact:
return False
if fact in text:
return True
# 超长卖点/标题模型常念前半截,允许前 4 字命中
return len(fact) >= 6 and fact[:4] in text
def assert_product_facts_used(draft: dict, *, brand: str = "", selling_titles: list[str] | None = None) -> None:
"""整版生成时至少用上一个勾选卖点原词。单字卖点(如「茶」)也算。
品牌不强制:口播经常只喊卖点不念品牌,硬卡会把已经写对的稿整单作废。
"""
del brand # 保留调用方签名,避免改一串入口
text = _script_plaintext(draft)
titles = [t.strip() for t in (selling_titles or []) if (t or "").strip()]
if titles and not any(_fact_mentioned(text, title) for title in titles):
raise ValueError("脚本没有用上商品卖点原词,请按【必须原词用上】重写")
def assert_shot_density(draft: dict, presentation_format: str = DEFAULT_PRESENTATION_FORMAT) -> None:
"""口播/短剧每镜要说满、画面要写厚;Vlog 允许个别镜无声,但开口就不能偷懒。"""
fmt, _ = coerce_combo(_FORMAT_KEY_BY_LABEL.get(presentation_format, presentation_format), DEFAULT_VIDEO_STRUCTURE)
allow_silent = fmt == "vlog"
for index, seg in enumerate(draft.get("segments") or []):
if not isinstance(seg, dict):
continue
try:
duration = int(seg.get("duration") or SEGMENT_DURATION_MAX)
except (TypeError, ValueError):
duration = SEGMENT_DURATION_MAX
visual_n = _compact_len(str(seg.get("visual") or ""))
if visual_n < VISUAL_CHARS_MIN:
raise ValueError(f"第 {index + 1} 镜画面描写太短,撑不满 {duration} 秒")
assert_intra_shot_beats(seg, index, duration)
speech_n = _compact_len(_speech_text(seg))
if speech_n == 0 and allow_silent:
continue
floor = narration_floor(duration)
if speech_n < floor:
raise ValueError(f"第 {index + 1} 镜旁白太短,口播至少要说到 {floor} 字才能撑满 {duration} 秒")
def assert_script_has_a_hook(draft: dict) -> None:
"""拦住最常见的模板开场,逼模型从一个具体瞬间切入而不是先介绍商品。"""
hook = str(draft.get("hook") or "")
if _compact_len(hook) < 6:
raise ValueError("开场钩子太弱,请用一个具体场景、反差或问题重写前3秒")
opening = hook[:18]
if any(phrase in opening for phrase in _GENERIC_HOOK_PHRASES):
raise ValueError("开场像泛泛推荐,请直接从具体场景、反差或问题切入")
def assert_intra_shot_beats(seg: dict, index: int, duration: int) -> None:
"""15 秒场必须按秒拆出分镜,否则下游视频只能对着一句空描述乱编。"""
need = min_beats_for_duration(duration)
if need <= 0:
return
visual = str(seg.get("visual") or "")
beats = parse_visual_beats(visual)
if len(beats) < need:
raise ValueError(
f"第 {index + 1} 镜画面必须按秒拆分镜,至少 {need} 条「0-3s:景别,动作」,接到 {duration}s"
)
if beats[0][0] > 1:
raise ValueError(f"第 {index + 1} 镜画面必须按秒拆分镜,第一条要从 0 秒起")
if beats[-1][1] < duration - 1:
raise ValueError(f"第 {index + 1} 镜画面必须按秒拆分镜,最后一条要接到 {duration}s")
markers = [mark for mark in _SHOT_SIZE_MARKERS if mark in visual]
if len(set(markers)) < 2:
raise ValueError(f"第 {index + 1} 镜 15 秒内至少要切两次景别,并写进秒级分镜")
# 模型每次生成都可能换字段名(scene/screenDescription/visual…、dialogue/lines/caption…),
# 与其逐一追变体,不如「优先键命中 → 否则按关键词模糊匹配」通用解析。SKIP 掉明显的非内容键,
# 避免误抓(shotNo/duration/bgMusic/note 等)。
_PICK_SKIP_KEYS = {
"shotno", "shotsize", "shotsizetype", "duration", "duration_seconds", "starttime", "endtime",
"timerange", "time_range", "bgmusic", "bg_music", "music", "sound", "sfx", "note", "notes",
"tips", "index", "role", "speaker", "entity_refs", "product_exposure", "id", "no", "transition",
"beats", "timeline",
# 标题/编号类:含 scene 字样会误命中画面 fuzzy,显式跳过(注意:不跳裸 scene,它常=画面)
"scene_id", "sceneid", "scene_no", "sceneno", "scene_number", "scenenumber",
"scene_title", "scenetitle", "title", "scene_title_type",
}
_VISUAL_EXACT = ("visual", "visual_prompt", "visual_description", "screen_description", "screendescription", "screen", "picture", "shot_description", "scene", "画面")
_VISUAL_FUZZY = ("visual", "screen", "picture", "scene", "画面", "镜头描述", "分镜画面")
_NARRATION_EXACT = ("narration", "voiceover", "voice_over", "vo", "line", "caption", "subtitle", "speech", "口播", "旁白")
_NARRATION_FUZZY = ("narrat", "voice", "旁白", "口播", "台词", "dialog", "caption", "subtitle", "字幕", "speech", "monolog", "audio")
def _flatten_text(val) -> str:
"""把 字符串/字典/列表 里的文本拍平成一句。模型常把 visual 写成 {setting,camera,key_shots}
对象、把口播写成数组,这里统一抽成纯文本,避免结构化值被当空丢弃(全空根因之一)。"""
if isinstance(val, str):
return val.strip()
if isinstance(val, dict):
return " · ".join(p for p in (_flatten_text(v) for v in val.values()) if p)
if isinstance(val, (list, tuple)):
return " ".join(p for p in (_flatten_text(v) for v in val) if p)
return ""
def _pick_field(seg: dict, exact: tuple[str, ...], fuzzy: tuple[str, ...]) -> str:
"""从一镜里取某类文本字段:先按优先键精确命中,再按关键词在剩余键里模糊匹配(跳过非内容键)。
值允许是 字符串/对象/数组(嵌套结构拍平成一句),不再只认裸字符串。"""
for key in exact:
if key in seg:
txt = _flatten_text(seg.get(key))
if txt:
return txt
for key, val in seg.items():
kl = str(key).lower()
if kl in _PICK_SKIP_KEYS:
continue
if any(f in kl for f in fuzzy):
txt = _flatten_text(val)
if txt:
return txt
return ""
def _opt_int(value) -> int | None:
try:
if value is None or value == "":
return None
return int(value)
except (TypeError, ValueError):
return None
def _beat_from_dict(item: dict) -> dict | None:
action = _flatten_text(
item.get("action")
or item.get("shot")
or item.get("visual")
or item.get("description")
or item.get("画面")
or ""
)
shot_size = str(item.get("shot_size") or item.get("camera") or item.get("景别") or "").strip()
start = _opt_int(item.get("start") if item.get("start") is not None else item.get("from"))
end = _opt_int(item.get("end") if item.get("end") is not None else item.get("to"))
time_s = str(item.get("time") or item.get("t") or "").strip()
if start is None and end is None and time_s:
parsed = parse_visual_beats(f"{time_s}{action or '画面'}")
if parsed:
start, end, parsed_body = parsed[0]
action = action or parsed_body
if not action and not shot_size:
return None
piece = f"{shot_size}{action}" if shot_size and action else (action or shot_size)
return {"start": start, "end": end, "action": piece}
def coerce_beats(raw) -> list[dict]:
if raw is None or raw == "":
return []
if isinstance(raw, str):
return [{"start": start, "end": end, "action": body} for start, end, body in parse_visual_beats(raw)]
if isinstance(raw, dict):
nested = raw.get("beats") or raw.get("shots") or raw.get("timeline") or raw.get("key_shots") or raw.get("分镜")
if nested:
return coerce_beats(nested)
beat = _beat_from_dict(raw)
return [beat] if beat else []
if isinstance(raw, list):
out: list[dict] = []
for item in raw:
if isinstance(item, dict):
nested = item.get("beats") or item.get("shots")
if nested and not (item.get("action") or item.get("shot") or item.get("visual")):
out.extend(coerce_beats(nested))
continue
beat = _beat_from_dict(item)
if beat:
out.append(beat)
else:
out.extend(coerce_beats(item))
return out
return []
def _assign_beat_times(beats: list[dict], duration: int) -> list[tuple[int, int, str]]:
n = len(beats)
if n == 0:
return []
out: list[tuple[int, int, str]] = []
for index, beat in enumerate(beats):
start = beat.get("start")
end = beat.get("end")
if start is None:
start = int(round(index * duration / n))
if end is None:
end = duration if index == n - 1 else int(round((index + 1) * duration / n))
if end <= start:
end = start + 1
out.append((int(start), int(end), str(beat.get("action") or "").strip()))
return [item for item in out if item[2]]
def compose_segment_visual(seg: dict, duration: int = SEGMENT_DURATION_MAX) -> str:
"""把 beats 数组或已写好的秒级 visual 收成多行「0-3s:…」。"""
visual_raw = seg.get("visual")
if isinstance(visual_raw, str) and visual_raw.strip():
parsed = parse_visual_beats(visual_raw)
if len(parsed) >= 2:
return format_visual_beats(parsed)
candidates = [
seg.get("beats"),
seg.get("timeline"),
seg.get("分镜"),
visual_raw if isinstance(visual_raw, (list, dict)) else None,
]
shots_raw = seg.get("shots")
if isinstance(shots_raw, list) and shots_raw:
sample = shots_raw[0]
looks_like_nested_segments = isinstance(sample, dict) and (
sample.get("narration") or sample.get("role") in VALID_ROLES
)
if not looks_like_nested_segments:
candidates.append(shots_raw)
for raw in candidates:
assigned = _assign_beat_times(coerce_beats(raw), duration)
if len(assigned) >= 2:
return format_visual_beats(assigned)
return _pick_field(seg, _VISUAL_EXACT, _VISUAL_FUZZY)
# 模型给分镜数组的键名五花八门(segments/scenes/script/shots…),且常同时给一个**空的**
# segments 骨架 + 真内容放在 scenes 里。所以不能「见 segments 是 list 就用」,要在所有候选里
# 挑「能解析出最多非空旁白/画面」的那个。segments(契约本名)排第一,同分时优先。
_SEGMENT_ARRAY_KEYS = (
"segments", "shots", "scenes", "script", "shot_list", "shotlist", "shotList",
"scene_list", "scenes_list", "storyboard", "分镜", "镜头",
)
def _resolve_segments(draft: dict) -> list:
"""在所有候选数组键里挑内容最丰富的分镜数组(按非空旁白/画面条数打分)。"""
best: list = []
best_score = -1
for key in _SEGMENT_ARRAY_KEYS:
arr = draft.get(key)
if not isinstance(arr, list) or not arr:
continue
dict_items = [s for s in arr if isinstance(s, dict)]
if not dict_items:
continue
score = sum(
1
for s in dict_items
if _pick_field(s, _NARRATION_EXACT, _NARRATION_FUZZY)
or _pick_field(s, _VISUAL_EXACT, _VISUAL_FUZZY)
)
if score > best_score: # 严格大于 → 同分保留更靠前的键(segments 优先)
best, best_score = arr, score
return best
def normalize_draft(
raw_text: str,
*,
aspect_ratio: str,
total_duration: int,
presentation_format: str = DEFAULT_PRESENTATION_FORMAT,
video_structure: str = DEFAULT_VIDEO_STRUCTURE,
preserve_layout: dict | None = None,
) -> dict:
"""把模型输出抽成 JSON 并按铁律1契约规范化。宽容:小问题就地修,不轻易抛错。"""
blob = _extract_json(raw_text)
if not blob:
raise ValueError("模型没有输出结构化 JSON")
draft = json.loads(blob)
if not isinstance(draft, dict):
raise ValueError("脚本 JSON 顶层不是对象")
# 兼容模型不按契约的常见变体:① 把内容裹进 {"ScriptDraft": {...}} 外壳;
# ② 用 basicInfo(camelCase)放时长/比例;③ 用 shots 代替 segments。
# 解开外壳并把别名拍平到契约字段,避免「找不到 segments → 全填空占位镜」(旁白/画面全空)。
for wrapper in ("ScriptDraft", "script_draft", "scriptDraft", "draft"):
inner = draft.get(wrapper)
if isinstance(inner, dict):
draft = {**inner, **{k: v for k, v in draft.items() if k != wrapper}}
break
basic = draft.get("basicInfo") if isinstance(draft.get("basicInfo"), dict) else {}
if basic:
draft.setdefault("total_duration", basic.get("totalDuration") or basic.get("total_duration"))
draft.setdefault("aspect_ratio", basic.get("aspectRatio") or basic.get("aspect_ratio"))
draft.setdefault("theme", basic.get("theme"))
# 在所有候选键里挑内容最丰富的分镜数组(空 segments 骨架会被更丰富的 scenes/shots 顶替)
draft["segments"] = _resolve_segments(draft)
draft["aspect_ratio"] = (draft.get("aspect_ratio") or aspect_ratio or "9:16").strip()
# 总时长以「请求参数」为准:模型经常把它算错,而下游出片/计价都按这个数走。
dur = coerce_total_duration(total_duration)
draft["total_duration"] = dur
fmt, structure = coerce_combo(presentation_format, video_structure)
draft["presentation_format"] = PRESENTATION_FORMATS[fmt]
draft["video_structure"] = VIDEO_STRUCTURES[structure]
tone = (draft.get("tone") or "").strip()
draft["tone"] = tone if tone in VALID_TONES else "种草"
draft["hook"] = (draft.get("hook") or "").strip()
# entities 规范化:补 id / ref_index,过滤非法 type
entities = draft.get("entities") if isinstance(draft.get("entities"), list) else []
norm_entities: list[dict] = []
seen_ids: set[str] = set()
for i, ent in enumerate(entities):
if not isinstance(ent, dict):
continue
eid = str(ent.get("id") or f"e{i + 1}").strip() or f"e{i + 1}"
while eid in seen_ids:
eid = f"{eid}_{i}"
seen_ids.add(eid)
etype = (ent.get("type") or "").strip()
if etype not in VALID_ENTITY_TYPES:
etype = "character"
norm_entities.append(
{
"id": eid,
"type": etype,
"name": (ent.get("name") or eid).strip(),
"visual_prompt": (ent.get("visual_prompt") or "").strip(),
"ref_index": ent.get("ref_index") if isinstance(ent.get("ref_index"), int) else i + 1,
"voice_ref": ent.get("voice_ref") or None,
}
)
draft["entities"] = norm_entities
valid_ids = {e["id"] for e in norm_entities}
# segments 规范化:主流程镜数 = 总时长/15;精准改一镜则保住原稿镜数。
segments = draft.get("segments") if isinstance(draft.get("segments"), list) else []
layout_count = 0
if preserve_layout:
try:
layout_count = int(preserve_layout.get("count") or 0)
except (TypeError, ValueError):
layout_count = 0
expected = layout_count or max(1, dur // SEGMENT_DURATION_MAX)
segments = segments[:expected]
seg_count = expected
role_plan = plan_roles(seg_count)
norm_segments: list[dict] = []
for i, seg in enumerate(segments):
if not isinstance(seg, dict):
seg = {}
role = (seg.get("role") or "").strip()
if role not in VALID_ROLES:
role = role_plan[i]
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(字符串/数组)/ lines(数组)/ 每项 line|text|content。
# 这里把任意数组形态归一成结构化对白,字符串形态留给下面当整句旁白。
raw_dialogue = seg.get("dialogue")
dialogue = []
dialogue_array = raw_dialogue if isinstance(raw_dialogue, list) else (seg.get("lines") if isinstance(seg.get("lines"), list) else [])
for d in dialogue_array:
if not isinstance(d, dict):
continue
line = (d.get("line") or d.get("text") or d.get("content") or "").strip()
if not line:
continue
sp = d.get("speaker")
dialogue.append({"speaker": sp if sp in valid_ids else None, "line": line})
# 旁白:结构化对白/lines 优先,其次整句字符串 dialogue,再退到通用字段解析(narration/voiceover/caption/字幕…)
narration = ""
if isinstance(raw_dialogue, str) and raw_dialogue.strip():
narration = raw_dialogue.strip()
elif dialogue:
narration = " ".join(d["line"] for d in dialogue) # 扁平拼接,兼容下游字幕/配音
if not narration:
narration = _pick_field(seg, _NARRATION_EXACT, _NARRATION_FUZZY)
# 画面:优先收成秒级分镜;beats 数组会折进 visual,下游故事板/视频直接读这一段。
visual = compose_segment_visual(seg)
norm_segments.append(
{
"index": i,
"duration": seg.get("duration"), # 先原样收着,后面统一写成 15 或保住原稿秒数
"role": role,
"narration": narration,
"speaker": speaker,
"visual": visual,
"product_exposure": (seg.get("product_exposure") or "").strip(),
"entity_refs": refs,
"dialogue": dialogue,
}
)
# 不足下限则补占位镜(极少发生,避免出现超过 15 秒的镜导致下游拒片)
while len(norm_segments) < seg_count:
i = len(norm_segments)
norm_segments.append(
{
"index": i,
"duration": None,
"role": role_plan[i],
"narration": "",
"speaker": None,
"visual": "",
"product_exposure": "",
"entity_refs": [],
"dialogue": [],
}
)
if not norm_segments:
raise ValueError("脚本没有任何分镜")
kept = _layout_durations(preserve_layout, len(norm_segments))
if kept:
fitted = kept
draft["total_duration"] = sum(fitted)
else:
fitted = plan_segment_durations(dur, fmt)
if len(fitted) != len(norm_segments):
fitted = [SEGMENT_DURATION_MAX] * len(norm_segments)
draft["total_duration"] = sum(fitted)
for index, (norm, seconds) in enumerate(zip(norm_segments, fitted)):
norm["duration"] = seconds
if index < len(segments) and isinstance(segments[index], dict):
composed = compose_segment_visual(segments[index], seconds)
if composed:
norm["visual"] = composed
draft["segments"] = norm_segments
draft["segment_count"] = len(norm_segments)
return draft
def _preserve_layout_from(draft: dict | None) -> dict | None:
if not draft:
return None
segs = [s for s in (draft.get("segments") or []) if isinstance(s, dict)]
if not segs:
return None
return {"count": len(segs), "durations": [s.get("duration") for s in segs]}
def _merge_single_segment(
base: dict,
new: dict,
idx: int,
aspect_ratio: str,
total_duration: int,
presentation_format: str = DEFAULT_PRESENTATION_FORMAT,
video_structure: str = DEFAULT_VIDEO_STRUCTURE,
) -> 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
layout = _preserve_layout_from(base)
return normalize_draft(
json.dumps(merged, ensure_ascii=False),
aspect_ratio=aspect_ratio,
total_duration=total_duration,
presentation_format=presentation_format,
video_structure=video_structure,
preserve_layout=layout,
)
# --------------------------------------------------------------------------- #
# 落库
# --------------------------------------------------------------------------- #
def _map_entities_to_project_metadata(project, entities: list[dict]) -> None:
"""把结构化 entities 回填到 project.metadata,复用下游已有的 cast/scenes/*_prompts 接线
(脚本页标签 + 基础资产 seed + 故事板 @图N)。只在有内容时覆盖,空结果不清旧标签。"""
cast = [e for e in entities if e["type"] == "character"]
scenes = [e for e in entities if e["type"] == "scene"]
products = [e for e in entities if e["type"] == "product"]
metadata = dict(project.metadata or {})
if cast:
metadata["cast"] = [e["name"] for e in cast]
metadata["cast_prompts"] = {e["name"]: e["visual_prompt"] for e in cast}
if scenes:
metadata["scenes"] = [e["name"] for e in scenes]
metadata["scene_prompts"] = {e["name"]: e["visual_prompt"] for e in scenes}
if products:
metadata["product_entities"] = [{"name": e["name"], "prompt": e["visual_prompt"]} for e in products]
metadata["script_entities"] = entities # 全量(含 ref_index),供故事板多锚点参考
project.metadata = metadata
project.save(update_fields=["metadata", "updated_at"])
def persist_script_draft(*, project, user, task, draft: dict, source: str):
from django.db import transaction
from apps.projects.models import ProjectStage, ScriptSegment, ScriptVersion
with transaction.atomic():
script = ScriptVersion.objects.create(
project=project,
task=task,
title=(draft.get("hook") or "AI 脚本")[:128],
content=json.dumps(draft, ensure_ascii=False, indent=2),
source=source if source in ("ai", "theme", "manual", "video", "revise") else "ai",
is_adopted=False,
metadata={
"hook": draft.get("hook", ""),
"tone": draft.get("tone", ""),
"aspect_ratio": draft.get("aspect_ratio", "9:16"),
"total_duration": draft.get("total_duration", DEFAULT_TOTAL_DURATION),
"segment_count": draft.get("segment_count", len(draft.get("segments") or [])),
# 二期:表现形式 × 视频结构 跟着稿子走,改稿和「保存模板」都要读它
"presentation_format": draft.get("presentation_format", ""),
"video_structure": draft.get("video_structure", ""),
"entities": draft.get("entities", []),
},
)
for seg in draft["segments"]:
ScriptSegment.objects.create(
script_version=script,
sort_order=seg["index"],
duration_seconds=seg.get("duration") or SEGMENT_DURATION_MAX,
narration=seg.get("narration", ""),
visual_prompt=seg.get("visual", ""),
role=seg.get("role", ""),
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", []))
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
# --------------------------------------------------------------------------- #
# 流式编排
# --------------------------------------------------------------------------- #
def _sse(obj: dict) -> str:
return f"data: {json.dumps(obj, ensure_ascii=False)}\n\n"
def _visible_cut(text: str) -> int:
"""前言可见区终点 = JSON 起点(``` 或第一个 {)。之后的内容不外露,只在后端解析。"""
cands = []
for marker in ("```", "{"):
i = text.find(marker)
if i != -1:
cands.append(i)
return min(cands) if cands else len(text)
def _closing_summary(raw: str) -> str:
"""模型在 json 之后写的收尾交付语 = 给用户的回复气泡(像同事汇报「这版做了啥/怎么接着调」)。
取最后一个 json 对象之后的文字,去掉收尾围栏与空白;没写或残留花括号就返回空(前端兜底)。"""
js = _extract_json(raw)
tail = ""
if js:
idx = raw.rfind(js)
if idx != -1:
tail = raw[idx + len(js):]
tail = re.sub(r"`+", " ", tail).strip() # 去掉 json 收尾的 ``` 围栏
if "{" in tail or len(tail) < 4:
return ""
return tail[:160]
def stream_script_agent(
*,
project,
user,
model_config: ModelConfig,
mode: str = "auto",
user_prompt: str = "",
selling_point_ids: list[str] | None = None,
base_version_id: str | None = None,
aspect_ratio: str = "9:16",
total_duration: int = DEFAULT_TOTAL_DURATION,
presentation_format: str = DEFAULT_PRESENTATION_FORMAT,
video_structure: str = DEFAULT_VIDEO_STRUCTURE,
target_index: int | None = None,
entry_source: str = "",
persona: str | None = None,
):
"""生成 SSE 帧字符串的同步生成器,供 StreamingHttpResponse 包裹。
target_index 非空 = 精准只改第 N 镜(读全脚本上下文,后端强制保留其余镜原样)。"""
from apps.ai.services import create_ai_task, stream_routed_text_request
fmt, structure = coerce_combo(presentation_format, video_structure)
yield _sse({"type": "tool", "id": "skill", "label": f"加载套路:{PRESENTATION_FORMATS[fmt]} · {VIDEO_STRUCTURES[structure]}", "status": "running"})
skill_loaded = bool(load_ecommerce_skill(fmt, structure))
yield _sse({"type": "tool", "id": "skill", "status": "done" if skill_loaded else "error"})
yield _sse({"type": "tool", "id": "analyze", "label": f"分析商品:{project.product.title}", "status": "running"})
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 # 没有基准稿就退回整版生成,单镜改无从谈起
# 改稿以基准稿的时长/镜数为准,避免请求侧默认值把长稿的尾镜挤掉
# 精准改一镜不把总时长夹成 15 步进,否则旧的不等长稿会被提示词误导切镜。
if target_index is not None and base_draft is not None:
effective_duration = int(base_draft.get("total_duration") or total_duration or DEFAULT_TOTAL_DURATION)
else:
effective_duration = coerce_total_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
selling_point_ids = _resolve_selling_point_ids(project, selling_point_ids)
persona = _resolve_persona(project, persona)
product, selling_points = _product_facts(project, selling_point_ids)
selling_titles = [sp.title for sp in selling_points]
layout = _preserve_layout_from(base_draft) if target_index is not None else None
messages = build_agent_messages(
project=project,
mode=mode,
user_prompt=user_prompt,
selling_point_ids=selling_point_ids,
base_draft=base_draft,
aspect_ratio=aspect_ratio,
total_duration=effective_duration, # 改稿用基准稿时长,prompt head 才不会误导模型镜数
presentation_format=fmt,
video_structure=structure,
target_index=target_index,
persona=persona,
)
yield _sse({"type": "tool", "id": "analyze", "status": "done"})
task_type = AITask.Type.SCRIPT_OPTIMIZATION if mode == "revise" else AITask.Type.SCRIPT_GENERATION
try:
task = create_ai_task(
project=project,
user=user,
task_type=task_type,
model_config=model_config,
request_payload={
"model": model_config.name,
"endpoint": model_config.endpoint,
"mode": mode,
"aspect_ratio": aspect_ratio,
"total_duration": total_duration,
"base_version_id": str(base_version_id or ""),
"target_index": target_index,
"model_routing_v1": True,
},
)
except Exception as exc: # noqa: BLE001 — 多为额度不足
internal_kind = "user_credit_insufficient" if str(exc).strip().lower() == "insufficient credit" else ""
yield _sse(_script_error_event(exc, internal_kind=internal_kind))
return
reservation = task.credit_reservation
# 额度是否已结算(charge 成功 / release 失败)。客户端中途断连时,生成器被 .close() 抛
# GeneratorExit —— 它是 BaseException 不是 Exception,普通 except 抓不到,会让预扣额度冻结。
# 故用 try/finally 兜底:任何未结算路径(含断连)都释放预扣。
settled = False
try:
yield _sse({"type": "tool", "id": "generate", "label": "按黄金结构生成分镜", "status": "running"})
full: list[str] = []
shown = 0
forwarding = True
try:
task.status = AITask.Status.SUBMITTED
task.submitted_at = timezone.now()
task.save(update_fields=["status", "submitted_at", "updated_at"])
def validate_script_text(raw_text: str) -> dict:
candidate = normalize_draft(
raw_text,
aspect_ratio=aspect_ratio,
total_duration=effective_duration,
presentation_format=fmt,
video_structure=structure,
preserve_layout=layout,
)
if target_index is not None and base_draft:
return _merge_single_segment(
base_draft,
candidate,
target_index,
aspect_ratio,
effective_duration,
fmt,
structure,
)
if mode != "revise":
assert_product_facts_used(
candidate,
brand=getattr(product, "brand", "") or "",
selling_titles=selling_titles,
)
assert_shot_density(candidate, fmt)
return candidate
routed_stream = stream_routed_text_request(
task=task,
primary_model=model_config,
messages=messages,
streaming=True,
structured_output=True,
business_operation="script_generate",
temperature=0.85,
validate_text=validate_script_text,
request_summary={
"mode": mode,
"target_index": target_index,
"base_version_id": str(base_version_id or ""),
"aspect_ratio": aspect_ratio,
"total_duration": effective_duration,
},
)
while True:
try:
ev = next(routed_stream)
except StopIteration as completed:
routed = completed.value
break
et = ev.get("type")
if et == "reasoning":
# 思考流:推理模型在出 JSON 前会先想很久,把思考逐字下发给前端(像对话一样可见),
# 不进 full(不是答案正文,纯展示)。这是「卡在生成分镜」假死的根因修复。
rpiece = ev.get("text") or ""
if rpiece:
yield _sse({"type": "reasoning", "text": rpiece})
continue
if et == "delta":
full.append(ev["text"])
if forwarding:
text = "".join(full)
cut = _visible_cut(text)
if cut < len(text):
forwarding = False
visible = text[:cut]
if len(visible) > shown:
piece = visible[shown:]
shown = len(visible)
if piece.strip():
yield _sse({"type": "delta", "text": piece})
elif et == "done":
continue
raw, _provider_response, draft = routed.value
except Exception as exc: # noqa: BLE001
_fail_task(task, reservation, str(exc))
settled = True
yield _sse({"type": "tool", "id": "generate", "status": "error"})
yield _sse(_script_error_event(exc, reference_id=str(task.id)))
return
yield _sse({"type": "tool", "id": "generate", "status": "done"})
yield _sse(
{
"type": "tool",
"id": "extract",
"label": f"提取实体 {len(draft['entities'])} 个 · {len(draft['segments'])} 镜",
"status": "done",
}
)
yield _sse({"type": "tool", "id": "check", "label": "自检:镜数 / ≤55字 / 违规词", "status": "done"})
yield _sse({"type": "draft", "draft": draft})
from django.db import transaction
try:
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)
# 三个入口(辅助生成 / 上传脚本 / 上传视频提炼)都走 mode=auto,只有 entry_source
# 分得清是哪个来的 —— 脚本卡的「来源」徽标靠它,别一律记成 ai。
if mode == "revise":
source = "revise"
elif mode == "theme":
source = "theme"
else:
source = entry_source if entry_source in {"manual", "video"} else "ai"
script = persist_script_draft(project=project, user=user, task=task, draft=draft, source=source)
settled = True # charge 已提交
except Exception as exc: # noqa: BLE001 — 落库失败:atomic 已回滚 charge,补释放预留
_fail_task(task, reservation, f"保存脚本失败:{exc}")
settled = True
yield _sse(_script_error_event(exc, reference_id=str(task.id), internal_kind="processing_failed"))
return
from apps.projects.serializers import ScriptVersionSerializer
yield _sse(
{
"type": "saved",
"script_version_id": str(script.id),
"version": ScriptVersionSerializer(script).data,
}
)
# 模型自己写的收尾交付语 → AI 回复气泡(没写则前端兜底默认句)
summary = _closing_summary(raw)
if summary:
yield _sse({"type": "summary", "text": summary})
yield _sse({"type": "done"})
finally:
# 断连(GeneratorExit)或任何 settled=False 的退出路径:释放预扣,避免额度冻结
if not settled:
_fail_task(task, reservation, "stream aborted (client disconnected)")
def _fail_task(task, reservation, message: str) -> None:
try:
task.status = AITask.Status.FAILED
task.error_message = message[:2000]
task.completed_at = timezone.now()
task.save(update_fields=["status", "error_message", "completed_at", "updated_at"])
finally:
try:
release_credit(reservation=reservation, reason=message[:200])
except Exception: # noqa: BLE001
pass
def _script_error_event(
exc: Exception,
*,
reference_id: str | None = None,
internal_kind: str = "",
) -> dict:
"""脚本 SSE 的安全失败帧;原始异常只落任务记录/日志,不回传普通用户。"""
public_error = classify_generation_error(
exc,
operation="script_generate",
internal_kind=internal_kind,
reference_id=reference_id,
)
return {
"type": "error",
"detail": public_error.fallback_message,
"error": public_error.as_dict(),
}
def _load_base_draft(project, base_version_id: str) -> dict | None:
from apps.projects.models import ScriptVersion
try:
version = ScriptVersion.objects.get(project=project, id=base_version_id)
except (ScriptVersion.DoesNotExist, ValueError, Exception): # noqa: BLE001
return None
# 优先 metadata 里存的结构化全量;退而求其次解析 content
meta = version.metadata or {}
if meta.get("entities") is not None or meta.get("hook"):
try:
return json.loads(version.content)
except (ValueError, TypeError):
pass
try:
return json.loads(version.content)
except (ValueError, TypeError):
return None
def _draft_from_version(version) -> dict:
"""从 ScriptVersion 的 DB 行(segments + metadata)重建 ScriptDraft —— 比解析可能已 stale 的 content 可靠
(用户增删/改镜后 content 不一定同步)。total_duration 按各镜真实秒数加总,避免 normalize 按 stale 值截/补镜。"""
meta = version.metadata or {}
segs = list(version.segments.order_by("sort_order"))
# 总时长按各镜真实秒数加总(旧稿可能不等长;新稿每镜 15 秒)。
actual_total = sum(s.duration_seconds or SEGMENT_DURATION_MAX for s in segs)
return {
"hook": meta.get("hook", ""),
"tone": meta.get("tone", ""),
"presentation_format": meta.get("presentation_format", ""),
"video_structure": meta.get("video_structure", ""),
"aspect_ratio": meta.get("aspect_ratio", "9:16"),
"total_duration": actual_total or coerce_total_duration(meta.get("total_duration")),
"segment_count": len(segs),
"entities": meta.get("entities", []),
"segments": [
{
"index": s.sort_order,
"duration": s.duration_seconds or SEGMENT_DURATION_MAX,
"role": s.role or "",
"narration": s.narration or "",
"speaker": s.speaker or None,
"visual": s.visual_prompt or "",
"product_exposure": s.product_exposure or "",
"entity_refs": s.entity_refs or [],
"dialogue": s.dialogue or [],
}
for s in segs
],
}
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 create_ai_task, execute_routed_text_request
from apps.billing.services.ledger import charge_reserved_credit
base_draft = _draft_from_version(segment.script_version) # 用 DB 行重建基准,别用可能 stale 的 content
target_index = segment.sort_order
aspect_ratio = (base_draft.get("aspect_ratio") or "9:16").strip()
seg_n = len(base_draft.get("segments") or [])
if not (0 <= target_index < seg_n):
raise ValueError(f"镜号越界:第 {target_index + 1} 镜(共 {seg_n} 镜)")
total_duration = base_draft["total_duration"] # 已按各镜真实秒数加总,normalize 不会截掉用户增删后的镜
# 改一镜要沿用原稿的套路,否则重写出来的那一镜镜头语言会跟其余镜打架
fmt, structure = combo_keys(base_draft.get("presentation_format"), base_draft.get("video_structure"))
messages = build_agent_messages(
project=project,
mode="revise",
user_prompt=instruction,
selling_point_ids=_resolve_selling_point_ids(project, None),
base_draft=base_draft,
aspect_ratio=aspect_ratio,
total_duration=total_duration,
presentation_format=fmt,
video_structure=structure,
target_index=target_index,
persona=_resolve_persona(project, None),
)
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,
"model_routing_v1": True,
},
)
reservation = task.credit_reservation
# 每条真实尝试的平台成本由统一执行器累计;用户积分仍只结算这一条脚本任务。
task.base_cost = Decimal("0")
task.save(update_fields=["base_cost", "updated_at"])
# 实际平台成本由每条 AIModelAttempt 累加;用户积分仍只结算这一条逻辑任务。
task.base_cost = Decimal("0")
task.save(update_fields=["base_cost", "updated_at"])
try:
task.status = AITask.Status.SUBMITTED
task.submitted_at = timezone.now()
task.save(update_fields=["status", "submitted_at", "updated_at"])
def validate_segment_text(raw_text: str) -> dict:
layout = _preserve_layout_from(base_draft)
candidate = normalize_draft(
raw_text,
aspect_ratio=aspect_ratio,
total_duration=total_duration,
presentation_format=fmt,
video_structure=structure,
preserve_layout=layout,
)
return _merge_single_segment(
base_draft, candidate, target_index, aspect_ratio, total_duration, fmt, structure
)
routed = execute_routed_text_request(
task=task,
primary_model=model_config,
messages=messages,
streaming=False,
structured_output=True,
business_operation="script_generate",
temperature=0.3,
validate_text=validate_segment_text,
request_summary={
"mode": "revise",
"target_index": target_index,
"base_version_id": str(segment.script_version_id),
},
)
raw, _response, draft = routed.value
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 as exc:
_fail_task(task, reservation, str(exc) or "单镜重跑失败")
raise