大量优化 模型改成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", ""))
+126 -22
View File
@@ -1937,16 +1937,64 @@ _PLATFORM_COVER_BLOCKS = {
),
}
# §3 / §3.1 slot 版式骨架(低信息密度版):默认 4 张按 hero→scene→selling→detail 取,8/12 张再补 multi/promo。
# §3 / §3.1 slot 版式骨架(低信息密度版):默认 4 张按 主图 hero → 卖点图 selling → 细节图 detail → 场景图 scene
# 取(与页面文案「主图、卖点图、细节图与场景图」一致),8/12 张再补 multi/promo 后循环。
_COVER_SLOTS = {
"hero": "正面或四分之三角度的商品 / 上身主视觉,主体占画面 60%-80%,纯净干净背景,经典电商主图构图,无文案",
"scene": "场景主视觉:挂拍 / 衣架 / 生活场景 / 手部整理 / 使用情境,环境自然光,氛围感,无文案或仅 1 个极短标题",
"selling": "轻卖点封面:商品主体居中或偏置,最多 1 个短标题加 1-2 个极短标签,文字区克制,不堆参数",
"detail": "质感特写:放大材质 / 做工 / 关键结构(扣位 / 肩带 / 边缘走线),近景视角,突出质感,无文案或 1 个短标签",
"hero": "主图:正面或四分之三角度的商品 / 上身主视觉,主体占画面 60%-80%,纯净干净背景,经典电商主图构图,无文案",
"selling": "卖点图:商品主体居中或偏置,最多 1 个短标题加 1-2 个极短标签,文字区克制,不堆参数",
"detail": "细节图:放大材质 / 做工 / 关键结构(扣位 / 肩带 / 边缘走线 / 质地),近景视角,突出质感,无文案或 1 个短标签",
"scene": "场景图:挂拍 / 衣架 / 生活场景 / 手部整理 / 使用情境,环境自然光,氛围感,无文案或仅 1 个极短标题",
"multi": "多角度组合:商品换一个朝向 / 视角,几何分区干净背景,现代简约风,无文案",
"promo": "促销封面:主体大、利益点单一,底部或角落留 1 条活动短语,高对比配色,不铺满文字、不编造价格",
"promo": "活动封面:主体大、利益点单一(只能取自用户提供的卖点),底部或角落留 1 条短语,高对比配色,不铺满文字、不编造价格",
}
_COVER_SLOT_ORDER = ["hero", "scene", "selling", "detail", "multi", "promo"]
_COVER_SLOT_ORDER = ["hero", "selling", "detail", "scene", "multi", "promo"]
# 通用电商主图规范:平台套图不再要求选平台(platform_id 为空)时使用;旧记录 / 旧调用方带 platform_id
# 仍走上面的平台块,保持向后兼容。比例由输出 size 控制(页面默认 1:1),这里只描述版式要求。
_ECOM_MAIN_IMAGE_SPEC = (
"请生成一张通用电商商品主图套图中的一张,适配主流电商平台商品主图规范:默认正方形构图,"
"商品主体清晰完整、不被裁切,移动端缩略图下一眼可识别;光线专业、边缘干净、质感真实;"
"整组图保持统一视觉调性(同一色系 / 光线 / 质感),只出主图 / 卖点图 / 细节图 / 场景图,不出详情长图。"
)
# 商品信息字段(前端「商品信息」输入 → request_payload.product_info),每项截断防止撑爆提示词。
_COVER_INFO_FIELDS = ("selling_points", "effect", "audience", "specs", "notes")
_COVER_INFO_MAX_LEN = 600
def normalize_cover_product_info(raw) -> dict[str, str]:
"""把前端传来的商品信息规范成 {selling_points, effect, audience, specs, notes} 的非空字符串字典。
兼容 JSON 字符串 / list 值;未知键丢弃;全空返回 {}(= 旧行为)。"""
if isinstance(raw, str):
try:
raw = json.loads(raw)
except (TypeError, ValueError):
return {}
if not isinstance(raw, dict):
return {}
out: dict[str, str] = {}
for key in _COVER_INFO_FIELDS:
val = raw.get(key)
if isinstance(val, (list, tuple)):
val = "\n".join(str(v).strip() for v in val if str(v or "").strip())
text = str(val or "").strip()
if text:
out[key] = text[:_COVER_INFO_MAX_LEN]
return out
def _split_selling_points(text: str) -> list[str]:
"""卖点文本 → 卖点列表:优先按换行 / 分号拆,只有一行时再按顿号 / 逗号拆;最多 6 条。"""
text = (text or "").strip()
if not text:
return []
# 去掉行首列表符号(「- 」「• 」「1.」「2、」),不误删卖点本身的数字(如「3重玻尿酸」)
parts = [re.sub(r"^\s*(?:[-•·*]+|\d+\s*[.、))])\s*", "", x).strip() for x in re.split(r"[\n\r;;]+", text)]
parts = [x for x in parts if x]
if len(parts) <= 1:
parts = [x.strip() for x in re.split(r"[、,,]+", parts[0] if parts else text) if x.strip()]
return parts[:6]
# §3.1 / §8 头图低信息密度上限:所有平台 / 所有模型都必须遵守,防止漂成详情页。
_COVER_LOW_DENSITY = (
@@ -1999,12 +2047,17 @@ def build_platform_cover_prompt_refs(
index: int = 0,
count: int = 4, # noqa: ARG001 — 透传保留,后续可据张数扩展 slot 选择
product_ref_count: int = 1,
product_info: dict | None = None,
) -> str:
"""平台套图 image_edit 提示词(refs 优化版,对照「平台套图线上提示词优化版.md」):
"""平台套图(电商主图套图)image_edit 提示词:
参考图1~N=同一件真实商品(锁外形/品牌/配色/Logo/比例),有模特时参考图N+1=出镜模特。
按 `platform_id` 注入平台块、按 `index` 选 slot 版式,统一服从「头图低信息密度」与「背景反差」,
内衣 + 有模特时叠加强约束。只出头图 / 主图 / 封面候选,不再出详情。"""
`product_info`(用户在页面填写的核心卖点 / 商品作用 / 适用人群 / 规格 / 补充要求)作为本组图的卖点依据注入,
并按 slot 分配到卖点图 / 细节图 / 场景图;未传时回落为把 `base_prompt` 当作商品信息 / 画面要求(旧记录兼容)。
`platform_id` 已改为可选:传了(旧记录 / 旧调用方)仍注入平台块,不传则用通用电商主图规范。
统一服从「头图低信息密度」与「背景反差」,内衣 + 有模特时叠加强约束。只出主图 / 卖点 / 细节 / 场景,不出详情。"""
name = (getattr(product, "title", "") or "商品").strip()
info = normalize_cover_product_info(product_info) if product_info else {}
selling_points = _split_selling_points(info.get("selling_points", ""))
n = max(1, int(product_ref_count or 1))
if n <= 1:
ref_word = "参考图1"
@@ -2018,15 +2071,37 @@ def build_platform_cover_prompt_refs(
f"请严格锁定{ref_word}中商品的外形、颜色、材质、结构、Logo、品牌文字与比例,"
"严禁重新设计 / 改样 / 改品类;这些参考图只锁商品本体,不锁原图里的床品 / 桌面 / 墙面 / 绿植 / 道具与拍摄光线。"
)
# 平台块(canonical key 命中则用 §4 平台块,否则回落平台名 / 通用)
block = _PLATFORM_COVER_BLOCKS.get(platform_id)
pname = _PLATFORM_NAMES.get(platform_id, "")
# 平台块(可选,旧记录兼容):canonical key 命中则用 §4 平台块;否则用通用电商主图规范
block = _PLATFORM_COVER_BLOCKS.get(platform_id or "")
pname = _PLATFORM_NAMES.get(platform_id or "", "")
if block:
lines.append(block)
elif pname:
lines.append(f"请生成一张适合「{pname}」平台的商品头图 / 主图 / 封面候选,统一视觉风格。")
else:
lines.append("请生成一张电商平台商品头图 / 主图 / 封面候选,统一视觉风格。")
lines.append(_ECOM_MAIN_IMAGE_SPEC)
# 商品信息:用户填写的卖点 / 作用是本组图唯一的卖点依据
if info:
facts = [f"商品名称:{name}"]
if selling_points:
facts.append("核心卖点:" + ";".join(selling_points))
if info.get("effect"):
facts.append("商品作用 / 功效:" + info["effect"])
if info.get("audience"):
facts.append("适用人群:" + info["audience"])
if info.get("specs"):
facts.append("规格参数:" + info["specs"])
lines.append(
"商品信息(用户提供,是本组图唯一的卖点与文案依据):" + ";".join(facts) + "。"
"画面要准确传达这些卖点与商品作用,画面文案只能从中提炼(短标题 ≤8 字),"
"不得编造信息之外的功效 / 成分 / 数据 / 认证 / 价格。"
)
elif base_prompt and base_prompt.strip():
# 旧记录 / 旧调用方:没有结构化商品信息,prompt 即用户的商品信息与画面要求
lines.append(
"用户提供的商品信息与画面要求(卖点 / 作用只能取自这里,不得编造;"
"不得覆盖商品一致性、版式与低信息密度规则):" + base_prompt.strip()
)
# 头图低信息密度上限
lines.append(_COVER_LOW_DENSITY)
# 模特身份 + 内衣强约束
@@ -2037,15 +2112,33 @@ def build_platform_cover_prompt_refs(
)
if _is_underwear_product(product):
lines.append(_UNDERWEAR_ON_MODEL)
# 本张 slot 版式
# 本张 slot 版式(主图 → 卖点图 → 细节图 → 场景图 → …),并把商品信息分配到对应图位
slot_key = _COVER_SLOT_ORDER[index % len(_COVER_SLOT_ORDER)]
lines.append("本张版式:" + _COVER_SLOTS[slot_key] + "。")
slot_line = "本张版式:" + _COVER_SLOTS[slot_key] + "。"
if info:
cycle = index // len(_COVER_SLOT_ORDER)
if slot_key in {"selling", "promo"} and selling_points:
point = selling_points[(cycle * 2 + (1 if slot_key == "promo" else 0)) % len(selling_points)]
slot_line += f"本张重点表现卖点「{point}」:用画面直观演示该卖点,短标题从该卖点提炼。"
elif slot_key == "selling" and info.get("effect"):
slot_line += "本张重点表现商品作用 / 功效,用画面直观演示使用效果,短标题从商品作用中提炼。"
elif slot_key == "detail":
slot_line += "细节要能佐证商品卖点 / 作用(如对应的材质、质地、成分形态、结构或工艺)。"
elif slot_key == "scene":
who = info.get("audience")
slot_line += (
f"场景贴合适用人群「{who}」的真实使用情境,体现商品作用。" if who
else "场景选择能体现商品作用的真实使用情境。"
)
elif slot_key == "hero" and selling_points:
slot_line += "主图不加卖点文案,只把商品本体拍得清楚、高级、可信。"
lines.append(slot_line)
# 背景反差
lines.append(_COVER_BG_CONTRAST)
if base_prompt and base_prompt.strip():
if info.get("notes"):
lines.append(
"用户补充(只影响氛围 / 构图 / 场景 / 光线 / 表达偏好,不得覆盖商品一致性、平台与版式规则):"
+ base_prompt.strip()
"用户补充要求(只影响氛围 / 构图 / 场景 / 光线 / 表达偏好,不得覆盖商品一致性与版式规则):"
+ info["notes"]
)
lines.append(_COVER_NEGATIVE)
return " ".join(lines)
@@ -3533,7 +3626,7 @@ def _reap_stale_standalone_image_tasks(*, team) -> None:
continue
def enqueue_standalone_images(*, team, user, prompt: str, mode: str = "image", count: int = 1, product_id: str | None = None, reference_product: bool = False, model_id: str | None = None, model_entity_id: str | None = None, ratio: str | None = None, image_model: str | None = None, conversation=None, reference_image_ids: list[str] | None = None, platform_id: str | None = None, batch_id: str | None = None, retry_of_task_id: str | None = None, tryon_prompt_v2_override: bool = False, tryon_ab: dict | None = None, feature: str | None = None, dispatch: bool = True) -> list[AITask]:
def enqueue_standalone_images(*, team, user, prompt: str, mode: str = "image", count: int = 1, product_id: str | None = None, reference_product: bool = False, model_id: str | None = None, model_entity_id: str | None = None, ratio: str | None = None, image_model: str | None = None, conversation=None, reference_image_ids: list[str] | None = None, reference_context: str | None = None, platform_id: str | None = None, product_info: dict | None = None, batch_id: str | None = None, retry_of_task_id: str | None = None, tryon_prompt_v2_override: bool = False, tryon_ab: dict | None = None, feature: str | None = None, dispatch: bool = True) -> list[AITask]:
"""独立生图(图片创作 / 模特上身图 / 平台套图)改为**异步**:本函数在 Web 请求里只做「建任务 +
预留额度」这种秒级的活,真正 ~30s 的 ARK 出图交给 Celery worker(generate_standalone_image_task)。
@@ -3591,9 +3684,12 @@ def enqueue_standalone_images(*, team, user, prompt: str, mode: str = "image", c
status__in=(AITask.Status.FAILED, AITask.Status.CANCELLED),
).first()
retry_of_task_id = str(retry_of_task.id) if retry_of_task else None
# 平台套图:规范化平台 id(前端 dy/tb… → canonical),用于注入平台版式块(优化版);非 cover 模式忽略。
# 平台套图:平台 id 已改为可选(页面不再选平台 → 走通用电商主图规范);旧调用方仍可传 canonical id
# 注入平台版式块。非 cover 模式忽略。
platform_key = str(platform_id or "").strip() if mode == "cover" else ""
platform_name = _PLATFORM_NAMES.get(platform_key, "")
# 平台套图:用户填写的商品信息(卖点 / 作用 / 人群 / 规格 / 补充),worker 据此构建主图提示词;非 cover 忽略。
cover_product_info = normalize_cover_product_info(product_info) if mode == "cover" else {}
from apps.billing.pricing import quote_flat
# Step 2.1:只为“模特上身图 + 商品”记录一次确定性分类快照。这里不读取图片、不调用模型;
@@ -3647,8 +3743,12 @@ def enqueue_standalone_images(*, team, user, prompt: str, mode: str = "image", c
for index in range(count):
quote = quote_flat(model_config, team=team)
request_payload = {"model": model_config.name, "endpoint": model_config.endpoint, "prompt": prompt, "mode": mode, "index": index, "product_id": str(product_id) if product_id else None, "reference_product": bool(reference_product), "model_id": str(model_id) if model_id else None, "model_entity_id": str(model_entity_id) if model_entity_id else None, "batch_id": batch_id, "ratio": str(ratio) if ratio else None, "reference_image_ids": ref_ids, "platform_id": platform_key or None, "platform_name": platform_name or None}
if reference_context:
request_payload["reference_context"] = str(reference_context)
if feature:
request_payload["feature"] = str(feature)
if cover_product_info:
request_payload["product_info"] = dict(cover_product_info)
if use_model_routing:
request_payload["model_routing_v1"] = True
if tryon_classification is not None:
@@ -3707,6 +3807,9 @@ def run_standalone_image_task(*, task_id: str) -> None:
user = task.created_by
payload = dict(task.request_payload or {})
prompt = str(payload.get("prompt") or "")
reference_context = str(payload.get("reference_context") or "").strip()
if reference_context:
prompt = f"{prompt}\n\n【已引用素材事实】\n{reference_context}"
mode = str(payload.get("mode") or "image")
index = int(payload.get("index") or 0)
product_id = payload.get("product_id") or None
@@ -3801,7 +3904,7 @@ def run_standalone_image_task(*, task_id: str) -> None:
}
elif mode == "cover" and product_urls:
# 平台套图:参考图1~N=商品真实图(多角度,_product_reference_urls 已优先真实上传图/排除 AI 图),
# 有模特则参考图N+1=模特(锁人脸/身形)。platform_id 注入平台版式块(优化版)。
# 有模特则参考图N+1=模特(锁人脸/身形)。product_info 注入商品卖点 / 作用;platform_id 可选(旧记录兼容)。
edit_images = product_urls + ([model_url] if model_url else [])
edit_prompt = build_platform_cover_prompt_refs(
product,
@@ -3810,6 +3913,7 @@ def run_standalone_image_task(*, task_id: str) -> None:
platform_id=str(payload.get("platform_id") or ""),
index=index,
product_ref_count=len(product_urls),
product_info=payload.get("product_info") or None,
)
elif bool(payload.get("reference_product")) and product_url:
edit_images = [product_url]
+295 -83
View File
@@ -51,6 +51,7 @@ from .creation_agent import (
TRUNCATION_GIVE_UP_NOTICE,
creation_agent_max_output_tokens,
creation_model_extra_body,
creation_model_temperature,
creation_agent_timeout_notice,
long_video_script_covers_requested_duration,
plan_video_segments,
@@ -136,6 +137,10 @@ class CreationAgentBaseTests(TestCase):
provider=provider, name="fake-text", display_name="Fake Text",
capability=ModelConfig.Capability.TEXT, endpoint="chat/completions",
)
self.creation_model = ModelConfig.objects.create(
provider=provider, name="gpt-6-luna", display_name="GPT-6 Luna",
capability=ModelConfig.Capability.TEXT, endpoint="chat/completions",
)
self.conversation = CreationConversation.objects.create(
team=self.team, created_by=self.user, mode="image", title="出图",
params={"ratio": "1:1", "model": "Seedream5.0"},
@@ -174,7 +179,10 @@ class CreationAgentBaseTests(TestCase):
refs = [ref for ref in (self.conversation.pinned_refs or []) if isinstance(ref, dict)]
refs.append({"type": "product", "id": str(product.id), "name": title})
self.conversation.pinned_refs = refs
self.conversation.save(update_fields=["pinned_refs", "updated_at"])
memory = dict(self.conversation.memory or {})
memory["product_brief_reviewed"] = True
self.conversation.memory = memory
self.conversation.save(update_fields=["pinned_refs", "memory", "updated_at"])
return product
@@ -900,6 +908,112 @@ class SendEndpointTests(TestCase):
for ref in self.conversation.pinned_refs
))
def test_prompt_confirmation_then_role_then_parameter_confirmation(self):
"""全能创作固定顺序:Prompt 确认后才补角色,角色完成后直接进参数卡。"""
self.conversation.mode = CreationConversation.Mode.VIDEO
self.conversation.preset = "达人口播种草"
self.conversation.memory = {
"stage": "prompt",
"pending_video_prompt": "总时长:15秒。达人展示控油粉饼并自然讲解。",
}
self.conversation.save(update_fields=["mode", "preset", "memory", "updated_at"])
prompt_confirm = append_step_confirm(self.conversation, "prompt")
response = self.client.post(
f"/api/ai/creations/{self.conversation.id}/send/",
{
"kind": "elicit_answer",
"reply_to": str(prompt_confirm.id),
"answers": {"step_action": "confirm"},
},
format="json",
)
self.assertEqual(response.status_code, 200, response.content)
role_gate = response.json()["messages"][0]
self.assertEqual(role_gate["payload"]["interaction"], "person_source_gate")
self.conversation.refresh_from_db()
self.assertEqual(self.conversation.memory["stage"], "cast")
portrait = Asset.objects.create(
team=self.team,
created_by=self.user,
name="固定达人",
asset_type=Asset.Type.IMAGE,
source=Asset.Source.UPLOAD,
category=Asset.Category.PERSON,
)
response = self.client.post(
f"/api/ai/creations/{self.conversation.id}/send/",
{
"kind": "elicit_answer",
"reply_to": role_gate["id"],
"answers": {"person_source": "local_upload"},
"refs": [{"type": "character", "id": str(portrait.id), "name": portrait.name}],
},
format="json",
)
self.assertEqual(response.status_code, 200, response.content)
confirm = next(item for item in response.json()["messages"] if item["kind"] == "confirm")
self.assertTrue(any(item["kind"] == "prompt_file" for item in response.json()["messages"]))
self.assertEqual(confirm["kind"], "confirm")
self.assertIn("param_options", confirm["payload"])
self.conversation.refresh_from_db()
self.assertEqual(self.conversation.memory["stage"], "confirm")
def test_prompt_confirmation_collects_multi_role_arrangement_before_params(self):
self.conversation.mode = CreationConversation.Mode.VIDEO
self.conversation.preset = "达人口播种草"
people = [
Asset.objects.create(
team=self.team,
created_by=self.user,
name=name,
asset_type=Asset.Type.IMAGE,
source=Asset.Source.UPLOAD,
category=Asset.Category.PERSON,
)
for name in ("主讲达人", "辅助体验官")
]
self.conversation.pinned_refs = [
{"type": "character", "id": str(person.id), "name": person.name}
for person in people
]
self.conversation.memory = {
"stage": "prompt",
"pending_video_prompt": "总时长:15秒。两位达人依次展示商品体验。",
}
self.conversation.save(update_fields=["mode", "preset", "pinned_refs", "memory", "updated_at"])
prompt_confirm = append_step_confirm(self.conversation, "prompt")
response = self.client.post(
f"/api/ai/creations/{self.conversation.id}/send/",
{
"kind": "elicit_answer",
"reply_to": str(prompt_confirm.id),
"answers": {"step_action": "confirm"},
},
format="json",
)
self.assertEqual(response.status_code, 200, response.content)
relation_gate = response.json()["messages"][0]
self.assertEqual(relation_gate["payload"]["topic"], "cast_relation")
lead = relation_gate["payload"]["fields"][0]["options"][1]["value"]
response = self.client.post(
f"/api/ai/creations/{self.conversation.id}/send/",
{
"kind": "elicit_answer",
"reply_to": relation_gate["id"],
"answers": {"cast_relation": lead},
},
format="json",
)
self.assertEqual(response.status_code, 200, response.content)
self.assertTrue(any(item["kind"] == "prompt_file" for item in response.json()["messages"]))
self.assertTrue(any(item["kind"] == "confirm" for item in response.json()["messages"]))
def test_official_model_can_be_selected_from_person_source_gate(self):
official_owner = User.objects.create_user(username="official-model-owner", password="p")
official_team = Team.objects.create(name="Official Models", owner=official_owner)
@@ -1520,7 +1634,7 @@ class VideoPlanAndConfirmTests(CreationAgentBaseTests):
self.conversation.refresh_from_db()
self.assertEqual(self.conversation.params["duration"], "15 秒")
def test_plot_twist_preset_requires_story_depth_before_creative_output(self):
def test_plot_twist_requires_story_depth_before_product(self):
self.conversation.preset = "剧情反转带货"
self.conversation.params = {**self.conversation.params, "duration": "智能时长"}
self.conversation.memory = {}
@@ -1543,12 +1657,65 @@ class VideoPlanAndConfirmTests(CreationAgentBaseTests):
self.assertEqual(question["payload"].get("interaction"), "plot_twist_story_depth")
self.assertEqual(
[option["value"] for option in question["payload"]["fields"][0]["options"]],
["15s", "30s", "60s", "180s", "smart"],
["15s", "30s", "60s"],
)
self.conversation.refresh_from_db()
self.assertEqual(self.conversation.agent_status, "awaiting_user")
self.assertEqual(fake.calls, [])
def test_plot_twist_requires_story_depth_after_product_is_ready(self):
self._pin_product("洗面奶")
self.conversation.preset = "剧情反转带货"
self.conversation.params = {**self.conversation.params, "duration": "智能时长"}
self.conversation.memory = {}
self.conversation.save(update_fields=["preset", "params", "memory", "updated_at"])
fake = FakeProvider([_text_chunks("这一轮不应调用模型")])
with patch("apps.ai.creation_agent.build_provider", return_value=fake):
events = _events(stream_creation_agent(
conversation=self.conversation,
user=self.user,
text="给洗面奶做一个剧情反转带货视频",
model_config=self.model,
))
question = next(
event["message"] for event in events
if event.get("type") == "message" and event["message"]["kind"] == "elicit"
)
self.assertEqual(question["payload"].get("interaction"), "plot_twist_story_depth")
self.assertEqual(
[option["value"] for option in question["payload"]["fields"][0]["options"]],
["15s", "30s", "60s"],
)
self.assertEqual(fake.calls, [])
def test_product_information_is_reviewed_as_status_checklist(self):
self._pin_product("舒缓面霜")
self.conversation.preset = "达人口播种草"
self.conversation.memory = {}
self.conversation.save(update_fields=["preset", "memory", "updated_at"])
fake = FakeProvider([_text_chunks("这一轮不应调用模型")])
with patch("apps.ai.creation_agent.build_provider", return_value=fake):
events = _events(stream_creation_agent(
conversation=self.conversation,
user=self.user,
text="做一条口播视频",
model_config=self.model,
))
card = next(
event["message"] for event in events
if event.get("type") == "message" and event["message"]["kind"] == "elicit"
)
self.assertEqual(card["payload"].get("interaction"), "product_brief_review")
self.assertEqual(
{item["status"] for item in card["payload"]["items"]},
{"ready", "missing", "not_needed"},
)
self.assertEqual(fake.calls, [])
def test_plot_twist_depth_syncs_duration_and_is_written_into_final_prompt(self):
self.conversation.preset = "剧情反转带货"
self.conversation.save(update_fields=["preset", "updated_at"])
@@ -2139,7 +2306,7 @@ class VideoPlanAndConfirmTests(CreationAgentBaseTests):
self.assertIn("还没写完", errors[0])
self.assertIn("按这个继续", errors[0])
def test_click_swap_requires_sequence_before_calling_model(self):
def test_click_swap_requires_mode_before_sequence_and_model(self):
self._pin_product("三色通勤包")
self.conversation.preset = "点击换款"
self.conversation.memory = {}
@@ -2158,8 +2325,8 @@ class VideoPlanAndConfirmTests(CreationAgentBaseTests):
event["message"] for event in events
if event.get("type") == "message" and event["message"]["kind"] == "elicit"
)
self.assertEqual(gate["payload"].get("interaction"), "click_swap_sku_gate")
self.assertEqual(gate["payload"]["fields"][0]["key"], "sku_sequence")
self.assertEqual(gate["payload"].get("interaction"), "click_swap_mode_gate")
self.assertEqual(gate["payload"]["fields"][0]["key"], "click_swap_mode")
self.assertEqual(fake.calls, [])
def test_click_swap_plan_overrides_conflicting_generic_script(self):
@@ -2240,8 +2407,8 @@ class VideoPlanAndConfirmTests(CreationAgentBaseTests):
self.assertEqual((self.conversation.memory or {}).get("stage"), "prompt")
self.assertEqual(len(fake.calls), 1)
def test_strategy_and_plan_same_round_stops_after_strategy(self):
"""模型若同轮连调 write_strategy+write_plan,只落策略闸门。"""
def test_strategy_and_plan_same_round_only_exposes_video_architecture(self):
"""内部策略不落用户卡,同轮继续交付唯一可见的视频架构。"""
from apps.ai.creation_agent import _parse_arguments # noqa: F401
self._pin_person()
@@ -2278,15 +2445,28 @@ class VideoPlanAndConfirmTests(CreationAgentBaseTests):
events = _events(stream_creation_agent(conversation=self.conversation, user=self.user,
text="做条视频", model_config=self.model))
kinds = [e["message"]["kind"] for e in events if e.get("type") == "message"]
self.assertIn("strategy", kinds)
self.assertNotIn("plan", kinds)
self.assertNotIn("strategy", kinds)
self.assertIn("plan", kinds)
self.assertEqual(len(fake.calls), 1)
def test_person_video_requires_a_source_before_the_provider_runs(self):
def test_person_video_writes_architecture_before_asking_for_a_source(self):
self._pin_product("控油粉饼")
self.conversation.preset = "达人口播种草"
self.conversation.save(update_fields=["preset", "updated_at"])
fake = FakeProvider([_text_chunks("这一轮不应调用模型")])
self.conversation.memory = {
"selling_point_ready": True,
"selling_point_mode": "auto",
"product_brief_reviewed": True,
}
self.conversation.save(update_fields=["preset", "memory", "updated_at"])
fake = FakeProvider([
_tool_chunks("write_strategy", {
"target": "油皮通勤人群",
"trust": "真实上妆体验",
"belief": "控油不厚重",
"direction": "达人自然口播",
}),
_tool_chunks("write_plan", self._plan_args()),
])
with patch("apps.ai.creation_agent.build_provider", return_value=fake):
events = _events(stream_creation_agent(
@@ -2296,18 +2476,15 @@ class VideoPlanAndConfirmTests(CreationAgentBaseTests):
model_config=self.model,
))
card = next(
event["message"] for event in events
if event.get("type") == "message" and event["message"]["kind"] == "elicit"
)
self.assertEqual(card["payload"]["interaction"], "person_source_gate")
self.assertEqual(
[option["value"] for option in card["payload"]["fields"][0]["options"]],
["local_upload", "model_library", "platform_generate"],
)
self.assertEqual(fake.calls, [])
kinds = [event["message"]["kind"] for event in events if event.get("type") == "message"]
self.assertIn("plan", kinds)
self.assertNotIn("person_source_gate", [
event["message"].get("payload", {}).get("interaction")
for event in events if event.get("type") == "message"
])
self.assertEqual(len(fake.calls), 2)
def test_multiple_characters_are_kept_and_relation_is_clarified_before_model_runs(self):
def test_multiple_characters_do_not_interrupt_architecture_writing(self):
product = Product.objects.create(team=self.team, created_by=self.user, title="蓝牙耳机")
characters = [
Asset.objects.create(
@@ -2328,8 +2505,14 @@ class VideoPlanAndConfirmTests(CreationAgentBaseTests):
for character in characters
],
]
self.conversation.save(update_fields=["preset", "pinned_refs", "updated_at"])
fake = FakeProvider([_text_chunks("这一轮不应调用模型")])
self.conversation.memory = {"selling_point_ready": True, "selling_point_mode": "auto"}
self.conversation.save(update_fields=["preset", "pinned_refs", "memory", "updated_at"])
fake = FakeProvider([_tool_chunks("write_strategy", {
"target": "耳机通勤用户",
"trust": "多角色实测",
"belief": "佩戴舒适稳定",
"direction": "剧情化口播",
})])
with patch("apps.ai.creation_agent.build_provider", return_value=fake):
events = _events(stream_creation_agent(
@@ -2342,43 +2525,14 @@ class VideoPlanAndConfirmTests(CreationAgentBaseTests):
model_config=self.model,
))
card = next(
event["message"] for event in events
if event.get("type") == "message" and event["message"]["kind"] == "elicit"
)
self.assertEqual(card["payload"]["topic"], "cast_relation")
self.assertIn("3 位角色,我都会保留", card["text"])
self.assertIn("共同出镜", card["text"])
self.assertIn("点一位角色作为主讲", card["text"])
self.assertNotIn("选哪一位", card["text"])
self.assertEqual(card["payload"]["fields"][0]["type"], "single")
self.assertEqual(
[option["label"] for option in card["payload"]["fields"][0]["options"]],
["全部共同出镜", "南卡主讲", "红发男生主讲", "戴墨镜女生主讲"],
)
self.assertEqual(fake.calls, [])
follow_up = FakeProvider([_text_chunks("这个安排能做,我按三位角色继续整理。")])
with patch("apps.ai.creation_agent.build_provider", return_value=follow_up):
_events(stream_creation_agent(
conversation=self.conversation,
user=self.user,
text="南卡",
model_config=self.model,
))
self.conversation.refresh_from_db()
self.assertEqual(
(self.conversation.memory or {}).get("cast_relation"),
"南卡作为主讲,其余已锁定角色辅助出镜",
)
self.assertEqual(len(follow_up.calls), 1)
system = follow_up.calls[0]["messages"][0]["content"]
self.assertIn("这些角色默认都必须保留", system)
self.assertIn("南卡作为主讲,其余已锁定角色辅助出镜", system)
self.assertIn("不得再次询问同一问题", system)
self.assertIn("已添加多位角色时保留全部角色", system)
self.assertNotIn("固定一位可信的真人", system)
kinds = [event["message"]["kind"] for event in events if event.get("type") == "message"]
self.assertIn("strategy", kinds)
topics = [
event["message"].get("payload", {}).get("topic")
for event in events if event.get("type") == "message"
]
self.assertNotIn("cast_relation", topics)
self.assertEqual(len(fake.calls), 1)
def test_commerce_preset_asks_brand_and_name_for_an_uploaded_product_image(self):
"""上传图现在可以直接当商品图用,但品牌和具体品名必须先问清楚,不许平台自己猜。"""
@@ -2829,6 +2983,38 @@ class ConfirmEndpointTests(TestCase):
# 出片没提交成功,闸门要放回去让用户改完再确认
self.assertFalse(self.card.payload["submitted"])
def test_stale_confirm_card_routes_missing_cast_to_guided_role_step(self):
"""更新前留下的确认卡也不能再把角色不足直接落成 ERROR。"""
portrait = Asset.objects.create(
team=self.team,
created_by=self.user,
name="角色1",
asset_type=Asset.Type.IMAGE,
category=Asset.Category.PERSON,
)
self.conversation.preset = "剧情反转带货"
self.conversation.pinned_refs = [
{"type": "character", "id": str(portrait.id), "name": portrait.name}
]
self.conversation.save(update_fields=["preset", "pinned_refs", "updated_at"])
self.card.payload = {
"video_prompt": "三位成年角色共同出镜,角色1、角色2、角色3依次推进剧情。",
"submitted": False,
}
self.card.save(update_fields=["payload", "updated_at"])
with patch("apps.ai.free_video.submit_free_video") as submit:
response = self._post()
self.assertEqual(response.status_code, 200, response.content)
payload = response.json()
self.assertEqual(payload["messages"][0]["payload"]["interaction"], "person_source_gate")
self.assertIn("第 2/3 位", payload["messages"][0]["text"])
submit.assert_not_called()
self.assertFalse(
self.conversation.messages.filter(kind=CreationMessage.Kind.ERROR).exists()
)
def test_duration_change_returns_regenerate_instead_of_submitting(self):
self.conversation.params = {
"model": "Seedance 2.0 Fast", "resolution": "480p",
@@ -2851,7 +3037,7 @@ class ConfirmEndpointTests(TestCase):
self.card.refresh_from_db()
self.assertTrue(self.card.payload["submitted"])
def test_confirm_applies_model_without_rewriting_script(self):
def test_confirm_model_change_rebuilds_architecture_before_submitting(self):
self.conversation.params = {
"model": "Seedance 2.0 Fast", "resolution": "480p",
"ratio": "1:1", "duration": "8 秒",
@@ -2874,11 +3060,11 @@ class ConfirmEndpointTests(TestCase):
"resolution": "720p", "ratio": "1:1"}},
format="json",
)
self.assertEqual(response.status_code, 201)
self.assertFalse(response.json().get("regenerate"))
self.assertEqual(submit.call_args.kwargs["params"]["model"], "doubao-seedance-2-5-260628")
self.assertEqual(submit.call_args.kwargs["params"]["resolution"], "720p")
self.assertEqual(submit.call_args.kwargs["params"]["duration"], 8)
self.assertEqual(response.status_code, 200)
self.assertTrue(response.json().get("regenerate"))
submit.assert_not_called()
self.conversation.refresh_from_db()
self.assertEqual((self.conversation.memory or {}).get("stage"), "strategy")
def test_confirm_ignores_duration_spacing_when_model_changes(self):
self.conversation.params = {
@@ -2903,9 +3089,9 @@ class ConfirmEndpointTests(TestCase):
"resolution": "720p", "ratio": "9:16"}},
format="json",
)
self.assertEqual(response.status_code, 201)
self.assertFalse(response.json().get("regenerate"))
submit.assert_called_once()
self.assertEqual(response.status_code, 200)
self.assertTrue(response.json().get("regenerate"))
submit.assert_not_called()
class MemoryCompressionTests(CreationAgentBaseTests):
@@ -3330,7 +3516,21 @@ class ChatVisionTests(CreationAgentBaseTests):
blob = " ".join(str(item.get("text") or "") for item in content if isinstance(item, dict))
self.assertIn("性别", blob)
def test_gpt_6_luna_receives_reference_images(self):
self.model.name = "gpt-6-luna"
self.model.save(update_fields=["name"])
context = AgentContext(conversation=self.conversation, user=self.user, model_config=self.model)
messages = build_messages(context)
content = [m for m in messages if m.get("role") == "user"][-1]["content"]
self.assertIsInstance(content, list)
self.assertIn(
"https://cdn/person.jpg",
[(item.get("image_url") or {}).get("url") for item in content if isinstance(item, dict)],
)
def test_plain_text_model_does_not_receive_images(self):
self.model.name = "plain-text"
self.model.save(update_fields=["name"])
context = AgentContext(conversation=self.conversation, user=self.user, model_config=self.model)
messages = build_messages(context)
for message in messages:
@@ -3338,19 +3538,31 @@ class ChatVisionTests(CreationAgentBaseTests):
class CreationChatModelTests(CreationAgentBaseTests):
def test_prefers_seed_21_as_chat_model(self):
self.model.is_default = False
self.model.save(update_fields=["is_default"])
seed = ModelConfig.objects.create(
provider=self.model.provider, name="doubao-seed-2-1-pro-260628",
display_name="Doubao-Seed-2.1-Pro",
capability=ModelConfig.Capability.TEXT, endpoint="chat/completions",
is_default=True,
def test_prefers_gpt_6_luna_as_chat_model(self):
luna = self.creation_model
doubao = ModelConfig.objects.create(
provider=self.model.provider, name="doubao-seed-2-0-pro",
display_name="Doubao", capability=ModelConfig.Capability.TEXT,
endpoint="chat/completions", is_default=True,
)
picked = get_creation_chat_model(None)
# 测试迁移已经可能种过同名 Seed 2.1,重点是编排层选到该模型家族,
# 不是强行命中本测试后建的重复记录。
self.assertEqual(picked.name, seed.name)
self.assertEqual(picked.pk, luna.pk)
self.assertEqual(get_creation_chat_model(doubao).pk, luna.pk)
self.assertEqual(creation_model_temperature(picked), 1.0)
self.assertEqual(creation_model_temperature(doubao), 0.8)
self.assertIn("max_completion_tokens", creation_model_extra_body(picked, []))
self.assertNotIn("max_tokens", creation_model_extra_body(picked, []))
self.assertEqual(creation_model_extra_body(picked, [])["reasoning_effort"], "none")
def test_does_not_fall_back_to_doubao_when_luna_is_unavailable(self):
self.creation_model.status = ModelConfig.Status.DISABLED
self.creation_model.save(update_fields=["status"])
doubao = ModelConfig.objects.create(
provider=self.model.provider, name="doubao-seed-2-0-pro-fallback",
display_name="Doubao fallback", capability=ModelConfig.Capability.TEXT,
endpoint="chat/completions", is_default=True,
)
self.assertIsNone(get_creation_chat_model(doubao))
class PureChitchatTests(SimpleTestCase):
@@ -202,7 +202,7 @@ class StandaloneSingleImageRoutingTests(TestCase):
product.save(update_fields=["cover_asset"])
return product
def submit_platform(self, primary, *, count=1, ratio="4:5", platform_id="taobao"):
def submit_platform(self, primary, *, count=1, ratio="4:5", platform_id="taobao", product_info=None):
product = self.platform_product()
return enqueue_standalone_images(
team=self.team,
@@ -213,6 +213,7 @@ class StandaloneSingleImageRoutingTests(TestCase):
product_id=str(product.id),
ratio=ratio,
platform_id=platform_id,
product_info=product_info,
image_model=f"{primary.provider.name}:{primary.name}",
dispatch=False,
)
@@ -681,6 +682,37 @@ class StandaloneSingleImageRoutingTests(TestCase):
self.assertEqual(self.ledger_count(task, CreditLedger.Type.RESERVE), 1)
self.assertEqual(self.ledger_count(task, CreditLedger.Type.CHARGE), 1)
def test_platform_kit_without_platform_uses_product_info_and_generic_spec(self):
primary = self.model(
self.provider("platform-info-primary", 20),
"platform-info-edit",
reference_modes=["none", "single", "multiple"],
max_reference_images=9,
)
task = self.submit_platform(
primary,
ratio="1:1",
platform_id=None,
product_info={"selling_points": "透气速干\n亲肤不起球", "effect": "夏季户外穿着清爽", "unknown": "丢弃"},
)[0]
self.assertIsNone(task.request_payload["platform_id"])
self.assertEqual(
task.request_payload["product_info"],
{"selling_points": "透气速干\n亲肤不起球", "effect": "夏季户外穿着清爽"},
)
run_standalone_image_task(task_id=str(task.id))
task.refresh_from_db()
self.assertEqual(task.status, AITask.Status.SUCCEEDED)
call = self.provider_mocks[primary.id].image_edit.call_args
self.assertEqual(call.kwargs["images"], ["http://example.test/platform-product.png"])
prompt = call.kwargs["prompt"]
self.assertIn("通用电商商品主图", prompt)
self.assertIn("透气速干", prompt)
self.assertIn("夏季户外穿着清爽", prompt)
self.assertNotIn("平台:淘宝", prompt)
def test_platform_kit_selected_seedream_stays_primary(self):
primary = self.model(
self.provider("volcano", 10),
+158 -32
View File
@@ -40,6 +40,7 @@ from .creation_agent import (
_RESTART_CONTINUATION,
apply_cast_relation_choice,
apply_click_swap_mode,
append_multi_character_relation_gate,
append_person_source_gate,
apply_pain_point_direction,
apply_confirm_params,
@@ -47,22 +48,27 @@ from .creation_agent import (
apply_session_params,
emit_prompt_gate,
emit_final_confirm_gate,
insufficient_cast_refs_message,
locked_product_references,
set_plot_twist_story_depth,
is_greeting,
is_pain_point_conversation,
is_pain_point_direction_payload,
is_restart_intent,
multi_character_relation_needs_clarification,
restore_gated_step_after_cancel,
get_video_gate_stage,
set_video_gate_stage,
sync_prompt_after_cast,
submit_confirmed_image,
submit_confirmed_video,
submit_generated_person_reference,
video_needs_person_source,
is_incomplete_product_brand_answer,
PRODUCT_BRAND_EMPTY_TEMPLATE,
)
from .tasks import run_creation_agent_turn_task
from .mentions import TYPE_LABELS, VALID_TYPES, refs_from_elicit_answers, search_mentions
from .mentions import TYPE_LABELS, VALID_TYPES, refs_from_elicit_answers, resolve_refs, search_mentions
from .models import AITask, CreationConversation, CreationMessage, ImageConversation, ModelConfig
from .serializers import (
AITaskSerializer,
@@ -73,7 +79,7 @@ from .serializers import (
ImageConversationTrashSerializer,
ModelConfigSerializer,
)
from .services import enqueue_standalone_images
from .services import enqueue_standalone_images, normalize_cover_product_info
logger = logging.getLogger(__name__)
@@ -433,6 +439,44 @@ _STEP_REVISE_INSTRUCTIONS = {
}
def _advance_after_video_prompt(conversation: CreationConversation) -> list[CreationMessage]:
"""Prompt 确认后按固定顺序补角色,再展示可调参数的最终生成确认。
视频架构与 Prompt 已经定稿,角色图只在这里锁定,保证预设的必经选择和
架构撰写不会被人物来源卡片抢断。多角色时继续在同一阶段确认出镜关系。
"""
from .creation_agent import video_needs_person_source
if video_needs_person_source(conversation):
set_video_gate_stage(conversation, "cast")
return [append_person_source_gate(conversation)]
if multi_character_relation_needs_clarification(conversation):
set_video_gate_stage(conversation, "cast")
return [append_multi_character_relation_gate(conversation)]
synced = sync_prompt_after_cast(conversation)
confirm = emit_final_confirm_gate(conversation)
return [item for item in (synced, confirm) if item is not None]
def _video_gate_response(
conversation: CreationConversation,
messages: list[CreationMessage],
) -> JsonResponse:
"""返回角色或最终确认卡;只有最终确认卡携带预计积分。"""
conversation.agent_status = CreationConversation.AgentStatus.AWAITING_USER
conversation.save(update_fields=["agent_status", "updated_at"])
body = {
"conversation_id": str(conversation.id),
"agent_status": conversation.agent_status,
"messages": [CreationMessageSerializer(message).data for message in messages],
}
if len(messages) == 1 and messages[0].kind == CreationMessage.Kind.CONFIRM:
credits = int((messages[0].payload or {}).get("estimated_credits") or 0)
if credits:
body["estimated_credits"] = credits
return JsonResponse(body, status=200)
def _handle_step_confirm_answer(
conversation: CreationConversation,
*,
@@ -497,20 +541,10 @@ def _handle_step_confirm_answer(
return JsonResponse(body, status=200), False, ""
if step == "prompt":
confirm = emit_final_confirm_gate(conversation)
if confirm is None:
messages = _advance_after_video_prompt(conversation)
if not messages:
return None, True, _STEP_CONTINUE_INSTRUCTIONS["prompt"]
conversation.agent_status = CreationConversation.AgentStatus.AWAITING_USER
conversation.save(update_fields=["agent_status", "updated_at"])
credits = int((confirm.payload or {}).get("estimated_credits") or 0)
body = {
"conversation_id": str(conversation.id),
"agent_status": conversation.agent_status,
"messages": [CreationMessageSerializer(confirm).data],
}
if credits:
body["estimated_credits"] = credits
return JsonResponse(body, status=200), False, ""
return _video_gate_response(conversation, messages), False, ""
# 未知 step:当普通继续
return None, True, "用户已确认上一步。继续推进创作,不要复述确认。"
@@ -539,8 +573,12 @@ class GenerateImageView(APIView):
model_entity_id = str(request.data.get("model_entity_id") or "").strip() or None
ratio = str(request.data.get("ratio") or "").strip() or None
image_model = str(request.data.get("image_model") or "").strip() or None
# 平台套图:前端传规范化平台 id(taobao/douyin/…),用于后端注入平台版式块(优化版)
# 平台套图:平台 id 已改为可选(页面不再选平台,默认通用电商主图规范);旧调用方传 canonical id
# (taobao/douyin/…)仍注入平台版式块,向后兼容。
platform_id = str(request.data.get("platform_id") or "").strip() or None
# 平台套图:用户填写的商品信息(selling_points/effect/audience/specs/notes),只对 cover 生效,
# 由 enqueue_standalone_images 规范化后写入 request_payload.product_info;不传 = 旧行为。
product_info = request.data.get("product_info") if mode == "cover" else None
conversation_id = str(request.data.get("conversation_id") or "").strip() or None
# 重跑/补图:前端带原批次 batch_id → enqueue 沿用(UUID 校验),记录归回原批次不裂新卡
batch_id = str(request.data.get("batch_id") or "").strip() or None
@@ -551,6 +589,25 @@ class GenerateImageView(APIView):
raw_refs = [s for s in raw_refs.split(",") if s.strip()]
reference_image_ids = [str(r).strip() for r in raw_refs if str(r).strip()]
team = get_current_team(request.user)
# 图片创作输入框的 @ 引用和全能创作使用同一份解析契约:
# 不信任前端传来的图片地址,只拿 type/id 回库取事实与真正的参考 Asset。
raw_mentions = request.data.get("mention_refs") or []
if not isinstance(raw_mentions, list):
raw_mentions = []
mention_refs = [
{"type": str(item.get("type") or ""), "id": str(item.get("id") or "")}
for item in raw_mentions
if isinstance(item, dict)
and str(item.get("type") or "") in VALID_TYPES
and str(item.get("id") or "").strip()
]
resolved_mentions = resolve_refs(team, mention_refs)
# @引用的素材与手动上传参考图都进同一条 image_edit 链路;保持解析器的
# 角色 → 场景 → 商品顺序,再追加用户临时上传图并去重。
reference_image_ids = list(dict.fromkeys(
[str(item["asset_id"]) for item in resolved_mentions.references if item.get("asset_id")]
+ reference_image_ids
))
if product_id:
try:
normalized_product_id = str(uuid.UUID(product_id))
@@ -604,7 +661,7 @@ class GenerateImageView(APIView):
title=(prompt[:24] or "默认创作"),
)
try:
tasks = enqueue_standalone_images(team=team, user=request.user, prompt=prompt, mode=mode, count=count, product_id=product_id, reference_product=reference_product, model_id=model_id, model_entity_id=model_entity_id, ratio=ratio, image_model=image_model, conversation=conversation, reference_image_ids=reference_image_ids, platform_id=platform_id, batch_id=batch_id, retry_of_task_id=retry_of_task_id)
tasks = enqueue_standalone_images(team=team, user=request.user, prompt=prompt, mode=mode, count=count, product_id=product_id, reference_product=reference_product, model_id=model_id, model_entity_id=model_entity_id, ratio=ratio, image_model=image_model, conversation=conversation, reference_image_ids=reference_image_ids, reference_context=resolved_mentions.facts_text, platform_id=platform_id, product_info=product_info, batch_id=batch_id, retry_of_task_id=retry_of_task_id)
except ValueError as exc: # 无可用模型 / 余额不足等,立即反馈
internal_kind = "user_credit_insufficient" if str(exc).strip().lower() == "insufficient credit" else ""
public_error = classify_generation_error(
@@ -901,6 +958,8 @@ class AITaskViewSet(TeamScopedViewSetMixin, ReadOnlyModelViewSet):
rp_prompt=KeyTextTransform("prompt", "request_payload"),
rp_ratio=KeyTextTransform("ratio", "request_payload"),
rp_platform_id=KeyTextTransform("platform_id", "request_payload"),
# 平台套图商品信息(JSON 对象的文本形态),前端恢复批次后重跑沿用;旧记录无此键 → NULL
rp_product_info=KeyTextTransform("product_info", "request_payload"),
rp_model_id=KeyTextTransform("model_id", "request_payload"),
rp_model_entity_id=KeyTextTransform("model_entity_id", "request_payload"),
# 只在重跑任务里落此键(值恒为 True);键不存在 → NULL → 假值,存在 → "true"/"1" → 真值
@@ -928,6 +987,7 @@ class AITaskViewSet(TeamScopedViewSetMixin, ReadOnlyModelViewSet):
"model_id": t.rp_model_id or "",
"model_entity_id": t.rp_model_entity_id or "",
"platform_id": t.rp_platform_id or "",
"product_info": normalize_cover_product_info(t.rp_product_info) or None,
"rerun": bool(t.rp_batch_append),
"retry_of_task_id": str((t.request_payload or {}).get("retry_of_task_id") or ""),
"created_at": t.created_at,
@@ -2030,6 +2090,26 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
record_user_message = False
force_creative_turn = True
continuation_instruction = _plot_twist_depth_continuation(depth)
elif payload.get("interaction") == "product_brief_review":
memory = dict(conversation.memory or {})
memory["product_brief_reviewed"] = True
if text:
memory["product_brief_note"] = text
conversation.memory = memory
conversation.save(update_fields=["memory", "updated_at"])
payload["answers"] = {
"review_action": "supplement" if text else "continue",
"product_brief_note": text,
}
payload["submitted"] = True
payload["answered_via"] = "chat"
pending.payload = payload
pending.save(update_fields=["payload", "updated_at"])
force_creative_turn = True
continuation_instruction = (
"用户已核对商品信息。把补充内容当作真实商品事实;未确认的品牌、价格、优惠或功效不得编造。"
"继续完成视频架构。"
)
elif payload.get("interaction") == "plot_twist_directions":
options = [item for item in (payload.get("directions") or []) if isinstance(item, dict)]
chosen = next(
@@ -2379,6 +2459,13 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
memory_now["person_confirm_pending"] = False
conversation.memory = memory_now
conversation.save(update_fields=["memory", "updated_at"])
# 角色图属于 Prompt 之后的固定闸门。用户确认平台定妆后,直接进入
# 多角色安排或最终参数确认,不再重新跑一轮架构 / Prompt Agent。
if get_video_gate_stage(conversation) == "cast":
append_message(conversation, role="user", text=clean_text)
messages = _advance_after_video_prompt(conversation)
if messages:
return _video_gate_response(conversation, messages)
force_creative_turn = True
continuation_instruction = (
"用户已确认使用当前生成的角色出镜。"
@@ -2455,7 +2542,7 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
incoming = request.data.get("params")
if incoming is not None and not isinstance(incoming, dict):
return JsonResponse({"detail": "params 必须是对象"}, status=400)
latest_params, duration_changed = apply_confirm_params(
latest_params, needs_rebuild = apply_confirm_params(
conversation, incoming if isinstance(incoming, dict) else None
)
card.payload = {
@@ -2464,14 +2551,34 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
"params": latest_params,
}
card.save(update_fields=["payload", "updated_at"])
# 改时长会让旧脚本对不上(5 秒方案不能直接出 10 秒)。确认卡作废,前端再发一轮让模型重写。
if duration_changed:
# 改时长或视频模型会影响能力与 Prompt。保留事实/素材,只重写受影响步骤。
if needs_rebuild:
return JsonResponse({
"regenerate": True,
"params": latest_params,
"message": None,
}, status=200)
is_image = (card.payload or {}).get("kind") == "image" or conversation.mode == CreationConversation.Mode.IMAGE
if not is_image:
prompt = str((card.payload or {}).get("video_prompt") or "").strip()
cast_gap = insufficient_cast_refs_message(conversation, prompt)
if cast_gap:
memory = conversation.memory if isinstance(conversation.memory, dict) else {}
if memory.get("person_source_pending"):
# 历史确认卡可能仍可见;角色正在生成时不落 ERROR,也不重复创建角色闸门。
card.payload = {**(card.payload or {}), "submitted": False}
card.save(update_fields=["payload", "updated_at"])
return JsonResponse({
"detail": "角色图正在生成,完成后会自动继续到参数确认。",
"message": None,
}, status=409)
# 兼容更新前已经生成的确认卡:把缺角色错误转换成明确的角色补全步骤。
if video_needs_person_source(conversation, prompt):
set_video_gate_stage(conversation, "cast")
return _video_gate_response(
conversation,
[append_person_source_gate(conversation, prompt)],
)
submitter = submit_confirmed_image if is_image else submit_confirmed_video
message, error = submitter(
conversation=conversation, user=request.user, confirm_message=card
@@ -2607,6 +2714,26 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
record_user_message = False
force_creative_turn = True
continuation_instruction = _plot_twist_depth_continuation(depth)
elif payload.get("interaction") == "product_brief_review":
action = str(answers.get("review_action") or "").strip()
if action not in {"continue", "supplement"}:
return JsonResponse({"detail": "请确认按现有信息继续,或补充真实商品信息"}, status=400)
note = str(answers.get("product_brief_note") or "").strip()
if action == "supplement" and not note:
return JsonResponse({"detail": "请填写要补充的真实商品信息"}, status=400)
memory = dict(conversation.memory or {})
memory["product_brief_reviewed"] = True
if note:
memory["product_brief_note"] = note
conversation.memory = memory
conversation.save(update_fields=["memory", "updated_at"])
text = ""
record_user_message = False
force_creative_turn = True
continuation_instruction = (
"用户已核对商品信息。把卡片中的补充内容当作真实商品事实;"
"任何未确认的品牌、价格、优惠或功效都不得编造。继续完成视频架构。"
)
elif payload.get("interaction") == "plot_twist_directions":
choice = str(answers.get("story_direction") or "").strip()
if not choice:
@@ -2670,6 +2797,10 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
)
if not relation:
return JsonResponse({"detail": "这个角色选项已经失效,请重新选择"}, status=400)
if get_video_gate_stage(conversation) == "cast":
messages = _advance_after_video_prompt(conversation)
if messages:
return _video_gate_response(conversation, messages)
text = ""
record_user_message = False
force_creative_turn = True
@@ -2717,6 +2848,11 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
conversation.memory = memory
conversation.status = CreationConversation.Status.RUNNING
conversation.save(update_fields=["memory", "status", "updated_at"])
# Prompt 已确认后才开始选角色;选择完成后直接开放可调视频参数。
if get_video_gate_stage(conversation) == "cast":
messages = _advance_after_video_prompt(conversation)
if messages:
return _video_gate_response(conversation, messages)
text = ""
record_user_message = False
force_creative_turn = True
@@ -2965,23 +3101,13 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
):
return _agent_busy_response()
model_config_id = None
requested = request.data.get("model_config_id")
if requested:
model_config = (
ModelConfig.objects.select_related("provider")
.filter(id=requested, capability=ModelConfig.Capability.TEXT, status=ModelConfig.Status.ACTIVE)
.first()
)
if model_config is not None:
model_config_id = str(model_config.id)
turn_kwargs = {
"conversation_id": str(conversation.id),
"user_id": str(request.user.id),
"text": text,
"refs": refs,
"model_config_id": model_config_id,
# 全能创作不接受前端覆盖语言模型;worker 内只解析固定的 GPT-6 Luna。
"model_config_id": None,
"record_user_message": record_user_message,
"force_creative_turn": force_creative_turn,
"continuation_instruction": continuation_instruction,