"""对话式脚本生成 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": "场景种草", } 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} # 设定卡人物 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()} # 可懂语速上限 3.5 字/秒;15 秒口播还要有下限,否则模型写一句 20 字就收,撑不满。 NARRATION_CHARS_PER_SECOND = 3.5 NARRATION_CHARS_PER_SECOND_MIN = 2.4 NARRATION_CHARS_HARD_CAP = 55 VISUAL_CHARS_MIN = 72 SHOT_BEATS_MIN = 3 BEAT_SPAN_RE = re.compile( r"(?P\d{1,2})\s*[-–—~到至]\s*(?P\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: """这一镜旁白的字数上限:秒数 × 3.5,且不超过硬上限 55。""" 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`,否则视为不合格。 """ # --------------------------------------------------------------------------- # # 提示词构建(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) + _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} 字(2–4 句短句," 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" ) 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" 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_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: """总时长夹到 15–60 秒、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_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