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