@@ -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", ""))
|
||||
|
||||
Reference in New Issue
Block a user