Files
yingqing/core/backend/apps/ai/creation_agent.py
T
Azmat@qq.com 2745760d33 全能创作闸门与多人物定妆收口
主页已填卖点跳过卖点确认;按图片内容创作不再重复问品牌品名;方案卡支撑改为 P0/P1/P2;多人物视频一次生成多张定妆图并在出片前校验数量;超管登录直进后台。
2026-09-22 10:02:47 +08:00

5858 lines
278 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 编排循环(契约 §3/§4)。
和 script_agent.py 的根本区别:那边是「单次结构化出稿」,模型只会写脚本;
这边是**真 function calling 循环** —— 模型自己决定这一轮该反问用户、该查素材,
还是该出图/出片。
铁律(踩过就回不来的三条):
1. **视频 510 分钟,绝不在 SSE 里等。** 生成工具立刻返回 task_id,落一条
`generating` 消息,发 `task` 事件,收流。前端轮询完成后原地换成 `result`。
2. **闸门必须等人确认。** `ask_user` / `write_strategy` / `write_plan` /
`write_prompt` 一旦落卡就中断循环;视频 5 步(澄清→策略→方案→Prompt→出片确认)
不可同轮连跳。
3. **一条用户消息最多计费生成一次。** 对话式会放大调用量,一句「多做几版」
能烧掉一堆积分。
SSE 事件见契约 §3。
"""
from __future__ import annotations
import json
import logging
import re
import time
from collections.abc import Iterator
from dataclasses import dataclass
from django.core.serializers.json import DjangoJSONEncoder
from django.db import transaction
from .creation import append_message, pin_refs
from .creation_presets import (
PLOT_TWIST_PRESET,
PLOT_TWIST_STORY_DEPTH_OPTIONS,
apply_image_preset_prompt,
apply_plot_twist_story_contract,
apply_plot_twist_direction_contract,
format_plot_twist_direction_contract,
apply_video_preset_prompt,
is_click_swap_preset,
is_fish_eye_outfit_preset,
fish_eye_outfit_prompt_template,
plot_twist_story_depth,
plot_twist_story_contract,
preset_guidance,
preset_workflow_guidance,
video_preset_delivery_contract,
scrub_click_swap_same_position_wording,
)
from .mentions import TYPE_LABELS, infer_field_types, resolve_refs, search_mentions
from .models import CreationConversation, CreationMessage, ModelConfig
from .services import (
build_provider,
enforce_no_embedded_captions,
get_default_model,
get_seed_text_model,
resolve_text_model,
)
logger = logging.getLogger(__name__)
# 单条用户消息的循环上限。8 轮足够「查素材 → 反问 → 写方案 → 出图」,
# 再多基本是模型在原地打转。
MAX_TOOL_ROUNDS = 8
# 单次模型调用的上限。正常一轮(含思考 + 写方案)在 1 分钟上下,3 分钟已属异常。
CREATION_AGENT_MODEL_TIMEOUT_SECONDS = 180
# 一条用户消息内所有轮次的总预算。单轮卡死不该连带吃光整轮额度,整轮也不能无限拖。
CREATION_AGENT_TURN_TIMEOUT_SECONDS = 420
# 输出被 max_tokens 截断后最多让模型重写几次;再失败就明确告诉用户,不再闷着重试到超时。
MAX_TRUNCATION_RETRIES = 2
LONG_VIDEO_DURATION_SLACK_SECONDS = 2
# 单条用户消息最多触发一次计费生成(契约 §4)
MAX_BILLED_GENERATIONS = 1
def creation_agent_max_output_tokens() -> int:
"""豆包 Seed 系列最大输出 16k、默认 4k。不抬高会把长方案的 tool 参数截成坏 JSON。"""
from django.conf import settings
return max(1024, int(getattr(settings, "CREATION_AGENT_MAX_OUTPUT_TOKENS", 16000) or 16000))
def creation_model_extra_body(model_config: ModelConfig, tools: list[dict]) -> dict:
"""统一拼模型请求体的可选参数。
max_tokens 所有网关都认;thinking 只对火山官方直连下发,避免中转站因未知参数报 400。
"""
from django.conf import settings
from .services import OFFICIAL_DIRECT_PROVIDERS
body: dict = {"tools": tools, "max_tokens": creation_agent_max_output_tokens()}
mode = (getattr(settings, "CREATION_AGENT_THINKING_MODE", "") or "").strip()
provider_name = str(getattr(getattr(model_config, "provider", None), "name", "") or "")
if mode in {"enabled", "disabled", "auto"} and provider_name in OFFICIAL_DIRECT_PROVIDERS:
body["thinking"] = {"type": mode}
return body
def is_unsupported_thinking_error(exc: Exception) -> bool:
"""模型不认 thinking 档位(Seed 2.1 Pro 收到 auto 就 400)。这种 400 应当去掉参数重试,
而不是让整条会话吐「生成过程出错了」。"""
blob = str(exc).lower()
return "thinking" in blob and ("unsupported" in blob or "invalidparameter" in blob)
def creation_agent_timeout_notice(seconds: int, *, has_open_gate: bool = False) -> str:
"""超时文案必须按现场说话:没有待确认的步骤卡时,页面上根本没有「按这个继续」可点。"""
minutes = max(1, round(int(seconds or 0) / 60))
tail = (
"原来的确认内容还在,直接重新点「按这个继续」即可。"
if has_open_gate
else "可以把刚才那条要求再发一次。"
)
return f"这次整理等了约 {minutes} 分钟还没写完,已经停止。{tail}"
TRUNCATION_RETRY_INSTRUCTION = (
"上一次输出撞到长度上限被截断,工具参数不是完整 JSON,本次作废。"
"请立刻重新调用同一个工具:先写 usp / points / timeline,再写 video_prompt"
"video_prompt 压到更紧凑的篇幅(长视频只写章节结构、每个 30 秒段 3–5 个关键镜头和段尾交接状态),"
"不要复述已写过的规则,不要输出工具调用以外的解释文字。"
)
TRUNCATION_GIVE_UP_NOTICE = (
"这一版方案太长,连续几次都写到一半被截断,已经停止。"
"可以把时长改短一点,或直接点「按这个继续」让我按更紧凑的结构重写一次。"
)
def long_video_script_covers_requested_duration(
raw_duration: int | None,
requested_duration: int,
) -> bool:
"""长视频允许时间轴和目标时长差 1–2 秒,避免整篇重写把超时额度耗光。"""
if raw_duration is None:
return False
return abs(int(raw_duration) - int(requested_duration)) <= LONG_VIDEO_DURATION_SLACK_SECONDS
def is_long_form_video(params: dict | None = None) -> bool:
return video_duration(params or {}) > LONG_VIDEO_CHAPTER_DURATION
def _public_reasoning_detail_key(reasoning_text: str) -> str:
"""把最近一小段内部 reasoning 映射为白名单进度,不回传原文。"""
text = str(reasoning_text or "")[-360:]
if re.search(r"镜头|分镜|脚本|画面|出片|prompt", text, re.IGNORECASE):
return "shots"
if re.search(r"时长|分段|章节|节奏|\d{2,3}\s*秒", text, re.IGNORECASE):
return "structure"
if re.search(r"一致|参考|锁定|衔接|连续", text):
return "consistency"
if re.search(r"角色|人物|模特|出镜|主讲", text):
return "cast"
if re.search(r"商品|卖点|功效|受众|质地", text):
return "product"
return "brief"
def set_public_agent_progress(
conversation: CreationConversation,
phase: str,
*,
detail_key: str = "",
) -> None:
"""记录给创作页轮询使用的受控进度阶段。
reasoning 原文是模型内部工作草稿,不适合作为用户内容。这里只存固定 phase,
由 serializer 映射成简短、稳定的可读文案,既能让用户看见正在推进,也不会泄露或堆积原始思考流。
同时保留本轮最近的受控阶段,供创作页以可滚动的「创作过程」呈现。
"""
memory = dict(conversation.memory or {})
if (
memory.get("agent_progress_phase") == phase
and memory.get("agent_progress_detail_key", "") == detail_key
):
return
history = list(memory.get("agent_progress_history") or [])
history.append({"phase": phase, "detail_key": detail_key})
memory["agent_progress_history"] = history[-12:]
memory["agent_progress_phase"] = phase
memory["agent_progress_detail_key"] = detail_key
conversation.memory = memory
conversation.save(update_fields=["memory", "updated_at"])
def clear_public_agent_progress(conversation: CreationConversation) -> None:
"""本轮完成、取消或失败后立即撤掉临时进度,不能和错误/确认卡同时出现。"""
memory = dict(conversation.memory or {})
if not any(
key in memory
for key in ("agent_progress_phase", "agent_progress_detail_key", "agent_progress_history")
):
return
memory.pop("agent_progress_phase", None)
memory.pop("agent_progress_detail_key", None)
memory.pop("agent_progress_history", None)
conversation.memory = memory
conversation.save(update_fields=["memory", "updated_at"])
# 视频闸门阶段(落在 conversation.memory.stage;resume 靠它)
# clarify → strategy → plan → prompt → confirm → done
VIDEO_GATE_STAGES = ("clarify", "strategy", "plan", "prompt", "confirm", "done")
PAIN_POINT_PRESET = "痛点解决演示"
PAIN_POINT_DIRECTION_KEY = "pain_point_direction"
_STEP_CONFIRM_LABELS = {
"strategy": "创作策略已写好。确认后继续写方案;要改就点「我想改」或直接说改哪里。",
"plan": "视频方案已写好。确认后我会整理出片细节,并带你确认生成参数;要改就点「我想改」或直接说改哪里。",
"prompt": "出片指令已整理好。确认后核对参数并生成;要改就点「我想改」或直接说改哪里。",
}
_PERSON_SOURCE_PRESETS = {
"痛点解决演示",
PLOT_TWIST_PRESET,
"短剧反转带货",
"达人口播种草",
"鱼眼换装",
"AI 宠物拟人",
}
def is_pet_preset(preset: str | None) -> bool:
name = str(preset or "").strip()
return "宠物" in name
_PERSON_VISUAL_RE = re.compile(
r"(人物|角色|模特|主角|达人|主播|出镜|口播|女生|女性|男生|男性|"
r"女主|男主|年轻人|手模|手部|真人|换装|穿搭|剧情|短剧)"
)
_CAST_RELATION_RE = re.compile(
r"(共同出镜|一起出镜|一同出镜|同框|都(?:要|会|需)?出镜|全部(?:角色)?出镜|"
r"(?:一起|一同)(?:拍|演)|全员出镜|(?:三个|三位|两位|两个|大家)(?:都要|一起)|"
r"轮流出镜|分别出镜|主讲|主角|主演|辅助出镜|配角|只(?:用|留|要)|单人出镜)"
)
_CAST_RELATION_AUTO_RE = re.compile(r"(你来定|你安排|你决定|随便|都行)")
_PRODUCT_REQUIRED_PRESETS = {
"痛点解决演示",
PLOT_TWIST_PRESET,
"达人口播种草",
"点击换款",
"多色商品换款",
"点触换款",
"商品拟人广告",
"商品图一键成片",
"前后对比实测",
"AI 宠物拟人",
}
def is_plot_twist_conversation(conversation: CreationConversation) -> bool:
return conversation.mode == CreationConversation.Mode.VIDEO and conversation.preset == PLOT_TWIST_PRESET
def plot_twist_selected_direction(conversation: CreationConversation) -> dict:
"""读取用户已选剧情方向(结构化优先,兼容旧会话只有 title/detail)。"""
memory = conversation.memory if isinstance(conversation.memory, dict) else {}
payload = memory.get("plot_twist_story_direction_payload")
if isinstance(payload, dict) and any(
str(payload.get(k) or "").strip() for k in ("title", "conflict", "product_role", "reversal", "detail")
):
return {
"title": str(payload.get("title") or memory.get("plot_twist_story_direction") or "").strip(),
"conflict": str(payload.get("conflict") or "").strip(),
"product_role": str(payload.get("product_role") or "").strip(),
"reversal": str(payload.get("reversal") or "").strip(),
"tone": str(payload.get("tone") or "").strip(),
"detail": str(memory.get("plot_twist_story_direction_detail") or "").strip(),
}
title = str(memory.get("plot_twist_story_direction") or "").strip()
detail = str(memory.get("plot_twist_story_direction_detail") or "").strip()
if not title and not detail:
return {}
return {
"title": title,
"conflict": "",
"product_role": "",
"reversal": "",
"tone": "",
"detail": detail,
}
def is_pain_point_conversation(conversation: CreationConversation) -> bool:
return conversation.mode == CreationConversation.Mode.VIDEO and conversation.preset == PAIN_POINT_PRESET
def is_pain_point_direction_payload(payload: dict) -> bool:
fields = [item for item in (payload.get("fields") or []) if isinstance(item, dict)]
return bool(fields and str(fields[0].get("key") or "") == PAIN_POINT_DIRECTION_KEY)
def apply_pain_point_direction(
conversation: CreationConversation,
payload: dict,
choice: str,
) -> str:
"""把已选方向直接作为本轮核心痛点/卖点,后续不再重复追问核心卖点。"""
fields = [item for item in (payload.get("fields") or []) if isinstance(item, dict)]
options = (fields[0].get("options") or []) if fields else []
selected = next(
(
item for item in options
if isinstance(item, dict)
and str(item.get("value") or "") == str(choice or "")
),
None,
)
direction = str((selected or {}).get("label") or choice or "").strip()
memory = dict(conversation.memory or {})
memory["pain_point_direction_ready"] = True
memory["pain_point_direction"] = direction
memory["selling_point_ready"] = True
memory["selling_point_mode"] = "manual"
memory["selling_point"] = direction
conversation.memory = memory
conversation.save(update_fields=["memory", "updated_at"])
return (
f"商家已选择痛点方向:【{direction}】。这个选择同时就是本轮要突出的核心痛点与核心卖点。"
"现在只调用 write_strategy 写创作策略,并让痛点、正常使用过程和可见结果都围绕它展开;"
"不要再询问核心卖点,不要复述选择,不要直接写方案或出片。"
)
def pain_point_direction_options_from_text(text: str) -> list[dict[str, str]]:
"""模型偶尔只输出三条列表而忘记 ask_user;把列表确定性转成可点击选项。"""
items: list[str] = []
for line in str(text or "").splitlines():
match = re.match(r"^\s*(?:[-*+•]|[1-3][.、.)])\s*(.+?)\s*$", line)
if not match:
continue
label = re.sub(r"\*\*", "", match.group(1)).strip()
if label and label not in items:
items.append(label)
if len(items) != 3:
return []
return [
{"value": f"direction_{index}", "label": label}
for index, label in enumerate(items, start=1)
]
def active_plot_twist_story_depth(conversation: CreationConversation) -> str:
"""时长是最终事实来源;用户中途改时长后,故事结构必须随之切换。"""
from_duration = plot_twist_story_depth(str((conversation.params or {}).get("duration") or ""))
if from_duration and from_duration["value"] != "smart":
return str(from_duration["value"])
memory = conversation.memory if isinstance(conversation.memory, dict) else {}
return str(memory.get("plot_twist_story_depth") or "").strip()
def set_plot_twist_story_depth(conversation: CreationConversation, value: str) -> dict | None:
"""卡片或自然语言选时长后,同步会话顶部参数与最终 Prompt 的故事结构。"""
if not is_plot_twist_conversation(conversation):
return None
depth = plot_twist_story_depth(value)
if depth is None:
return None
memory = dict(conversation.memory or {})
params = dict(conversation.params or {})
memory["plot_twist_story_depth"] = depth["value"]
if depth["duration"]:
params["duration"] = depth["duration"]
conversation.memory = memory
conversation.params = params
conversation.save(update_fields=["memory", "params", "updated_at"])
return depth
def append_plot_twist_story_depth_question(conversation: CreationConversation) -> CreationMessage:
return append_message(
conversation,
role="assistant",
kind=CreationMessage.Kind.ELICIT,
text="你希望这支剧情带货视频做到什么程度?",
payload={
"interaction": "plot_twist_story_depth",
"fields": [
{
"key": "story_depth",
"label": "故事深度选择",
"type": "single",
"required": True,
"options": [
{"value": item["value"], "label": f"{item['label']}{item['summary']}"}
for item in PLOT_TWIST_STORY_DEPTH_OPTIONS
],
}
],
"submitted": False,
"answers": {},
},
)
def _plot_twist_direction_fallback(conversation: CreationConversation) -> list[dict]:
"""模型遗漏方向卡工具时的平台兜底,不能只留下「我准备了三个方向」的空话。"""
refs = [item for item in (conversation.pinned_refs or []) if isinstance(item, dict)]
product = next((str(item.get("name") or "").strip() for item in refs if item.get("type") == "product"), "这款商品")
return [
{
"id": "misunderstanding",
"title": "误会翻盘",
"conflict": "主角把眼前的麻烦误判成无解,情绪不断升级。",
"product_role": f"{product} 作为关键证据或解决工具,在最需要时自然出现。",
"reversal": "原来问题的答案一直在眼前,误会被当场化解。",
"tone": "轻喜剧、反差感强",
},
{
"id": "last_chance",
"title": "最后一次机会",
"conflict": "主角已经试过常规办法仍然失败,只剩最后一次选择。",
"product_role": f"{product} 承担最后一次可验证的尝试,完整展示正常使用过程。",
"reversal": "看似失败的局面被扭转,结果恰好印证核心卖点。",
"tone": "紧张后释然、转化更强",
},
{
"id": "foreshadowing",
"title": "伏笔回收",
"conflict": "开头埋下一个不起眼的细节,人物关系或目标因此受阻。",
"product_role": f"{product} 先作为日常细节出现,后半段成为推动结局的关键线索。",
"reversal": "前面的细节被回收,观众才发现结局早有铺垫。",
"tone": "温暖小反转、故事感更强",
},
]
def _coerce_plot_twist_directions(raw) -> list[dict]:
"""把模型给的三条方向收成可直接渲染的卡片字段。"""
if not isinstance(raw, list):
return []
directions: list[dict] = []
for index, item in enumerate(raw[:3], start=1):
if not isinstance(item, dict):
continue
title = str(item.get("title") or item.get("name") or "").strip()
conflict = str(item.get("conflict") or item.get("setup") or "").strip()
product_role = str(item.get("product_role") or item.get("product") or "").strip()
reversal = str(item.get("reversal") or item.get("twist") or "").strip()
tone = str(item.get("tone") or item.get("feel") or "").strip()
if not all((title, conflict, product_role, reversal)):
continue
directions.append({
"id": str(item.get("id") or f"direction_{index}").strip() or f"direction_{index}",
"title": title,
"conflict": conflict,
"product_role": product_role,
"reversal": reversal,
"tone": tone or "剧情带货",
})
return directions if len(directions) == 3 else []
def _append_plot_twist_direction_question(context: "AgentContext", directions: list[dict]) -> CreationMessage:
return append_message(
context.conversation,
role="assistant",
kind=CreationMessage.Kind.ELICIT,
text="选一个剧情方向,我会按它写成完整方案。",
payload={
"interaction": "plot_twist_directions",
"directions": directions,
"fields": [{
"key": "story_direction",
"label": "三个剧情反转方向",
"type": "single",
"required": True,
"options": [
{"value": item["id"], "label": item["title"]}
for item in directions
],
}],
"submitted": False,
"answers": {},
},
)
def get_video_gate_stage(conversation: CreationConversation) -> str:
memory = conversation.memory if isinstance(conversation.memory, dict) else {}
stage = str(memory.get("stage") or "clarify").strip()
return stage if stage in VIDEO_GATE_STAGES else "clarify"
def set_video_gate_stage(
conversation: CreationConversation,
stage: str,
*,
pending_video_prompt: str | None = None,
clear_pending_prompt: bool = False,
) -> None:
"""持久化闸门阶段;可选缓存尚未展示的 video_prompt。"""
if stage not in VIDEO_GATE_STAGES:
stage = "clarify"
memory = dict(conversation.memory or {})
memory["stage"] = stage
if pending_video_prompt is not None:
memory["pending_video_prompt"] = str(pending_video_prompt)
if clear_pending_prompt:
memory.pop("pending_video_prompt", None)
conversation.memory = memory
conversation.save(update_fields=["memory", "updated_at"])
def get_pending_video_prompt(conversation: CreationConversation) -> str:
memory = conversation.memory if isinstance(conversation.memory, dict) else {}
return str(memory.get("pending_video_prompt") or "").strip()
_SELLING_POINT_DECLARE_RE = re.compile(
r"(?:核心)?卖点\s*(?:是|为||:)\s*(.+)",
re.IGNORECASE | re.DOTALL,
)
_SELLING_POINT_FOCUS_RE = re.compile(
# 「重点讲」必须带「是/为/:」,避免预设文案「重点讲清使用场景…」被当成已填卖点。
r"(?:我想?突出(?:的)?|重点突出|主要讲|主打)\s*(?:的是|是|为||:)\s*(.+)"
r"|重点讲\s*(?:的是|是|为||:)\s*(.+)",
re.IGNORECASE | re.DOTALL,
)
_SELLING_POINT_BRIEF_CREATIVE_RE = re.compile(
r"(?:创作|做[一条]?|视频|帮我|生成|脚本|出片|参考|预设|方向)",
)
def extract_declared_selling_point(text: str) -> str:
"""从用户话术里抽出已声明的核心卖点。
主页开场描述常写成「核心卖点是…」或一串短卖点列表;这些都应视为已确认,
避免 write_strategy 前再弹一次卖点闸门。
"""
raw = (text or "").strip()
if not raw:
return ""
for pattern in (_SELLING_POINT_DECLARE_RE, _SELLING_POINT_FOCUS_RE):
match = pattern.search(raw)
if not match:
continue
value = next((group for group in match.groups() if group), "").strip()
value = re.split(r"[\n\r]", value)[0].strip()
value = value.strip("。;;!?,、 ")
if len(value) >= 2:
return value[:200]
# 整段就是短卖点列表(如「补水保湿,便于携带,买三送一」),且不像创作指令
if len(raw) <= 80 and not _SELLING_POINT_BRIEF_CREATIVE_RE.search(raw):
parts = [part.strip() for part in re.split(r"[,、;;]", raw) if part.strip()]
if len(parts) >= 2 and all(2 <= len(part) <= 24 for part in parts):
return raw[:200]
return ""
def product_selling_points_summary(conversation: CreationConversation) -> str:
"""取已钉商品库商品上的卖点标题,供闸门预填或作为已填卖点。"""
from apps.products.models import Product
for ref in locked_product_references(conversation):
if str(ref.get("type") or "") != "product":
continue
product = (
Product.objects.filter(id=ref.get("id"), team_id=conversation.team_id)
.prefetch_related("selling_points")
.first()
)
if product is None:
continue
titles: list[str] = []
for point in product.selling_points.order_by("sort_order", "created_at"):
title = str(point.title or "").strip()
if title and title not in titles:
titles.append(title)
if titles:
return "".join(titles)[:200]
return ""
def lock_selling_point(
conversation: CreationConversation,
selling_point: str,
*,
mode: str = "manual",
) -> None:
memory = dict(conversation.memory or {})
memory["selling_point_ready"] = True
memory["selling_point_mode"] = mode
memory["selling_point"] = selling_point if mode == "manual" else ""
conversation.memory = memory
conversation.save(update_fields=["memory", "updated_at"])
def ensure_selling_point_ready_from_context(
conversation: CreationConversation,
user_text: str = "",
) -> bool:
"""主页/聊天已声明卖点,或商品库已填卖点时,直接锁定,避免重复询问。"""
if conversation.mode != CreationConversation.Mode.VIDEO:
return False
# 痛点解决演示用「方向三选一」把方向写成核心卖点;这里抢先 ready 会跳过那道闸门。
if is_pain_point_conversation(conversation):
return False
memory = conversation.memory if isinstance(conversation.memory, dict) else {}
if memory.get("selling_point_ready"):
return True
candidates: list[str] = []
declared = extract_declared_selling_point(user_text)
if declared:
candidates.append(declared)
for text in conversation.messages.filter(role=CreationMessage.Role.USER).order_by("seq").values_list("text", flat=True):
found = extract_declared_selling_point(text)
if found:
candidates.append(found)
if candidates:
# 以最近一次声明为准(主页首条或后续补充)
lock_selling_point(conversation, candidates[-1], mode="manual")
return True
summary = product_selling_points_summary(conversation)
if summary:
lock_selling_point(conversation, summary, mode="manual")
return True
return False
def append_selling_point_gate(conversation: CreationConversation) -> CreationMessage:
"""在视频方案生成前确认卖点来源。
商家可以直接给真实卖点;若交给系统,则后续只从商品资料和参考素材中选择可证实的表达,
不把“系统推荐”误做成无依据的夸大文案。
"""
prefill = product_selling_points_summary(conversation)
return append_message(
conversation,
role="assistant",
kind=CreationMessage.Kind.ELICIT,
text="这条视频准备先讲哪个卖点?你可以直接写真实卖点,也可以让我从商品和素材里推荐一个。",
payload={
"interaction": "selling_point_gate",
"fields": [
{
"key": "selling_point",
"label": "商品卖点",
"type": "text",
"required": False,
"placeholder": "例如:油污一喷一擦就干净,适合厨房重油污…",
}
],
"submitted": False,
"answers": {"selling_point": prefill} if prefill else {},
},
)
def has_locked_person_reference(conversation: CreationConversation) -> bool:
"""人物一致性的前提是会话里有可解析的人物 Ref。
本地上传人物图由前端标记为 character,模特库则是 model;普通 asset
不能被默认当人,否则商品图/场景图会误跳过这个闸门。
"""
return any(
isinstance(ref, dict) and ref.get("type") in {"model", "character"} and ref.get("id")
for ref in (conversation.pinned_refs or [])
)
def person_identity_ready(conversation: CreationConversation) -> bool:
"""角色图是否已真正可用:已钉 Ref、正在生成、或明确只要手指(点击换款)。
仅有 person_source_ready / 「你来推荐」但库里没命中、也没钉住人,不算完成。
"""
if has_locked_person_reference(conversation):
return True
memory = conversation.memory if isinstance(conversation.memory, dict) else {}
if memory.get("person_source_pending"):
return True
if str(memory.get("person_source") or "") == "finger_only":
return True
return False
def locked_person_references(conversation: CreationConversation) -> list[dict]:
"""返回去重后的已锁定人物,顺序与用户添加顺序一致。"""
people: list[dict] = []
seen: set[tuple[str, str]] = set()
for ref in conversation.pinned_refs or []:
if not isinstance(ref, dict) or ref.get("type") not in {"model", "character"} or not ref.get("id"):
continue
key = (str(ref.get("type")), str(ref.get("id")))
if key in seen:
continue
seen.add(key)
people.append(ref)
return people
def _cast_relation_signature(conversation: CreationConversation) -> str:
return "|".join(
f"{ref.get('type')}:{ref.get('id')}"
for ref in locked_person_references(conversation)
)
def cast_relation_options(conversation: CreationConversation) -> list[dict[str, str]]:
"""把当前已锁定人物直接变成一键选择项,不再让用户重复输入名称。"""
options = [{"value": "all_together", "label": "全部共同出镜"}]
for ref in locked_person_references(conversation):
name = str(ref.get("name") or "未命名角色").split(" · ")[0].strip() or "未命名角色"
options.append({
"value": f"lead:{ref.get('type')}:{ref.get('id')}",
"label": f"{name}主讲",
})
return options
def apply_cast_relation_choice(conversation: CreationConversation, choice: str) -> str:
"""保存多角色标签选择,并返回可直接喂给 Agent 的完整人物关系。"""
raw = str(choice or "").strip()
people = locked_person_references(conversation)
signature = _cast_relation_signature(conversation)
relation = ""
if raw == "all_together":
relation = "全部已锁定角色共同出镜"
elif raw.startswith("lead:"):
selected = next(
(
ref for ref in people
if raw == f"lead:{ref.get('type')}:{ref.get('id')}"
),
None,
)
if selected is not None:
name = str(selected.get("name") or "未命名角色").split(" · ")[0].strip() or "未命名角色"
relation = f"{name}作为主讲,其余已锁定角色辅助出镜"
if not relation:
return ""
memory = dict(conversation.memory or {})
memory["cast_relation"] = relation
memory["cast_relation_ref_signature"] = signature
memory.pop("cast_relation_pending_signature", None)
conversation.memory = memory
conversation.save(update_fields=["memory", "updated_at"])
return relation
def _normalized_cast_relation(conversation: CreationConversation, user_text: str) -> str:
"""把简短回答补成不会丢角色的出镜安排。"""
answer = str(user_text or "").strip()
if _CAST_RELATION_AUTO_RE.search(answer):
return "由系统安排一位主讲,其余已锁定角色辅助出镜;全部角色都保留"
if re.search(
r"(共同出镜|一起出镜|一同出镜|同框|都(?:要|会|需)?出镜|全部(?:角色)?出镜|"
r"(?:一起|一同)(?:拍|演)|全员出镜|(?:三个|三位|两位|两个|大家)(?:都要|一起))",
answer,
):
return "全部已锁定角色共同出镜"
if not _CAST_RELATION_RE.search(answer):
# 闸门明确提示「有主讲就直接说角色名」;用户只回名字时,补全语义。
names = [str(ref.get("name") or "").strip() for ref in locked_person_references(conversation)]
aliases = {
alias
for name in names
for alias in (name, name.split(" · ")[0].strip())
if alias
}
if any(answer == alias or answer in alias or alias in answer for alias in aliases):
return f"{answer}作为主讲,其余已锁定角色辅助出镜"
return answer
def multi_character_relation_needs_clarification(
conversation: CreationConversation,
user_text: str = "",
) -> bool:
"""多人物已添加但关系不明时,先确认共同出镜还是主讲+辅助。
用人物 Ref 签名记录答案:后续新增/替换人物会重新确认,原班角色继续改稿时
不会反复追问。若当前消息本身已经写明关系,则直接采纳并放行。
"""
people = locked_person_references(conversation)
if len(people) < 2:
return False
signature = _cast_relation_signature(conversation)
memory = dict(conversation.memory or {})
if memory.get("cast_relation_ref_signature") == signature and memory.get("cast_relation"):
return False
text = str(user_text or "").strip()
if memory.get("cast_relation_pending_signature") == signature:
if not text:
return True
memory["cast_relation"] = _normalized_cast_relation(conversation, text)
memory["cast_relation_ref_signature"] = signature
memory.pop("cast_relation_pending_signature", None)
conversation.memory = memory
conversation.save(update_fields=["memory", "updated_at"])
return False
if _CAST_RELATION_RE.search(text) or _CAST_RELATION_AUTO_RE.search(text):
memory["cast_relation"] = _normalized_cast_relation(conversation, text)
memory["cast_relation_ref_signature"] = signature
memory.pop("cast_relation_pending_signature", None)
conversation.memory = memory
conversation.save(update_fields=["memory", "updated_at"])
return False
return True
def append_multi_character_relation_gate(conversation: CreationConversation) -> CreationMessage:
"""保留全部人物,只询问人物之间的出镜关系。"""
people = locked_person_references(conversation)
signature = _cast_relation_signature(conversation)
memory = dict(conversation.memory or {})
memory["cast_relation_pending_signature"] = signature
conversation.memory = memory
conversation.save(update_fields=["memory", "updated_at"])
count = len(people)
question = (
f"这次已经添加了 {count} 位角色,我都会保留。"
"直接选择共同出镜,或点一位角色作为主讲,其余角色会辅助出镜。"
)
return append_message(
conversation,
role="assistant",
kind=CreationMessage.Kind.ELICIT,
text=question,
payload={
"interaction": "chat",
"topic": "cast_relation",
"fields": [{
"key": "cast_relation",
"label": question,
"type": "single",
"required": True,
"options": cast_relation_options(conversation),
}],
"submitted": False,
"answers": {},
},
)
def video_needs_person_source(conversation: CreationConversation, user_text: str = "") -> bool:
"""需要真人/角色的视频在写策略前必须先锁定人物来源。"""
if conversation.mode != CreationConversation.Mode.VIDEO:
return False
# 已钉角色图 / 正在生成 / 只出手:才算人物步骤完成。禁止仅凭「你来推荐」空跑跳过。
if person_identity_ready(conversation):
return False
memory = conversation.memory if isinstance(conversation.memory, dict) else {}
# 点击换款:只有「角色日常换款」才要人物;「只出手」跳过,且不被开场文案里的「口播/剧情」否定词误触发。
if is_click_swap_preset(conversation.preset):
return click_swap_mode(conversation) == "character"
if conversation.preset in _PERSON_SOURCE_PRESETS or is_pet_preset(conversation.preset):
return True
recent = list(
conversation.messages.order_by("-seq").values_list("text", flat=True)[:12]
)
pending_prompt = str(memory.get("pending_video_prompt") or "")
return bool(_PERSON_VISUAL_RE.search("\n".join([user_text, pending_prompt, *recent])))
def locked_product_references(conversation: CreationConversation) -> list[dict]:
"""返回会话里所有已锁定的商品/产品素材实体(包括商品库商品和本地上传的商品图片)。"""
products: list[dict] = []
seen: set[tuple[str, str]] = set()
person_ids = {
str(ref.get("id"))
for ref in locked_person_references(conversation)
if isinstance(ref, dict) and ref.get("id")
}
for ref in (conversation.pinned_refs or []):
if not isinstance(ref, dict):
continue
type_ = str(ref.get("type") or "").strip()
ref_id = str(ref.get("id") or "").strip()
if not ref_id or ref_id in person_ids:
continue
# 显式商品,或上传的素材图片(排除人物角色/模特)
if type_ == "product" or (type_ == "asset" and ref.get("category") != "character"):
mark = (type_, ref_id)
if mark not in seen:
products.append(ref)
seen.add(mark)
return products
def has_locked_product_reference(conversation: CreationConversation) -> bool:
return len(locked_product_references(conversation)) > 0
PRODUCT_BRAND_EMPTY_TEMPLATE = "品牌是:,商品名是:"
def is_incomplete_product_brand_answer(text: str) -> bool:
"""空模板或只有「品牌是:/商品名是:」骨架、没有真实内容时视为未填完。"""
raw = (text or "").strip()
if not raw:
return True
skeletons = {
PRODUCT_BRAND_EMPTY_TEMPLATE,
"品牌是:,商品名是:",
"品牌是:,品名是:",
"品牌是:,品名是:",
}
if raw in skeletons:
return True
cleaned = raw
for sk in skeletons:
cleaned = cleaned.replace(sk, "")
cleaned = (
cleaned.replace("品牌是:", "")
.replace("品牌是:", "")
.replace("商品名是:", "")
.replace("商品名是:", "")
.replace("品名是:", "")
.replace("品名是:", "")
.replace("", "")
.replace(",", "")
.strip()
)
if not cleaned:
return True
bm = re.search(r"品牌[:是为]\s*([^\n,。!!]+)", raw)
nm = re.search(r"(?:品名|商品名|产品名)[:是为]\s*([^\n,。!!]+)", raw)
if bm or nm:
brand = (bm.group(1).strip() if bm else "")
name = (nm.group(1).strip() if nm else "")
token = re.compile(r"[\u4e00-\u9fffA-Za-z0-9]{2,}")
if not token.search(brand) and not token.search(name) and not token.search(cleaned):
return True
return False
def product_info_needs_confirmation(conversation: CreationConversation, user_text: str = "") -> bool:
"""针对本地上传的商品素材,在对话开始或方案前必须确认品牌与具体品名。"""
if conversation.preset not in _PRODUCT_REQUIRED_PRESETS:
return False
memory = conversation.memory if isinstance(conversation.memory, dict) else {}
# 「按图片内容创作」只标记 resolved、不写真实品牌;也必须视为已确认,否则会反复弹闸门。
if memory.get("product_info_resolved") or memory.get("product_info_from_image"):
return False
probe = str(user_text or "")
if any(kw in probe for kw in ("按图片", "设计品牌", "不用再问")):
return False
brand = str(memory.get("product_brand") or "").strip()
name = str(memory.get("product_name") or "").strip()
combined = str(memory.get("product_brand_and_name") or "").strip()
token = re.compile(r"[\u4e00-\u9fffA-Za-z0-9]{2,}")
has_real_brand = bool(token.search(brand))
has_real_name = bool(token.search(name))
has_real_combined = bool(combined) and not is_incomplete_product_brand_answer(combined)
if has_real_brand or has_real_name or has_real_combined:
return False
# 如果已锁定的首个商品是正式商品库商品(已自带品牌和品名),无需重复询问
first_prod = next(
(ref for ref in (conversation.pinned_refs or []) if isinstance(ref, dict) and ref.get("type") == "product"),
None,
)
if first_prod is not None:
return False
# 检查是否包含本地上传的商品素材
uploaded_prods = [ref for ref in locked_product_references(conversation) if ref.get("type") == "asset"]
if not uploaded_prods:
return False
# 检查用户输入的文字中是否已经明确提供了品牌/品名
user_messages = (
conversation.messages.filter(role="user").order_by("seq")
if getattr(conversation, "created_at", None)
else []
)
all_text = " ".join(str(m.text or "") for m in user_messages) + " " + (user_text or "")
if any(kw in all_text for kw in ("按图片", "设计品牌", "不用再问")):
return False
has_explicit_brand = bool(re.search(r"(?:品牌|牌子)[:是为]\s*([^\n,。!!]{2,30})", all_text))
has_explicit_name = bool(re.search(r"(?:品名|商品名|产品名)[:是为]\s*([^\n,。!!]{2,30})", all_text))
if has_explicit_brand or has_explicit_name:
return False
return True
def append_product_info_gate(conversation: CreationConversation) -> CreationMessage:
"""针对已上传商品图但尚未说明品牌/品名的情况,主动询问商品品牌与品名。"""
question = (
"已收到你上传的商品图。请问这款商品的**品牌**和**具体品名**是什么?"
"有想要重点突出的核心卖点也可以一起告诉我,以便在后续脚本中精准植入。"
)
return append_message(
conversation,
role="assistant",
kind=CreationMessage.Kind.ELICIT,
text=question,
payload={
"interaction": "chat",
"topic": "product_info",
"fields": [{
"key": "product_brand_and_name",
"label": "请提供商品品牌与具体品名",
"type": "text",
"required": True,
"placeholder": "例如:品牌「浅蓝小熊」,品名「婴儿柔湿巾」",
}],
"submitted": False,
"answers": {},
"reply_hint": "输入品牌和品名,例如「品牌XX,品名YY」…",
"reply_options": [
{"label": "按图片内容创作", "text": "直接根据图片内容设计品牌与品名,不用再问"},
{
"label": "我来补充品牌品名",
"text": PRODUCT_BRAND_EMPTY_TEMPLATE,
"action": "compose",
},
],
},
)
def creation_needs_product_source(conversation: CreationConversation, user_text: str = "") -> bool:
"""明确的商品类预设缺少结构化商品时,先索取而不是把普通素材猜成商品。
自由创作仍由 Agent 的 ask_user 规则和无输出兜底负责自然追问,避免抢在模型
已有澄清问题之前重复弹卡;预设创作则由平台确定性保证商品前置。
"""
if has_locked_product_reference(conversation):
return False
memory = conversation.memory if isinstance(conversation.memory, dict) else {}
if memory.get("product_source_resolved"):
return False
return conversation.preset in _PRODUCT_REQUIRED_PRESETS
def append_person_source_gate(conversation: CreationConversation) -> CreationMessage:
"""可视化的人物/角色来源闸门;三个选项分别进文件、模特库和生图流程。"""
product_name = ""
for ref in locked_product_references(conversation):
name = str(ref.get("name") or "").split(" · ")[0].strip()
if name and not name.lower().endswith((".png", ".jpg", ".jpeg", ".webp")):
product_name = name
break
if not product_name:
memory = conversation.memory if isinstance(conversation.memory, dict) else {}
product_name = str(memory.get("product_name") or memory.get("product_brand_and_name") or "").strip()
is_pet = is_pet_preset(conversation.preset)
if is_pet:
prompt_text = (
f"商品已选定【{product_name}】。这条视频想由哪只宠物角色出镜?选定后,所有镜头和分段都会锁定同一只宠物形象。"
if product_name else
"先确定这条视频出镜的宠物角色。选定后,所有镜头和分段都会锁定同一只宠物形象。"
)
field_label = "选择宠物来源"
library_label = "从角色库选择"
else:
cast_needed = infer_needed_cast_count(conversation)
if cast_needed > 1:
prompt_text = (
f"商品已选定【{product_name}】。这条视频需要 {cast_needed} 位出镜人物;"
"请上传/选择/生成对应数量的角色定妆图,所有镜头和分段都会按这些图锁脸。"
if product_name else
f"这条视频需要 {cast_needed} 位出镜人物。请上传/选择/生成对应数量的角色定妆图,"
"所有镜头和分段都会按这些图锁脸,避免长视频前后形象漂移。"
)
else:
prompt_text = (
f"商品已选定【{product_name}】。这条视频想由哪位角色/达人出镜?选定后,所有镜头和分段都会锁定同一位人物。"
if product_name else
"先确定这条视频的出镜人物。选定后,所有镜头和分段都会锁定同一位人物。"
)
field_label = "选择人物来源"
library_label = "从模特库选择"
return append_message(
conversation,
role="assistant",
kind=CreationMessage.Kind.ELICIT,
text=prompt_text,
payload={
"interaction": "person_source_gate",
"is_pet": is_pet,
"fields": [{
"key": "person_source",
"label": field_label,
"type": "single",
"required": True,
"options": [
{"value": "local_upload", "label": "本地上传"},
{"value": "model_library", "label": library_label},
{"value": "platform_generate", "label": "平台帮忙生成"},
],
}],
"submitted": False,
"answers": {},
},
)
def click_swap_sequence(conversation: CreationConversation) -> str:
memory = conversation.memory if isinstance(conversation.memory, dict) else {}
return str(memory.get("click_swap_sequence") or "").strip()
def click_swap_needs_sequence(conversation: CreationConversation) -> bool:
"""点击换款只有在商家明确款式和顺序后才能写脚本。"""
if conversation.mode != CreationConversation.Mode.VIDEO:
return False
if not is_click_swap_preset(conversation.preset):
return False
memory = conversation.memory if isinstance(conversation.memory, dict) else {}
return not bool(memory.get("click_swap_ready") and click_swap_sequence(conversation))
def append_click_swap_sequence_gate(conversation: CreationConversation) -> CreationMessage:
mode = click_swap_mode(conversation)
if mode == "character":
hint = "先确认要切换的款式和展示顺序。角色会在日常场景中按这个顺序变色换款。"
placeholder = "例如:曜石黑 → 象牙白 → 樱花粉"
else:
hint = "先确认要切换的款式和展示顺序。后续按这个顺序用手指点击触发换款;每一次点击落点都要与上一次不同。"
placeholder = "例如:黑色 → 白色 → 樱花粉"
return append_message(
conversation,
role="assistant",
kind=CreationMessage.Kind.ELICIT,
text=hint,
payload={
"interaction": "click_swap_sku_gate",
"fields": [{
"key": "sku_sequence",
"label": "款式与切换顺序",
"type": "text",
"required": True,
"placeholder": placeholder,
}],
"submitted": False,
"answers": {},
},
)
def click_swap_mode(conversation: CreationConversation) -> str:
memory = conversation.memory if isinstance(conversation.memory, dict) else {}
return str(memory.get("click_swap_mode") or "").strip()
def click_swap_needs_mode(conversation: CreationConversation) -> bool:
"""点击换款必须先选形态:只出手 / 角色日常换款。"""
if conversation.mode != CreationConversation.Mode.VIDEO:
return False
if not is_click_swap_preset(conversation.preset):
return False
return click_swap_mode(conversation) not in {"finger", "character"}
def append_click_swap_mode_gate(conversation: CreationConversation) -> CreationMessage:
return append_message(
conversation,
role="assistant",
kind=CreationMessage.Kind.ELICIT,
text="这条点击换款想用哪种形态?",
payload={
"interaction": "click_swap_mode_gate",
"fields": [{
"key": "click_swap_mode",
"label": "选择换款形态",
"type": "single",
"required": True,
"options": [
{"value": "finger", "label": "只出手指出镜(不用角色图)"},
{"value": "character", "label": "角色在日常场景中换款"},
],
}],
"submitted": False,
"answers": {},
},
)
def apply_click_swap_mode(conversation: CreationConversation, mode: str) -> str:
"""写入换款形态;只出手时直接跳过人物闸门。"""
value = str(mode or "").strip()
if value not in {"finger", "character"}:
return ""
memory = dict(conversation.memory or {})
memory["click_swap_mode"] = value
if value == "finger":
memory["person_source"] = "finger_only"
memory["person_source_ready"] = True
memory["person_source_pending"] = False
else:
memory.pop("person_source_ready", None)
memory.pop("person_source_pending", None)
if memory.get("person_source") == "finger_only":
memory.pop("person_source", None)
conversation.memory = memory
conversation.save(update_fields=["memory", "updated_at"])
if value == "finger":
return (
"商家已选择【只出手指出镜】。不要生成角色定妆图,不要追问出镜人物;"
"后续只围绕固定机位 + 手指点击触发换款推进;每一次换款点击落点必须与上一次不同,禁止写「每次点击同一位置」。"
)
return (
"商家已选择【角色在日常场景中换款】。下一步先锁定出镜角色来源;"
"脚本必须是同一角色在日常使用场景中按序变色换款,不要改成只出手指出镜。"
)
def _character_only_appearance_text(raw: str) -> str:
"""定妆图只保留角色外观描述,去掉商品/卖点/带货指令,避免图里跑出商品。"""
text = str(raw or "").strip()
if not text:
return ""
text = re.sub(
r"(?:商品|产品|品牌|品名|卖点|空气炸锅|猫粮|狗粮|包装|瓶身|机身|SKU|链接|下单|购买)[^。!?\n]{0,40}",
" ",
text,
flags=re.IGNORECASE,
)
text = re.sub(r"[\s,、;;]{2,}", "", text).strip(",。;; ")
return text[:500]
def _conversation_cast_text_blob(conversation: CreationConversation, extra_text: str = "") -> str:
"""汇总可用来判断「几位出镜人物」的文本。"""
chunks: list[str] = [str(extra_text or "").strip()]
memory = conversation.memory if isinstance(conversation.memory, dict) else {}
for key in ("person_prompt", "cast_relation", "pending_video_prompt", "pain_point_direction"):
value = str(memory.get(key) or "").strip()
if value:
chunks.append(value)
if getattr(conversation, "created_at", None):
for text in conversation.messages.filter(role=CreationMessage.Role.USER).order_by("-seq").values_list("text", flat=True)[:8]:
if text and str(text).strip():
chunks.append(str(text).strip())
for payload in conversation.messages.filter(kind__in=[
CreationMessage.Kind.STRATEGY,
CreationMessage.Kind.PLAN,
]).order_by("-seq").values_list("payload", flat=True)[:4]:
if not isinstance(payload, dict):
continue
for key in ("belief", "direction", "usp", "video_prompt", "target", "trust"):
value = str(payload.get(key) or "").strip()
if value:
chunks.append(value)
points = payload.get("points")
if isinstance(points, list):
chunks.extend(str(item).strip() for item in points if str(item).strip())
return "\n".join(chunks)
def infer_needed_cast_count(conversation: CreationConversation, extra_text: str = "") -> int:
"""从主页描述/策略/方案推断需要几张角色定妆图。
多人物长视频若只锁一张脸,分段后很容易前后形象漂移;这里至少按剧情人数生成。
宠物预设默认 1;人手上限 4,避免一次刷太多出图。
"""
if is_pet_preset(conversation.preset):
return 1
blob = _conversation_cast_text_blob(conversation, extra_text)
if not blob.strip():
return 1
explicit = [
(5, r"(?:五位|五个|5\s*人|5\s*位|五人)"),
(4, r"(?:四位|四个|4\s*人|4\s*位|四人)"),
(3, r"(?:三位|三个|3\s*人|3\s*位|三人)"),
(2, r"(?:两位|两个|2\s*人|2\s*位|两人|双人|一对|情侣|夫妻|母女|父女|母子|父子|姐妹|兄弟|闺蜜|搭档|CP|男女主|男主.*?女主|女主.*?男主)"),
]
needed = 1
for count, pattern in explicit:
if re.search(pattern, blob, flags=re.IGNORECASE | re.DOTALL):
needed = max(needed, count)
# 「角色1 / 角色2」或分行描述多个角色
role_labels = re.findall(r"(?:角色|人物|出演)\s*([1-4一二三四])", blob)
if len(set(role_labels)) >= 2:
mapping = {"1": 1, "2": 2, "3": 3, "4": 4, "一": 1, "二": 2, "三": 3, "四": 4}
needed = max(needed, max(mapping.get(item, 1) for item in role_labels))
split_roles = [
part.strip()
for part in re.split(r"(?:^|\n)\s*(?:角色|人物)\s*[1-4一二三四][::、.\s]", blob)
if part.strip()
]
if len(split_roles) >= 2:
needed = max(needed, min(4, len(split_roles)))
return max(1, min(4, needed))
def build_cast_role_briefs(count: int, appearance_prompt: str = "") -> list[str]:
"""把用户外观描述拆成 N 条;不够时补「与其他角色外貌明显区分」的配角 brief。"""
raw = (appearance_prompt or "").strip()
briefs: list[str] = []
if raw:
labeled = re.findall(
r"(?:角色|人物)\s*[1-4一二三四][::、.\s]+([^\n;]+)",
raw,
)
labeled = [part.strip() for part in labeled if part.strip()]
if len(labeled) >= 2:
briefs = labeled
else:
numbered = re.split(
r"(?:^|[\n;])\s*(?:角色|人物)\s*[1-4一二三四][::、.\s]+",
raw,
)
numbered = [part.strip() for part in numbered if part.strip()]
if len(numbered) >= 2:
briefs = numbered
else:
lined = [line.strip(" -•\t") for line in raw.splitlines() if line.strip()]
if len(lined) >= 2:
briefs = lined
else:
parts = [part.strip() for part in re.split(r"[;/|]", raw) if part.strip()]
briefs = parts if len(parts) >= 2 else [raw]
while len(briefs) < count:
index = len(briefs) + 1
if index == 1:
briefs.append("")
else:
briefs.append(
f"第{index}位出镜角色,成年,五官发型服装气质与其余角色明显不同,"
"可作为配角/对手戏人物独立辨认"
)
return [_character_only_appearance_text(item)[:500] for item in briefs[:count]]
def _build_single_person_reference_prompt(
*,
conversation: CreationConversation,
appearance_only: str,
context_brief: str,
cast_index: int,
cast_total: int,
) -> tuple[str, str, str]:
"""返回 (prompt, generating_label, model_name)。"""
is_pet = is_pet_preset(conversation.preset)
no_product = (
"画面中只允许出现角色本身与简洁背景,禁止出现任何商品、包装、瓶罐、袋装、纸盒、"
"电子产品、食品零食、广告道具或手持卖品;不要商品特写,不要把商品画进画面。"
)
if is_pet:
context_text = f"{appearance_only} {context_brief} {conversation.preset}"
for ref in locked_product_references(conversation):
context_text += " " + str(ref.get("name") or "")
if any(w in context_text for w in ("狗", "犬", "咬胶", "磨牙", "骨头", "汪", "狗粮", "犬粮", "幼犬", "成犬", "中大型犬", "小型犬")):
pet_type_hint = "一只呆萌可爱、神采奕奕的小狗(如金毛幼犬、柯基或柴犬等萌犬)"
elif any(w in context_text for w in ("猫", "喵", "猫砂", "猫条", "猫粮", "冻干", "猫草", "逗猫", "幼猫")):
pet_type_hint = "一只大眼睛、圆脸可爱的萌宠猫咪(如英短、美短或布偶等萌猫)"
else:
pet_type_hint = "一只可爱灵动的萌宠(如呆萌小狗或可爱猫咪)"
prompt = (
f"为AI宠物拟人短视频生成一张可反复用于锁定角色身份的萌宠主角定妆参考图。"
f"主角必须是{pet_type_hint},展现拟人化的生动表情与灵动神态,"
f"正面或微侧三分之四角度,中近景特写,眼神清澈,毛发蓬松有光泽且根根分明,"
f"可搭配精致简约的拟人小配饰(如可爱小领结、小方巾或背带,契合萌宠调性),"
f"写实摄影风格,电影级柔和摄影棚光影,简洁纯净背景,"
f"画面中只出现这一只可爱的宠物动物主角,绝对不要出现真人人类!不要文字、水印、拼图或变形。"
f"{no_product}"
)
if appearance_only:
prompt += f" 用户指定的宠物外观与品种:{appearance_only}。严格保留这些宠物外观要求。"
label = "正在生成宠物角色参考"
model_name = "平台生成宠物角色"
else:
role_tag = f"第{cast_index}位/共{cast_total}位" if cast_total > 1 else "唯一"
prompt = (
f"为短视频生成一张可反复用于锁定身份的真人模特定妆参考图({role_tag}出镜角色)。"
"只出现一位成年人物,正面或轻微三分之四角度,中近景,表情自然,"
"五官、发型、肤色、身形和服装细节清晰,简洁中性背景,写实摄影,"
"不要文字、水印、拼图、多人、遮挡脸部或夸张滤镜。"
f"{no_product}"
)
if appearance_only:
prompt += f" 用户指定的人物外观:{appearance_only}。严格保留这些外观要求。"
if cast_total > 1:
prompt += (
f" 这是多人物视频中的角色{cast_index},必须与其他角色在性别或发型或年龄段或服装气质上"
"有清晰可辨的差异,便于整片锁脸。"
)
label = f"正在生成人物参考({cast_index}/{cast_total}"
model_name = f"平台生成出镜人物{cast_index}"
else:
label = "正在生成人物参考"
model_name = "平台生成出镜人物"
return prompt, label, model_name
def submit_generated_person_reference(
*,
conversation: CreationConversation,
user,
appearance_prompt: str = "",
cast_count: int | None = None,
) -> list:
"""生成人物/宠物角色定妆参考;多人物视频会一次生成多张,完成后再建模特并锁定。
定妆图 prompt 只写角色外观,不写入商品名/卖点/用户创作简述,也不带商品参考图,
否则出图模型常会把商品画进角色照。
返回 GENERATING 消息列表(单人时长度为 1)。
"""
from .services import enqueue_standalone_images
recent_user = (
list(
conversation.messages.filter(role=CreationMessage.Role.USER)
.order_by("-seq").values_list("text", flat=True)[:6]
)
if getattr(conversation, "created_at", None)
else []
)
# 仅用于推断宠物品种;绝不拼进最终定妆 prompt。
context_brief = "\n".join(reversed([item.strip() for item in recent_user if item and item.strip()]))[:700]
raw_appearance = (appearance_prompt or "").strip()[:500]
total = cast_count if cast_count is not None else infer_needed_cast_count(conversation, raw_appearance)
total = max(1, min(4, int(total or 1)))
if is_pet_preset(conversation.preset):
total = 1
role_briefs = build_cast_role_briefs(total, raw_appearance)
memory = dict(conversation.memory or {})
memory["person_source"] = "platform_generate"
memory["person_source_pending"] = True
memory["person_prompt"] = appearance_prompt
memory["person_cast_total"] = total
memory["person_cast_pending"] = total
memory["person_cast_model_ids"] = []
memory.pop("person_confirm_pending", None)
memory.pop("person_source_ready", None)
memory.pop("person_model_id", None)
conversation.memory = memory
conversation.status = CreationConversation.Status.RUNNING
conversation.agent_status = CreationConversation.AgentStatus.IDLE
conversation.save(update_fields=["memory", "status", "agent_status", "updated_at"])
messages = []
for index, brief in enumerate(role_briefs, start=1):
prompt, label, model_name = _build_single_person_reference_prompt(
conversation=conversation,
appearance_only=brief,
context_brief=context_brief,
cast_index=index,
cast_total=total,
)
tasks = enqueue_standalone_images(
team=conversation.team,
user=user,
prompt=prompt,
mode="model",
count=1,
ratio="portrait",
feature="omni_create",
)
task = tasks[0]
messages.append(
append_message(
conversation,
role="assistant",
kind=CreationMessage.Kind.GENERATING,
payload={
"task_id": str(task.id),
"kind": "person_reference",
"prompt": prompt,
"label": label,
"cast_index": index,
"cast_total": total,
"cast_model_name": model_name,
},
task=task,
)
)
return messages
def insufficient_cast_refs_message(conversation: CreationConversation, prompt: str = "") -> str:
"""出片前:多人物但角色图不够时给出阻断文案。"""
memory = conversation.memory if isinstance(conversation.memory, dict) else {}
if str(memory.get("person_source") or "") == "finger_only":
return ""
needed = infer_needed_cast_count(conversation, prompt)
have = len(locked_person_references(conversation))
if needed <= have:
return ""
return (
f"这是多人物视频,需要 {needed} 张角色定妆图锁定前后形象,当前只有 {have} 张。"
"请先用「平台帮忙生成」一次生成多位角色,或从模特库/本地补齐后再出片。"
)
def _step_confirm_payload(step: str) -> dict:
return {
"interaction": "step_confirm",
"step": step,
"fields": [
{
"key": "step_action",
"label": "这一步可以继续吗?",
"type": "single",
"required": True,
"options": [
{"value": "confirm", "label": "按这个继续"},
{"value": "revise", "label": "我想改"},
],
}
],
"submitted": False,
"answers": {},
}
def append_step_confirm(conversation: CreationConversation, step: str) -> CreationMessage:
return append_message(
conversation,
role="assistant",
kind=CreationMessage.Kind.ELICIT,
text=_STEP_CONFIRM_LABELS.get(step, "请确认这一步后再继续。"),
payload=_step_confirm_payload(step),
)
_GATE_CONTENT_KIND = {
"strategy": CreationMessage.Kind.STRATEGY,
"plan": CreationMessage.Kind.PLAN,
"prompt": CreationMessage.Kind.PROMPT_FILE,
}
def restore_gated_step_after_cancel(conversation: CreationConversation) -> bool:
"""取消修订/推进中的规划时,恢复最近闸门的 step_confirm,供用户再点「按这个继续 / 我想改」。
返回 True → 会话应回到 awaiting_user;False → 保持 idle(尚无闸门可恢复)。
幂等:已有未提交的 step_confirm 时只校正 stage。
"""
gated = ("strategy", "plan", "prompt")
confirms = []
for message in (
conversation.messages.filter(kind=CreationMessage.Kind.ELICIT)
.order_by("-seq")[:30]
):
payload = message.payload if isinstance(message.payload, dict) else {}
step = str(payload.get("step") or "").strip()
if payload.get("interaction") == "step_confirm" and step in gated:
confirms.append(message)
if not confirms:
return False
latest = confirms[0]
payload = dict(latest.payload or {})
step = str(payload.get("step") or "").strip()
if step not in gated:
return False
if not payload.get("submitted"):
set_video_gate_stage(conversation, step)
return True
content_kind = _GATE_CONTENT_KIND.get(step)
newer_content = False
if content_kind:
newer_content = conversation.messages.filter(
seq__gt=latest.seq, kind=content_kind
).exists()
if newer_content:
# 重写已落了新卡但还没出确认条 / 或已出过但本条仍是旧 submitted —— 补一条干净确认
has_open = any(
(not (m.payload or {}).get("submitted"))
and str((m.payload or {}).get("step") or "") == step
for m in confirms
)
if not has_open:
append_step_confirm(conversation, step)
else:
payload["submitted"] = False
payload["answers"] = {}
latest.payload = payload
latest.save(update_fields=["payload", "updated_at"])
set_video_gate_stage(conversation, step)
if step == "strategy":
memory = dict(conversation.memory or {})
if "strategy_confirmed" in memory:
memory.pop("strategy_confirmed", None)
conversation.memory = memory
conversation.save(update_fields=["memory", "updated_at"])
return True
def emit_prompt_gate(
conversation: CreationConversation, video_prompt: str | None = None
) -> list[CreationMessage]:
"""方案确认后:落 prompt_file + 步骤确认卡(确定性,不再跑模型)。"""
prompt = (video_prompt or get_pending_video_prompt(conversation) or "").strip()
if not prompt:
return []
prompt = apply_product_voice_visual_guard(conversation, prompt)
prompt = apply_product_reality_guard(prompt)
prompt = apply_clothing_video_guard(conversation, prompt)
prompt = apply_product_appearance_guard(conversation, prompt)
prompt = apply_pet_dialogue_guard(conversation, prompt)
prompt = apply_video_platform_safety_guard(prompt)
messages = []
prompt_file = append_message(
conversation,
role="assistant",
kind=CreationMessage.Kind.PROMPT_FILE,
payload={
"title": "视频生成Prompt.md",
"body": prompt,
"ref_count": len(conversation.pinned_refs or []),
},
)
messages.append(prompt_file)
# 缓存供最终确认卡使用
set_video_gate_stage(conversation, "prompt", pending_video_prompt=prompt)
messages.append(append_step_confirm(conversation, "prompt"))
return messages
def emit_final_confirm_gate(
conversation: CreationConversation,
*,
context: "AgentContext | None" = None,
) -> CreationMessage | None:
"""Prompt 确认后:落最终积分确认卡(确定性)。"""
if conversation.mode != CreationConversation.Mode.VIDEO:
return None
prompt = get_pending_video_prompt(conversation)
if not prompt:
last = (
conversation.messages.filter(kind=CreationMessage.Kind.PROMPT_FILE)
.order_by("-seq")
.first()
)
prompt = str((last.payload or {}).get("body") or "").strip() if last else ""
if not prompt:
return None
latest_plan = (
conversation.messages.filter(kind=CreationMessage.Kind.PLAN)
.order_by("-seq")
.first()
)
timeline = list((latest_plan.payload or {}).get("timeline") or []) if latest_plan else []
prompt = apply_product_voice_visual_guard(conversation, prompt)
prompt = apply_product_reality_guard(prompt)
prompt = apply_clothing_video_guard(conversation, prompt)
prompt = apply_product_appearance_guard(conversation, prompt)
prompt = apply_pet_dialogue_guard(conversation, prompt)
prompt = apply_video_platform_safety_guard(prompt)
credits = 0
if context is not None:
try:
credits = estimate_video_credits(context)
except Exception: # noqa: BLE001
credits = 0
else:
# 无 AgentContext 时用临时壳估分(model_config 可空)
try:
credits = estimate_video_credits(
AgentContext(
conversation=conversation,
user=conversation.created_by,
model_config=None, # type: ignore[arg-type]
)
)
except Exception: # noqa: BLE001
credits = 0
confirm = append_message(
conversation,
role="assistant",
kind=CreationMessage.Kind.CONFIRM,
payload={
"kind": "video",
"label": "开始生成",
"estimated_credits": credits,
"video_prompt": prompt,
"timeline": timeline,
"submitted": False,
"params": snapshot_session_params(conversation),
"param_options": confirm_param_options(True),
},
)
set_video_gate_stage(conversation, "confirm", pending_video_prompt=prompt)
return confirm
# 记忆压缩(契约 §5):超过这么多条消息就把最老的一批压成一段摘要,
# 只保留最近 KEEP_RECENT_MESSAGES 条原文。
COMPRESS_AFTER_MESSAGES = 24
KEEP_RECENT_MESSAGES = 12
# 攒够这么多条没压过的消息才重压一次。没有它的话,过了阈值以后**每一轮都要多花
# 一次模型调用**去重压那么两三句话 —— 长会话的成本会翻倍。
COMPRESS_MIN_BATCH = 8
FIELD_TYPES = ("single", "multi", "text", "asset")
# 顶栏下拉里的展示名 → 火山模型名。前端给的是人看的label,submit_free_video 只认真名。
VIDEO_MODEL_BY_LABEL = {
"Seedance 2.5": "doubao-seedance-2-5-260628",
"Seedance 2.0": "doubao-seedance-2-0-260128",
"Seedance 2.0 Fast": "doubao-seedance-2-0-fast-260128",
"Seedance 2.0 Mini": "doubao-seedance-2-0-mini-260615",
}
DEFAULT_VIDEO_MODEL = "doubao-seedance-2-5-260628"
IMAGE_MODEL_BY_LABEL = {
"Seedream5.0": "volcano",
"Seedream-5.0-pro": "volcano",
"YQ image2": "gpt-image",
"影擎-Image2": "gpt-image",
}
# 「智能时长」没有可读到的脚本时才用的保守兜底。实际方案生成后必须从时间轴推导,
# 不能把每条片都悄悄压成 15 秒。
SMART_DURATION = 15
# 临时关闭 >60 秒长视频生成;恢复时改回 180。
MAX_VIDEO_DURATION = 60
MAX_VIDEO_SEGMENT_DURATION = 30
LONG_VIDEO_CHAPTER_DURATION = 60
_SMART_DURATION_RE = re.compile(
# 结尾必须明确带「秒/s」。原来的「秒?」会把“18–22岁”误当成 22 秒。
r"(?<!\d)(\d{1,3}(?:\.\d+)?)\s*(?:-|—||~|至|到)\s*(\d{1,3}(?:\.\d+)?)\s*(?:秒|s)",
re.IGNORECASE,
)
_TOTAL_DURATION_RE = re.compile(
r"(?:总时长|成片时长|时长)\s*[:]?\s*(\d{1,3}(?:\.\d+)?)\s*秒",
re.IGNORECASE,
)
# 全能创作 video_prompt 写作规范。目标是把 Prompt 写成导演、摄影、声音与出片模型都能
# 直接执行的制作文件,而不是只有几行「0-3 秒做什么」的提纲。
_OMNI_VIDEO_PROMPT_RULES = """
【视频生成 Prompt · 制作级交付】
video_prompt 是交给出片模型的完整制作文件,不是方案摘要、更不是几句分镜大纲。保留用户选定的
预设、人物、商品和创意方向;下面规定的是文档完整度与写法,不能照搬任何其他项目的商品、人设或台词。
请按以下顺序完整输出,每个标题都要有具体内容:
时长:X 秒以内,比例:X。任务:一句话说明要生成什么、引用哪些参考素材。
标题:《本片标题》
风格与视觉参考:说明成片质感、真实度、拍摄媒介感与 4–6 个风格关键词。
镜头语言:说明主景别、人物/商品与镜头关系、机位、手持或稳定方式、镜头切换节奏。
视觉与美术规则:说明场景陈设、主体外观连续性、商品外观连续性;有参考图时要说清「只取什么特征、忽略什么背景/构图」。
色彩与材质系统:写出主色、材质、皮肤/产品/环境的可见质感。
打光规则:光源方向、软硬、色温、人物和产品分别如何受光。
剪辑节奏:列出时间段与 Hook → 证据/体验 → 转化收束的推进逻辑。
声音方向:人声身份、语气、语速、环境声/拟音、背景音乐的进入和收束;人声原文必须分配到对应分镜。
场景:逐一写清可见地点、前中后景、环境道具与景深。
主体与参考素材:逐一说明角色、商品、场景的可见身份和一致性要求。已 @ 的素材按 @图片1、@图片2 … 标注其用途;仅引用实际提供的素材。
各个分镜的具体内容:按时间顺序逐段展开。
分镜不能省略细节:每一段都必须严格用下面四行写完,不得只写一句画面描述:
「03 秒」
拍法:景别 + 机位 + 运镜 + 节奏。
画面内容:谁在何处、哪只手/身体如何动作、商品如何进入画面、前后发生什么可见变化。
主体/产品露出:本段产品、人物或关键物件处在什么位置,哪些外观细节必须清晰稳定。
声音:人声(仅音频,由人物口型与配音表达,不作为画面文字):{本段原话};再写清对应拟音和背景音乐状态。
硬性质量要求:
- 15 秒成片至少写 4 段连续分镜;16–60 秒按约 3–5 秒一个镜头节点。超过 60 秒后不要机械放大短片字数,改为每个 30 秒生成段写 3–5 个关键镜头节点,并把段尾交接状态写清。
- 15 秒 Prompt 通常不少于约 1200 个汉字;16–60 秒按内容需要展开。超过 60 秒时 write_plan 不要写制作级长文:只写章节结构、每个 30 秒生成段 3–5 个关键镜头、段尾交接状态,全文约 800–1400 个汉字。禁止用重复对白、空镜或堆砌形容词凑长度。每一段都要有可拍的连续动作、物理反馈或产品证据,不能用「高级感、展示质感、氛围拉满」代替动作。
- 人声必须是自然口语,约 5.0–5.7 字/秒;钩子前 15 个字禁止「大家好 / 今天分享 / 给你们推荐」。CTA 像朋友提醒,禁止小黄车、立即购买、闭眼入等平台指令腔。
- 全片围绕一个具体情境和一个主卖点推进,卖点必须有可见证据,例如质地、使用动作、前后变化或真实反应。
- 商品演示先过“真实用途与状态”检查:只展示商品在其已知用途和已提供事实范围内的正常、完整状态;
禁止无依据出现破损、漏液、渗水、失效、异常变形、脏污,或把商品拿去做超出用途的压力测试。
不确定防水、防漏、承重、耐热、容量、材质等关键能力时,不编造测试和结果;要么问用户,要么改用可观察的正常使用动作。
- 服装/服饰类商品:人物从第一镜起就已经穿着当前商品,只拍上身效果、版型、面料、走动与搭配;
不要写穿衣服、套袖、扣扣、拉拉链、脱换过程等穿戴动作镜头。换款用瞬间切换或已穿好的下一套展示。
- 画面中不出现新增字幕、花字、标题贴片、弹幕、角标、水印、购物浮层或说明性文字;口播只存在于声音,包装本身原有印刷字除外。
- 结尾单列「全片一致性与禁用项」:用正向、可执行的句子重申角色、商品、场景、光线、服装/材质的连续性。
有商品参考图时,商品颜色与外形必须写明「以参考商品图为准」,禁止另写冲突颜色。
- 用户说「商品说话 / 商品自述 / 商品拟人」时,默认无脸拟人:商品声音是画外角色声,商品本体不做口型、不新增卡通五官;性格只通过整体倾斜、转向、弹跳、进退、镜头和音效表达。只有用户明确要求可见卡通五官时才例外。
- 审核安全必须在第一次生成时完成,不能依赖提交前清洗。没有锁定人物素材时,人物只写「成年女性 / 成年男性 / 成年人」,不要自创精确年龄区间,不使用带幼态联想的称呼、音色或人设。服装写日常得体,默认平视、自然俯拍或尊重主体的正面构图,不强调身体局部。
- 最终 video_prompt 只使用正向安全描述。不要把平台风险类别、禁用词或用户原始高风险措辞逐项写进 Prompt,否定句、免责声明和「禁止出现某词」也不能照抄;先在内部把冲突改写成成年人之间积极、友善的日常互动,再输出改写后的可拍内容。
- 平台安全优先于戏剧冲突:若原意不适合直接出片,只保留情绪转折和真实商品卖点,改成成年人之间的日常误会、压力或良性竞争,并采用原创人物与场景;不要在输出中复述被替换掉的原情节。
- 不写医疗诊断、治愈/根治、绝对效果、虚构权威背书或夸大功效;把卖点改为真实可见的使用动作、材质细节和日常体验。最终 video_prompt 必须是可直接提交审核和出片的安全版本。
长视频追加规则(总时长超过 60 秒时强制执行):
- write_plan 先交用户能看懂的方案卡,再存一份紧凑可执行稿,不要把 15 秒短片的「制作级完整文件」模板整篇套进去。
- 当前平台成片上限 60 秒:不要提议或生成 90/120/180 秒方案。若总时长接近 60 秒,按单章内连续推进即可,不能每 30 秒重新开场、重新介绍人物或重复 CTA。
- 整片仍只围绕一个核心卖点;长出来的时长用于增加处境、证据、使用过程、反应与回收,不得复制同一段对白、空镜或卖点凑时长。
- 每个 30 秒出片边界都写清「交接状态」:边界前人物所在位置与动作、手中商品/SKU、商品朝向和完整状态、场景道具、机位、光线、情绪及下一动作。后一段第一镜必须从该状态自然续接。
- 连续性只写一份短台账:角色、每个商品/SKU、场景、主光。不要把风格/灯光/声音规则每个镜头重复一遍。
- 多商品、多颜色或多 SKU 必须分别绑定对应参考图与名称。同一商品的正面图、细节图、三视图视为同一 SKU 的多角度证据;不同名称的商品/SKU 相互独立,禁止拼合外形、互换颜色、标签、配件或功能。
- 只有第一段承担全片开场,只有最后一段承担最终 CTA;中间段从未完成的动作、对白或因果关系直接续接。声音音色、语速、环境底噪与 BGM 节拍也要跨段连续。
- 用户确认方案后,write_prompt 只补一段简短全片规则,不要把逐镜再扩写成短片密度。
"""
_PRODUCT_VOICE_HINT_RE = re.compile(
r"(商品|产品|包装|瓶|机身).{0,8}(说话|开口|自述|拟人)"
r"|拟人.{0,8}(商品|产品|包装|瓶|机身)"
)
_VISIBLE_PRODUCT_FACE_RE = re.compile(
r"(商品|产品|包装|瓶|机身).{0,12}(卡通)?(五官|眼睛|眼珠|嘴巴|嘴|口型)"
r"|(卡通)?(五官|眼睛|眼珠|嘴巴|嘴|口型).{0,12}(商品|产品|包装|瓶|机身)"
r"|(给|让).{0,12}(长出|加上|出现).{0,6}(卡通)?(五官|眼睛|眼珠|嘴巴|嘴|口型)"
)
PRODUCT_VOICE_VISUAL_GUARD = (
"【商品角色表现·最高优先级】商品始终是参考图里的真实物件。包装正面、瓶身、机身和全部可见表面"
"保持原有设计,只保留参考图本来就有的标签、图案与结构。拟人感完全通过整个商品轻微倾斜、"
"转向、弹跳、进退,配合镜头、光影、环境反应和音效表达。商品台词采用画外角色声,声源不在"
"画面内,商品保持完整物件形态;场景里的其他物件也保持真实原貌。整体采用精致实拍广告质感"
"与克制幽默。前文若有改造商品外观的动作描述,统一改成商品整体运动的对应表达。"
)
# 这段在最终出片 Prompt 上再加一道确定性约束,避免模型虽然在方案里写了“真实使用”,
# 但生成时又把商品拍进不合理的失效状态。它不替用户补商品性能,只限制无依据的错误演示。
PRODUCT_REALITY_GUARD = (
"【商品真实使用约束·最高优先级】商品只按已知真实用途和用户提供的事实展示,"
"全程保持正常、完整、可用的状态。没有用户明确提供的性能依据时,不出现破损、漏液、渗水、"
"失效、异常变形、脏污、超载或超出用途的测试;也不把不确定的防水、防漏、承重、耐热、容量、"
"材质等能力拍成已被验证的结果。若关键信息不足,使用可观察的正常操作替代夸张测试。"
)
# 服装类视频禁止「穿衣过程」镜头:模特从第一帧就已穿着商品,只展示上身效果。
# marker 单独拎出来:去重要按标记认,按整段原文认会被改写规则绕过。
CLOTHING_GUARD_MARKER = "【服装类镜头约束·最高优先级】"
CLOTHING_VIDEO_NO_DRESSING_GUARD = (
f"{CLOTHING_GUARD_MARKER}人物从第一镜起就已经穿着当前服装商品,"
"只展示上身效果、版型线条、面料质感、走动垂坠与场景搭配。"
"不要出现穿衣服、往身上套、伸袖、套头、拉拉链、扣纽扣、系腰带、脱衣换装过程等穿戴动作镜头;"
"换款或换装用瞬间切换、转场或已穿好的下一套直接展示,不要拍穿衣过程。"
)
# 已锁定商品参考图时:文字描述不得改写商品颜色/外形;与参考图冲突时以图为准。
PRODUCT_APPEARANCE_GUARD = (
"【商品外观锁定·最高优先级】商品颜色、材质、外形、面板、按键、把手与整体结构必须与用户上传的商品参考图完全一致。"
"禁止把参考图里的商品改成其他颜色或近似款(例如把黑色写成白色、把深色写成浅色)。"
"若前文颜色或外形描述与参考图冲突,一律以参考图为准;不确定时只写「严格按参考商品图外观」,不要猜测颜色。"
)
PET_DIALOGUE_GUARD_MARKER = "【宠物拟人台词·最高优先级】"
PET_DIALOGUE_GUARD = (
f"{PET_DIALOGUE_GUARD_MARKER}片中人声必须是出镜宠物的第一人称说话,像这只宠物自己在开口:"
"短句、口语、带性格,可用贴合物种的自称;声音从宠物角色发出(可配合口型或明确的角色声)。"
"禁止旁白解说、第三人称讲述、达人口播腔、画外音旁白,或「今天给大家介绍/测评」类带货开场。"
)
_CLOTHING_CATEGORY_HINTS = (
"服饰", "女装", "男装", "服装", "内衣", "裤装", "裙装", "上衣", "外套", "鞋服",
"连衣裙", "半身裙", "衬衫", "卫衣", "毛衣", "夹克", "大衣", "羽绒服", "风衣",
"牛仔裤", "阔腿裤", "休闲裤", "西装", "套装", "吊带", "背心", "T恤", "t恤",
"短袖", "长袖", "针织", "打底", "睡衣", "家居服", "泳装", "泳衣",
)
_CLOTHING_DRESSING_REWRITES: tuple[tuple[str, str], ...] = (
(r"把(?:这款|该|那件|这件)?(?:衣服|服装|外套|上衣|裙子|裤子)?穿上", "已穿着该服装展示上身效果"),
(r"(?:开始|正在|慢慢)?穿(?:上|好)?(?:这款|该|那件|这件)?(?:衣服|服装|外套|上衣|裙子|裤子)", "已穿着该服装自然展示"),
(r"穿衣服(?:的)?(?:过程|镜头|动作)?", "已穿好服装的上身展示"),
(r"(?:套上|换上|穿进)(?:袖子|衣袖|外套|上衣|裙子|裤子|衣服)", "已穿着该服装"),
(r"(?:拉上|拉开)拉链(?:穿上|穿好)?", "已穿着该服装,拉链细节特写"),
(r"(?:扣上|解开)纽扣", "已穿着该服装,门襟与纽扣细节特写"),
(r"脱下(?:旧衣|旧的)?(?:衣服|外套|上衣)?(?:再|后)?(?:换|穿)", "瞬间切换为已穿好的下一套"),
(r"换装过程中.{0,12}(?:穿|套|脱)", "瞬间切换为已穿好的下一套"),
)
# 这一层不是替代平台审核,而是在脚本落成最终 video_prompt 前先把最容易被视频模型
# 拦截的明确高风险表述改成等价的安全叙事。模型仍会收到下方完整约束,避免只靠关键词替换。
VIDEO_PLATFORM_SAFETY_GUARD = (
"【平台安全出片约束·最高优先级】全片采用健康、友善、合法且适合公开传播的原创商业表达。"
"出镜人物均明确为二十二岁以上成年人,着装与镜头语言自然得体,构图保持尊重。"
"所有情节通过日常互动、积极沟通和轻松表达推进,并以正向结果收束。"
"商品只呈现已知事实、正常用途和可观察的使用体验,所有描述保持客观克制。"
)
_VIDEO_PLATFORM_SAFETY_REWRITES: tuple[tuple[re.Pattern[str], str], ...] = (
(
re.compile(
r"暴打|殴打|群殴|互殴|动手伤人|打伤|捅伤|刺伤|砍伤|见血|鲜血(?:直流)?|血泊|"
r"杀人|杀死|自杀|自残|割腕|跳楼|爆炸|绑架|虐待"
),
"强烈情绪冲突通过沟通与日常误会化解",
),
(
re.compile(r"裸体|裸露|色情|性爱|性行为|性暗示|挑逗|床戏"),
"得体着装下的自然成年人互动",
),
(
re.compile(r"吸毒|毒品交易|贩毒|赌博|赌钱|诈骗|抢劫|偷窃|枪战|持枪|开枪"),
"合规、稳妥的日常情节",
),
(
re.compile(r"治愈|根治|药到病除|包治|抗癌|无副作用|百分之百(?:有效|治愈)|永久(?:有效|瘦)"),
"真实、可观察的日常使用体验",
),
(
re.compile(r"(?:模仿|复刻|扮演|像).{0,16}(?:明星|艺人|名人|网红|演员)"),
"采用原创人物设定与表演",
),
(
re.compile(r"(?<!\d)18\s*(?:-|—||~|至|到)\s*22\s*岁\s*(?:软甜)?少女音"),
"二十二岁以上成年女性的清甜自然声线",
),
(
re.compile(r"(?<!\d)18\s*(?:-|—||~|至|到)\s*22\s*岁"),
"二十二岁以上",
),
(re.compile(r"少女"), "年轻成年女性"),
(re.compile(r"甜妹"), "甜美风成年女性"),
(re.compile(r"低角度轻微仰拍"), "略低于视线的正面拍摄"),
(re.compile(r"低角度鱼眼机位"), "正面鱼眼机位"),
)
def apply_product_voice_visual_guard(conversation: CreationConversation, prompt: str) -> str:
"""商品拟人默认不长脸;用户明确要求可见卡通五官时尊重其创作选择。"""
base = str(prompt or "").strip()
recent_user_text = " ".join(
conversation.messages.filter(
role=CreationMessage.Role.USER,
kind=CreationMessage.Kind.TEXT,
).order_by("-seq").values_list("text", flat=True)[:12]
)
if _VISIBLE_PRODUCT_FACE_RE.search(recent_user_text):
return base
needs_guard = (
conversation.preset == "商品拟人广告"
or bool(_PRODUCT_VOICE_HINT_RE.search(recent_user_text))
or bool(_PRODUCT_VOICE_HINT_RE.search(base))
)
if not needs_guard or PRODUCT_VOICE_VISUAL_GUARD in base:
return base
return f"{base}\n{PRODUCT_VOICE_VISUAL_GUARD}".strip()
def apply_product_reality_guard(prompt: str) -> str:
"""所有商品视频在出片前补上物理与用途边界,避免最终模型误演“产品坏了”。"""
base = str(prompt or "").strip()
if not base or PRODUCT_REALITY_GUARD in base:
return base
return f"{base}\n{PRODUCT_REALITY_GUARD}".strip()
def _clothing_context_blob(conversation: CreationConversation | None, prompt: str = "") -> str:
"""汇总商品名、品类、预设与 prompt,用于判断是否服装类视频。"""
parts = [str(prompt or "")]
if conversation is None:
return "\n".join(parts)
parts.append(str(conversation.preset or ""))
memory = conversation.memory if isinstance(conversation.memory, dict) else {}
for key in ("product_name", "product_brand", "product_brand_and_name", "selling_point", "pending_video_prompt"):
parts.append(str(memory.get(key) or ""))
for ref in locked_product_references(conversation):
if not isinstance(ref, dict):
continue
parts.append(str(ref.get("name") or ""))
parts.append(str(ref.get("category") or ""))
parts.append(str(ref.get("title") or ""))
return "\n".join(parts)
def _clothing_hint_hit(blob: str) -> bool:
lower = str(blob or "").lower()
return any(hint.lower() in lower for hint in _CLOTHING_CATEGORY_HINTS)
def _product_context_blob(conversation: CreationConversation | None) -> str:
"""只汇总「卖的是什么」:商品名、品类和记忆里的商品字段,不含 prompt。"""
if conversation is None:
return ""
parts: list[str] = []
memory = conversation.memory if isinstance(conversation.memory, dict) else {}
for key in ("product_name", "product_brand", "product_brand_and_name"):
parts.append(str(memory.get(key) or ""))
for ref in locked_product_references(conversation):
if not isinstance(ref, dict):
continue
parts.append(str(ref.get("name") or ""))
parts.append(str(ref.get("category") or ""))
parts.append(str(ref.get("title") or ""))
return "\n".join(part for part in parts if part.strip())
def is_clothing_video_context(conversation: CreationConversation | None, prompt: str = "") -> bool:
"""服装/服饰类视频:含鱼眼换装预设,或商品本身命中服装关键词。
已知卖什么时**以商品为准**:任何带人的脚本都会写人物穿的卫衣、针织、阔腿裤,
拿整段 prompt 去判会把护肤品、家电一律误判成服装视频,白套一身穿衣禁令。
"""
if conversation is not None and str(conversation.preset or "").strip() == "鱼眼换装":
return True
product_blob = _product_context_blob(conversation)
if product_blob.strip():
return _clothing_hint_hit(product_blob)
# 还不知道卖什么(未锁商品/无会话)时才退回看文案。
return _clothing_hint_hit(_clothing_context_blob(conversation, prompt))
def apply_clothing_video_guard(
conversation: CreationConversation | None,
prompt: str,
) -> str:
"""服装类视频去掉穿衣过程镜头,改为已穿好上身展示;可重复调用。"""
base = str(prompt or "").strip()
if not base or not is_clothing_video_context(conversation, base):
return base
# 先摘掉已有的约束段再改写:改写规则会把约束段里的「穿衣服」也一起替换掉,
# 于是下一次调用按原文比对不上、又补一段,重复调用能叠出四五份。
base = "\n".join(
line for line in base.splitlines() if CLOTHING_GUARD_MARKER not in line
).strip()
for pattern, replacement in _CLOTHING_DRESSING_REWRITES:
base = re.sub(pattern, replacement, base)
return f"{base}\n{CLOTHING_VIDEO_NO_DRESSING_GUARD}".strip()
def apply_product_appearance_guard(
conversation: CreationConversation | None,
prompt: str,
) -> str:
"""已锁定商品参考图时,出图/出片指令追加外观锁定,避免文字把黑商品写成白。"""
base = str(prompt or "").strip()
if not base or conversation is None:
return base
if not has_locked_product_reference(conversation):
return base
if PRODUCT_APPEARANCE_GUARD in base:
return base
return f"{base}\n{PRODUCT_APPEARANCE_GUARD}".strip()
def apply_pet_dialogue_guard(
conversation: CreationConversation | None,
prompt: str,
) -> str:
"""AI 宠物拟人:台词必须是宠物第一人称,禁止旁白/口播/画外音感。"""
base = str(prompt or "").strip()
if not base or conversation is None:
return base
if not is_pet_preset(conversation.preset):
return base
# 先摘掉已有的约束段再改写:改写规则会把约束段里当反例的「今天给大家介绍」也一起换掉,
# 于是下一次调用比对不上、又补一段,出片确认 / 重试几轮能叠出好几份。
base = "\n".join(
line for line in base.splitlines() if PET_DIALOGUE_GUARD_MARKER not in line
).strip()
# 轻量改写常见旁白开场,避免最终模型照念
rewrites = (
(r"今天给大家介绍", "我今天发现"),
(r"给大家推荐", "我超喜欢"),
(r"旁白[:]", "宠物说:"),
(r"画外音[:]", "宠物说:"),
(r"口播[:]", "宠物说:"),
)
for pattern, replacement in rewrites:
base = re.sub(pattern, replacement, base)
return f"{base}\n{PET_DIALOGUE_GUARD}".strip()
def apply_video_platform_safety_guard(prompt: str) -> str:
"""将最终出片指令收束为较不易触发视频模型审核的安全版本,且可重复调用。"""
base = str(prompt or "").strip()
if not base or VIDEO_PLATFORM_SAFETY_GUARD in base:
return base
for pattern, replacement in _VIDEO_PLATFORM_SAFETY_REWRITES:
base = pattern.sub(replacement, base)
return f"{base}\n{VIDEO_PLATFORM_SAFETY_GUARD}".strip()
class AgentError(Exception):
"""Agent 循环里的业务错误,已经是可以直接给用户看的中文。"""
@dataclass
class AgentContext:
conversation: CreationConversation
user: object
model_config: ModelConfig
generations_used: int = 0
@property
def team(self):
return self.conversation.team
@property
def is_video(self) -> bool:
return self.conversation.mode == CreationConversation.Mode.VIDEO
_ASSET_PICK_LABEL = {
"product": "换成哪个商品?",
"character": "换成哪个角色?",
"model": "换成哪个模特?",
"scene": "换成哪个场景?",
}
_ASSET_PICK_PATTERNS = (
("product", re.compile(r"(改|换|修改|更换|重新选|选(一个|个)?|挑).{0,8}商品")),
("character", re.compile(r"(改|换|修改|更换|重新选|选(一个|个)?).{0,8}(角色|人物)")),
("model", re.compile(r"(改|换|修改|更换|重新选|选(一个|个)?).{0,8}模特")),
("scene", re.compile(r"(改|换|修改|更换|重新选|选(一个|个)?).{0,8}场景")),
)
_CHITCHAT_RE = re.compile(
r"^\s*("
r"hi|hello|hey|yo|hola|"
r"你好呀?|您好|嗨|哈喽|嘿|"
r"在吗|在不在|有人吗|"
r"早+|早安|早上好|午安|晚安|"
r"嗯+|哦+|噢+|额+|呃+|"
r"好的?|行|可以|ok(?:ay)?|thanks?|thank\s*you|谢谢了?|感谢|"
r"收到|知道了|明白了|了解"
r")[\s!.。~~??…]*$",
re.IGNORECASE,
)
_GREETING_RE = re.compile(
r"^\s*("
r"hi|hello|hey|yo|hola|"
r"你好呀?|您好|嗨|哈喽|嘿|"
r"在吗|在不在|有人吗|"
r"早+|早安|早上好|午安|晚安"
r")[\s!.。~~??…]*$",
re.IGNORECASE,
)
def is_greeting(user_text: str) -> bool:
"""单纯打招呼要立刻回应,不能为了分析引用素材去等模型。"""
return bool(_GREETING_RE.match((user_text or "").strip()))
def is_pure_chitchat(user_text: str) -> bool:
"""纯打招呼 / 应答,没有创作意图。这类消息绝不能触发 write_strategy / write_plan。"""
text = (user_text or "").strip()
if not text or len(text) > 24:
return False
return bool(_CHITCHAT_RE.match(text))
# ---------------------------------------------------------------- 每轮引导(文字也要告诉用户下一步怎么回)
_GUIDANCE_MARKER_RE = re.compile(
r"("
r"请回复|回复[::]|请直接|请告诉我|请选|点「|直接输入|"
r"选择一个|发「|例如「"
r")"
)
def text_already_guides(text: str) -> bool:
"""正文里是否已经写明用户下一步该怎么回。"""
return bool(_GUIDANCE_MARKER_RE.search((text or "").strip()))
_NUMERIC_REPLY_INSTRUCTION_RE = re.compile(
r"(?:你|请)?\s*(?:直接)?(?:回复|选择|选)\s*(?:数字|编号)?\s*"
r"1\s*(?:[/、,,]\s*2)(?:\s*(?:[/、,,]\s*3))?[^。!?!\n]*(?:[。!?!]|$)"
)
def strip_numeric_reply_instruction(text: str) -> str:
"""选择器会把 1/2/3 直接做成按钮,正文不再要求用户手输数字。"""
return _NUMERIC_REPLY_INSTRUCTION_RE.sub("", text or "").strip()
def default_reply_hint(
conversation: CreationConversation | None = None,
*,
has_context: bool = False,
is_video: bool = True,
) -> str:
"""纯文字收束时的默认「下一步可以这样回」提示。"""
stage = ""
if conversation is not None:
try:
stage = get_video_gate_stage(conversation)
except Exception: # noqa: BLE001
stage = ""
if stage in ("strategy", "plan", "prompt"):
return "请点上方确认卡的「按这个继续」,或直接说想改哪里。"
if stage == "confirm":
return "请在确认卡上核对参数后点「开始生成」,或直接说要改的参数。"
if stage == "done":
return "可以回复「再出一版」,也可以选下方想调整的部分。"
if has_context:
if is_video:
return "可以回复「继续完善方案」,也可以选下方想调整的方向。"
return "可以回复「按这个方向出图」,也可以选下方想调整的方向。"
kind = "短视频" if is_video else "商品图"
return f"请直接丢一句想法,例如「帮我做一条{kind}」。"
def default_reply_options(
conversation: CreationConversation | None = None,
*,
has_context: bool = False,
is_video: bool = True,
assistant_text: str = "",
) -> list[dict[str, str]]:
"""只给与上一句相关的快捷回复,绝不塞与当前进度无关的固定话术。"""
stage = ""
if conversation is not None:
try:
stage = get_video_gate_stage(conversation)
except Exception: # noqa: BLE001
stage = ""
if stage == "done":
return [
{"label": "再出一版", "text": "按当前方向再出一版"},
{"label": "调整画面", "text": "我想调整画面"},
{"label": "重新来", "text": "重新来"},
]
if has_context:
text = str(assistant_text or "")
# 创作描述常同时出现商品、人物、场景、颜色。快捷回复只看末尾真正交给用户
# 决定的部分,避免正文里的普通名词抢走最后一句的意图。
paragraphs = [part.strip() for part in re.split(r"\n+", text) if part.strip()]
source = paragraphs[-1] if paragraphs else text
sentences = [part.strip() for part in re.split(r"(?<=[。!?!?])", source) if part.strip()]
guidance = "".join(sentences[-2:])[-240:] if sentences else source[-240:]
asks_for_detail = bool(re.search(
r"(?:额外|另外|其他|重点).{0,12}(?:突出|强调).{0,12}(?:细节|重点|卖点)|"
r"(?:细节|重点|卖点).{0,12}(?:突出|强调)",
guidance,
re.IGNORECASE,
))
asks_for_color_order = bool(re.search(
r"配色.{0,12}(?:顺序|排序|调换|调整)|(?:调换|调整).{0,12}配色",
guidance,
re.IGNORECASE,
))
if asks_for_detail and asks_for_color_order:
return [
{"label": "补充突出细节", "text": "我想补充需要额外突出的细节"},
{"label": "调整配色顺序", "text": "我想调整配色的展示顺序"},
{"label": "按当前描述继续", "text": "没有其他调整,按当前描述继续"},
]
if re.search(r"颜色|色号|色彩|配色|SKU|款式|几种|展示顺序", guidance, re.IGNORECASE):
return [
{"label": "补充颜色和顺序", "text": "我来补充每个颜色和展示顺序"},
{"label": "上传各款实物图", "text": "我补充各颜色/款式的实物图"},
{"label": "先按当前主款做", "text": "先按当前主款做,其他颜色后面再补"},
]
if re.search(
r"(?:哪款|哪个|什么).{0,8}(?:商品|产品)|"
r"(?:商品|产品).{0,12}(?:选择|选|换|更换|主推|想推|要推)|"
r"(?:选择|选|换|更换|主推|想推|要推).{0,12}(?:商品|产品)",
guidance,
re.IGNORECASE,
):
return [
{"label": "发商品列表", "text": "把商品列表发给我选"},
{"label": "我直接说商品名", "text": "我直接告诉你商品名"},
{"label": "你来推荐", "text": "你根据当前需求推荐一款"},
]
if re.search(
r"是否使用这[个位]角色|角色.{0,8}(?:已生成|合适吗)|你看这[个位]角色|人物参考已生成",
guidance,
re.IGNORECASE,
):
return [
{"label": "使用这个角色", "text": "使用这个角色继续创作"},
{"label": "重新生成一个", "text": "重新生成一个角色"},
{"label": "上传其他人物", "text": "我上传人物参考图"},
]
if re.search(
r"(?:哪位|哪个|什么|选择|选|换|更换|调整|修改|改).{0,12}(?:人物|角色|模特|出镜)|"
r"(?:人物|角色|模特|出镜).{0,12}(?:哪位|哪个|选择|选|换|更换|调整|修改|改)",
guidance,
re.IGNORECASE,
):
return [
{"label": "上传人物图", "text": "我上传人物参考图"},
{"label": "由你设定角色", "text": "你先帮我设定一个合适的角色"},
{"label": "不需要人物", "text": "这条先不需要人物出镜"},
]
if re.search(
r"(?:哪里|哪儿|什么|选择|选|换|更换|调整|修改|改).{0,12}(?:场景|地点|背景)|"
r"(?:场景|地点|背景).{0,12}(?:哪里|哪儿|选择|选|换|更换|调整|修改|改)",
guidance,
re.IGNORECASE,
):
return [
{"label": "上传场景图", "text": "我上传场景参考图"},
{"label": "你来推荐场景", "text": "你按商品和预设推荐场景"},
{"label": "用干净日常场景", "text": "先用干净自然的日常场景"},
]
if re.search(
r"(?:补充|选择|选|换|更换|调整|修改|改|突出|强调).{0,12}(?:卖点|功能|效果|优惠|价格)|"
r"(?:卖点|功能|效果|优惠|价格).{0,12}(?:补充|选择|选|换|更换|调整|修改|改|突出|强调)",
guidance,
re.IGNORECASE,
):
return [
{"label": "补充真实卖点", "text": "我来补充商品真实卖点"},
{"label": "从素材里判断", "text": "先根据我上传的素材判断可表达的卖点"},
{"label": "先只突出一个点", "text": "先围绕一个最核心的卖点创作"},
]
# 已有创作上下文但未命中更具体的追问时,给一个可识别的「继续完善方案」按钮,
# 避免只剩文案引导、用户手输后又不算创作意图而卡住。
return [
{"label": "继续完善方案", "text": "继续完善方案"},
]
kind = "短视频" if is_video else "商品图"
return [
{"label": f"帮我做一条{kind}" if is_video else "帮我做一张商品图", "text": f"帮我做一条{kind}" if is_video else "帮我做一张商品图"},
{"label": "我先说想法", "text": "我想做一个新的创作"},
]
def apply_reply_hint(message: CreationMessage, hint: str, options: list[dict[str, str]]) -> CreationMessage:
"""给文字气泡挂上回复示例和可点选项,供前端把下一步直接交到用户手里。"""
payload = dict(message.payload or {})
changed = False
if not str(payload.get("reply_hint") or "").strip():
payload["reply_hint"] = hint
changed = True
if not isinstance(payload.get("reply_options"), list) or not payload.get("reply_options"):
payload["reply_options"] = options
changed = True
if not changed:
return message
message.payload = payload
message.save(update_fields=["payload"])
return message
def ensure_turn_guides(
conversation: CreationConversation,
*,
turn_has_gate: bool,
last_text_bubble: CreationMessage | None,
has_context: bool,
is_video: bool,
) -> list[dict]:
"""回合收束校验:若本轮只有散文气泡、没有追问/确认闸门,补上回复引导。
优先给已有文字气泡挂 reply_hint;若本轮连文字都没有,再落一条短引导。
不打断 5 步闸门(已有 elicit/confirm 时直接跳过)。
"""
if turn_has_gate:
return []
# 会话里若仍有未提交的追问/确认卡,用户本就能点卡推进,不必再注脚。
open_gate = conversation.messages.filter(
role="assistant",
kind__in=(CreationMessage.Kind.ELICIT, CreationMessage.Kind.CONFIRM),
).order_by("-seq").first()
if open_gate is not None and not bool((open_gate.payload or {}).get("submitted")):
return []
hint = default_reply_hint(conversation, has_context=has_context, is_video=is_video)
options = default_reply_options(
conversation,
has_context=has_context,
is_video=is_video,
assistant_text=last_text_bubble.text if last_text_bubble is not None else "",
)
events: list[dict] = []
if last_text_bubble is not None:
if text_already_guides(last_text_bubble.text):
return []
updated = apply_reply_hint(last_text_bubble, hint, options)
events.append({"type": "message", "message": _message_payload(updated)})
return events
guide = append_message(
conversation,
role="assistant",
text=hint,
payload={"reply_hint": hint, "reply_options": options},
)
events.append({"type": "message", "message": _message_payload(guide)})
return events
# 明确要做内容才算出方案/出图。闲聊、吐槽、问功能都不算。
_CREATIVE_INTENT_RE = re.compile(
r"("
r"帮我做|帮我拍|帮我出|帮我写|帮我改|帮我生成|"
r"创作[一两]?[条个张]?|创作(?:一条|个|短)?|"
r"做[一两]?[条个张](?:视频|片|图|广告)?|拍[一两]?[条个张](?:视频|片|图|广告)?|"
r"出[一两]?[条个张](?:视频|片|图|广告)?|出片|出图|出方案|写方案|改方案|完善方案|继续完善|继续创作|重写方案|重新写|"
r"生成(?:一下|一张|几张|一条)?(?:视频|图|片|广告|脚本)?|做条|做个片|短视频|带货视频|短广告|广告片|带货片|"
r"换卖点|改卖点|换剧情|改剧情|重做|重新出|重新来|再来一次|从头开始|重新做|按这个出|确认出片|"
r"分镜|脚本|口播稿|storyboard|拟人|"
r"(改|换|修改|更换).{0,8}(时长|秒数|比例|尺寸|画幅|分辨率|清晰度|模型)|"
r"改成\s*\d+\s*秒|改成\s*\d+\s*[:]\s*\d+|改成.{0,8}(竖屏|横屏|比例|分辨率)"
r")",
re.IGNORECASE,
)
# 创作 brief 常见搭配:动词 + 成片名词(「创作一条短广告」)
_CREATIVE_VERB_RE = re.compile(r"创作|制作|拍摄|生成|做|拍|出|写|弄|来一条|来个")
_CREATIVE_NOUN_RE = re.compile(r"视频|短片|短视频|广告|带货|出片|分镜|脚本|口播|主图|海报|成片")
def has_creative_intent(user_text: str, refs: list | None = None) -> bool:
"""用户本轮是否明确要做片/出图/改方案。
没意图时不把 write_strategy / write_plan / generate_image 塞进 tools,
避免模型把闲聊「整理」成方案卡。仅 @ 素材不算意图。
"""
text = (user_text or "").strip()
if not text:
return False
if is_pure_chitchat(text):
return False
if is_restart_intent(text):
return True
if _CREATIVE_INTENT_RE.search(text):
return True
# 「把商品…创作一条…短广告」这类 brief:同时有创作动词和成片名词
if len(text) >= 8 and _CREATIVE_VERB_RE.search(text) and _CREATIVE_NOUN_RE.search(text):
return True
return False
_CONTINUE_INTENT_RE = re.compile(
r"^\s*("
r"继续|继续做|接着|接着做|往下做|开始吧|开做吧|就这样|就按这个|按这个来|照这个做|直接做|直接来|"
r"继续完善方案|完善方案|继续创作|继续推进|往下推进|"
r"按当前描述继续|按这个继续|没有其他调整[,,]?按当前描述继续|"
r"使用这[个位只]角色继续创作|使用这[个位只]宠物角色继续创作"
r")[吧啊呀呢。.!]*\s*$"
)
def is_continue_intent(user_text: str) -> bool:
"""已有创作上下文时,这些短句是在授权继续,不是闲聊。
含系统引导里的「继续完善方案」——若不识别,用户一点就又回到同一句引导,死循环。
"""
return bool(_CONTINUE_INTENT_RE.match((user_text or "").strip()))
# 整轮重来:不是回答上一张追问卡,也不是局部「改策略/换模特」。
_RESTART_INTENT_RE = re.compile(
r"^\s*("
r"重新来|重来|从头开始|再来一次|重新做|"
r"重新开始|从头再来|重做一遍|再做一次|重来一遍|"
r"restart|start\s*over|start\s*again"
r")[吧啊呀呢]*[\s!.。~~??…]*$",
re.IGNORECASE,
)
_RESTART_CONTINUATION = (
"用户明确要求重新来/从头开始。平台已重置闸门阶段并关闭未完成的追问/确认卡。"
"先用一句很短的话确认「好,我们重新来」,然后基于会话最初 brief 与已钉素材,"
"从澄清或缺信息时 ask_user、信息够了就 write_strategy 重新开一整轮创作;"
"不要再打开上一轮的模特库/商品库追问,不要沿用旧策略/方案/Prompt,不要猜模特。"
)
def is_restart_intent(user_text: str) -> bool:
"""用户明确要整轮重来(重新来/重来/从头开始…)。"""
return bool(_RESTART_INTENT_RE.match((user_text or "").strip()))
def apply_restart_intent(conversation: CreationConversation) -> int:
"""重置视频闸门 memory,并关闭未提交的 elicit/confirm 卡。返回关闭张数。"""
memory = dict(conversation.memory or {})
memory["stage"] = "clarify"
memory.pop("pending_video_prompt", None)
memory.pop("strategy_confirmed", None)
memory.pop("selling_point_ready", None)
memory.pop("selling_point_mode", None)
memory.pop("selling_point", None)
memory.pop("product_source_resolved", None)
memory.pop("pain_point_direction_ready", None)
memory.pop("pain_point_direction", None)
memory.pop("plot_twist_story_direction", None)
memory.pop("plot_twist_story_direction_detail", None)
memory.pop("plot_twist_story_direction_payload", None)
memory.pop("click_swap_ready", None)
memory.pop("click_swap_sequence", None)
memory.pop("click_swap_mode", None)
conversation.memory = memory
conversation.save(update_fields=["memory", "updated_at"])
closed = 0
candidates = conversation.messages.filter(
kind__in=(CreationMessage.Kind.ELICIT, CreationMessage.Kind.CONFIRM),
).order_by("-seq")[:40]
for message in candidates:
payload = dict(message.payload or {})
if payload.get("submitted"):
continue
payload["submitted"] = True
payload["cancelled"] = True
payload["cancelled_reason"] = "restart"
message.payload = payload
message.save(update_fields=["payload", "updated_at"])
closed += 1
return closed
def session_has_creative_context(conversation: CreationConversation) -> bool:
"""会话里是否已有可继续的创作进度(钉了素材 / 出过策略或方案)。
这个信号只用于判断一句短回复是否承接创作,不拿来主动追问流程确认。
"""
if conversation.pinned_refs:
return True
if conversation.messages.filter(
kind__in=(
CreationMessage.Kind.STRATEGY,
CreationMessage.Kind.PLAN,
CreationMessage.Kind.PROMPT_FILE,
CreationMessage.Kind.CONFIRM,
)
).exists():
return True
# 追问发生在策略卡之前时,会话里可能还没有任何结构化产物。原始 brief 本身
# 就是创作上下文;不认它的话,刷新后一句「继续」会被当成空闲聊天。
recent_user_texts = conversation.messages.filter(
role=CreationMessage.Role.USER,
kind=CreationMessage.Kind.TEXT,
).order_by("-seq").values_list("text", flat=True)[:8]
return any(has_creative_intent(item) for item in recent_user_texts)
def wanted_asset_pick(user_text: str, refs: list | None) -> str | None:
"""用户说「改商品」却没点名时,启动对应素材的自然确认。"""
if refs:
return None
text = user_text or ""
for type_, pattern in _ASSET_PICK_PATTERNS:
if pattern.search(text):
return type_
return None
def requested_asset_card_from_context(
conversation: CreationConversation,
user_text: str,
) -> str | None:
"""「发一下卡片我选」没有说类型时,沿用最近一次素材追问的类型。"""
text = str(user_text or "")
wants_card = re.search(
r"(发|打开|展示|看看|看下|给我).{0,10}(卡片|列表|商品库|素材库)"
r"|(卡片|列表).{0,10}(选|选择|看看|看下)",
text,
)
if not wants_card:
return None
# 用户明确要某类素材列表时,不能依赖上一条是否碰巧留下追问卡。
# 否则模型会把检索结果念成名称段落,而不会返回可点击的卡片。
direct_types = (
("product", ("商品", "产品")),
("character", ("角色", "人物")),
("model", ("模特",)),
("scene", ("场景",)),
)
for type_, keywords in direct_types:
if any(keyword in text for keyword in keywords):
return type_
recent = conversation.messages.filter(
kind=CreationMessage.Kind.ELICIT
).order_by("-seq")[:8]
for message in recent:
payload = message.payload or {}
fields = payload.get("pending_fields") or payload.get("fields") or []
for field in fields:
if not isinstance(field, dict):
continue
inferred = infer_field_types(field)
if len(inferred) == 1 and inferred[0] in TYPE_LABELS:
return inferred[0]
return None
VIDEO_MODELS = ["Seedance 2.5", "Seedance 2.0", "Seedance 2.0 Fast", "Seedance 2.0 Mini"]
IMAGE_MODELS = ["Seedream5.0", "YQ image2"]
RATIOS = ["16:9", "9:16", "4:3", "3:4", "1:1"]
RESOLUTIONS = ["480p", "720p", "1080p"]
VIDEO_DURATIONS = [
"智能时长", "4 秒", "5 秒", "6 秒", "8 秒", "10 秒", "12 秒", "15 秒",
"30 秒", "45 秒", "60 秒",
]
IMAGE_COUNTS = ["1 张", "2 张", "4 张", "8 张"]
def filter_visible_duration_options(options: list) -> list:
"""临时隐藏 >60 秒长视频选项(含「90秒」「120 秒」等写法)。"""
out = []
for item in options or []:
if isinstance(item, dict):
label = str(item.get("label") or item.get("value") or "")
else:
label = str(item or "")
digits = "".join(ch for ch in label if ch.isdigit())
# 仅当像「N秒」时长选项时过滤;普通文案不动
if "秒" in label and digits and int(digits) > MAX_VIDEO_DURATION:
continue
out.append(item)
return out
SESSION_PARAM_KEYS = ("duration", "ratio", "resolution", "video_model", "count")
_PARAM_TO_STORED = {"video_model": "model"}
_PARAM_PICK_LABEL = {
"duration": "想改成多少秒?",
"ratio": "想换成什么画幅?比如 9:16 竖屏或 16:9 横屏。",
"resolution": "想换成什么清晰度?直接说 480p、720p 或 1080p 就行。",
"video_model": "想换哪个模型?直接告诉我模型名就行。",
"count": "这次想出几张?",
}
def _param_options(key: str, is_video: bool) -> list[str]:
if key == "duration":
return VIDEO_DURATIONS
if key == "ratio":
return RATIOS
if key == "resolution":
return RESOLUTIONS
if key == "count":
return IMAGE_COUNTS
return VIDEO_MODELS if is_video else IMAGE_MODELS
def session_param_fields(keys: list[str], is_video: bool) -> list[dict]:
fields = []
# 对话式追问一次只问一件事,避免又退化成参数问卷。
for key in keys[:1]:
if key not in _PARAM_PICK_LABEL:
continue
raw_options = _param_options(key, is_video)
if key == "duration":
raw_options = filter_visible_duration_options(raw_options)
options = [{"value": item, "label": item} for item in raw_options]
fields.append({
"key": key,
"label": _PARAM_PICK_LABEL[key],
"type": "single",
"required": True,
"options": options,
})
return fields
def wanted_param_keys(user_text: str, *, is_video: bool) -> list[str]:
"""用户说「改时长」「换模型」时弹出参数卡。不和「改模特」抢。"""
text = user_text or ""
keys: list[str] = []
if re.search(r"(改|换|修改|更换).{0,8}(时长|秒数)|改成\s*\d+\s*秒", text):
keys.append("duration" if is_video else "count")
if re.search(r"(改|换|修改|更换).{0,8}(比例|尺寸|画幅)", text):
keys.append("ratio")
if re.search(r"(改|换|修改|更换).{0,8}(分辨率|清晰度)", text):
keys.append("resolution")
if re.search(r"(改|换|修改|更换).{0,8}模型", text) and "模特" not in text:
keys.append("video_model")
if re.search(r"(改|换|修改|更换).{0,8}张数", text):
keys.append("count")
if not keys and re.search(r"(改|换|修改).{0,6}(参数|设置|规格)", text):
keys = ["duration", "video_model", "ratio"] if is_video else ["count", "video_model", "ratio"]
seen = set()
out = []
for key in keys:
if key in seen:
continue
seen.add(key)
out.append(key)
return out
_EXPLICIT_VIDEO_DURATION_RE = re.compile(
r"(?:做|制作|生成|来|要|成片|视频|时长).{0,12}?(?<!\d)(\d{1,3})\s*(?:秒|s)(?![a-z])",
re.IGNORECASE,
)
def apply_explicit_video_duration_from_text(
conversation: CreationConversation,
user_text: str,
) -> bool:
"""把自然 brief 里的明确成片时长写回会话参数。
例如「用这三个角色做一条 120 秒视频」不能继续沿用顶部的「智能时长」。
正则要求时长前有创作语义,避免把「0-30 秒镜头」或年龄误判为整片时长。
"""
if conversation.mode != CreationConversation.Mode.VIDEO:
return False
match = _EXPLICIT_VIDEO_DURATION_RE.search(str(user_text or ""))
if match is None:
return False
seconds = int(match.group(1))
if seconds < 4 or seconds > MAX_VIDEO_DURATION:
return False
params = dict(conversation.params or {})
duration = f"{seconds} 秒"
if params.get("duration") == duration:
return False
params["duration"] = duration
conversation.params = params
conversation.save(update_fields=["params", "updated_at"])
return True
def apply_session_params(conversation, fields, answers: dict) -> bool:
"""追问卡里选出的时长/模型等写回会话参数。返回是否有改动。"""
current = dict(conversation.params or {})
field_by_key = {str(item.get("key") or ""): item for item in (fields or []) if isinstance(item, dict)}
changed = False
for key, raw in (answers or {}).items():
field = field_by_key.get(str(key)) or {}
if field.get("type") == "asset":
continue
stored = _PARAM_TO_STORED.get(str(key), str(key))
if stored not in {"model", "ratio", "resolution", "duration", "count"}:
continue
value = "、".join(raw) if isinstance(raw, list) else str(raw or "").strip()
if not value or current.get(stored) == value:
continue
if stored == "duration" and value != "智能时长":
digits = "".join(ch for ch in value if ch.isdigit())
if digits and int(digits) > MAX_VIDEO_DURATION:
value = f"{MAX_VIDEO_DURATION} 秒"
current[stored] = value
changed = True
if changed:
conversation.params = current
conversation.save(update_fields=["params", "updated_at"])
if is_plot_twist_conversation(conversation):
# 用户在参数卡里改了时长,剧情结构也必须立即跟着切换。
set_plot_twist_story_depth(conversation, str(current.get("duration") or ""))
return changed
def snapshot_session_params(conversation) -> dict:
params = conversation.params or {}
return {
"model": str(params.get("model") or ""),
"resolution": str(params.get("resolution") or ""),
"ratio": str(params.get("ratio") or ""),
"duration": str(params.get("duration") or ""),
"count": str(params.get("count") or params.get("duration") or ""),
}
def confirm_param_options(is_video: bool) -> dict:
return {
"model": VIDEO_MODELS if is_video else IMAGE_MODELS,
"resolution": RESOLUTIONS if is_video else [],
"ratio": RATIOS,
"duration": VIDEO_DURATIONS if is_video else [],
"count": IMAGE_COUNTS if not is_video else [],
}
def _normalize_confirm_duration(value) -> tuple[str, int | str]:
"""确认卡只按实际秒数判断时长变化,兼容「8秒 / 8 秒」等历史格式。"""
raw = str(value or "").strip()
match = re.search(r"\d+(?:\.\d+)?", raw)
if match:
return "seconds", int(float(match.group()))
return "label", re.sub(r"\s+", "", raw).lower()
def apply_confirm_params(conversation, incoming: dict | None) -> tuple[dict, bool]:
"""确认卡上改的参数写回会话。返回 (最新 params, 视频时长是否变了)。"""
current = dict(conversation.params or {})
old_duration = str(current.get("duration") or "")
changed = False
for key, raw in (incoming or {}).items():
if key not in {"model", "ratio", "resolution", "duration", "count"}:
continue
value = str(raw or "").strip()
if not value or current.get(key) == value:
continue
if key == "duration" and _normalize_confirm_duration(current.get(key)) == _normalize_confirm_duration(value):
continue
current[key] = value
changed = True
duration_changed = (
conversation.mode == CreationConversation.Mode.VIDEO
and _normalize_confirm_duration(current.get("duration")) != _normalize_confirm_duration(old_duration)
and bool(str(current.get("duration") or ""))
and bool(old_duration)
)
if changed:
conversation.params = current
conversation.save(update_fields=["params", "updated_at"])
if is_plot_twist_conversation(conversation):
# 在确认卡改时长也要切换故事契约;随后视图会要求重写旧方案。
set_plot_twist_story_depth(conversation, str(current.get("duration") or ""))
return snapshot_session_params(conversation), duration_changed
# ---------------------------------------------------------------- 工具 schema
def tool_schemas(context: AgentContext, *, allow_plan: bool = True) -> list[dict]:
"""给模型看的工具清单。图片会话不暴露 generate_video,反之亦然 ——
会话 mode 是定死的(契约 §0),把不该用的工具摆出来只会诱导模型走错路。
allow_plan=False 时隐藏 write_strategy / write_plan / generate_image,闲聊用不出来。"""
tools = [
{
"type": "function",
"function": {
"name": "ask_user",
"description": (
"缺少必要信息、或需要用户做选择时向用户追问 —— 这是优先的引导方式。"
"只在信息**确实缺失且无法合理推断**时用;能自己定的就自己定,别把用户当填表机器。"
"用户要选/改/换商品、角色、模特、场景时**必须**调这个工具,"
"type 用 asset 并填 asset_types。系统会以类Agent自然对话轻量询问是否需要发送商品列表(同时支持直接输入名字或由你推荐),避免一上来粗暴弹出大卡片打断交流。"
"需要用户在几个明确选项里选时,用 type=single/multi 并给出 options。"
"一次只问 1 项,禁止问「要不要继续」「要不要生成」「是否开始创作」这类流程问题。"
"若本轮只写说明文字、不做选择,也必须在文字里写清用户下一句该回什么。"
),
"parameters": {
"type": "object",
"properties": {
"fields": {
"type": "array",
"maxItems": 1,
"items": {
"type": "object",
"properties": {
"key": {"type": "string", "description": "英文标识,如 product / duration"},
"label": {"type": "string", "description": "问题原文,中文"},
"type": {"type": "string", "enum": list(FIELD_TYPES)},
"required": {"type": "boolean"},
"options": {
"type": "array",
"items": {
"type": "object",
"properties": {
"value": {"type": "string"},
"label": {"type": "string"},
},
"required": ["value", "label"],
},
},
"asset_types": {
"type": "array",
"items": {"type": "string", "enum": list(TYPE_LABELS)},
},
"placeholder": {"type": "string"},
},
"required": ["key", "label", "type"],
},
}
},
"required": ["fields"],
},
},
},
{
"type": "function",
"function": {
"name": "search_library",
"description": "在团队的商品库/模特库/角色/场景/资产库里找素材。用户说了名字但没 @ 时用它找回来。",
"parameters": {
"type": "object",
"properties": {
"query": {"type": "string"},
"types": {"type": "array", "items": {"type": "string", "enum": list(TYPE_LABELS)}},
},
"required": ["query"],
},
},
},
]
if not allow_plan:
# 闲聊轮次不给 ask_user,避免模型追问「要不要出片」;
# 换商品/改参数仍由 wanted_asset_pick / wanted_param_keys 兜底追问。
return [t for t in tools if t.get("function", {}).get("name") == "search_library"]
if context.is_video:
if is_plot_twist_conversation(context.conversation) and active_plot_twist_story_depth(context.conversation):
tools.append({
"type": "function",
"function": {
"name": "present_story_directions",
"description": (
"剧情反转带货预设在用户选定故事深度后,先调用此工具展示 3 个可点击的剧情方向。"
"每条都必须有不同的冲突、商品承担的实际作用、反转和情绪;不能只说‘我准备了三个方向’。"
"用户点击其一后才可 write_strategy;本工具调用后必须停下来等待选择。"
),
"parameters": {
"type": "object",
"properties": {
"directions": {
"type": "array",
"minItems": 3,
"maxItems": 3,
"items": {
"type": "object",
"properties": {
"id": {"type": "string"},
"title": {"type": "string", "description": "短标题,不超过 10 个字"},
"conflict": {"type": "string", "description": "人物处境与开场冲突"},
"product_role": {"type": "string", "description": "商品如何自然推进剧情"},
"reversal": {"type": "string", "description": "最终反转如何回收"},
"tone": {"type": "string", "description": "情绪与转化倾向"},
},
"required": ["title", "conflict", "product_role", "reversal", "tone"],
},
},
},
"required": ["directions"],
},
},
})
tools.append({
"type": "function",
"function": {
"name": "write_strategy",
"description": (
"写「创作策略理解」卡:说清这条片给谁看、他为什么会信、你想让他信什么、整体创作方向。"
"四个字段都必须写具体非空文案,禁止空字符串。"
"策略从第一稿就使用健康、正向、明确成年的人物与情节表达,不要复述需要规避的原始措辞。"
"调完会停下来等用户确认或提出修改,不要同轮接着 write_plan。"
"仅当用户明确要做片/出方案时调用;打招呼或闲聊不要调。"
),
"parameters": {
"type": "object",
"properties": {
"target": {"type": "string", "description": "这条视频给谁看,要具体到人群特征"},
"trust": {"type": "string", "description": "用户为什么相信,靠什么建立可信度"},
"belief": {"type": "string", "description": "希望用户看完相信什么"},
"direction": {"type": "string", "description": "创作方向一句话,说清是什么类型的片"},
},
"required": ["target", "trust", "belief", "direction"],
},
},
})
tools.append({
"type": "function",
"function": {
"name": "write_plan",
"description": (
"写「视频最终方案」卡(USP/卖点/时间轴)并请用户确认。"
"**仅当用户已确认策略、或明确要改方案时调用**;打招呼或闲聊不要调。"
"调完只出方案卡并停下等人确认 —— 不要同轮出 Prompt 卡或积分确认卡。"
"usp / points / timeline 必须写满具体文案;同时把 video_prompt 写好存档,"
"用户确认方案后由平台展示 Prompt。"
+ (
"当前是超过 60 秒的长视频:video_prompt 只写章节结构、每个 30 秒段 3–5 个关键镜头和段尾交接,"
"全文约 800–1400 字。不要套 15 秒短片的制作级长文模板,不要为凑字数重复灯光/声音/风格。"
if is_long_form_video(context.conversation.params)
else
"video_prompt 按系统里的「制作级交付」写成完整 Prompt 文件:必须有整体规则、"
"声音/灯光/场景/参考素材锁定、可执行镜头和一致性收束,不要只写大纲。"
)
+
"第一稿必须已经可直接过平台审核:只写正向安全描述,不要输出风险词清单或否定式免责声明。"
"先有已确认的 write_strategy,再调它。"
),
"parameters": {
"type": "object",
"properties": {
"usp": {"type": "string", "description": "主打卖点,全片只讲这一个核心价值"},
"points": {
"type": "array", "maxItems": 3, "items": {"type": "string"},
"description": "核心支撑卖点,最多 3 条",
},
"timeline": {
"type": "array",
"items": {
"type": "object",
"properties": {
"start": {"type": "number"}, "end": {"type": "number"},
"stage": {"type": "string", "description": "Hook / 过桥 / 正文 / CTA"},
"desc": {"type": "string"},
},
"required": ["start", "end", "stage"],
},
},
"voice_chars": {
"type": "array", "items": {"type": "integer"},
"description": "口播字数区间 [下限, 上限]",
},
"video_prompt": {
"type": "string",
"description": (
"超过 60 秒时写紧凑可执行稿:章节结构 + 每个 30 秒段 3–5 个关键镜头 + 段尾交接状态,"
"约 800–1400 字。不要写成短片制作级长文。"
if is_long_form_video(context.conversation.params)
else
"交给出片模型的制作级完整指令,不是大纲。按系统规定的标题顺序写:"
"时长任务、标题、风格、镜头语言、视觉美术、色彩材质、打光、剪辑、声音、场景、主体参考、逐镜脚本、一致性收束。"
"60 秒以内每一镜包含拍法/画面内容/主体或产品露出/声音;15 秒至少 4 镜,含人声原文、拟音和 BGM 节奏。"
"已 @ 素材标明用途与需锁定的特征;禁止把口播做成画面文字。"
"人物必须明确为成年人且构图得体;只写正向可拍内容,不列风险词或禁用词。"
),
},
},
"required": ["usp", "points", "video_prompt"],
},
},
})
tools.append({
"type": "function",
"function": {
"name": "write_prompt",
"description": (
"写出片 Prompt 文件卡并请用户确认。"
"仅当用户已确认方案、或明确要求改 Prompt 时调用;"
"不要在 write_plan 同轮调用。调完停下等人确认,不要同轮出积分确认卡或直接出片。"
+ (
"长视频优先沿用方案里已存的 video_prompt,只补一段简短全片规则(风格/光线/参考图/一致性),"
"不要把逐镜再扩写成短片密度。"
if is_long_form_video(context.conversation.params)
else
"video_prompt 必须是系统规定的制作级完整文件,不能只把旧 Prompt 缩写成几行分镜。"
)
),
"parameters": {
"type": "object",
"properties": {
"video_prompt": {
"type": "string",
"description": (
"优先沿用方案草稿,只补一段简短全片规则。不要把每个 30 秒段再扩写成短片制作长文。"
if is_long_form_video(context.conversation.params)
else
"交给出片模型的完整制作文件。必须含总体视觉/美术/色彩/打光/声音/场景/参考图锁定规则,"
"以及按秒分段、每镜均含拍法/画面内容/主体或产品露出/声音的完整脚本与一致性收束。"
),
},
},
"required": ["video_prompt"],
},
},
})
else:
tools.append({
"type": "function",
"function": {
"name": "generate_image",
"description": (
"生成图片。prompt 必须是完整、可独立执行的画面描述(主体/动作/环境/光线/构图/风格),"
"不要写成对用户说的话。已 @ 引用的素材会自动作为参考图带上。"
"若已锁定商品参考图:商品颜色、材质、外形必须与参考图一致;看不清就写「严格按参考商品图外观」,"
"禁止凭印象编造颜色(例如把黑色空气炸锅写成白色)。人物参考图同理,外貌以图为准。"
),
"parameters": {
"type": "object",
"properties": {
"prompt": {"type": "string"},
"count": {"type": "integer", "minimum": 1, "maximum": 4},
},
"required": ["prompt"],
},
},
})
return tools
# ---------------------------------------------------------------- 工具执行
def _coerce_fields(raw) -> list[dict]:
"""把模型给的 fields 规整成契约 §2 的 Field。脏数据丢弃而不是抛 ——
模型偶尔漏个 type 不该让整条对话崩掉。"""
fields: list[dict] = []
for item in (raw or [])[:1]:
if not isinstance(item, dict):
continue
key = str(item.get("key") or "").strip()
label = str(item.get("label") or "").strip()
raw_options = item.get("options") or []
if isinstance(raw_options, dict):
raw_options = [{"value": str(k), "label": str(v)} for k, v in raw_options.items()]
options = []
for idx, o in enumerate(raw_options, start=1):
if isinstance(o, str):
s = o.strip()
if s:
options.append({"value": f"option_{idx}", "label": s})
elif isinstance(o, dict):
val = o.get("value")
lbl = o.get("label") or o.get("text") or o.get("title") or o.get("name")
if val is None or str(val).strip() == "":
val = lbl if lbl is not None else f"option_{idx}"
if lbl is None or str(lbl).strip() == "":
lbl = val
val_str = str(val).strip()
lbl_str = str(lbl).strip()
if lbl_str:
options.append({"value": val_str, "label": lbl_str})
type_ = str(item.get("type") or "").strip()
if not type_:
type_ = "single" if options else "text"
elif type_ in ("choice", "select", "radio"):
type_ = "single"
if not key or not label or type_ not in FIELD_TYPES:
continue
field = {
"key": key,
"label": label,
"type": type_,
"required": bool(item.get("required", True)),
}
if type_ in ("single", "multi"):
if not options:
continue # 单选/多选没选项 = 废卡,丢掉
field["options"] = options
# 明确提供了具体 options 的选择题(如痛点方向三选一),保留 options,绝不降级转为 asset
fields.append(field)
continue
if type_ == "asset":
asset_types = [t for t in (item.get("asset_types") or []) if t in TYPE_LABELS]
field["asset_types"] = asset_types or list(TYPE_LABELS)
if type_ == "text":
field["placeholder"] = str(item.get("placeholder") or "")
inferred = infer_field_types(field)
# 商品/角色这类必须出素材卡,文字单选钉不上参考图
if key in SESSION_PARAM_KEYS:
fields.append(field)
continue
if len(inferred) == 1 and inferred[0] in TYPE_LABELS and type_ != "asset":
field["type"] = "asset"
field["asset_types"] = inferred
field.pop("options", None)
field.pop("placeholder", None)
fields.append(field)
return fields
def _normalize_model_label(value: str) -> str:
return "".join(ch for ch in str(value or "").lower() if ch.isalnum())
def image_model_name(params: dict) -> str | None:
"""出图模型 label → 供应商模型名;目录优先,认不出返回 None 让下游用默认。"""
from django.db.models import Q
from .models import ModelConfig
label = str(params.get("model") or "").strip()
if not label:
return None
mapped = IMAGE_MODEL_BY_LABEL.get(label)
if mapped:
return mapped
hit = (
ModelConfig.objects.filter(capability=ModelConfig.Capability.IMAGE, status=ModelConfig.Status.ACTIVE)
.filter(Q(display_name=label) | Q(name=label))
.order_by("created_at")
.first()
)
return hit.name if hit else None
def video_model_name(params: dict) -> str:
"""会话参数里的模型 label → 供应商模型名。
先认历史写死映射,再按 ModelConfig.display_name / name 查目录 —— 后台新加模型不用改代码。
"""
from django.db.models import Q
from .models import ModelConfig
label = str(params.get("model") or "").strip()
if not label:
return DEFAULT_VIDEO_MODEL
mapped = VIDEO_MODEL_BY_LABEL.get(label)
if not mapped:
norm = _normalize_model_label(label)
for k, v in VIDEO_MODEL_BY_LABEL.items():
if _normalize_model_label(k) == norm:
mapped = v
break
if mapped:
return mapped
hit = (
ModelConfig.objects.filter(capability=ModelConfig.Capability.VIDEO, status=ModelConfig.Status.ACTIVE)
.filter(Q(display_name=label) | Q(name=label))
.order_by("created_at")
.first()
)
if hit is None:
hit = (
ModelConfig.objects.filter(capability=ModelConfig.Capability.VIDEO, status=ModelConfig.Status.ACTIVE)
.filter(Q(display_name__icontains=label) | Q(name__icontains=label))
.order_by("created_at")
.first()
)
return hit.name if hit else DEFAULT_VIDEO_MODEL
def _is_smart_duration(params: dict) -> bool:
raw = str((params or {}).get("duration") or "").strip().lower()
return not raw or "智能" in raw or raw in {"smart", "auto"}
def infer_script_duration(*, timeline: list[dict] | None = None, prompt: str = "") -> int | None:
"""从已写好的方案推算实际片长:优先方案时间轴,其次 Prompt 的秒级分段。"""
ends: list[float] = []
for item in timeline or []:
if not isinstance(item, dict):
continue
try:
end = float(item.get("end"))
except (TypeError, ValueError):
continue
if end > 0:
ends.append(end)
for match in _SMART_DURATION_RE.finditer(prompt or ""):
try:
ends.append(float(match.group(2)))
except (TypeError, ValueError):
continue
for match in _TOTAL_DURATION_RE.finditer(prompt or ""):
try:
ends.append(float(match.group(1)))
except (TypeError, ValueError):
continue
if not ends:
return None
# 方案的最后一个时间点就是成片总长;向上取整避免 19.2 秒被截成 19 秒。
return max(4, min(MAX_VIDEO_DURATION, int(max(ends) + 0.999)))
def _raw_script_duration(*, timeline: list[dict] | None = None, prompt: str = "") -> int | None:
"""返回方案真实的最大时间点,不在这里截断,供 180 秒硬上限校验使用。"""
ends: list[float] = []
for item in timeline or []:
if not isinstance(item, dict):
continue
try:
end = float(item.get("end"))
except (TypeError, ValueError):
continue
if end > 0:
ends.append(end)
for match in _SMART_DURATION_RE.finditer(prompt or ""):
try:
ends.append(float(match.group(2)))
except (TypeError, ValueError):
continue
for match in _TOTAL_DURATION_RE.finditer(prompt or ""):
try:
ends.append(float(match.group(1)))
except (TypeError, ValueError):
continue
return int(max(ends) + 0.999) if ends else None
def plan_video_segments(duration: int, timeline: list[dict] | None = None) -> list[dict]:
"""按方案节奏把 31180 秒视频拆成 Seedance 2.5 可完成的片段。
每段不超过 30 秒;优先落在时间轴的自然转场处,找不到合适转场再均分。
分点逐个校验,保证剩余片段仍能落在 4–30 秒,不会出现最后一段过短或超长。
"""
total = max(4, min(int(duration or 0), MAX_VIDEO_DURATION))
if total <= MAX_VIDEO_SEGMENT_DURATION:
return [{"index": 1, "start": 0, "end": total, "duration": total}]
candidates: list[int] = []
for item in timeline or []:
if not isinstance(item, dict):
continue
try:
end = int(round(float(item.get("end"))))
except (TypeError, ValueError):
continue
if 4 <= end <= total - 4:
candidates.append(end)
segment_count = (total + MAX_VIDEO_SEGMENT_DURATION - 1) // MAX_VIDEO_SEGMENT_DURATION
points = [0]
for boundary_index in range(1, segment_count):
previous = points[-1]
remaining_segments = segment_count - boundary_index
lower = max(previous + 4, total - MAX_VIDEO_SEGMENT_DURATION * remaining_segments)
upper = min(previous + MAX_VIDEO_SEGMENT_DURATION, total - 4 * remaining_segments)
target = total * boundary_index / segment_count
legal = [point for point in candidates if lower <= point <= upper]
split = min(legal, key=lambda point: abs(point - target)) if legal else int(round(target))
points.append(max(lower, min(upper, split)))
points.append(total)
return [
{"index": index + 1, "start": start, "end": end, "duration": end - start}
for index, (start, end) in enumerate(zip(points, points[1:]))
]
def segment_video_prompt(prompt: str, segment: dict, total_duration: int) -> str:
"""让单段模型只拍本段,不把整条长脚本压回每一个片段里。"""
index = int(segment.get("index") or 1)
start = int(segment.get("start") or 0)
end = int(segment.get("end") or 0)
segment_count = max(1, (int(total_duration) + MAX_VIDEO_SEGMENT_DURATION - 1) // MAX_VIDEO_SEGMENT_DURATION)
chapter_index = min(
max(1, (start // LONG_VIDEO_CHAPTER_DURATION) + 1),
max(1, (int(total_duration) + LONG_VIDEO_CHAPTER_DURATION - 1) // LONG_VIDEO_CHAPTER_DURATION),
)
opening_rule = (
"这是全片唯一开场,按完整脚本建立人物、商品与场景。"
if index == 1 else
"这不是新视频的开场:直接承接上一段结束时尚未完成的动作、对白、商品位置、机位与光线,不重复介绍人物、商品和卖点。"
)
ending_rule = (
"这是全片最后一段,完成因果回收与唯一一次最终 CTA。"
if index == segment_count else
"本段结尾保持动作、视线或运镜未完全结束,为下一段留下明确可续接状态;不要提前做总结或 CTA。"
)
return (
f"{prompt.strip()}\n\n"
f"【分段出片约束·第 {index}/{segment_count} 段·第 {chapter_index} 章】"
f"整支视频共 {total_duration} 秒,本任务只生成全片 {start}{end} 秒,对应时长 {end - start} 秒。"
"只呈现完整脚本中落在这个时间窗的镜头、对白与声音,不得把整支故事压缩重演一遍。"
f"{opening_rule}{ending_rule}"
"所有分段必须复用完全相同的参考图编号、人物阵容、商品/SKU 映射与确定性 seed。"
"人物五官、发型、肤色、年龄感、身形、手部和基础服装保持不变,不得换人、漏人或合并角色。"
"商品颜色、材质、包装、标签、结构、配件和使用状态严格按对应参考图保持;不同商品或 SKU 不得混合、互换或变成近似款。"
"场景空间关系、道具位置、主光方向、色温、人物音色、语速、环境底噪与 BGM 节拍保持连续。"
"画面不添加字幕、文字、标题贴片、弹幕、角标、水印或购物浮层。"
)
def apply_person_identity_guard(prompt: str, references: list[dict]) -> str:
"""把解析后的人物参考编号写进最终出片 Prompt。
resolve_refs 会把人物排在最前,但仍按真实位置计算 @图N,避免混入
场景/商品后编号指错。
"""
indexes = [
index for index, item in enumerate(references or [], start=1)
if isinstance(item, dict) and item.get("type") in {"model", "character"}
]
if not indexes:
return prompt
labels = "、".join(f"参考图{index}" for index in indexes)
if len(indexes) == 1:
identity_rule = (
"整片及所有分段、远景、近景、转场都必须保持同一人;五官比例、脸型、发型、"
"肤色、年龄、身形、手部特征和基础服装不得漂移。不得换人、随机造人、合并成新面孔,"
"不得因镜头或光线变化而改变身份。"
)
else:
identity_rule = (
"这些参考图分别对应不同角色。整片及所有分段、远景、近景、转场都必须保持每位角色各自的"
"五官比例、脸型、发型、肤色、年龄、身形、手部特征和基础服装;人物不得互换、遗漏、"
"随机替换或合并成新面孔,也不得因镜头或光线变化而改变任何角色身份。"
)
return (
f"{prompt.strip()}\n\n【人物一致性硬约束】{labels}定义本片的固定出镜人物。"
f"{identity_rule}"
)
def apply_product_reference_guard(prompt: str, references: list[dict]) -> str:
"""把每张商品参考图的真实编号与名称写进所有分段,避免长视频里串 SKU。"""
products = [
(index, str(item.get("label") or f"商品{index}"))
for index, item in enumerate(references or [], start=1)
if isinstance(item, dict) and item.get("type") == "product"
]
if not products:
return prompt
mapping = "".join(f"参考图{index}{label}" for index, label in products)
return (
f"{prompt.strip()}\n\n【商品参考映射·全片与所有分段最高优先级】{mapping}。"
"同一名称或仅带‘三视图/细节图’后缀的商品图片共同定义同一 SKU;其余不同名称代表相互独立的商品或 SKU。"
"每次露出都必须调用对应参考图,保持颜色、材质、比例、包装、标签、结构、按键、接口、配件和正常使用状态一致。"
"不得把多张商品图融合成新商品,不得跨 SKU 互换颜色、部件、标签或功能,也不得用近似款替代。"
)
def build_segment_video_submit(submit: dict, segment: dict, total_duration: int) -> dict:
"""从同一份最终 Prompt/参考图/seed 派生单段请求,供首批与后续批次共用。"""
segment_prompt = (
segment_video_prompt(str(submit.get("prompt") or ""), segment, total_duration)
if total_duration > MAX_VIDEO_SEGMENT_DURATION else str(submit.get("prompt") or "")
)
return {
**submit,
"duration": int(segment["duration"]),
"prompt": enforce_no_embedded_captions(segment_prompt),
"extra_payload": {
"omni_segment": {
"index": int(segment["index"]),
"start": int(segment["start"]),
"end": int(segment["end"]),
"total_duration": int(total_duration),
}
},
}
def video_duration(params: dict, *, prompt: str = "", timeline: list[dict] | None = None) -> int:
"""显式时长优先;智能时长从方案/Prompt 推导,完全缺失才回退 15 秒。"""
raw = str(params.get("duration") or "")
digits = "".join(ch for ch in raw if ch.isdigit())
if not digits:
inferred = infer_script_duration(timeline=timeline, prompt=prompt)
if inferred is not None:
return inferred
return SMART_DURATION
return max(4, min(int(digits), MAX_VIDEO_DURATION))
def _model_supports_duration(model_name: str, duration: int) -> bool:
"""仅用于智能时长的自动路由;配置缺失时不武断拦截,交由提交校验给出明确错误。"""
model = ModelConfig.objects.filter(
name=model_name,
capability=ModelConfig.Capability.VIDEO,
status=ModelConfig.Status.ACTIVE,
).first()
if model is None:
return True
meta = model.metadata if isinstance(model.metadata, dict) else {}
listed = (meta.get("capabilities") or {}).get("durations") or meta.get("durations") or []
values = [int(value) for value in listed if str(value).isdigit()]
return not values or min(values) <= duration <= max(values)
def resolve_smart_video_duration(
conversation: CreationConversation,
*,
prompt: str = "",
timeline: list[dict] | None = None,
) -> int:
"""把智能时长固化为方案真实时长,并在需要时切换到能承载它的模型。
这一步发生在方案写完、用户看到最终确认卡之前,所以页面显示、计费和实际 API 参数
始终是同一个秒数。
"""
params = dict(conversation.params or {})
smart_duration = _is_smart_duration(params)
duration = video_duration(params, prompt=prompt, timeline=timeline)
if smart_duration:
params["duration"] = f"{duration} 秒"
selected_model = video_model_name(params)
switched = False
# Seedance 2.5 是当前唯一可稳定承载 16–30 秒单段、以及 31–180 秒分段的模型。
# 总时长不在单段能力表里时,后续会拆成多条 <=30 秒的 2.5 任务。
if duration > 15 and selected_model != DEFAULT_VIDEO_MODEL:
params["model"] = "Seedance 2.5"
switched = True
elif not _model_supports_duration(selected_model, duration):
# 智能模式可自动选能完成完整脚本的模型;确认卡会清楚展示变更,用户仍能手动调整。
if _model_supports_duration(DEFAULT_VIDEO_MODEL, duration):
params["model"] = "Seedance 2.5"
switched = True
memory = dict(conversation.memory or {})
memory["smart_duration_resolved"] = duration
if switched:
memory["smart_duration_model_switched"] = True
if smart_duration or switched:
conversation.params = params
conversation.memory = memory
conversation.save(update_fields=["params", "memory", "updated_at"])
return duration
def _image_count(params: dict, raw) -> int:
"""出图张数:首页选过「N 张」就用它,否则用模型传的 count,默认 1,上限 8。"""
label = str((params or {}).get("count") or (params or {}).get("duration") or "")
if "张" in label:
digits = "".join(ch for ch in label if ch.isdigit())
if digits:
raw = digits
try:
count = int(raw or 1)
except (TypeError, ValueError):
count = 1
return max(1, min(count, 8))
def _run_search_library(context: AgentContext, args: dict) -> dict:
results = search_mentions(
context.team,
q=str(args.get("query") or "").strip(),
types=[t for t in (args.get("types") or []) if t in TYPE_LABELS] or None,
limit=5,
)
return {
"results": [
{"type": r["type"], "id": r["id"], "name": r["name"], "kind": TYPE_LABELS[r["type"]]}
for r in results
]
}
def _run_generate_image(context: AgentContext, args: dict) -> tuple[dict, list]:
"""提交出图。返回 (给模型看的结果, AITask 列表)。
出图也是异步的(worker 出图 ~30s),所以这里同样只提交不等待 —— 和视频一条路子,
前端拿 task_id 轮询 GET /api/ai/generate-image/?ids=…
"""
from .services import enqueue_standalone_images
prompt = str(args.get("prompt") or "").strip()
if not prompt:
raise AgentError("生成失败:模型没有给出画面描述")
prompt = apply_image_preset_prompt(context.conversation.preset, prompt)
prompt = apply_product_appearance_guard(context.conversation, prompt)
params = context.conversation.params or {}
resolved = resolve_refs(context.team, context.conversation.pinned_refs or [])
reference_image_ids = [r["asset_id"] for r in resolved.references if r.get("asset_id")]
count = _image_count(params, args.get("count"))
tasks = enqueue_standalone_images(
team=context.team,
user=context.user,
prompt=prompt,
mode="image",
count=count,
ratio=params.get("ratio") or None,
image_model=image_model_name(params) or params.get("model") or None,
reference_image_ids=reference_image_ids or None,
feature="omni_create",
)
context.generations_used += 1
return (
{"submitted": True, "count": len(tasks), "note": "已提交生成,结果稍后回填,不要重复提交"},
list(tasks),
)
def _video_submit_params(context: AgentContext, prompt: str) -> tuple[dict, list]:
"""拼 submit_free_video 的入参。references 直接用 resolve_refs 的产物 ——
它已经排好 角色 → 场景 → 商品 的顺序,那正是出片模型 @图N 的语义依据。"""
resolved = resolve_refs(context.team, context.conversation.pinned_refs or [])
prompt = apply_video_preset_prompt(context.conversation.preset, prompt, click_swap_mode=click_swap_mode(context.conversation))
prompt = apply_product_voice_visual_guard(context.conversation, prompt)
prompt = apply_product_reality_guard(prompt)
prompt = apply_clothing_video_guard(context.conversation, prompt)
prompt = apply_product_appearance_guard(context.conversation, prompt)
prompt = apply_pet_dialogue_guard(context.conversation, prompt)
prompt = apply_video_platform_safety_guard(prompt)
prompt = apply_plot_twist_story_contract(
context.conversation.preset,
active_plot_twist_story_depth(context.conversation),
prompt,
)
if is_plot_twist_conversation(context.conversation):
selected = plot_twist_selected_direction(context.conversation)
if selected:
prompt = apply_plot_twist_direction_contract(prompt, **selected)
prompt = apply_person_identity_guard(prompt, resolved.references)
prompt = apply_product_reference_guard(prompt, resolved.references)
duration = resolve_smart_video_duration(context.conversation, prompt=prompt)
# resolve_smart_video_duration 可能为了完整脚本切到支持长时长的模型,必须重新取参数。
params = context.conversation.params or {}
submit = {
"prompt": prompt,
"feature": "omni_create",
"mode": "universal",
"model": video_model_name(params),
"aspect_ratio": params.get("ratio") or "9:16",
"resolution": params.get("resolution") or "720p",
"duration": duration,
"generate_audio": True,
"references": resolved.references,
}
if any(item.get("type") in {"model", "character", "product"} for item in resolved.references):
# 长视频的多个任务共用同一个确定性 seed,减少跨段的人脸、服装和商品外观漂移。
# 必须过 normalize_video_seed8 位十六进制最大约 42.9 亿,直接送会超火山的 int32 上限被拒。
from .free_video import normalize_video_seed
submit["seed"] = normalize_video_seed(int(str(context.conversation.id).replace("-", "")[:8], 16))
return submit, resolved.references
def estimate_video_credits(context: AgentContext) -> int:
"""确认按钮旁的预计积分。算不出来返回 0,前端就不显示 ——
估价失败绝不能挡住出片(用户仍会在扣费环节看到真实数字)。"""
from apps.billing.pricing import quote_video_estimate
params = context.conversation.params or {}
submit, references = _video_submit_params(context, "")
model_config = ModelConfig.objects.filter(
name=submit["model"], capability=ModelConfig.Capability.VIDEO
).first()
if model_config is None:
return 0
try:
_tokens, quote = quote_video_estimate(
model_config,
aspect_ratio=submit["aspect_ratio"],
resolution=submit["resolution"],
duration=submit["duration"],
references=references,
team=context.team,
)
return int(quote.points)
except Exception: # noqa: BLE001 — 估价挂了不该挡住出片
logger.warning("omni create: video estimate failed", exc_info=True)
return 0
def estimate_image_credits(context: AgentContext) -> int:
"""出图确认卡预计积分:挂牌单价(含团队系数)逐张取整后再 × 张数,与 enqueue 逐任务预留同口径。"""
from apps.billing.pricing import quote_flat
from apps.ai.services import resolve_image_model, get_default_model
params = context.conversation.params or {}
model_name = image_model_name(params) or str(params.get("model") or "").strip() or None
model_config = resolve_image_model(model_name) if model_name else None
if model_config is None:
model_config = get_default_model(ModelConfig.Capability.IMAGE)
if model_config is None:
return 0
count = _image_count(params, None)
try:
per = quote_flat(model_config, units=1, team=context.team)
return int(per.points) * count
except Exception: # noqa: BLE001
logger.warning("omni create: image estimate failed", exc_info=True)
return 0
def submit_confirmed_video(*, conversation: CreationConversation, user, confirm_message: CreationMessage):
"""用户点了确认 → 直接按方案卡里存好的 video_prompt 出片。
**这里不再跑一轮模型**:方案已经确认过了,再让模型决定一次既费钱又可能它不调工具。
返回 (生成中消息, 错误文案),两者必有其一。
"""
from django.conf import settings
from .free_video import IN_FLIGHT_STATUSES, submit_free_video
from .models import AITask
payload = confirm_message.payload or {}
prompt = str(payload.get("video_prompt") or "").strip()
if not prompt:
return None, "这条方案没有存下出片指令,请让我重新写一次方案。"
cast_gap = insufficient_cast_refs_message(conversation, prompt)
if cast_gap:
return None, cast_gap
context = AgentContext(conversation=conversation, user=user, model_config=None)
submit, _references = _video_submit_params(context, prompt)
total_duration = int(submit["duration"])
segments = plan_video_segments(total_duration, timeline=payload.get("timeline") or [])
# 长视频最多 6 段,不能为了它抬高全站并发上限。先占用当前可用槽位;后续由会话轮询在
# 已完成片段释放槽位后自动补交下一批,既遵守团队并发限制,也不会让 180 秒一开始就被拒。
in_flight = AITask.objects.filter(
team=conversation.team,
task_type=AITask.Type.FREE_VIDEO,
status__in=IN_FLIGHT_STATUSES,
).count()
max_concurrent = int(getattr(settings, "FREE_VIDEO_MAX_CONCURRENT", 3))
available_slots = max(0, max_concurrent - in_flight)
if available_slots <= 0:
return None, f"当前有 {in_flight} 个视频任务进行中(上限 {max_concurrent}),请等待完成后再提交。"
tasks = []
try:
for segment in segments[:available_slots]:
# 长视频也必须从完整的最终 Prompt 分段;这个唯一构造函数同时用于后续批次,确保
# 预设、人物、商品、安全、无字幕、参考图与 seed 不会在换批次时丢失。
segment_submit = build_segment_video_submit(submit, segment, total_duration)
tasks.append(submit_free_video(team=conversation.team, user=user, params=segment_submit))
except ValueError as exc: # 校验类错误(时长/比例/额度),给用户看原文
if not tasks:
return None, str(exc)
# 并发额度可能在逐段提交期间被另一请求抢占。已成功提交的片段不能变成孤儿;
# 先落生成中消息,剩余片段会在槽位释放后由分批调度器继续提交。
if len(segments) == 1:
message_payload = {"task_id": str(tasks[0].id), "kind": "video", "prompt": prompt}
else:
submitted_by_index = {
int(segment["index"]): str(task.id)
for segment, task in zip(segments, tasks, strict=False)
}
message_payload = {
"task_id": str(tasks[0].id),
"task_ids": [str(task.id) for task in tasks],
"kind": "video_segments",
"prompt": prompt,
"total_duration": total_duration,
"segments": [
{**segment, **({"task_id": submitted_by_index[int(segment["index"])]} if int(segment["index"]) in submitted_by_index else {})}
for segment in segments
],
# 仅保存后续分批提交所需的非敏感生成参数。所有批次从同一份 prompt、refs 与 seed
# 派生,避免第二批退化成无参考图的“续写”。
"generation_spec": submit,
}
message = append_message(
conversation, role="assistant", kind=CreationMessage.Kind.GENERATING,
payload=message_payload, task=tasks[0],
)
if len(segments) > 1:
# 每个片段都记住聚合消息。这样用户离开页面后,任一 worker 完成也能找到整组任务、
# 回填进度并补交下一批,不再只依赖首段 FK 或浏览器轮询。
for segment, task in zip(segments, tasks, strict=False):
request_payload = dict(task.request_payload or {})
marker = dict(request_payload.get("omni_segment") or {})
marker.update({
"index": int(segment["index"]),
"start": int(segment["start"]),
"end": int(segment["end"]),
"total_duration": total_duration,
"message_id": str(message.id),
})
request_payload["omni_segment"] = marker
task.request_payload = request_payload
task.save(update_fields=["request_payload", "updated_at"])
_remember_artifact(conversation, prompt, "video")
set_video_gate_stage(conversation, "done", clear_pending_prompt=True)
return message, ""
def submit_confirmed_image(*, conversation: CreationConversation, user, confirm_message: CreationMessage):
"""用户点了确认 → 按确认卡里存的画面描述出图。同样不跑一轮模型。"""
payload = confirm_message.payload or {}
prompt = str(payload.get("prompt") or payload.get("image_prompt") or "").strip()
if not prompt:
return None, "这条方案没有存下出图指令,请让我重新写一次。"
context = AgentContext(conversation=conversation, user=user, model_config=None)
try:
_result, tasks = _run_generate_image(context, {"prompt": prompt})
except AgentError as exc:
return None, str(exc)
except ValueError as exc:
return None, str(exc)
message = None
for task in tasks:
message = append_message(
conversation, role="assistant",
kind=CreationMessage.Kind.GENERATING,
payload={"task_id": str(task.id), "kind": "image", "prompt": prompt},
task=task,
)
if message is None:
return None, "出图没有提交成功,请再试一次。"
_remember_artifact(conversation, prompt, "image")
return message, ""
# ---------------------------------------------------------------- 提示词
def get_creation_chat_model(requested: ModelConfig | None = None) -> ModelConfig | None:
"""全能创作编排固定优先 Seed 2.1 Pro;显式传入的可用模型仍尊重用户选择。"""
if requested is not None:
return resolve_text_model(requested)
return get_seed_text_model() or resolve_text_model(None)
def _creation_model_sees_images(model_config: ModelConfig | None) -> bool:
"""对话模型能不能收图。参考图只在能看图时才塞进 chat messages,避免纯文本模型整轮失败。"""
if model_config is None:
return False
if getattr(model_config, "capability", "") == ModelConfig.Capability.VISION:
return True
name = str(getattr(model_config, "name", "") or "").lower()
# 豆包 Seed 2.x / 1.6 文本档都支持图文;vl / vision 后缀同理。
if name.startswith("doubao-seed-") or "vision" in name or name.endswith("-vl") or "-vl-" in name:
return True
metadata = model_config.metadata if isinstance(getattr(model_config, "metadata", None), dict) else {}
capabilities = metadata.get("capabilities") if isinstance(metadata.get("capabilities"), dict) else {}
features = {str(item) for item in capabilities.get("features") or []}
return bool({"vision", "image_input", "multimodal"} & features)
def _prefer_vision_text_model(current: ModelConfig | None, team, refs: list | None) -> ModelConfig | None:
"""有参考图时,尽量换成能看图的文本模型(豆包 Seed 等),否则聊天侧完全看不见男女。"""
if current is not None and _creation_model_sees_images(current):
return current
if not _ref_image_urls(team, refs):
return current
qs = (
ModelConfig.objects.select_related("provider")
.filter(
capability=ModelConfig.Capability.TEXT,
status=ModelConfig.Status.ACTIVE,
provider__status="active",
)
.order_by("created_at")
)
for candidate in qs:
if _creation_model_sees_images(candidate):
return candidate
return get_default_model(ModelConfig.Capability.VISION) or current
def _ref_image_urls(team, refs: list | None) -> list[str]:
resolved = resolve_refs(team, refs or [])
urls: list[str] = []
seen: set[str] = set()
for item in resolved.references:
url = str(item.get("url") or "").strip()
if not url or url in seen:
continue
seen.add(url)
urls.append(url)
return urls[:6]
def _attach_ref_images(messages: list[dict], image_urls: list[str]) -> list[dict]:
"""把锁定素材图挂到最近一条 user 消息上(OpenAI image_url 格式)。"""
if not image_urls or not messages:
return messages
note = (
f"【参考图·请亲眼看】下面 {len(image_urls)} 张是用户锁定的素材。"
"人物的性别、年龄段、发型、服装必须以图为准;商品的颜色、材质、外形、面板与结构也必须以图为准。"
"看不清再问用户;禁止凭文件名、品类常识或默认印象猜测性别或把商品写成别的颜色(例如把黑色写成白色)。"
)
out = [dict(message) for message in messages]
index = next((i for i in range(len(out) - 1, -1, -1) if out[i].get("role") == "user"), None)
if index is None:
content = [{"type": "text", "text": note}]
content.extend({"type": "image_url", "image_url": {"url": url}} for url in image_urls)
out.append({"role": "user", "content": content})
return out
last = dict(out[index])
raw = last.get("content")
if isinstance(raw, list):
content = list(raw)
text_bits = [str(item.get("text") or "") for item in content if isinstance(item, dict) and item.get("type") == "text"]
if not any(note[:8] in bit for bit in text_bits):
content.append({"type": "text", "text": note})
existing = {
(item.get("image_url") or {}).get("url")
for item in content
if isinstance(item, dict) and item.get("type") == "image_url"
}
content.extend(
{"type": "image_url", "image_url": {"url": url}}
for url in image_urls
if url not in existing
)
else:
content = [{"type": "text", "text": f"{raw or ''}\n\n{note}".strip()}]
content.extend({"type": "image_url", "image_url": {"url": url}} for url in image_urls)
last["content"] = content
out[index] = last
return out
def build_system_prompt(context: AgentContext, *, allow_plan: bool = True, has_context: bool = False) -> str:
conversation = context.conversation
params = conversation.params or {}
kind = "视频" if context.is_video else "图片"
lines = [
"你是影擎「全能创作」的创作 agent,帮电商商家做短视频和商品图。",
f"本次会话产出的是**{kind}**,这一点在整个会话里不会改变 —— 用户要另一种就请他新开一个创作。",
"",
"【怎么说话】",
"- 说人话,像个懂创作、会一起把事做完的同事;自然、简短、有判断,不要写成客服话术或需求确认清单。",
"- 禁止用「好的」「收到」「明白了」「有需要再说」「我将为你」起手;这些句子没有信息量,也很像机器人。",
"- 不要逐字复述用户刚说过的话。需要承接时,只说你的判断或下一步,例如「这个方向能做,我先把反转落在商品登场上。」",
"- 一次只推进一步,但不要把能直接做的事停在寒暄、确认或客套话上。",
"- 缺信息时一次只问一个真正影响结果的问题,不要把对话做成问卷。",
"- 缺少商品、模特、角色、场景等素材时:**绝不要直接弹出大块素材选择卡**打断对话。像懂创作、懂电商的专业伙伴一样先自然询问用户想推什么商品/用哪个角色,询问是否需要把商品列表发给他选,同时说明也可以直接输入商品名或由你推荐。",
"- 只有当用户明确说要发列表(如「发给我」「发列表」「给我看看」「我来选」)时,才展示可视化卡片。",
"- 用户直接打字输入商品名时,直接采纳该商品并继续推进创作方案,不要强迫用户去卡片里点选。",
"- 调性、受众、文案、时长等其他信息用 ask_user 或聊天追问;无论哪种,都必须让用户知道下一句怎么回。",
"- 用户刚回答过你的问题时,直接沿着答案继续;不要复述答案,也不要额外回一句「收到」。",
"- 用户说「重新来」「重来」「从头开始」「再来一次」「重新做」:整轮重开创作。"
" 先短确认,再按最初 brief/已钉素材从澄清或 write_strategy 推进;禁止再打开上一轮模特/商品库追问。",
"- 用户选择暂不提供某项素材时,把它当成明确授权:按已有信息和合理默认继续。除非任务客观上无法完成,否则不要再次追问同一素材。",
"- 用户说「你来定」「你帮我选」「随便」「都行」时,就是授权你做专业判断;直接选合理方案继续,不要把选择题再抛回去。",
"- 禁止问「要不要继续」「要不要生成」「是否开始创作」这类流程问题。缺信息用 ask_user;信息够了就写策略。"
" 视频每写完策略或方案,平台会出确认卡;方案确认后平台会在后台整理出片指令,再让用户核对生成参数。不要口头问流程。",
"- 用户打招呼或闲聊(hi / 你好 / 在吗 / 你在干什么 / 嗯 / 好的 / ok):"
" **禁止**调用 write_strategy、write_plan、generate_image,不要「整理方案」或直接开写脚本。",
"- 会话里**还没有**商品/方向时:自然地告诉用户可以直接丢一句想法,别硬推销,也别用客服式结束语。",
"- 会话里**已有**商品或方向时:针对用户这句话本身自然回应;别用「要现在生成还是先调细节」把工作又抛回给用户。",
"- 没有明确创作指令时不要自己写策略卡/方案卡,也不要主动追问流程确认;等用户给出具体想法或修改。",
"",
"【每轮必须给引导】",
"- 每一轮回复结束时,用户必须知道下一步怎么做:要么调用 ask_user 弹出可选项,"
"要么在文字里明确告诉用户该回复什么(例如「直接说想改的卖点」)。",
"- 当你给出 23 个方向时,必须调用 ask_user 生成可点击选项;正文只解释各方向,"
"绝不要求用户回复数字、编号或 1/2/3。",
"- 禁止只丢一段解释/分析就结束、让用户不知道该回什么。",
"- 用户已经给出可直接执行的图片需求(主体、场景或氛围已足够)时,直接调用 generate_image 进入确认卡;"
"不要只描述你准备怎么拍,再让用户继续补一句。",
"- 禁止用「有想调整可以直接说」这类泛泛收尾代替引导;要么 ask_user 给出可选项,"
"要么给出一句用户可以直接点击或复制回复的话。",
"- 缺信息或要用户做选择时:优先 ask_user(带 options 的 single/multi,或 asset)。",
"- 纯说明/判断/闲聊回应:文末必须带一句可执行的回复指引。",
"- 视频闸门的 step_confirm / 积分确认卡本身已是引导,不要再口头追问流程。",
"",
"【什么时候反问】",
"- 只有信息**确实缺失且无法合理推断**时才调用 ask_user;能自己定的就自己定。",
"- ask_user 一次只问一件事。type=asset 会以类Agent自然追问轻量询问是否发送列表或由你推荐;其他类型显示普通聊天问题,让用户直接输入。",
"- 商品是谁、给谁看、什么调性 —— 这些缺了会直接影响成片,值得问。",
"- 让用户选商品/角色/模特/场景时,ask_user 必须用 type=asset 并填 asset_types。",
"- 用户已经添加多位角色并分别描述造型时,这些角色默认都属于本次创作,不得擅自删掉或要求用户从中三选一。"
" 如果人物关系没说清,只确认‘共同出镜’还是‘一位主讲、其余辅助’;只有用户明确说只用一位时才缩减角色。",
"- 用户说「改商品」「换角色」却没点名是哪个:立刻 ask_user 启动素材确认,不要强行出大卡片。",
"- 用户说改时长/模型/比例/分辨率但没给新值:立刻 ask_user,type=single 提供可识别的候选值;聊天里只显示问题,用户直接输入。回答后旧方案作废,必须按新参数重新 write_plan。改时长时 options 只能给 ≤60 秒的常用档(如 15/30/45/60 秒),禁止出现 90/120/180 秒或更长。",
"- 光线、构图、镜头这些专业判断是你的活,不要反过来问用户。",
]
if context.is_video:
lines.extend(["", _OMNI_VIDEO_PROMPT_RULES.strip()])
lines.extend([
"",
"【视频创作工作原则】",
"- 先从文字和已锁定素材整理商品事实、人物/服装、场景、参考视频或音频、现成脚本,以及时长、比例和语言;已经给出的信息不重复问。",
"- 用户已给脚本或分镜时,以它为基础补足,不强制从头重写。只改用户点名的镜头、人物、商品、台词或参数,其他内容保持。",
"- 可以自己决定转场、灯光、普通镜头细节;商品功能、规格、价格、活动、功效和关键使用边界不能猜,缺失时一次只问一项。",
"- 用户上传的人物、商品、服装和场景优先作为参考;如确实需要额外生成角色、场景或道具,先说明用途和预计积分,等用户确认。",
"- 出片前检查:主卖点都有画面或台词证据、口播能在时长内说完、脚本中每位人物/商品/服装/场景都有对应素材、商品正常使用、全片一致、所有 SKU 都已安排。内容超出时长时建议删减、延长或拆分,而不是硬塞。",
"- 安全检查前置到创作第一稿:人物默认明确为成年人,服装与构图得体;策略、方案和 video_prompt 只写改写后的正向可拍内容,不复述风险情节,不罗列平台禁用词,也不写否定式免责声明。",
])
# 按会话时长给出口播字数锚点(与专业创作 narration_limit 同口径)
try:
dur = video_duration(params)
except Exception: # noqa: BLE001
dur = SMART_DURATION
# 长视频不应沿用 15 秒短片的 85 字上限;按自然中文口播速度给整片留出呼吸空间。
lo = max(1, int(dur * 3.4))
hi = max(lo, int(dur * 4.0))
lines.append(f"- 当前按约 {dur} 秒出片,口播建议 {lo}{hi} 字;write_plan 的 voice_chars 填这个区间。")
if dur > LONG_VIDEO_CHAPTER_DURATION:
lines.append(
f"- 当前是 {dur} 秒长视频。write_plan 只交方案卡和紧凑章节稿(每章任务、每个 30 秒段 3–5 个关键镜头、段尾交接),"
"全文约 800–1400 字;禁止把 15 秒短片的制作级长文整篇套用。用户确认方案后,write_prompt 只补短规则,不再扩写逐镜。"
)
lines.append(
"- 视频 5 步闸门(不可同轮连跳):①缺信息 ask_user 停 → ②write_strategy 停等确认 → "
"③用户确认后 write_plan 停等确认 → ④方案确认后展示完整出片指令并停等确认 → ⑤再显示积分确认卡。"
)
lines.append(
"- 只有用户明确要做片、出方案、改方案、换卖点/剧情时才调用 write_strategy / write_plan;"
"闲聊与打招呼绝对不要。用户对某一步提出修改时,只重写那一步,不要跳到后面。"
+ (
"write_plan 的 video_prompt 按长视频紧凑章节稿来写,不要为了完整去凑字数。"
if dur > LONG_VIDEO_CHAPTER_DURATION else
"write_plan 里的 video_prompt 要按上面的秒级分镜规范写满,供平台后台出片使用;不要只给大纲,也禁止只回「好的,有需要再说」。"
)
)
stage = get_video_gate_stage(conversation)
memory = conversation.memory if isinstance(conversation.memory, dict) else {}
selling_point = str(memory.get("selling_point") or "").strip()
selling_mode = str(memory.get("selling_point_mode") or "").strip()
if selling_mode == "manual" and selling_point:
lines.append(
f"- 商家已确认核心卖点:【{selling_point}】。策略、方案、脚本和出片指令必须围绕它展开;"
"只补充可从素材或正常使用中证明的支撑,不得替换或夸大。"
)
elif selling_mode == "auto":
lines.append(
"- 商家已授权系统推荐卖点。你必须从商品资料、可见素材和正常使用动作中选择一个最易证明的核心卖点;"
"不要虚构功效、价格或规格。"
)
strategy_confirmed = bool(memory.get("strategy_confirmed"))
stage_hint = {
"clarify": "当前阶段=澄清:缺关键信息就 ask_user;信息够了只调 write_strategy。",
"strategy": (
"当前阶段=策略已确认,请调用 write_plan 写方案;不要再写策略。"
if strategy_confirmed else
"当前阶段=等策略确认:不要再写方案或出片;用户确认后才会进入方案。若用户在改策略,只重调 write_strategy。"
),
"plan": "当前阶段=等方案确认:不要出片。若用户在改方案,只重调 write_plan。",
"prompt": "当前阶段=兼容历史会话的出片指令确认:不要出片,按用户反馈重写内部出片指令。",
"confirm": "当前阶段=等出片确认:不要再写策略/方案;用户会在确认卡上点开始生成。",
"done": "当前阶段=已出片:等用户新的修改或新需求再行动。用户说重新来/重来/从头开始时,当作新一轮创作,从澄清或 write_strategy 重开,不要再弹旧模特/商品追问。",
}.get(stage, "")
if stage_hint:
lines.append(f"- {stage_hint}")
else:
lines.extend([
"",
"【出图】",
"- 决定出图时必须调用 generate_image,不要只口头说「我这就出图」。",
"- 一次用户消息只出一轮;张数用会话已定参数,不要自己加张。",
"- 已锁定商品参考图时:prompt 里商品颜色、材质、外形必须与参考图一致;"
"看不清就写「严格按参考商品图外观」,禁止凭印象编造颜色。",
])
if not allow_plan:
lines.extend([
"",
"【本轮闸门】",
"- 用户本轮没有明确说「去做/出方案」。没有 write_strategy / write_plan / generate_image。",
"- **禁止**本轮直接写出策略卡或方案卡。",
])
if has_context:
lines.extend([
"- 会话已有素材或方向:针对用户本轮实际说的话自然回应;不要问要不要继续、要不要生成。",
"- 禁止只回「在呢」「有需要随时招呼」「收到」这种空话。",
])
else:
lines.extend([
"- 会话还没有创作进度:短回一句,告诉用户直接说想做什么即可;不要用客服式结束语,也不要硬推销出片。",
])
if params:
meta = "、".join(f"{k}:{v}" for k, v in params.items() if v)
if meta:
lines.append(f"\n【会话已定参数】{meta}(出片按这套;用户改动后必须按新值重写方案)")
if conversation.preset:
# 只给名字模型只能靠猜;把这个预设的拍法约束一起给它
guidance = preset_guidance(conversation.preset)
lines.append(f"\n【创作预设】{conversation.preset}")
if context.is_video:
lines.append(
"预设必须贯穿本次对话:据此判断该补问哪些必要事实和素材、给什么创意选择、"
"如何写脚本/分镜、商品如何出现、生成前检查什么;不能把预设当成最后才附加的一行风格词。"
)
if guidance:
lines.append(guidance)
lines.append("用户选了这个预设,就按它的拍法来;要偏离得先问过用户。")
if is_plot_twist_conversation(conversation):
depth = active_plot_twist_story_depth(conversation)
if not depth:
lines.append("剧情反转带货尚未选择故事深度:必须先调用 ask_user 让用户选 15 秒、30 秒、60 秒或智能推荐;此时禁止给剧情方向、策略或方案。")
else:
lines.append(plot_twist_story_contract(depth))
memory = conversation.memory if isinstance(conversation.memory, dict) else {}
selected = plot_twist_selected_direction(conversation)
if selected:
contract = format_plot_twist_direction_contract(**{
k: selected.get(k, "") for k in ("title", "conflict", "product_role", "reversal", "tone", "detail")
})
lines.append(contract)
lines.append(
"已选定剧情方向:策略卡的创作方向、方案时间轴和 video_prompt 必须严格按上方冲突→商品作用→反转展开;"
"禁止另起一套常见带货桥段;不要重发方向卡。"
)
else:
lines.append(
"【强制下一步】现在必须调用 present_story_directions,直接展示 3 张完整剧情方向卡。"
"每张卡写清冲突、商品如何推进剧情、反转和情绪;不得只在文字中说‘我准备了三个方向’,"
"不得先 write_strategy / write_plan,也不要让用户输入编号。"
)
if is_pain_point_conversation(conversation):
memory = conversation.memory if isinstance(conversation.memory, dict) else {}
selected_direction = str(memory.get("pain_point_direction") or "").strip()
if selected_direction:
lines.append(
f"用户已选择痛点方向:【{selected_direction}】。这个方向就是已确认的核心卖点;"
"直接围绕它写策略,不得再询问核心卖点。"
)
else:
lines.append(
"【强制下一步·痛点方向三选一】先根据商品事实与可见素材整理恰好 3 个明显不同、可被画面证明的痛点方向。"
"必须调用 ask_userfield key 固定为 pain_point_directiontype=singleoptions 恰好 3 项;"
"每个 label 直接写完整的‘具体困扰 + 商品正常使用后可见结果’,让用户点击即选中。"
"不得只在正文里列三条,不得要求用户回复编号,不得先 write_strategy,也不得另问核心卖点。"
)
workflow_guidance = preset_workflow_guidance(conversation.preset) if context.is_video else ""
if workflow_guidance:
lines.append(f"【当前预设的工作重点】{workflow_guidance}")
delivery_contract = video_preset_delivery_contract(conversation.preset, click_swap_mode=click_swap_mode(conversation)) if context.is_video else ""
if delivery_contract:
lines.append(f"【当前预设必须贯穿脚本与出片】{delivery_contract}")
if context.is_video and is_clothing_video_context(conversation):
lines.append(
"【服装类视频硬性要求】人物从第一镜起就已经穿着商品;只拍上身效果、版型、面料与搭配。"
"禁止写穿衣服、套袖、扣扣、拉拉链、脱换过程等穿戴动作镜头;换款用瞬间切换或已穿好的下一套。"
)
if context.is_video and is_click_swap_preset(conversation.preset) and click_swap_mode(conversation) != "character":
lines.append(
"【点击换款分镜硬性要求】换款由手指点击触发;撰写 video_prompt / 方案分镜时,"
"每一次换款的点击落点必须与上一次不同(点商品的不同区域/角点);"
"禁止写或暗示「每次点击同一位置 / 点同一点 / 手指始终点同一处」;"
"各镜要写明本次点击位置不同于上次,并重点写款式变化与节奏。"
)
if context.is_video and is_fish_eye_outfit_preset(conversation.preset):
lines.append(
"【鱼眼换装 Prompt 硬性要求】write_plan / write_prompt 的 video_prompt 必须按小云雀「鱼眼连环换装」模板撰写:"
"时长按套数估算、9:16、超广角鱼眼风格、全片单一机位节拍换装、转场瞬间切装、主体只取五官发型妆容姿态、"
"服装状态机 S0→SN、无对白、转场配快门/whoosh;禁止穿衣过程与口播。"
"不要套用通用「制作级分镜四行+人声」长文模板。"
)
lines.append(fish_eye_outfit_prompt_template())
if context.is_video and is_pet_preset(conversation.preset):
lines.append(
"【宠物拟人台词硬性要求】策略、方案和 video_prompt 里的人声必须是宠物第一人称开口,"
"像这只宠物自己在说话;禁止旁白、口播腔、画外音解说或第三人称讲述。"
)
if context.is_video:
lines.append(
"【角色定妆图硬性要求】生成人物/宠物定妆参考图时,prompt 只写角色外貌、发型、服装气质与表情;"
"禁止写商品名、卖点、包装或使用动作;出图画面中不得出现任何商品、瓶罐、袋装或手持卖品。"
"用户对已出定妆图提出修改(如短发、御姐气质)时,必须真正重新提交定妆出图;"
"禁止只在聊天里写「我重新生成…prompt:…」而不调用出图。"
)
resolved = resolve_refs(context.team, conversation.pinned_refs or [])
if resolved.facts:
lines.append("\n【本次会话已锁定的素材事实】")
lines.append(resolved.facts_text)
lines.append(
"以上素材的参考图会附给你看,出片时也会自动锁人锁物。"
"人物性别、年龄段、发型、服装、商品颜色外形必须以图为准;"
"禁止凭品类常识或默认印象把商品写成别的颜色(例如参考图是黑色却写成白色)。"
"图上看不清或没附图时,必须问用户,禁止凭文件名猜测男女或颜色。"
)
memory = conversation.memory or {}
people = locked_person_references(conversation)
if len(people) > 1:
names = "、".join(str(ref.get("name") or "未命名角色") for ref in people)
relation = str(memory.get("cast_relation") or "").strip()
keep_rule = (
"按用户明确要求缩减出镜角色,但未出镜的人物素材仍保留在会话中。"
if re.search(r"(只用|只留|只要|单人出镜)", relation)
else "这些角色默认都必须保留。"
)
lines.append(f"\n【多角色锁定】本次已添加 {len(people)} 位角色:{names}{keep_rule}")
if relation:
lines.append(f"已确认出镜安排:{relation}。后续策略、脚本和分镜必须遵守,不得再次询问同一问题。")
else:
lines.append("出镜关系尚未确认:只询问共同出镜还是主讲+辅助,不得问‘三位选哪一位’。")
if memory.get("summary"):
lines.append(f"\n【前情提要】{memory['summary']}")
artifacts = memory.get("artifacts") or []
if artifacts:
recent = artifacts[-3:]
lines.append("\n【本会话已生成过】")
for index, item in enumerate(recent, 1):
lines.append(f"{index}. {item.get('prompt', '')[:120]}")
lines.append(
"用户说「改成…」「换成…」时,是要在**最后一次生成**的基础上重新生成一版,"
"把改动合进完整 prompt 再调生成工具 —— 不要只写改动部分。"
)
return "\n".join(lines)
def _answer_label(field: dict, raw) -> str:
"""追问卡的内部值翻成人能读的标签,避免把 UUID / gate key 喂回模型。"""
values = raw if isinstance(raw, list) else [raw]
options = {
str(item.get("value")): str(item.get("label") or item.get("value") or "")
for item in (field.get("options") or [])
if isinstance(item, dict)
}
return "、".join(options.get(str(value), str(value)) for value in values if value not in (None, ""))
def build_messages(context: AgentContext, *, allow_plan: bool = True, has_context: bool = False) -> list[dict]:
"""会话历史 → 模型消息。只喂对模型有意义的:文字、追问和它的答案、生成过什么。
策略卡/方案卡这类结构化产物压成一句话,原样塞 JSON 只会挤爆上下文。
长会话只喂最近 KEEP_RECENT_MESSAGES 条原文,更早的靠 system 里的【前情提要】
——摘要在 compress_memory() 里生成,不在这里现算。
"""
messages = [{"role": "system", "content": build_system_prompt(context, allow_plan=allow_plan, has_context=has_context)}]
history = list(context.conversation.messages.all())
if len(history) > COMPRESS_AFTER_MESSAGES:
history = history[-KEEP_RECENT_MESSAGES:]
for message in history:
if message.kind == CreationMessage.Kind.TEXT:
if message.text.strip():
messages.append({"role": message.role, "content": message.text})
elif message.kind == CreationMessage.Kind.ELICIT:
payload = message.payload or {}
answers = payload.get("answers") or {}
fields = [item for item in (payload.get("fields") or []) if isinstance(item, dict)]
field_by_key = {str(item.get("key") or ""): item for item in fields}
if payload.get("interaction") == "chat" or (
payload.get("interaction") == "asset_picker"
and payload.get("answered_via") == "chat"
):
question = message.text.strip() or str((fields[0] if fields else {}).get("label") or "").strip()
if question:
messages.append({"role": "assistant", "content": question})
# 聊天式回答本身就是下一条 TEXT user 消息,这里不再合成一条伪造答案。
continue
if answers:
if payload.get("phase") == "gate":
choice = str(answers.get("_asset_gate") or "")
if choice == "skip":
answer_text = "(用户选择暂不添加这项素材,要求按现有信息继续原任务)"
elif choice == "auto":
answer_text = "(用户希望你帮忙挑选/决定素材,按合理推荐继续推进)"
elif choice == "send":
answer_text = "(用户希望打开素材列表继续选择)"
else:
answer_text = f"(用户指定了素材:{choice})"
else:
joined = "".join(
f"{field_by_key.get(str(key), {}).get('label') or key}"
f"{_answer_label(field_by_key.get(str(key), {}), value)}"
for key, value in answers.items()
)
answer_text = f"(用户完成了刚才的选择:{joined})"
messages.append({"role": "assistant", "content": "(我请用户补充了一项会影响创作的信息)"})
messages.append({"role": "user", "content": answer_text})
else:
messages.append({"role": "assistant", "content": "(我正在等用户回答刚才的问题)"})
elif message.kind in (CreationMessage.Kind.GENERATING, CreationMessage.Kind.RESULT):
prompt = (message.payload or {}).get("prompt") or ""
messages.append({"role": "assistant", "content": f"(我生成了一版,prompt:{prompt[:200]})"})
elif message.kind == CreationMessage.Kind.ERROR:
messages.append({"role": "assistant", "content": f"(上一次生成失败:{message.text})"})
if _creation_model_sees_images(context.model_config):
messages = _attach_ref_images(
messages,
_ref_image_urls(context.team, context.conversation.pinned_refs or []),
)
return messages
# ---------------------------------------------------------------- 流式循环
def _sse(event: dict) -> str:
"""一帧 SSE。**必须用 DjangoJSONEncoder** —— 消息里带 UUID(task 外键)和
datetime(created_at),标准 json.dumps 直接抛 TypeError,整条流当场断掉。"""
return f"data: {json.dumps(event, ensure_ascii=False, cls=DjangoJSONEncoder)}\n\n"
def _merge_tool_call_deltas(buffer: dict, deltas: list) -> None:
"""OpenAI 流式把一次 tool_call 的 arguments 拆成很多片,按 index 拼回来。
name 只在第一片出现,arguments 要逐片累加 —— 直接覆盖会只剩最后一个字符。"""
for delta in deltas or []:
if not isinstance(delta, dict):
continue
index = delta.get("index", 0)
slot = buffer.setdefault(index, {"name": "", "arguments": ""})
function = delta.get("function") or {}
if function.get("name"):
slot["name"] = function["name"]
if function.get("arguments"):
slot["arguments"] += function["arguments"]
def _parse_arguments(raw: str) -> dict:
"""解析工具 arguments。模型偶发包 markdown 围栏或夹杂前后缀,尽量救回 JSON。"""
text = (raw or "").strip()
if not text:
return {}
if text.startswith("```"):
text = text.strip("`")
if text.lower().startswith("json"):
text = text[4:].lstrip()
text = text.strip()
try:
parsed = json.loads(text)
except ValueError:
start, end = text.find("{"), text.rfind("}")
if start < 0 or end <= start:
return {}
try:
parsed = json.loads(text[start : end + 1])
except ValueError:
return {}
return parsed if isinstance(parsed, dict) else {}
def _pick_str(args: dict, *keys: str) -> str:
"""按候选键取非空字符串;兼容中文别名与嵌套一层 dict。"""
for key in keys:
value = args.get(key)
if isinstance(value, dict):
# 偶发 {"text": "..."} / {"value": "..."}
for nested in ("text", "value", "content", "desc", "description"):
inner = value.get(nested)
if isinstance(inner, str) and inner.strip():
return inner.strip()
continue
if value is None:
continue
text = str(value).strip()
if text:
return text
return ""
def _coerce_points(raw) -> list[str]:
"""points 规整成最多 3 条非空文案。兼容纯字符串、对象数组、dict。"""
if raw is None:
return []
items: list = []
if isinstance(raw, str):
text = raw.strip()
if not text:
return []
# 中文分号/换行拆条
for part in text.replace("\r", "\n").replace("", "\n").split("\n"):
part = part.strip(" ·•-、,,")
if part:
items.append(part)
elif isinstance(raw, dict):
# {"P0": "...", "P1": "..."} 或 {"0": "..."}
for key in sorted(raw.keys(), key=lambda k: str(k)):
val = raw[key]
if isinstance(val, dict):
text = _pick_str(val, "text", "value", "content", "desc", "point", "label")
else:
text = str(val or "").strip()
if text:
items.append(text)
elif isinstance(raw, (list, tuple)):
for item in raw:
if isinstance(item, dict):
text = _pick_str(item, "text", "value", "content", "desc", "point", "label")
else:
text = str(item or "").strip()
if text:
items.append(text)
else:
text = str(raw).strip()
if text:
items.append(text)
# 过滤单字符噪声(字符串被误当成 list 迭代时的残留)
cleaned = [p for p in items if len(p) > 1]
return cleaned[:3]
def _coerce_voice_chars(raw, fallback: list[int] | None = None) -> list[int]:
"""voice_chars 必须是 [下限, 上限];模型常误塞语气文案或单个整数。"""
if isinstance(raw, (list, tuple)) and len(raw) >= 2:
try:
lo, hi = int(raw[0]), int(raw[1])
if lo > 0 and hi >= lo:
return [lo, hi]
except (TypeError, ValueError):
pass
if isinstance(raw, (int, float)) and int(raw) > 0:
n = int(raw)
return [max(1, n - 5), n + 5]
return list(fallback or [])
def _coerce_timeline(raw) -> list[dict]:
items: list[dict] = []
for item in (raw or []) if isinstance(raw, list) else []:
if not isinstance(item, dict):
continue
try:
start = float(item.get("start"))
end = float(item.get("end"))
except (TypeError, ValueError):
continue
stage = str(item.get("stage") or "").strip()
if not stage:
continue
entry = {"start": start, "end": end, "stage": stage}
desc = str(item.get("desc") or item.get("description") or "").strip()
if desc:
entry["desc"] = desc
items.append(entry)
return items
def _default_plan_matrix(usp: str, points: list[str]) -> dict:
"""设计稿里的「卖点覆盖矩阵」:有 USP/支撑点就自动铺一版,避免方案卡干瘪。"""
rows = [{"point": "主打卖点 USP", "hits": [1, 3]}]
labels = ["核心支撑 P0", "视觉支撑 P1", "转化支撑 P2"]
for index, point in enumerate(points[:3]):
label = labels[index] if index < len(labels) else f"支撑卖点 P{index}"
# 点名用文案前缀,hits 错落分布到 4 镜
hit = (index % 4) + 1
rows.append({"point": label if not point else f"{label}", "hits": [hit]})
return {"shots": 4, "rows": rows}
def _coerce_strategy_args(args: dict) -> dict:
return {
"target": _pick_str(
args, "target", "audience", "who", "给谁看", "目标人群", "人群",
),
"trust": _pick_str(
args, "trust", "credibility", "为什么相信", "信任", "可信度",
),
"belief": _pick_str(
args, "belief", "希望相信", "想让他信什么", "认知", "takeaway",
),
"direction": _pick_str(
args, "direction", "创作方向", "方向", "style", "路线",
),
}
_STRATEGY_TEXT_SECTION_ALIASES = {
"目标受众": "target",
"目标人群": "target",
"这条视频给谁看": "target",
"给谁看": "target",
"用户为什么相信": "trust",
"为什么相信": "trust",
"内容逻辑": "trust",
"可信依据": "trust",
"信任依据": "trust",
"希望用户相信什么": "belief",
"希望相信": "belief",
"核心卖点": "belief",
"核心主张": "belief",
"创作方向": "direction",
"视觉调性": "direction",
"视觉风格": "direction",
"表达方向": "direction",
}
def strategy_args_from_text(text: str) -> dict:
"""模型漏调 write_strategy 时,把带明确栏目名的策略正文救回结构化卡片。
只接受四个栏目都能识别的高置信文本,普通聊天不会被误转成策略卡。
"""
sections = {"target": [], "trust": [], "belief": [], "direction": []}
current = ""
for raw_line in str(text or "").splitlines():
line = re.sub(r"^\s*(?:[-*#>]+\s*)?", "", raw_line).replace("**", "").strip()
if not line:
continue
match = re.match(r"^([^:\n]{2,36})\s*[:]\s*(.*)$", line)
if match:
heading = re.sub(r"[(].*$", "", match.group(1)).strip().replace(" ", "")
key = _STRATEGY_TEXT_SECTION_ALIASES.get(heading)
if key:
current = key
content = match.group(2).strip()
if content:
sections[key].append(content)
continue
if heading in {"创作策略", "策略理解", "创作策略理解"}:
current = ""
continue
if not current:
continue
if re.match(r"^(?:你看|请确认|如果你|是否需要|可以再)", line):
continue
sections[current].append(line)
payload = {
key: "\n".join(parts).strip()
for key, parts in sections.items()
}
return payload if all(payload.values()) else {}
def _coerce_plan_card_args(args: dict) -> dict:
usp = _pick_str(args, "usp", "主打卖点", "卖点", "core_usp", "main_point")
points = _coerce_points(
args.get("points")
if args.get("points") is not None
else args.get("支撑点") or args.get("supports") or args.get("selling_points")
)
# 兼容 point1/point2/point3 展开写法(设计稿 blueprint)
if not points:
for key in ("point1", "point2", "point3", "P0", "P1", "P2"):
text = _pick_str(args, key)
if text:
points.append(text)
points = points[:3]
timeline = _coerce_timeline(args.get("timeline") or args.get("时间轴"))
matrix = args.get("matrix")
if not isinstance(matrix, dict) or not matrix.get("rows"):
matrix = _default_plan_matrix(usp, points) if (usp or points) else {}
return {
"usp": usp,
"points": points,
"timeline": timeline,
"matrix": matrix,
"voice_chars": args.get("voice_chars"),
}
def apply_click_swap_plan_card(
conversation: CreationConversation,
card: dict,
duration: int,
) -> dict:
"""把模型可能写偏的方案卡收束成可核对的点击换款时间轴。"""
if not is_click_swap_preset(conversation.preset):
return card
sequence = click_swap_sequence(conversation)
if not sequence:
return card
total = max(4, min(int(duration or 0), MAX_VIDEO_DURATION))
first_end = max(1, round(total * 0.2))
second_end = max(first_end + 1, round(total * 0.45))
third_end = max(second_end + 1, round(total * 0.75))
third_end = min(third_end, total - 1)
second_end = min(second_end, third_end - 1)
if click_swap_mode(conversation) == "character":
return {
**card,
"usp": f"同一角色在日常场景中按「{sequence}」变色换款",
"points": [
"锁定同一角色、同一生活场景与光线",
"角色自然使用或展示商品,按确认顺序换款",
f"严格按「{sequence}」逐款展示,结尾给出全款式收束",
],
"timeline": [
{
"start": 0,
"end": first_end,
"stage": "日常开场",
"desc": "同一角色在日常使用场景中亮相,商品作为手头物件自然入画。",
},
{
"start": first_end,
"end": second_end,
"stage": "首次换款",
"desc": "角色保持同一身份与场景,商品切换到下一款,颜色/款式变化清楚。",
},
{
"start": second_end,
"end": third_end,
"stage": "按序换款",
"desc": f"按「{sequence}」继续换款;人物五官、发型、身形、基础服装与场景不变。",
},
{
"start": third_end,
"end": total,
"stage": "全款式收束",
"desc": "同一角色与场景下给出全款式收束,不改成口播带货腔或复杂剧情。",
},
],
"matrix": {
"shots": 4,
"rows": [
{"point": "角色一致", "hits": [1, 2, 3, 4]},
{"point": "日常场景", "hits": [1, 2, 3, 4]},
{"point": "款式顺序", "hits": [1, 2, 3, 4]},
],
},
"voice_chars": [0, 0],
}
return {
**card,
"usp": f"手指点击触发,商品按「{sequence}」连续换款",
"points": [
"固定机位、背景与光线,商品构图稳定",
"换款由手指点击触发,每一次点击落点必须与上一次不同",
f"严格按「{sequence}」逐款展示,结尾给出全款式总览",
],
"timeline": [
{
"start": 0,
"end": first_end,
"stage": "首款定帧",
"desc": "固定机位建立首款,机位、背景与商品构图作为后续基准。",
},
{
"start": first_end,
"end": second_end,
"stage": "首次点击换款",
"desc": "手指点击商品某一区域触发换款,接触后以干净 match cut 切到下一款;本次落点作为基准,后续不得复用同一落点。",
},
{
"start": second_end,
"end": third_end,
"stage": "按序连续换款",
"desc": f"按「{sequence}」继续点击触发换款;每一次点击都换到商品的不同区域/角点,机位、构图与光线保持稳定。",
},
{
"start": third_end,
"end": total,
"stage": "全款式收束",
"desc": "保持同一构图完成全款式总览,不加入口播、剧情、换景或字幕。",
},
],
"matrix": {
"shots": 4,
"rows": [
{"point": "固定构图", "hits": [1, 2, 3, 4]},
{"point": "点击触发", "hits": [2, 3]},
{"point": "款式顺序", "hits": [1, 2, 3, 4]},
],
},
"voice_chars": [0, 0],
}
def iter_creation_agent_events(
*,
conversation: CreationConversation,
user,
text: str,
refs: list[dict] | None = None,
model_config: ModelConfig | None = None,
record_user_message: bool = True,
force_creative_turn: bool = False,
continuation_instruction: str = "",
) -> Iterator[dict]:
"""一条用户消息 → 事件 dict 流(message/delta/tool/done/error)。消息已落库。"""
refs = refs or []
fast_greeting = record_user_message and is_greeting(text)
# 打招呼不依赖模型,模型未配置也能即时回应;其他消息保持原有的先校验模型行为。
if not fast_greeting:
model_config = get_creation_chat_model(model_config)
if model_config is None:
yield {"type": "error", "detail": "没有可用的文本模型,请先在模型库配置"}
return
context = AgentContext(conversation=conversation, user=user, model_config=model_config)
try:
user_message = None
with transaction.atomic():
pin_refs(conversation, refs)
if record_user_message:
user_message = append_message(conversation, role="user", text=text, refs=refs)
if user_message is not None:
yield {"type": "message", "message": _message_payload(user_message)}
# 主页/聊天已写卖点时尽早锁定,避免模型再追问或 write_strategy 弹闸门
if conversation.mode == CreationConversation.Mode.VIDEO:
ensure_selling_point_ready_from_context(conversation, text)
set_public_agent_progress(conversation, "starting")
# brief 里直接写「做一条 120 秒视频」就是明确参数,不能仍沿用顶部的智能时长。
# 要在任何创作闸门之前落库,后面的策略、脚本和出片分段才会统一使用该时长。
if not fast_greeting:
apply_explicit_video_duration_from_text(conversation, text)
# 单纯打招呼无论有没有参考素材、有没有既有上下文,都不值得等模型。
# 素材先收下,等用户说清用途再分析,避免「你好」卡住还冒出一大段建议。
if fast_greeting:
greet_text = (
"你好,素材我收到了。想拿它做什么,直接说就行。"
if refs else "你好。想做什么,直接说就行。"
)
reply = append_message(
conversation,
role="assistant",
text=greet_text,
payload={
"reply_hint": default_reply_hint(
conversation, has_context=False, is_video=conversation.mode == CreationConversation.Mode.VIDEO
),
"reply_options": default_reply_options(
conversation, has_context=False, is_video=conversation.mode == CreationConversation.Mode.VIDEO
),
},
)
yield {"type": "message", "message": _message_payload(reply)}
yield {"type": "done"}
return
# 空会话里的简短应答:短回一句就够。已有商品/方向时走模型,
# 针对用户实际说的话自然回应,但不给方案工具。
has_context = session_has_creative_context(conversation)
if record_user_message and is_pure_chitchat(text) and not refs and not has_context:
chit_text = "我在。想做什么,直接丢一句想法给我就行。"
reply = append_message(
conversation,
role="assistant",
text=chit_text,
payload={
"reply_hint": default_reply_hint(
conversation, has_context=False, is_video=conversation.mode == CreationConversation.Mode.VIDEO
),
"reply_options": default_reply_options(
conversation, has_context=False, is_video=conversation.mode == CreationConversation.Mode.VIDEO
),
},
)
yield {"type": "message", "message": _message_payload(reply)}
yield {"type": "done"}
return
# 0. 本地上传商品图优先确认品牌与具体品名
if product_info_needs_confirmation(conversation, text):
question = append_product_info_gate(conversation)
set_video_gate_stage(conversation, "clarify")
conversation.agent_status = CreationConversation.AgentStatus.AWAITING_USER
conversation.save(update_fields=["agent_status", "updated_at"])
yield {"type": "message", "message": _message_payload(question)}
yield {"type": "done"}
return
# 剧情反转预设必须先选故事深度。平台直接落选择卡,不能把这一步交给模型猜,
# 否则默认 15 秒会吞掉 30/60 秒该有的人物关系和冲突发展。
if is_plot_twist_conversation(conversation) and not active_plot_twist_story_depth(conversation):
explicit_depth = plot_twist_story_depth(text)
if explicit_depth is not None and explicit_depth["value"] != "smart":
set_plot_twist_story_depth(conversation, explicit_depth["value"])
else:
question = append_plot_twist_story_depth_question(conversation)
conversation.agent_status = CreationConversation.AgentStatus.AWAITING_USER
conversation.save(update_fields=["agent_status", "updated_at"])
yield {"type": "message", "message": _message_payload(question)}
yield {"type": "done"}
return
# 商品类创作先锁定结构化商品。普通上传始终只是素材,不能靠图片内容猜成商品。
if creation_needs_product_source(conversation, text):
result, _stop = _dispatch_tool(
context,
"ask_user",
{"fields": [{
"key": "product",
"label": "这条内容要使用哪个商品?",
"type": "asset",
"required": True,
"asset_types": ["product"],
}]},
allow_pick=False,
)
for event in result.get("_events", []):
yield event
yield {"type": "done"}
return
# 点击换款的款式清单和顺序是脚本事实,不能让模型自行猜色号或把预设改成普通展示片。
if click_swap_needs_sequence(conversation):
question = append_click_swap_sequence_gate(conversation)
set_video_gate_stage(conversation, "clarify")
conversation.agent_status = CreationConversation.AgentStatus.AWAITING_USER
conversation.save(update_fields=["agent_status", "updated_at"])
yield {"type": "message", "message": _message_payload(question)}
yield {"type": "done"}
return
# 需要真人/角色的视频必须先选定人物来源。这是平台闸门,
# 不交给模型自由发挥,否则它会在脚本里随机造人,到 60s 分段时必然漂移。
if video_needs_person_source(conversation, text):
question = append_person_source_gate(conversation)
set_video_gate_stage(conversation, "clarify")
conversation.agent_status = CreationConversation.AgentStatus.AWAITING_USER
conversation.save(update_fields=["agent_status", "updated_at"])
yield {"type": "message", "message": _message_payload(question)}
yield {"type": "done"}
return
# 已经添加多位人物时,人物都属于本次 brief。只确认他们如何出镜,不能让
# 模型把「多角色 + 各自造型」误读成候选人列表并强迫用户三选一。
if context.is_video and multi_character_relation_needs_clarification(conversation, text):
question = append_multi_character_relation_gate(conversation)
set_video_gate_stage(conversation, "clarify")
conversation.agent_status = CreationConversation.AgentStatus.AWAITING_USER
conversation.save(update_fields=["agent_status", "updated_at"])
yield {"type": "message", "message": _message_payload(question)}
yield {"type": "done"}
return
# 明确要商品/角色/场景列表时,直接生成真实选择卡,绝不先让模型念出素材名称。
requested_card = requested_asset_card_from_context(conversation, text)
if requested_card:
result, _stop = _dispatch_tool(
context,
"ask_user",
{"fields": [{
"key": requested_card,
"label": _ASSET_PICK_LABEL.get(
requested_card,
_ASSET_CARD_LABELS.get(requested_card, "请选择素材"),
),
"type": "asset",
"required": True,
"asset_types": [requested_card],
}]},
allow_pick=True,
)
for event in result.get("_events", []):
yield event
yield {"type": "done"}
return
model_config = _prefer_vision_text_model(model_config, conversation.team, conversation.pinned_refs or [])
context.model_config = model_config
resolved = resolve_refs(context.team, refs)
if resolved.missing:
names = "、".join(r.get("name") or "某个素材" for r in resolved.missing)
note = append_message(
conversation, role="assistant",
text=f"有几个引用的素材已经找不到了({names}),我先按其余信息继续。",
)
yield {"type": "message", "message": _message_payload(note)}
# 压缩放在**建消息之前**:摘要要进这一轮的 system 提示词才有意义。
# 用户消息已经先回显了,所以这一小段等待不会看起来像卡住。
compress_memory(context)
allow_plan = (
force_creative_turn
or has_creative_intent(text, refs)
or (has_context and is_continue_intent(text))
)
provider = build_provider(model_config)
messages = build_messages(context, allow_plan=allow_plan, has_context=has_context)
if continuation_instruction.strip():
# 卡片答案已经在历史里,这里仅给本轮一个不落库的执行指令。这样既不会多出
# 一条伪造的用户气泡,也不会让模型把「跳过素材」误判成闲聊后停住。
messages.append({"role": "user", "content": continuation_instruction.strip()})
tools = tool_schemas(context, allow_plan=allow_plan)
from .creation import is_agent_cancel_requested
turn_has_gate = False
last_text_bubble = None
turn_started = time.monotonic()
turn_deadline = turn_started + CREATION_AGENT_TURN_TIMEOUT_SECONDS
extra_body = creation_model_extra_body(model_config, tools)
truncation_retries = 0
for _round in range(MAX_TOOL_ROUNDS):
if is_agent_cancel_requested(conversation.id):
# 用户终止:干净收束,不落 ERROR,已落库消息保留
yield {"type": "cancelled"}
return
text_buffer: list[str] = []
reasoning_buffer: list[str] = []
tool_buffer: dict = {}
finish_reason = ""
set_public_agent_progress(conversation, "reasoning")
try:
# 单轮上限与整轮预算取小者:一轮卡住不至于吃光整轮,整轮也不会被多轮拖成无限等待。
round_deadline = min(turn_deadline, time.monotonic() + CREATION_AGENT_MODEL_TIMEOUT_SECONDS)
remaining = max(1.0, round_deadline - time.monotonic())
for chunk in provider.chat_completion_stream(
model=model_config.name,
messages=messages,
endpoint=model_config.endpoint or "chat/completions",
extra_body=extra_body,
timeout=remaining,
):
if time.monotonic() >= round_deadline:
raise TimeoutError("creation agent model deadline exceeded")
kind = chunk.get("type")
if kind == "finish":
finish_reason = str(chunk.get("reason") or "")
continue
if kind == "reasoning":
# 只从 reasoning 中识别当前阶段;原文不落库、不向用户展示。
reasoning_buffer.append(str(chunk.get("text", "")))
set_public_agent_progress(
conversation,
"reasoning",
detail_key=_public_reasoning_detail_key("".join(reasoning_buffer)),
)
yield {"type": "reasoning", "text": chunk.get("text", "")}
elif kind == "delta":
set_public_agent_progress(conversation, "responding")
piece = chunk.get("text", "")
text_buffer.append(piece)
yield {"type": "delta", "text": piece}
elif kind == "tool_call":
_merge_tool_call_deltas(tool_buffer, chunk.get("tool_calls"))
except Exception as exc: # noqa: BLE001 — provider 超时类型会因 SDK/requests 而不同
if is_unsupported_thinking_error(exc) and extra_body.pop("thinking", None):
logger.warning("model rejected thinking param, retrying without it: %s", exc)
continue
is_timeout = isinstance(exc, TimeoutError) or "timeout" in type(exc).__name__.lower()
if not is_timeout:
raise
# 错误卡一落库就撤掉进度,禁止出现「已停止」和「正在分析」并存。
clear_public_agent_progress(conversation)
restored = restore_gated_step_after_cancel(conversation)
has_open_gate = False
if restored:
open_gate = next((
item
for item in conversation.messages.filter(
kind=CreationMessage.Kind.ELICIT,
).order_by("-seq")[:30]
if (item.payload or {}).get("interaction") == "step_confirm"
and not (item.payload or {}).get("submitted")
), None)
if open_gate is not None:
turn_has_gate = True
has_open_gate = True
yield {"type": "message", "message": _message_payload(open_gate)}
failure = append_message(
conversation,
role="assistant",
kind=CreationMessage.Kind.ERROR,
text=creation_agent_timeout_notice(
int(time.monotonic() - turn_started), has_open_gate=has_open_gate
),
)
yield {"type": "message", "message": _message_payload(failure)}
yield {"type": "done"}
return
said = strip_numeric_reply_instruction("".join(text_buffer))
calls = [tool_buffer[i] for i in sorted(tool_buffer) if tool_buffer[i].get("name")]
# 撞到 max_tokens 的那一轮,tool 参数往往断在 JSON 中间。此时 _parse_arguments 只会
# 返回 {},工具随后抱怨「缺正文」,模型被误导去重写而不是压缩 —— 必须显式识别并要求它写短。
broken_calls = [
call for call in calls
if str(call.get("arguments") or "").strip() and not _parse_arguments(call["arguments"])
]
output_truncated = finish_reason == "length"
if broken_calls or (output_truncated and not calls and not said):
truncation_retries += 1
logger.warning(
"creation agent output truncated (finish_reason=%s, broken_calls=%s, retry=%s)",
finish_reason or "-",
[call.get("name") for call in broken_calls],
truncation_retries,
)
if truncation_retries > MAX_TRUNCATION_RETRIES:
clear_public_agent_progress(conversation)
restore_gated_step_after_cancel(conversation)
failure = append_message(
conversation,
role="assistant",
kind=CreationMessage.Kind.ERROR,
text=TRUNCATION_GIVE_UP_NOTICE,
)
yield {"type": "message", "message": _message_payload(failure)}
yield {"type": "done"}
return
# 坏 JSON 不进历史:既省上下文,也避免模型照着残稿续写。
messages.append({"role": "user", "content": TRUNCATION_RETRY_INSTRUCTION})
continue
fallback_fields = None
allow_pick = False
memory = conversation.memory if isinstance(conversation.memory, dict) else {}
# 痛点解决预设若模型只写了三条列表却漏调 ask_user,平台直接把这三条转成单选按钮。
# 这一条要排在「想挑素材」兜底**前面**:用户常说「用这个商品做一条痛点解决演示」,
# 那句话会先命中挑素材兜底,而商品早就钉住了、那张卡随后又被去重抑制,
# 最后三条方向只剩一段纯文字,用户点不了也不该再手抄一遍。
pain_options = (
pain_point_direction_options_from_text(said)
if (
not calls
and allow_plan
and is_pain_point_conversation(conversation)
and not memory.get("selling_point_ready")
)
else []
)
if pain_options:
fallback_fields = [{
"key": PAIN_POINT_DIRECTION_KEY,
"label": "选择这条视频要重点解决的痛点",
"type": "single",
"required": True,
"options": pain_options,
}]
elif not calls:
requested_card = requested_asset_card_from_context(conversation, text)
wanted_pick = wanted_asset_pick(text, refs)
pick = requested_card or wanted_pick
param_keys = wanted_param_keys(text, is_video=context.is_video)
if pick:
fallback_fields = [{
"key": pick,
"label": _ASSET_PICK_LABEL.get(pick, _ASSET_CARD_LABELS.get(pick, "请选择素材")),
"type": "asset",
"required": True,
"asset_types": [pick],
}]
allow_pick = bool(requested_card)
elif param_keys:
fallback_fields = session_param_fields(param_keys, context.is_video)
elif allow_plan and not said:
# 部分推理模型会只给 reasoning_content 后结束本轮。不能让用户
# 只看到自己那条消息;没有商品上下文时,收敛成一条自然追问。
has_product = any(
isinstance(ref, dict) and ref.get("type") == "product"
for ref in (conversation.pinned_refs or [])
)
if not has_product:
fallback_fields = [{
"key": "product",
"label": "这条想推哪款商品?",
"type": "asset",
"required": True,
"asset_types": ["product"],
}]
# 方向卡是剧情反转预设的固定入口。模型偶尔只会说「我准备了三个方向」而忘了调工具,
# 此处直接补上可点击卡,不能让用户面对一段空话再自己追问。
needs_plot_twist_directions = (
not calls
and allow_plan
and is_plot_twist_conversation(conversation)
and bool(active_plot_twist_story_depth(conversation))
and not str(memory.get("plot_twist_story_direction") or "").strip()
)
if needs_plot_twist_directions:
result, _stop = _dispatch_tool(
context,
"present_story_directions",
{"directions": _plot_twist_direction_fallback(conversation)},
)
for event in result.get("_events", []):
yield event
if event.get("type") == "message":
turn_has_gate = True
break
# 有些模型会把完整策略按「核心卖点/目标受众/内容逻辑/视觉调性」写成散文,
# (痛点三方向已经成卡时不走这条:那段列表会被误认成散文策略,而 write_strategy
# 在痛点卖点没定前只回错误、不落卡,整轮就会一张卡都不给。)
# 却漏掉 write_strategy 工具调用。高置信识别后直接走同一条结构化策略闸门,
# 避免前端退化成一整块普通聊天气泡,也避免缺失「按这个继续」。
current_stage = get_video_gate_stage(conversation)
may_write_strategy = (
current_stage == "clarify"
or (current_stage == "strategy" and not bool(memory.get("strategy_confirmed")))
)
prose_strategy = (
strategy_args_from_text(said)
if not calls and context.is_video and may_write_strategy and not pain_options
else {}
)
if prose_strategy:
result, _stop = _dispatch_tool(context, "write_strategy", prose_strategy)
for event in result.get("_events", []):
yield event
if event.get("type") == "message":
msg = event.get("message") or {}
if msg.get("kind") in (
CreationMessage.Kind.ELICIT,
CreationMessage.Kind.CONFIRM,
):
turn_has_gate = True
break
# ask_user 自己会落一条可追踪的聊天问题。模型同时吐出的过渡文案不再
# 另存一条,否则界面会连续出现两遍几乎相同的问题。
asks_user = bool(fallback_fields) or any(call.get("name") == "ask_user" for call in calls)
if said and not asks_user:
bubble = append_message(conversation, role="assistant", text=said)
last_text_bubble = bubble
yield {"type": "message", "message": _message_payload(bubble)}
if not calls:
if fallback_fields:
result, _stop = _dispatch_tool(
context, "ask_user", {"fields": fallback_fields}, allow_pick=allow_pick
)
for event in result.get("_events", []):
yield event
if event.get("type") == "message":
msg = event.get("message") or {}
if msg.get("kind") in (
CreationMessage.Kind.ELICIT,
CreationMessage.Kind.CONFIRM,
):
turn_has_gate = True
break
messages.append({
"role": "assistant",
"content": said or None,
"tool_calls": [
{"id": f"call_{i}", "type": "function",
"function": {"name": c["name"], "arguments": c["arguments"]}}
for i, c in enumerate(calls)
],
})
stop = False
for index, call in enumerate(calls):
if is_agent_cancel_requested(conversation.id):
yield {"type": "cancelled"}
return
name = call["name"]
args = _parse_arguments(call["arguments"])
set_public_agent_progress(conversation, name)
yield {"type": "tool", "id": name, "label": _TOOL_LABELS.get(name, name), "status": "running"}
try:
result, stop_after = _dispatch_tool(context, name, args)
except AgentError as exc:
yield {"type": "tool", "id": name, "label": _TOOL_LABELS.get(name, name), "status": "error"}
failure = append_message(
conversation, role="assistant",
kind=CreationMessage.Kind.ERROR, text=str(exc),
)
yield {"type": "message", "message": _message_payload(failure)}
yield {"type": "done"}
return
yield {"type": "tool", "id": name, "label": _TOOL_LABELS.get(name, name), "status": "done"}
for event in result.get("_events", []):
yield event
if event.get("type") == "message":
msg = event.get("message") or {}
if msg.get("kind") in (
CreationMessage.Kind.ELICIT,
CreationMessage.Kind.CONFIRM,
):
turn_has_gate = True
messages.append({
"role": "tool",
"tool_call_id": f"call_{index}",
"content": json.dumps(result.get("payload", {}), ensure_ascii=False),
})
stop = stop or stop_after
# 闸门中断后不再执行同轮后续工具,防止策略+方案+Prompt 一锅端
if stop_after:
break
if stop:
break
# 强制创作回合若模型既没落闸门也没写正文,补一条可点的「继续完善方案」引导,
# 且该文案已被 is_continue_intent 识别,避免用户再点又回到空引导死循环。
if force_creative_turn and not turn_has_gate and last_text_bubble is None:
nudge = append_message(
conversation,
role="assistant",
text=(
"角色已锁定。接下来直接推进创作方案;"
"也可以回复「继续完善方案」让我继续写策略。"
),
payload={
"reply_hint": "可以回复「继续完善方案」,也可以直接说卖点或痛点方向。",
"reply_options": [
{"label": "继续完善方案", "text": "继续完善方案"},
{"label": "先定痛点方向", "text": "先帮我定痛点方向"},
],
},
)
last_text_bubble = nudge
yield {"type": "message", "message": _message_payload(nudge)}
# 收束校验:本轮若只剩散文、没有追问/确认卡,补 reply_hint 或短引导。
for event in ensure_turn_guides(
conversation,
turn_has_gate=turn_has_gate,
last_text_bubble=last_text_bubble,
has_context=has_context,
is_video=context.is_video,
):
yield event
yield {"type": "done"}
except Exception as exc: # noqa: BLE001 — SSE 里任何未捕获异常都会变成前端「白屏卡死」
logger.exception("creation agent stream failed: %s", exc)
yield {"type": "error", "detail": "生成过程出错了,请再试一次"}
def stream_creation_agent(
*,
conversation: CreationConversation,
user,
text: str,
refs: list[dict] | None = None,
model_config: ModelConfig | None = None,
record_user_message: bool = True,
force_creative_turn: bool = False,
continuation_instruction: str = "",
) -> Iterator[str]:
"""兼容旧 SSE 消费方(单测 / 调试)。生产路径走 Celery + poll。"""
for event in iter_creation_agent_events(
conversation=conversation,
user=user,
text=text,
refs=refs,
model_config=model_config,
record_user_message=record_user_message,
force_creative_turn=force_creative_turn,
continuation_instruction=continuation_instruction,
):
yield _sse(event)
def run_creation_agent_turn(
*,
conversation_id: str,
user_id: str,
text: str = "",
refs: list | None = None,
model_config_id: str | None = None,
record_user_message: bool = True,
force_creative_turn: bool = False,
continuation_instruction: str = "",
) -> str:
"""Celery worker 入口:跑完一轮 tool loop,消息落库;更新 agent_status;释放团队锁。
不复用 CreationMessage.kind=generating / AITask —— 那些是确认后出片/出图用的。
"""
from apps.accounts.models import User
from .creation import finish_agent_planning
conversation = (
CreationConversation.objects.select_related("team", "created_by")
.filter(id=conversation_id)
.first()
)
if conversation is None:
return conversation_id
user = User.objects.filter(id=user_id).first() or conversation.created_by
model_config = None
if model_config_id:
model_config = (
ModelConfig.objects.select_related("provider")
.filter(id=model_config_id, capability=ModelConfig.Capability.TEXT, status=ModelConfig.Status.ACTIVE)
.first()
)
awaiting_user = False
user_cancelled = False
try:
for event in iter_creation_agent_events(
conversation=conversation,
user=user,
text=text or "",
refs=refs or [],
model_config=model_config,
record_user_message=record_user_message,
force_creative_turn=force_creative_turn,
continuation_instruction=continuation_instruction or "",
):
if not isinstance(event, dict):
continue
if event.get("type") == "cancelled":
user_cancelled = True
awaiting_user = False
break
if event.get("type") == "message":
message = event.get("message") or {}
kind = message.get("kind") if isinstance(message, dict) else None
if kind in (CreationMessage.Kind.ELICIT, CreationMessage.Kind.CONFIRM):
awaiting_user = True
elif event.get("type") == "error":
detail = str(event.get("detail") or "生成过程出错了,请再试一次")
# 循环内多数错误已落 ERROR 消息;这里兜底一条,避免前端只看到卡在 planning
last = conversation.messages.order_by("-seq").first()
if last is None or last.kind != CreationMessage.Kind.ERROR:
append_message(
conversation,
role="assistant",
kind=CreationMessage.Kind.ERROR,
text=detail,
)
except Exception as exc: # noqa: BLE001 — worker 不能把异常冒成无限 planning
logger.exception("creation agent turn failed: %s", exc)
try:
append_message(
conversation,
role="assistant",
kind=CreationMessage.Kind.ERROR,
text="生成过程出错了,请再试一次",
)
except Exception: # noqa: BLE001
logger.exception("creation agent turn: failed to append error message")
awaiting_user = False
finally:
try:
clear_public_agent_progress(conversation)
except Exception: # noqa: BLE001 — 清临时进度失败不该影响规划收束
logger.exception("creation agent turn: failed to clear public progress")
try:
conversation.refresh_from_db(fields=["agent_status", "team_id"])
except Exception: # noqa: BLE001
pass
# 用户终止:恢复闸门确认条(若有),再 finish;避免卡在「已收到…正在重写」且 agent 变 idle
if user_cancelled:
try:
awaiting_user = restore_gated_step_after_cancel(conversation)
except Exception: # noqa: BLE001
logger.exception("creation agent cancel: restore gated step failed")
awaiting_user = False
finish_agent_planning(
conversation,
awaiting_user=awaiting_user,
)
return conversation_id
_TOOL_LABELS = {
"ask_user": "向你确认",
"present_story_directions": "整理剧情方向",
"search_library": "查找素材",
"generate_image": "生成图片",
"write_strategy": "梳理创作策略",
"write_plan": "编排视频方案",
"write_prompt": "整理出片指令",
}
_ASSET_CARD_LABELS = {
"product": "这条要展示哪款商品?",
"character": "这条想用哪个角色?",
"model": "这条想用哪位模特?",
"scene": "这条想放在哪个场景里?",
"asset": "这一步要使用哪项素材?",
}
_GATE_LABELS = {
"product": "这条想推哪款商品?可以直接告诉我商品名,或者需要我把商品库列表发给你选吗?",
"character": "这条想用哪个角色?可以直接告诉我角色名,或者需要我发角色列表给你选吗?",
"model": "这条想用哪位模特?可以直接告诉我,或者需要我把模特库发给你选吗?",
"scene": "这条想放在哪个场景里?可以直接告诉我,或者需要我把场景库发给你选吗?",
"asset": "这一步想用哪项素材?可以直接告诉我,或者需要我把素材列表发给你选吗?",
}
def _guided_elicit_text(field: dict) -> str:
"""追问既要问清一件事,也要让用户知道下一句怎么回。"""
label = str(field.get("label") or "这项你想怎么定?").strip()
if field.get("type") == "asset" or field.get("key") == "_asset_gate":
return label
if str(field.get("key") or "") in SESSION_PARAM_KEYS:
return label
options = field.get("options")
if field.get("type") in ("single", "multi") and isinstance(options, list) and len(options) > 0:
return f"{label} 直接点击一个选项,也可以输入自己的想法。"
return f"{label} 直接用一句话告诉我就行,不用整理成完整需求。"
def _elicit_payload_for_fields(
fields: list[dict], *, allow_pick: bool = False, is_video: bool = True
) -> dict:
"""素材问题默认出轻量对话闸门;用户明确要求发列表时才出 pick 选择卡。"""
field = dict(fields[0])
if field.get("type") == "asset":
asset_types = [item for item in (field.get("asset_types") or []) if item in _GATE_LABELS]
primary_type = asset_types[0] if asset_types else "product"
if allow_pick:
field["label"] = _ASSET_CARD_LABELS.get(primary_type, _ASSET_CARD_LABELS["asset"])
return {
"interaction": "asset_picker",
"phase": "pick",
"fields": [field],
"submitted": False,
"answers": {},
}
if primary_type == "product":
gate_label = (
"这条视频想推哪款商品?可以直接告诉我商品的品牌与品名,也可以上传一张商品实物图,或需要我把商品库列表发给你选吗?"
if is_video else
"这次想做哪款商品?可以直接告诉我商品的品牌与品名,也可以上传一张商品实物图,或需要我把商品库列表发给你选吗?"
)
else:
gate_label = _GATE_LABELS.get(primary_type, _GATE_LABELS["asset"])
asset_label = {
"product": "商品",
"character": "角色",
"model": "模特",
"scene": "场景",
}.get(primary_type, "素材")
gate_options = [
{"value": "send", "label": f"发{asset_label}列表"},
{"value": "auto", "label": "你来推荐"},
]
if primary_type == "product":
gate_options.insert(0, {"value": "upload", "label": "上传商品图"})
gate_field = {
"key": "_asset_gate",
"label": gate_label,
"type": "text",
"required": False,
"options": gate_options,
}
return {
# 这是 Agent 的一句自然追问,不是让用户点选流程的卡片。
# 只有用户明确要求发列表后,才会生成上面 allow_pick 分支的商品卡。
"interaction": "chat",
"phase": "gate",
"fields": [gate_field],
"pending_fields": [field],
"submitted": False,
"answers": {},
}
return {
"interaction": "chat",
"fields": [field],
"submitted": False,
"answers": {},
}
def _asset_ask_already_resolved(conversation: CreationConversation, fields: list[dict]) -> str | None:
"""素材类 ask_user 若本轮/会话已确认过,返回跳过原因;避免「你来推荐」后同款闸门再弹一次。"""
if not fields:
return None
field = fields[0] if isinstance(fields[0], dict) else {}
key = str(field.get("key") or "").strip().lower()
declared = [t for t in (field.get("asset_types") or []) if t in TYPE_LABELS]
if declared:
types = declared
elif key in {"character", "person", "model", "product", "scene", "asset"}:
types = [key if key != "person" else "character"]
elif str(field.get("type") or "").strip() == "asset":
types = [t for t in (infer_field_types(field) or []) if t in TYPE_LABELS] or ["product"]
else:
# 非素材卡(痛点方向、时长、卖点等单选)不参与素材去重。
# infer_field_types 认不出字段时会回落成「全部素材类型」,照着去重会在
# 锁人或锁商品之后把这些卡整张吃掉,那一轮就一个可点的东西都不剩。
return None
memory = conversation.memory if isinstance(conversation.memory, dict) else {}
if any(t in {"character", "model", "person"} for t in types):
if person_identity_ready(conversation):
return (
"出镜角色图已锁定或正在生成。不要再 ask_user 追问角色;直接继续创作。"
)
if "product" in types:
if memory.get("product_source_resolved") or has_locked_product_reference(conversation):
return (
"商品来源已确认(含本地上传或「你来推荐」)。不要再 ask_user 追问商品;直接继续创作。"
)
# 最近一条同类型闸门若已 submitted,也视为已答(防同轮或紧邻轮重复弹)。
recent = conversation.messages.filter(kind=CreationMessage.Kind.ELICIT).order_by("-seq")[:6]
for message in recent:
payload = message.payload or {}
if not payload.get("submitted"):
continue
if payload.get("phase") not in {"gate", "pick"} and payload.get("interaction") not in {
"chat", "asset_picker", "person_source_gate"
}:
continue
pending = payload.get("pending_fields") or payload.get("fields") or []
for item in pending:
if not isinstance(item, dict):
continue
item_types = [t for t in (item.get("asset_types") or infer_field_types(item) or []) if t]
if item.get("key") == "_asset_gate":
# pending_fields 才是真实素材类型
continue
if set(item_types) & set(types):
return (
f"刚刚已确认过「{'/'.join(types)}」素材选择。不要重复弹出同一问题;直接继续创作。"
)
# gate 的 pending_fields
for item in (payload.get("pending_fields") or []):
if not isinstance(item, dict):
continue
item_types = [t for t in (item.get("asset_types") or infer_field_types(item) or []) if t]
if set(item_types) & set(types) and payload.get("answers"):
return (
f"刚刚已确认过「{'/'.join(types)}」素材选择。不要重复弹出同一问题;直接继续创作。"
)
return None
def _dispatch_tool(
context: AgentContext, name: str, args: dict, *, allow_pick: bool = False
) -> tuple[dict, bool]:
"""执行一个工具。返回 (结果, 是否中断循环)。
结果里的 `_events` 会原样转发给前端,`payload` 回喂给模型。
"""
if name == "ask_user":
fields = _coerce_fields(args.get("fields"))
if not fields:
return {"payload": {"error": "fields 不合法,请重新组织问题"}}, False
# 临时隐藏 >60 秒长视频:模型自拟的时长选项也裁掉
for field in fields:
if not isinstance(field, dict):
continue
key = str(field.get("key") or "")
opts = field.get("options")
if not isinstance(opts, list) or not opts:
continue
if key == "duration" or any("秒" in str((o.get("label") if isinstance(o, dict) else o) or "") for o in opts):
field["options"] = filter_visible_duration_options(opts)
# allow_pick = 用户明确要「把列表发出来」。这种点名要卡的请求不能被去重吃掉,
# 否则上一轮回过「你发一下卡片我选择」之后,再要卡永远只得到一句话。
skip_reason = None if allow_pick else _asset_ask_already_resolved(context.conversation, fields)
if skip_reason:
return {"payload": {"skipped": True, "reason": "already_resolved", "note": skip_reason}}, False
payload = _elicit_payload_for_fields(fields, allow_pick=allow_pick, is_video=context.is_video)
display_field = (payload.get("fields") or fields)[0]
message = append_message(
context.conversation, role="assistant",
kind=CreationMessage.Kind.ELICIT,
text=_guided_elicit_text(display_field),
payload=payload,
)
# 反问一旦发出就必须停,等人回答。继续跑等于自问自答。
if context.is_video:
set_video_gate_stage(context.conversation, "clarify")
return {
"payload": {"asked": True},
"_events": [{"type": "message", "message": _message_payload(message)}],
}, True
if name == "present_story_directions":
if not is_plot_twist_conversation(context.conversation):
return {"payload": {"error": "当前会话不是剧情反转带货预设"}}, False
directions = _coerce_plot_twist_directions(args.get("directions"))
if not directions:
return {
"payload": {
"error": "必须提供恰好 3 个完整剧情方向,每个都要有标题、冲突、商品作用、反转和情绪。"
}
}, False
message = _append_plot_twist_direction_question(context, directions)
set_video_gate_stage(context.conversation, "clarify")
return {
"payload": {"presented": True, "count": 3},
"_events": [{"type": "message", "message": _message_payload(message)}],
}, True
if name == "search_library":
return {"payload": _run_search_library(context, args)}, False
if name == "write_strategy":
if context.is_video and click_swap_needs_mode(context.conversation):
gate = append_click_swap_mode_gate(context.conversation)
set_video_gate_stage(context.conversation, "clarify")
return {
"payload": {"asked": True, "field": "click_swap_mode"},
"_events": [{"type": "message", "message": _message_payload(gate)}],
}, True
if context.is_video and click_swap_needs_sequence(context.conversation):
gate = append_click_swap_sequence_gate(context.conversation)
set_video_gate_stage(context.conversation, "clarify")
return {
"payload": {"asked": True, "field": "sku_sequence"},
"_events": [{"type": "message", "message": _message_payload(gate)}],
}, True
if context.is_video and video_needs_person_source(
context.conversation,
json.dumps(args if isinstance(args, dict) else {}, ensure_ascii=False),
):
gate = append_person_source_gate(context.conversation)
set_video_gate_stage(context.conversation, "clarify")
return {
"payload": {"asked": True, "field": "person_source"},
"_events": [{"type": "message", "message": _message_payload(gate)}],
}, True
memory = context.conversation.memory if isinstance(context.conversation.memory, dict) else {}
if context.is_video and is_plot_twist_conversation(context.conversation) and not plot_twist_selected_direction(context.conversation):
return {
"payload": {
"error": (
"剧情反转带货必须先选定剧情方向:调用 present_story_directions 展示 3 张方向卡,"
"等用户选择后再写策略。不得自行编造未选中的故事桥段。"
)
}
}, False
if context.is_video and not memory.get("selling_point_ready"):
# 主页开场描述 / 聊天声明 / 商品库已填卖点 → 直接锁定,不再弹闸门
ensure_selling_point_ready_from_context(context.conversation)
memory = context.conversation.memory if isinstance(context.conversation.memory, dict) else {}
if context.is_video and is_pain_point_conversation(context.conversation) and not memory.get("selling_point_ready"):
return {
"payload": {
"error": (
"痛点解决演示必须先调用 ask_user 展示痛点方向三选一:"
"key=pain_point_direction、type=single、恰好 3 个 options。"
"用户点击后该方向会直接成为核心卖点,不要另开卖点确认卡。"
)
}
}, False
if context.is_video and not memory.get("selling_point_ready"):
gate = append_selling_point_gate(context.conversation)
set_video_gate_stage(context.conversation, "clarify")
return {
"payload": {"asked": True, "field": "selling_point"},
"_events": [{"type": "message", "message": _message_payload(gate)}],
}, True
strategy_payload = _coerce_strategy_args(args if isinstance(args, dict) else {})
# 空卡会落成「只有标签没有正文」——拒绝,让模型把四字段写满再调
if not all(strategy_payload.values()):
return {
"payload": {
"error": (
"创作策略卡四字段都不能为空:请填写具体的 target / trust / belief / direction"
"(给谁看、为什么信、希望他信什么、创作方向),不要留空。"
)
}
}, False
message = append_message(
context.conversation, role="assistant",
kind=CreationMessage.Kind.STRATEGY,
payload=strategy_payload,
)
confirm = append_step_confirm(context.conversation, "strategy")
set_video_gate_stage(context.conversation, "strategy")
memory = dict(context.conversation.memory or {})
memory.pop("strategy_confirmed", None)
context.conversation.memory = memory
context.conversation.save(update_fields=["memory", "updated_at"])
# 策略闸门:必须停下等人确认,禁止同轮连写方案
return {
"payload": {"written": True, "awaiting_step": "strategy"},
"_events": [
{"type": "message", "message": _message_payload(message)},
{"type": "message", "message": _message_payload(confirm)},
],
}, True
if name == "write_plan":
if context.is_video and click_swap_needs_mode(context.conversation):
gate = append_click_swap_mode_gate(context.conversation)
set_video_gate_stage(context.conversation, "clarify")
return {
"payload": {"asked": True, "field": "click_swap_mode"},
"_events": [{"type": "message", "message": _message_payload(gate)}],
}, True
video_prompt = str(args.get("video_prompt") or "").strip()
if not video_prompt:
return {"payload": {"error": "video_prompt 不能为空,请把完整出片指令写进去"}}, False
if context.is_video and click_swap_needs_sequence(context.conversation):
gate = append_click_swap_sequence_gate(context.conversation)
set_video_gate_stage(context.conversation, "clarify")
return {
"payload": {"asked": True, "field": "sku_sequence"},
"_events": [{"type": "message", "message": _message_payload(gate)}],
}, True
if context.is_video and video_needs_person_source(context.conversation, video_prompt):
gate = append_person_source_gate(context.conversation)
set_video_gate_stage(context.conversation, "clarify")
return {
"payload": {"asked": True, "field": "person_source"},
"_events": [{"type": "message", "message": _message_payload(gate)}],
}, True
video_prompt = apply_video_preset_prompt(context.conversation.preset, video_prompt, click_swap_mode=click_swap_mode(context.conversation))
if is_click_swap_preset(context.conversation.preset):
video_prompt = (
f"{video_prompt}\n\n【商家确认的换款顺序】{click_swap_sequence(context.conversation)}\n"
"只允许按这个顺序逐款切换;不得跳序、漏款或自行增加颜色和款式。"
)
if click_swap_mode(context.conversation) != "character":
video_prompt = scrub_click_swap_same_position_wording(video_prompt)
video_prompt = apply_product_voice_visual_guard(context.conversation, video_prompt)
video_prompt = apply_product_reality_guard(video_prompt)
video_prompt = apply_clothing_video_guard(context.conversation, video_prompt)
video_prompt = apply_product_appearance_guard(context.conversation, video_prompt)
video_prompt = apply_pet_dialogue_guard(context.conversation, video_prompt)
video_prompt = apply_video_platform_safety_guard(video_prompt)
video_prompt = apply_plot_twist_story_contract(
context.conversation.preset,
active_plot_twist_story_depth(context.conversation),
video_prompt,
)
if is_plot_twist_conversation(context.conversation):
selected = plot_twist_selected_direction(context.conversation)
if selected:
video_prompt = apply_plot_twist_direction_contract(video_prompt, **selected)
else:
return {
"payload": {
"error": "剧情反转带货必须先选定剧情方向,再写方案。请先 present_story_directions 让用户选择。"
}
}, False
card = _coerce_plan_card_args(args if isinstance(args, dict) else {})
if not card["usp"] or not card["points"]:
return {
"payload": {
"error": (
"方案卡缺正文:请填写非空的 usp(主打卖点)和 points(13 条核心支撑),"
"不要只写 video_prompt。卡片上要让用户看见卖点文案。"
)
}
}, False
selling_memory = context.conversation.memory if isinstance(context.conversation.memory, dict) else {}
chosen_selling_point = str(selling_memory.get("selling_point") or "").strip()
if chosen_selling_point and str(selling_memory.get("selling_point_mode") or "") == "manual":
# 用户亲自给出的真实卖点是本轮的唯一 USP,模型只能围绕它补充画面证据,不能擅自替换。
card["usp"] = chosen_selling_point
video_prompt = (
f"{video_prompt}\n\n【商家确认的核心卖点】{chosen_selling_point}\n"
"整条视频必须围绕这个卖点展开,并用真实使用动作或素材可见细节证明;不得替换、夸大或新增未经确认的功效。"
)
raw_duration = _raw_script_duration(timeline=card["timeline"], prompt=video_prompt)
if raw_duration is not None and raw_duration > MAX_VIDEO_DURATION:
return {
"payload": {"error": "脚本时长不能超过 60 秒。请把方案收束到 60 秒以内,再重新给出完整时间轴和出片指令。"}
}, False
requested_duration = video_duration(
context.conversation.params or {},
prompt=video_prompt,
timeline=card["timeline"],
)
if requested_duration > LONG_VIDEO_CHAPTER_DURATION and not long_video_script_covers_requested_duration(
raw_duration, requested_duration
):
covered = f"目前时间轴到 {raw_duration} 秒" if raw_duration else "目前没有完整时间轴"
return {
"payload": {
"error": (
f"长视频脚本必须完整覆盖 {requested_duration} 秒,{covered}。"
"请按每章不超过 60 秒重写章节结构,并补齐每个 30 秒边界的交接状态和逐镜出片指令。"
)
}
}, False
if requested_duration > LONG_VIDEO_CHAPTER_DURATION and len(card["timeline"]) < (
requested_duration + LONG_VIDEO_CHAPTER_DURATION - 1
) // LONG_VIDEO_CHAPTER_DURATION:
return {
"payload": {
"error": (
f"{requested_duration} 秒方案需要至少 "
f"{(requested_duration + LONG_VIDEO_CHAPTER_DURATION - 1) // LONG_VIDEO_CHAPTER_DURATION} 个连续章节。"
"请补齐章节目标、承接关系和商品证据,再重新提交完整方案。"
)
}
}, False
duration = resolve_smart_video_duration(
context.conversation,
prompt=video_prompt,
timeline=card["timeline"],
)
card = apply_click_swap_plan_card(context.conversation, card, duration)
lo = max(20, round(duration * 3.4))
hi = max(lo + 1, round(duration * 4))
plan_payload = {
"usp": card["usp"],
"points": card["points"],
"timeline": card["timeline"],
"matrix": card["matrix"],
"voice_chars": (
[0, 0]
if is_click_swap_preset(context.conversation.preset)
else _coerce_voice_chars(card["voice_chars"], [lo, hi])
),
"ref_count": len(context.conversation.pinned_refs or []),
}
events = []
plan = append_message(
context.conversation, role="assistant",
kind=CreationMessage.Kind.PLAN, payload=plan_payload,
)
events.append({"type": "message", "message": _message_payload(plan)})
# video_prompt 先作为方案产物存档;用户确认方案后会展示指令文件供确认。
set_video_gate_stage(
context.conversation, "plan", pending_video_prompt=video_prompt
)
step = append_step_confirm(context.conversation, "plan")
events.append({"type": "message", "message": _message_payload(step)})
return {
"payload": {"awaiting_step": "plan", "stored_prompt": True},
"_events": events,
}, True
if name == "write_prompt":
video_prompt = str(args.get("video_prompt") or "").strip()
if not video_prompt:
video_prompt = get_pending_video_prompt(context.conversation)
if not video_prompt:
return {"payload": {"error": "video_prompt 不能为空,请把完整出片指令写进去"}}, False
if context.is_video and click_swap_needs_sequence(context.conversation):
gate = append_click_swap_sequence_gate(context.conversation)
set_video_gate_stage(context.conversation, "clarify")
return {
"payload": {"asked": True, "field": "sku_sequence"},
"_events": [{"type": "message", "message": _message_payload(gate)}],
}, True
video_prompt = apply_video_preset_prompt(context.conversation.preset, video_prompt, click_swap_mode=click_swap_mode(context.conversation))
video_prompt = apply_product_voice_visual_guard(context.conversation, video_prompt)
video_prompt = apply_product_reality_guard(video_prompt)
video_prompt = apply_clothing_video_guard(context.conversation, video_prompt)
video_prompt = apply_product_appearance_guard(context.conversation, video_prompt)
video_prompt = apply_pet_dialogue_guard(context.conversation, video_prompt)
video_prompt = apply_video_platform_safety_guard(video_prompt)
video_prompt = apply_plot_twist_story_contract(
context.conversation.preset,
active_plot_twist_story_depth(context.conversation),
video_prompt,
)
if is_plot_twist_conversation(context.conversation):
selected = plot_twist_selected_direction(context.conversation)
if selected:
video_prompt = apply_plot_twist_direction_contract(video_prompt, **selected)
prompt_messages = emit_prompt_gate(context.conversation, video_prompt)
if not prompt_messages:
return {"payload": {"error": "无法准备出片指令"}}, False
events = [
{"type": "message", "message": _message_payload(message)}
for message in prompt_messages
]
return {
"payload": {"awaiting_step": "prompt", "prepared": True},
"_events": events,
}, True
if name == "generate_image":
if context.generations_used >= MAX_BILLED_GENERATIONS:
# 一条用户消息只计费一次。模型想连出好几版时在这里挡住。
return {"payload": {"error": "本轮已经生成过一次了,请让用户看过再决定要不要改"}}, True
prompt = str(args.get("prompt") or "").strip()
if not prompt:
return {"payload": {"error": "生成失败:模型没有给出画面描述"}}, False
prompt = apply_image_preset_prompt(context.conversation.preset, prompt)
prompt = apply_product_appearance_guard(context.conversation, prompt)
# 出图也走确认卡:用户先看当前模型/比例/张数,点了才提交。
credits = estimate_image_credits(context)
confirm = append_message(
context.conversation, role="assistant",
kind=CreationMessage.Kind.CONFIRM,
payload={
"kind": "image",
"label": "开始生成",
"estimated_credits": credits,
"prompt": prompt,
"submitted": False,
"params": snapshot_session_params(context.conversation),
"param_options": confirm_param_options(False),
},
)
events = [
{"type": "message", "message": _message_payload(confirm)},
{"type": "credits", "estimated": credits},
]
return {"payload": {"awaiting_confirmation": True}, "_events": events}, True
return {"payload": {"error": f"未知工具 {name}"}}, False
def _summarize_source(messages: list[CreationMessage]) -> str:
"""要压缩的那批消息 → 喂给模型的纯文本。只取有信息量的部分。"""
lines = []
for message in messages:
if message.kind == CreationMessage.Kind.TEXT and message.text.strip():
who = "用户" if message.role == "user" else "我"
lines.append(f"{who}:{message.text.strip()}")
elif message.kind == CreationMessage.Kind.ELICIT:
answers = (message.payload or {}).get("answers") or {}
if answers:
lines.append("用户确认:" + "".join(f"{k}={v}" for k, v in answers.items()))
elif message.kind in (CreationMessage.Kind.GENERATING, CreationMessage.Kind.RESULT):
prompt = (message.payload or {}).get("prompt") or ""
if prompt:
lines.append(f"我生成了一版:{prompt[:120]}")
return "\n".join(lines)
def compress_memory(context: AgentContext) -> None:
"""把早期消息压成一段摘要存进 conversation.memory.summary(契约 §5)。
只在消息数超过阈值时做;压缩失败**静默跳过** —— 摘要是锦上添花,
为它把整条对话打断不值得。压缩额外调一次模型,所以按 summarized_upto
记进度,同一批消息不重复压。
"""
conversation = context.conversation
history = list(conversation.messages.all())
if len(history) <= COMPRESS_AFTER_MESSAGES:
return
memory = dict(conversation.memory or {})
cutoff = len(history) - KEEP_RECENT_MESSAGES
if cutoff - int(memory.get("summarized_upto") or 0) < COMPRESS_MIN_BATCH:
return # 没压过的还不够一批,攒着 —— 每轮重压一次太贵
source = _summarize_source(history[:cutoff])
if not source.strip():
memory["summarized_upto"] = cutoff
conversation.memory = memory
conversation.save(update_fields=["memory", "updated_at"])
return
previous = memory.get("summary") or ""
instruction = (
"把下面这段创作对话压成一段中文摘要,150 字以内。"
"保留:用户明确提过的要求和否决过的方向、已确认的设定、生成过什么。"
"丢掉:寒暄、过程性的话。直接输出摘要正文,不要前言。\n\n"
+ (f"【已有摘要】{previous}\n\n" if previous else "")
+ f"【新增对话】\n{source}"
)
try:
provider = build_provider(context.model_config)
pieces = []
for chunk in provider.chat_completion_stream(
model=context.model_config.name,
messages=[{"role": "user", "content": instruction}],
endpoint=context.model_config.endpoint or "chat/completions",
):
if chunk.get("type") == "delta":
pieces.append(chunk.get("text", ""))
summary = "".join(pieces).strip()
except Exception: # noqa: BLE001 — 摘要失败不该打断对话
logger.warning("omni create: memory compression failed", exc_info=True)
return
if not summary:
return
memory["summary"] = summary[:400]
memory["summarized_upto"] = cutoff
conversation.memory = memory
conversation.save(update_fields=["memory", "updated_at"])
def _remember_artifact(conversation: CreationConversation, prompt: str, kind: str) -> None:
"""记进产物索引,让下一轮「把背景换成夜景」能定位到这一版(契约 §5)。"""
memory = dict(conversation.memory or {})
artifacts = list(memory.get("artifacts") or [])
artifacts.append({"prompt": prompt, "kind": kind})
memory["artifacts"] = artifacts[-10:]
conversation.memory = memory
conversation.save(update_fields=["memory", "updated_at"])
def _message_payload(message: CreationMessage) -> dict:
from .serializers import CreationMessageSerializer
return CreationMessageSerializer(message).data