大量优化 模型改成gpt6
Deploy dev / deploy (push) Successful in 41s

This commit is contained in:
Azmat@qq.com
2026-09-28 18:15:08 +08:00
parent a84431c9c5
commit 4421706290
14 changed files with 1878 additions and 540 deletions
+323 -130
View File
@@ -7,8 +7,8 @@
铁律(踩过就回不来的三条):
1. **视频 5–10 分钟,绝不在 SSE 里等。** 生成工具立刻返回 task_id,落一条
`generating` 消息,发 `task` 事件,收流。前端轮询完成后原地换成 `result`。
2. **闸门必须等人确认。** `ask_user` / `write_strategy` / `write_plan` /
`write_prompt` 一旦落卡就中断循环;视频 5 步(澄清→策略→方案→Prompt→出片确认)
2. **闸门必须等人确认。** `ask_user` / `write_plan` / `write_prompt`
一旦落卡就中断循环;`write_strategy` 仅供模型内部梳理,不展示给用户;视频 4 步(澄清→架构→Prompt→出片确认)
不可同轮连跳。
3. **一条用户消息最多计费生成一次。** 对话式会放大调用量,一句「多做几版」
能烧掉一堆积分。
@@ -52,8 +52,6 @@ from .services import (
build_provider,
enforce_no_embedded_captions,
get_default_model,
get_seed_text_model,
resolve_text_model,
)
logger = logging.getLogger(__name__)
@@ -71,9 +69,20 @@ LONG_VIDEO_DURATION_SLACK_SECONDS = 2
# 单条用户消息最多触发一次计费生成(契约 §4)
MAX_BILLED_GENERATIONS = 1
# 全能创作的对话编排模型由后台模型库配置;当前指定为 YunQi 的 GPT-6 Luna。
# 生图仍由各生成工具按 gpt-image-2 的图片模型路由,不受这里影响。
CREATION_CHAT_MODEL_NAME = "gpt-6-luna"
def creation_model_temperature(model_config: ModelConfig | None) -> float:
"""返回全能创作模型允许的 temperature。"""
name = str(getattr(model_config, "name", "") or "").lower()
# GPT-6 Luna 网关拒绝非默认值;显式传 1 保持与 OpenAI 默认行为一致。
return 1.0 if name == CREATION_CHAT_MODEL_NAME else 0.8
def creation_agent_max_output_tokens() -> int:
"""豆包 Seed 系列最大输出 16k、默认 4k。不抬高会把长方案的 tool 参数截成坏 JSON。"""
"""长架构需要足够输出空间,避免 tool 参数被截成不完整 JSON。"""
from django.conf import settings
return max(1024, int(getattr(settings, "CREATION_AGENT_MAX_OUTPUT_TOKENS", 16000) or 16000))
@@ -82,13 +91,23 @@ def creation_agent_max_output_tokens() -> int:
def creation_model_extra_body(model_config: ModelConfig, tools: list[dict]) -> dict:
"""统一拼模型请求体的可选参数。
max_tokens 所有网关都认;thinking 只对火山官方直连下发,避免中转站因未知参数报 400。
Luna 使用 OpenAI 新版的 max_completion_tokens;其余网关维持 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()}
token_key = (
"max_completion_tokens"
if str(getattr(model_config, "name", "") or "").lower() == CREATION_CHAT_MODEL_NAME
else "max_tokens"
)
body: dict = {"tools": tools, token_key: creation_agent_max_output_tokens()}
if str(getattr(model_config, "name", "") or "").lower() == CREATION_CHAT_MODEL_NAME:
# Luna 在 chat/completions 使用 function tools 时,不支持 reasoning_effort 默认档。
# 显式关掉后仍可走标准 OpenAI tools 协议。
body["reasoning_effort"] = "none"
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:
@@ -199,8 +218,10 @@ def clear_public_agent_progress(conversation: CreationConversation) -> None:
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")
# clarify → strategy → plan → prompt → cast → confirm → done
# 角色定妆必须在 Prompt 之后补齐:脚本先围绕需求完成,最后才把已定稿的
# 角色参考锁进出片参数,避免角色选择反过来打断视频架构。
VIDEO_GATE_STAGES = ("clarify", "strategy", "plan", "prompt", "cast", "confirm", "done")
PAIN_POINT_PRESET = "痛点解决演示"
PAIN_POINT_DIRECTION_KEY = "pain_point_direction"
_STEP_CONFIRM_LABELS = {
@@ -383,6 +404,7 @@ def append_plot_twist_story_depth_question(conversation: CreationConversation) -
"options": [
{"value": item["value"], "label": f"{item['label']}|{item['summary']}"}
for item in PLOT_TWIST_STORY_DEPTH_OPTIONS
if item["value"] in {"15s", "30s", "60s"}
],
}
],
@@ -838,23 +860,23 @@ def append_multi_character_relation_gate(conversation: CreationConversation) ->
def video_needs_person_source(conversation: CreationConversation, user_text: str = "") -> bool:
"""需要真人/角色的视频在写策略前必须先锁定人物来源。"""
"""Prompt 完成后检查角色图是否按架构人数补齐。"""
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 memory.get("person_source_pending") or str(memory.get("person_source") or "") == "finger_only":
return False
# 点击换款:只有「角色日常换款」才要人物;「只出手」跳过,且不被开场文案里的「口播/剧情」否定词误触发。
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
return len(locked_person_references(conversation)) < infer_needed_cast_count(conversation, user_text)
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])))
needs_person = bool(_PERSON_VISUAL_RE.search("\n".join([user_text, pending_prompt, *recent])))
return needs_person and len(locked_person_references(conversation)) < infer_needed_cast_count(conversation, user_text)
def locked_product_references(conversation: CreationConversation) -> list[dict]:
@@ -1026,7 +1048,85 @@ def creation_needs_product_source(conversation: CreationConversation, user_text:
return conversation.preset in _PRODUCT_REQUIRED_PRESETS
def append_person_source_gate(conversation: CreationConversation) -> CreationMessage:
def product_brief_items(conversation: CreationConversation) -> list[dict]:
"""把已知商品事实整理成可核对清单;缺少事实只标待补充,不替用户编造。"""
memory = conversation.memory if isinstance(conversation.memory, dict) else {}
products = locked_product_references(conversation)
product = products[0] if products else {}
history = " ".join(
str(item or "")
for item in conversation.messages.filter(role="user").values_list("text", flat=True)
)
name = str(
memory.get("product_brand_and_name")
or memory.get("product_name")
or product.get("name")
or ""
).strip()
selling = str(memory.get("selling_point") or "").strip()
def item(label: str, status: str, value: str = "") -> dict:
return {"label": label, "status": status, "value": value}
factual_price_requested = bool(re.search(r"价格|售价|优惠|折扣|券|满减|到手", history))
special = ""
special_match = re.search(r"(?:重点|强调|禁止|不要)[::]?([^。!!\n]{2,80})", history)
if special_match:
special = special_match.group(0).strip()
return [
item("商品图", "ready" if products else "missing", "已锁定参考图" if products else ""),
item("名称与品牌", "ready" if name else "missing", name),
item("品类与外观", "ready" if products else "missing", "将以已锁定商品图为准" if products else ""),
item("核心卖点", "ready" if selling else "missing", selling),
item("使用场景与目标用户", "missing", ""),
item("价格与优惠", "missing" if factual_price_requested else "not_needed", ""),
item("特别强调或禁止内容", "ready" if special else "not_needed", special),
]
def product_brief_needs_review(conversation: CreationConversation) -> bool:
if conversation.preset not in _PRODUCT_REQUIRED_PRESETS:
return False
memory = conversation.memory if isinstance(conversation.memory, dict) else {}
return has_locked_product_reference(conversation) and not bool(memory.get("product_brief_reviewed"))
def append_product_brief_review(conversation: CreationConversation) -> CreationMessage:
"""商品信息只核对一次;已获取、待补充、暂不需要在一张卡里说清。"""
return append_message(
conversation,
role="assistant",
kind=CreationMessage.Kind.ELICIT,
text="先核对商品信息。已知内容会直接复用;品牌、价格、优惠或功效等事实缺失时只接受你补充,不会自动编造。",
payload={
"interaction": "product_brief_review",
"items": product_brief_items(conversation),
"fields": [
{
"key": "review_action",
"label": "商品信息是否可以继续?",
"type": "single",
"required": True,
"options": [
{"value": "continue", "label": "按现有信息继续"},
{"value": "supplement", "label": "我来补充"},
],
},
{
"key": "product_brief_note",
"label": "补充真实商品信息(选填)",
"type": "text",
"required": False,
"placeholder": "例如:主打卖点、使用场景、目标用户、真实价格或优惠",
},
],
"submitted": False,
"answers": {},
},
)
def append_person_source_gate(conversation: CreationConversation, extra_text: str = "") -> CreationMessage:
"""可视化的人物/角色来源闸门;三个选项分别进文件、模特库和生图流程。"""
product_name = ""
for ref in locked_product_references(conversation):
@@ -1039,6 +1139,7 @@ def append_person_source_gate(conversation: CreationConversation) -> CreationMes
product_name = str(memory.get("product_name") or memory.get("product_brand_and_name") or "").strip()
is_pet = is_pet_preset(conversation.preset)
missing_cast = 1
if is_pet:
prompt_text = (
f"商品已选定【{product_name}】。这条视频想由哪只宠物角色出镜?选定后,所有镜头和分段都会锁定同一只宠物形象。"
@@ -1048,14 +1149,17 @@ def append_person_source_gate(conversation: CreationConversation) -> CreationMes
field_label = "选择宠物来源"
library_label = "从角色库选择"
else:
cast_needed = infer_needed_cast_count(conversation)
cast_needed = infer_needed_cast_count(conversation, extra_text)
cast_have = len(locked_person_references(conversation))
missing_cast = max(1, cast_needed - cast_have)
cast_index = min(cast_needed, cast_have + 1)
if cast_needed > 1:
prompt_text = (
f"商品已选定【{product_name}】。这条视频需要 {cast_needed} 位出镜人物;"
"请上传/选择/生成对应数量的角色定妆图,所有镜头和分段都会按这些图锁脸。"
f"商品已选定【{product_name}】。这条视频需要 {cast_needed} 位出镜人物,"
f"当前补全第 {cast_index}/{cast_needed} 位;所有镜头和分段都会按角色图锁脸。"
if product_name else
f"这条视频需要 {cast_needed} 位出镜人物。请上传/选择/生成对应数量的角色定妆图,"
"所有镜头和分段都会按这些图锁脸,避免长视频前后形象漂移。"
f"这条视频需要 {cast_needed} 位出镜人物,当前补全第 {cast_index}/{cast_needed} 位。"
"所有镜头和分段都会按角色图锁脸,避免前后形象漂移。"
)
else:
prompt_text = (
@@ -1074,6 +1178,9 @@ def append_person_source_gate(conversation: CreationConversation) -> CreationMes
payload={
"interaction": "person_source_gate",
"is_pet": is_pet,
"cast_needed": 1 if is_pet else infer_needed_cast_count(conversation, extra_text),
"cast_have": len(locked_person_references(conversation)),
"estimated_credits": estimate_role_image_credits(conversation, missing_cast),
"fields": [{
"key": "person_source",
"label": field_label,
@@ -1091,6 +1198,21 @@ def append_person_source_gate(conversation: CreationConversation) -> CreationMes
)
def estimate_role_image_credits(conversation: CreationConversation, count: int) -> int:
"""角色图开始生成前展示预计积分;失败时返回 0,不阻塞流程。"""
from apps.billing.pricing import quote_flat
model_config = get_default_model(ModelConfig.Capability.IMAGE)
if model_config is None:
return 0
try:
per = quote_flat(model_config, units=1, team=conversation.team)
return int(per.points) * max(1, int(count or 1))
except Exception: # noqa: BLE001
logger.warning("omni create: role image estimate failed", exc_info=True)
return 0
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()
@@ -1413,7 +1535,9 @@ def submit_generated_person_reference(
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 = cast_count if cast_count is not None else (
infer_needed_cast_count(conversation, raw_appearance) - len(locked_person_references(conversation))
)
total = max(1, min(4, int(total or 1)))
if is_pet_preset(conversation.preset):
total = 1
@@ -1619,6 +1743,43 @@ def emit_prompt_gate(
return messages
def sync_prompt_after_cast(conversation: CreationConversation) -> CreationMessage | None:
"""角色图确定后把真实参考图编号补进 Prompt;没有变化时不重复落文件卡。"""
prompt = get_pending_video_prompt(conversation)
if not prompt:
return None
resolved = resolve_refs(conversation.team, conversation.pinned_refs or [])
references = list(resolved.references)
people = [item for item in references if item.get("type") in {"model", "character"}]
if not people:
# 兼容本地上传角色尚未被 resolver 补齐 URL 的瞬间;锁定身份事实本身仍要写进 Prompt。
people = locked_person_references(conversation)
references = [*people, *references]
if not people:
return None
signature = "|".join(str(item.get("id") or item.get("asset_id") or item.get("url") or "") for item in people)
memory = dict(conversation.memory or {})
if signature and memory.get("prompt_cast_signature") == signature:
return None
synced = apply_person_identity_guard(prompt, references)
synced = apply_product_reference_guard(synced, references)
memory["pending_video_prompt"] = synced
memory["prompt_cast_signature"] = signature
conversation.memory = memory
conversation.save(update_fields=["memory", "updated_at"])
return append_message(
conversation,
role="assistant",
kind=CreationMessage.Kind.PROMPT_FILE,
payload={
"title": "视频生成Prompt-角色已同步.md",
"body": synced,
"ref_count": len(conversation.pinned_refs or []),
"synced_after_cast": True,
},
)
def emit_final_confirm_gate(
conversation: CreationConversation,
*,
@@ -1667,6 +1828,8 @@ def emit_final_confirm_gate(
)
except Exception: # noqa: BLE001
credits = 0
duration = video_duration(conversation.params or {}, prompt=prompt, timeline=timeline)
segments = plan_video_segments(duration, timeline=timeline)
confirm = append_message(
conversation,
role="assistant",
@@ -1677,6 +1840,16 @@ def emit_final_confirm_gate(
"estimated_credits": credits,
"video_prompt": prompt,
"timeline": timeline,
"generation_plan": {
"duration": duration,
"segment_count": len(segments),
"segments": segments,
"note": (
f"目标时长 {duration} 秒,将分 {len(segments)} 段生成后自动合并。"
if len(segments) > 1
else f"目标时长 {duration} 秒,单段生成。"
),
},
"submitted": False,
"params": snapshot_session_params(conversation),
"param_options": confirm_param_options(True),
@@ -2756,9 +2929,10 @@ def _normalize_confirm_duration(value) -> tuple[str, int | str]:
def apply_confirm_params(conversation, incoming: dict | None) -> tuple[dict, bool]:
"""确认卡上改的参数写回会话。返回 (最新 params, 视频时长是否变了)。"""
"""确认卡上改的参数写回会话。返回 (最新 params, 是否必须同步重写架构/Prompt)。"""
current = dict(conversation.params or {})
old_duration = str(current.get("duration") or "")
old_model = str(current.get("model") or "")
changed = False
for key, raw in (incoming or {}).items():
if key not in {"model", "ratio", "resolution", "duration", "count"}:
@@ -2776,13 +2950,23 @@ def apply_confirm_params(conversation, incoming: dict | None) -> tuple[dict, boo
and bool(str(current.get("duration") or ""))
and bool(old_duration)
)
model_changed = (
conversation.mode == CreationConversation.Mode.VIDEO
and bool(old_model)
and bool(str(current.get("model") or ""))
and str(current.get("model") or "") != old_model
)
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
needs_rebuild = duration_changed or model_changed
if needs_rebuild:
# 角色和商品素材继续保留;只让 GPT 基于新参数重写受影响的架构与 Prompt。
set_video_gate_stage(conversation, "strategy", clear_pending_prompt=True)
return snapshot_session_params(conversation), needs_rebuild
# ---------------------------------------------------------------- 工具 schema
@@ -2904,10 +3088,10 @@ def tool_schemas(context: AgentContext, *, allow_plan: bool = True) -> list[dict
"function": {
"name": "write_strategy",
"description": (
"写「创作策略理解」卡:说清这条片给谁看、他为什么会信、你想让他信什么、整体创作方向。"
"内部梳理创作策略:说清这条片给谁看、他为什么会信、你想让他信什么、整体创作方向。"
"四个字段都必须写具体非空文案,禁止空字符串。"
"策略从第一稿就使用健康、正向、明确成年的人物与情节表达,不要复述需要规避的原始措辞。"
"调完会停下来等用户确认或提出修改,不要同轮接着 write_plan。"
"本工具不会展示给用户;调用成功后必须在同一轮立即调用 write_plan,交付唯一可见的视频架构卡。"
"仅当用户明确要做片/出方案时调用;打招呼或闲聊不要调。"
),
"parameters": {
@@ -2927,9 +3111,9 @@ def tool_schemas(context: AgentContext, *, allow_plan: bool = True) -> list[dict
"function": {
"name": "write_plan",
"description": (
"写「视频最终方案」卡(USP/卖点/时间轴)并请用户确认。"
"**仅当用户已确认策略、或明确要改方案时调用**;打招呼或闲聊不要调。"
"调完只出方案卡并停下等人确认 —— 不要同轮出 Prompt 卡或积分确认卡。"
"写唯一对用户展示的「视频架构」卡并请用户确认。"
"架构要让用户看懂并能逐段修改;打招呼或闲聊不要调。"
"调完只出架构卡并停下等人确认 —— 不要同轮出 Prompt 卡或积分确认卡。"
"usp / points / timeline 必须写满具体文案;同时把 video_prompt 写好存档,"
"用户确认方案后由平台展示 Prompt。"
+ (
@@ -2942,11 +3126,14 @@ def tool_schemas(context: AgentContext, *, allow_plan: bool = True) -> list[dict
)
+
"第一稿必须已经可直接过平台审核:只写正向安全描述,不要输出风险词清单或否定式免责声明。"
"先有已确认的 write_strategy,再调它。"
"先完成内部 write_strategy,再调它。修改架构时必须沿用上一版,只改用户指出的部分,未受影响的时间段原样保留。"
),
"parameters": {
"type": "object",
"properties": {
"goal": {"type": "string", "description": "视频目标,例如建立认知、证明卖点或推动下单"},
"duration": {"type": "string", "description": "本架构采用的目标时长"},
"concept": {"type": "string", "description": "一句话创意概念"},
"usp": {"type": "string", "description": "主打卖点,全片只讲这一个核心价值"},
"points": {
"type": "array", "maxItems": 3, "items": {"type": "string"},
@@ -2959,9 +3146,13 @@ def tool_schemas(context: AgentContext, *, allow_plan: bool = True) -> list[dict
"properties": {
"start": {"type": "number"}, "end": {"type": "number"},
"stage": {"type": "string", "description": "Hook / 过桥 / 正文 / CTA"},
"desc": {"type": "string"},
"visual": {"type": "string", "description": "这一段的画面、剧情或冲突"},
"action_dialogue": {"type": "string", "description": "角色动作与对白/口播"},
"product": {"type": "string", "description": "商品何时出现、如何承载已确认卖点"},
"purpose": {"type": "string", "description": "这一段承担 Hook、冲突、证明、反转或 CTA 中的什么作用"},
"desc": {"type": "string", "description": "兼容旧稿的简述;已有四个详细字段时可省略"},
},
"required": ["start", "end", "stage"],
"required": ["start", "end", "stage", "visual", "action_dialogue", "product", "purpose"],
},
},
"voice_chars": {
@@ -2983,7 +3174,7 @@ def tool_schemas(context: AgentContext, *, allow_plan: bool = True) -> list[dict
),
},
},
"required": ["usp", "points", "video_prompt"],
"required": ["goal", "duration", "concept", "usp", "points", "timeline", "video_prompt"],
},
},
})
@@ -3733,10 +3924,25 @@ def submit_confirmed_image(*, conversation: CreationConversation, user, confirm_
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)
"""全能创作的语言编排只允许 GPT-6 Luna,绝不回退到豆包或后台默认模型。"""
if (
requested is not None
and str(getattr(requested, "name", "") or "").lower() == CREATION_CHAT_MODEL_NAME
and getattr(requested, "status", "") == ModelConfig.Status.ACTIVE
and getattr(getattr(requested, "provider", None), "status", "") == "active"
):
return requested
return (
ModelConfig.objects.select_related("provider")
.filter(
name=CREATION_CHAT_MODEL_NAME,
capability=ModelConfig.Capability.TEXT,
status=ModelConfig.Status.ACTIVE,
provider__status="active",
)
.order_by("created_at")
.first()
)
def _creation_model_sees_images(model_config: ModelConfig | None) -> bool:
@@ -3746,8 +3952,9 @@ def _creation_model_sees_images(model_config: ModelConfig | None) -> bool:
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:
# 全能创作指定的 GPT-6 Luna 与 gpt-image-2 共用 YunQi 网关,走 chat/completions
# 时也接收 OpenAI image_url 内容;不能因后台能力栏标作 text 又切回豆包。
if name == CREATION_CHAT_MODEL_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 {}
@@ -3756,24 +3963,8 @@ def _creation_model_sees_images(model_config: ModelConfig | None) -> bool:
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
"""参考图也继续交给 Luna;全能创作不得为了视觉能力暗中切换到豆包。"""
return current
def _ref_image_urls(team, refs: list | None) -> list[str]:
@@ -3853,8 +4044,8 @@ def build_system_prompt(context: AgentContext, *, allow_plan: bool = True, has_c
" 先短确认,再按最初 brief/已钉素材从澄清或 write_strategy 推进;禁止再打开上一轮模特/商品库追问。",
"- 用户选择暂不提供某项素材时,把它当成明确授权:按已有信息和合理默认继续。除非任务客观上无法完成,否则不要再次追问同一素材。",
"- 用户说「你来定」「你帮我选」「随便」「都行」时,就是授权你做专业判断;直接选合理方案继续,不要把选择题再抛回去。",
"- 禁止问「要不要继续」「要不要生成」「是否开始创作」这类流程问题。缺信息用 ask_user;信息够了就写策略。"
" 视频每写完策略或方案,平台会出确认卡;方案确认后平台会在后台整理出片指令,再让用户核对生成参数。不要口头问流程。",
"- 禁止问「要不要继续」「要不要生成」「是否开始创作」这类流程问题。缺信息用 ask_user;信息够了内部整理策略并直接写视频架构。"
" 视频架构写完后平台会出确认卡;架构确认后平台会在后台整理出片 Prompt,再让用户核对生成参数。不要口头问流程。",
"- 用户打招呼或闲聊(hi / 你好 / 在吗 / 你在干什么 / 嗯 / 好的 / ok):"
" **禁止**调用 write_strategy、write_plan、generate_image,不要「整理方案」或直接开写脚本。",
"- 会话里**还没有**商品/方向时:自然地告诉用户可以直接丢一句想法,别硬推销,也别用客服式结束语。",
@@ -3913,8 +4104,8 @@ def build_system_prompt(context: AgentContext, *, allow_plan: bool = True, has_c
"全文约 800–1400 字;禁止把 15 秒短片的制作级长文整篇套用。用户确认方案后,write_prompt 只补短规则,不再扩写逐镜。"
)
lines.append(
"- 视频 5 步闸门(不可同轮连跳):①缺信息 ask_user 停 → ②write_strategy 停等确认 → "
"③用户确认后 write_plan 停等确认 → ④方案确认后展示完整出片指令并停等确认 → ⑤再显示积分确认卡。"
"- 视频 4 步闸门:①缺信息 ask_user 停 → ②write_strategy 仅内部梳理并同轮 write_plan,视频架构停等确认 → "
"③架构确认后展示完整出片 Prompt 并停等确认 → ④角色补全后显示参数与积分确认卡。"
)
lines.append(
"- 只有用户明确要做片、出方案、改方案、换卖点/剧情时才调用 write_strategy / write_plan;"
@@ -3939,15 +4130,22 @@ def build_system_prompt(context: AgentContext, *, allow_plan: bool = True, has_c
"- 商家已授权系统推荐卖点。你必须从商品资料、可见素材和正常使用动作中选择一个最易证明的核心卖点;"
"不要虚构功效、价格或规格。"
)
product_note = str(memory.get("product_brief_note") or "").strip()
if product_note:
lines.append(f"- 用户在商品信息核对卡补充的真实事实:【{product_note}】。后续架构与 Prompt 必须沿用。")
lines.append(
"- 品牌、商品名、价格、优惠、规格、功效属于事实:缺失时只能询问或标待补充,"
"禁止用三个创意选项让用户从虚构事实里选择。创意方向可以给 3 个基于已知商品的建议并允许自定义。"
)
strategy_confirmed = bool(memory.get("strategy_confirmed"))
stage_hint = {
"clarify": "当前阶段=澄清:缺关键信息就 ask_user;信息够了只调 write_strategy。",
"clarify": "当前阶段=澄清:缺关键信息就 ask_user;信息够了先调内部 write_strategy,再同轮调 write_plan。",
"strategy": (
"当前阶段=策略已确认,请调用 write_plan 写方案;不要再写策略。"
"当前阶段=内部策略已完成,请调用 write_plan 写视频架构;不要再写策略。"
if strategy_confirmed else
"当前阶段=等策略确认:不要再写方案或出片;用户确认后才会进入方案。若用户在改策略,只重调 write_strategy。"
"当前阶段=内部策略整理中:立即调用 write_plan 输出视频架构,不要等待策略确认。"
),
"plan": "当前阶段=等方案确认:不要出片。若用户在改方案,只重调 write_plan。",
"plan": "当前阶段=等视频架构确认:不要出片。若用户在改架构,只重调 write_plan,保留未受影响段落。",
"prompt": "当前阶段=兼容历史会话的出片指令确认:不要出片,按用户反馈重写内部出片指令。",
"confirm": "当前阶段=等出片确认:不要再写策略/方案;用户会在确认卡上点开始生成。",
"done": "当前阶段=已出片:等用户新的修改或新需求再行动。用户说重新来/重来/从头开始时,当作新一轮创作,从澄清或 write_strategy 重开,不要再弹旧模特/商品追问。",
@@ -4326,6 +4524,18 @@ def _coerce_timeline(raw) -> list[dict]:
desc = str(item.get("desc") or item.get("description") or "").strip()
if desc:
entry["desc"] = desc
for key, aliases in {
"visual": ("visual", "画面", "plot"),
"action_dialogue": ("action_dialogue", "action", "dialogue", "动作对白"),
"product": ("product", "product_exposure", "商品"),
"purpose": ("purpose", "goal", "作用"),
}.items():
value = next((str(item.get(alias) or "").strip() for alias in aliases if item.get(alias)), "")
if value:
entry[key] = value
# 历史模型只有 desc 时也能渲染,不丢旧会话。
if desc and not entry.get("visual"):
entry["visual"] = desc
items.append(entry)
return items
@@ -4436,6 +4646,9 @@ def _coerce_plan_card_args(args: dict) -> dict:
if not isinstance(matrix, dict) or not matrix.get("rows"):
matrix = _default_plan_matrix(usp, points) if (usp or points) else {}
return {
"goal": _pick_str(args, "goal", "目标", "video_goal"),
"duration": _pick_str(args, "duration", "时长", "target_duration"),
"concept": _pick_str(args, "concept", "创意概念", "idea"),
"usp": usp,
"points": points,
"timeline": timeline,
@@ -4636,18 +4849,7 @@ def iter_creation_agent_events(
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":
@@ -4660,6 +4862,16 @@ def iter_creation_agent_events(
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
# 商品类创作先锁定结构化商品。普通上传始终只是素材,不能靠图片内容猜成商品。
if creation_needs_product_source(conversation, text):
result, _stop = _dispatch_tool(
@@ -4674,12 +4886,31 @@ def iter_creation_agent_events(
}]},
allow_pick=False,
)
conversation.agent_status = CreationConversation.AgentStatus.AWAITING_USER
conversation.save(update_fields=["agent_status", "updated_at"])
for event in result.get("_events", []):
yield event
yield {"type": "done"}
return
if product_brief_needs_review(conversation):
question = append_product_brief_review(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
# 点击换款的款式清单和顺序是脚本事实,不能让模型自行猜色号或把预设改成普通展示片。
if click_swap_needs_mode(conversation):
question = append_click_swap_mode_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
if click_swap_needs_sequence(conversation):
question = append_click_swap_sequence_gate(conversation)
set_video_gate_stage(conversation, "clarify")
@@ -4689,28 +4920,6 @@ def iter_creation_agent_events(
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:
@@ -4789,6 +4998,7 @@ def iter_creation_agent_events(
model=model_config.name,
messages=messages,
endpoint=model_config.endpoint or "chat/completions",
temperature=creation_model_temperature(model_config),
extra_body=extra_body,
timeout=remaining,
):
@@ -5480,16 +5690,6 @@ def _dispatch_tool(
"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 {
@@ -5532,25 +5732,21 @@ def _dispatch_tool(
)
}
}, False
message = append_message(
context.conversation, role="assistant",
kind=CreationMessage.Kind.STRATEGY,
payload=strategy_payload,
)
confirm = append_step_confirm(context.conversation, "strategy")
# 策略只作为 GPT 的内部工作记忆,不再给用户多放一张模糊策略卡。
# 同轮继续 write_plan,页面只展示一张可编辑的「视频架构」。
set_video_gate_stage(context.conversation, "strategy")
memory = dict(context.conversation.memory or {})
memory.pop("strategy_confirmed", None)
memory["strategy_confirmed"] = True
memory["internal_strategy"] = strategy_payload
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
"payload": {
"written": True,
"internal_only": True,
"next": "请立即调用 write_plan,输出唯一可见的视频架构卡。",
},
}, False
if name == "write_plan":
if context.is_video and click_swap_needs_mode(context.conversation):
@@ -5570,13 +5766,6 @@ def _dispatch_tool(
"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 = (
@@ -5668,6 +5857,9 @@ def _dispatch_tool(
lo = max(20, round(duration * 3.4))
hi = max(lo + 1, round(duration * 4))
plan_payload = {
"goal": card["goal"],
"duration": card["duration"] or str((context.conversation.params or {}).get("duration") or ""),
"concept": card["concept"],
"usp": card["usp"],
"points": card["points"],
"timeline": card["timeline"],
@@ -5826,6 +6018,7 @@ def compress_memory(context: AgentContext) -> None:
model=context.model_config.name,
messages=[{"role": "user", "content": instruction}],
endpoint=context.model_config.endpoint or "chat/completions",
temperature=creation_model_temperature(context.model_config),
):
if chunk.get("type") == "delta":
pieces.append(chunk.get("text", ""))