全能创作:换款双形态与素材闸门防跳过

本地上传优先于商品库推荐;点击换款支持只出手/角色日常;角色未锁定禁止进核心卖点,「你来推荐」空库不再假完成。
This commit is contained in:
Azmat@qq.com
2026-09-20 09:28:31 +08:00
parent b5d970a4b5
commit f2e8a5f3c1
5 changed files with 638 additions and 67 deletions
+225 -9
View File
@@ -376,6 +376,22 @@ def has_locked_person_reference(conversation: CreationConversation) -> bool:
)
def person_identity_ready(conversation: CreationConversation) -> bool:
"""角色图是否已真正可用:已钉 Ref、正在生成、或明确只要手指(点击换款)。
仅有 person_source_ready / 「你来推荐」但库里没命中、也没钉住人,不算完成。
"""
if has_locked_person_reference(conversation):
return True
memory = conversation.memory if isinstance(conversation.memory, dict) else {}
if memory.get("person_source_pending"):
return True
if str(memory.get("person_source") or "") == "finger_only":
return True
return False
def locked_person_references(conversation: CreationConversation) -> list[dict]:
"""返回去重后的已锁定人物,顺序与用户添加顺序一致。"""
people: list[dict] = []
@@ -539,11 +555,15 @@ def append_multi_character_relation_gate(conversation: CreationConversation) ->
def video_needs_person_source(conversation: CreationConversation, user_text: str = "") -> bool:
"""需要真人/角色的视频在写策略前必须先锁定人物来源。"""
if conversation.mode != CreationConversation.Mode.VIDEO or has_locked_person_reference(conversation):
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_ready") or memory.get("person_source_pending"):
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
recent = list(
@@ -731,11 +751,18 @@ def click_swap_needs_sequence(conversation: CreationConversation) -> bool:
def append_click_swap_sequence_gate(conversation: CreationConversation) -> CreationMessage:
mode = click_swap_mode(conversation)
if mode == "character":
hint = "先确认要切换的款式和展示顺序。角色会在日常场景中按这个顺序变色换款。"
placeholder = "例如:曜石黑 → 象牙白 → 樱花粉"
else:
hint = "先确认要切换的款式和展示顺序。后续每次手指点击都会严格按这个顺序原位换款。"
placeholder = "例如:黑色 → 白色 → 樱花粉"
return append_message(
conversation,
role="assistant",
kind=CreationMessage.Kind.ELICIT,
text="先确认要切换的款式和展示顺序。后续每次手指点击都会严格按这个顺序原位换款。",
text=hint,
payload={
"interaction": "click_swap_sku_gate",
"fields": [{
@@ -743,7 +770,7 @@ def append_click_swap_sequence_gate(conversation: CreationConversation) -> Creat
"label": "款式与切换顺序",
"type": "text",
"required": True,
"placeholder": "例如:黑色 → 白色 → 樱花粉",
"placeholder": placeholder,
}],
"submitted": False,
"answers": {},
@@ -751,6 +778,73 @@ def append_click_swap_sequence_gate(conversation: CreationConversation) -> Creat
)
def click_swap_mode(conversation: CreationConversation) -> str:
memory = conversation.memory if isinstance(conversation.memory, dict) else {}
return str(memory.get("click_swap_mode") or "").strip()
def click_swap_needs_mode(conversation: CreationConversation) -> bool:
"""点击换款必须先选形态:只出手 / 角色日常换款。"""
if conversation.mode != CreationConversation.Mode.VIDEO:
return False
if not is_click_swap_preset(conversation.preset):
return False
return click_swap_mode(conversation) not in {"finger", "character"}
def append_click_swap_mode_gate(conversation: CreationConversation) -> CreationMessage:
return append_message(
conversation,
role="assistant",
kind=CreationMessage.Kind.ELICIT,
text="这条点击换款想用哪种形态?",
payload={
"interaction": "click_swap_mode_gate",
"fields": [{
"key": "click_swap_mode",
"label": "选择换款形态",
"type": "single",
"required": True,
"options": [
{"value": "finger", "label": "只出手指出镜(不用角色图)"},
{"value": "character", "label": "角色在日常场景中换款"},
],
}],
"submitted": False,
"answers": {},
},
)
def apply_click_swap_mode(conversation: CreationConversation, mode: str) -> str:
"""写入换款形态;只出手时直接跳过人物闸门。"""
value = str(mode or "").strip()
if value not in {"finger", "character"}:
return ""
memory = dict(conversation.memory or {})
memory["click_swap_mode"] = value
if value == "finger":
memory["person_source"] = "finger_only"
memory["person_source_ready"] = True
memory["person_source_pending"] = False
else:
memory.pop("person_source_ready", None)
memory.pop("person_source_pending", None)
if memory.get("person_source") == "finger_only":
memory.pop("person_source", None)
conversation.memory = memory
conversation.save(update_fields=["memory", "updated_at"])
if value == "finger":
return (
"商家已选择【只出手指出镜】。不要生成角色定妆图,不要追问出镜人物;"
"后续只围绕固定机位 + 手指点击 + 商品原位换款推进。"
)
return (
"商家已选择【角色在日常场景中换款】。下一步先锁定出镜角色来源;"
"脚本必须是同一角色在日常使用场景中按序变色换款,不要改成只出手指出镜。"
)
def submit_generated_person_reference(
*,
conversation: CreationConversation,
@@ -1622,6 +1716,7 @@ def apply_restart_intent(conversation: CreationConversation) -> int:
memory.pop("pain_point_direction", None)
memory.pop("click_swap_ready", None)
memory.pop("click_swap_sequence", None)
memory.pop("click_swap_mode", None)
conversation.memory = memory
conversation.save(update_fields=["memory", "updated_at"])
@@ -2521,7 +2616,7 @@ def _video_submit_params(context: AgentContext, prompt: str) -> tuple[dict, list
"""拼 submit_free_video 的入参。references 直接用 resolve_refs 的产物 ——
它已经排好 角色 → 场景 → 商品 的顺序,那正是出片模型 @图N 的语义依据。"""
resolved = resolve_refs(context.team, context.conversation.pinned_refs or [])
prompt = apply_video_preset_prompt(context.conversation.preset, prompt)
prompt = apply_video_preset_prompt(context.conversation.preset, prompt, click_swap_mode=click_swap_mode(context.conversation))
prompt = apply_product_voice_visual_guard(context.conversation, prompt)
prompt = apply_product_reality_guard(prompt)
prompt = apply_video_platform_safety_guard(prompt)
@@ -2997,7 +3092,7 @@ def build_system_prompt(context: AgentContext, *, allow_plan: bool = True, has_c
workflow_guidance = preset_workflow_guidance(conversation.preset) if context.is_video else ""
if workflow_guidance:
lines.append(f"【当前预设的工作重点】{workflow_guidance}")
delivery_contract = video_preset_delivery_contract(conversation.preset) if context.is_video else ""
delivery_contract = video_preset_delivery_contract(conversation.preset, click_swap_mode=click_swap_mode(conversation)) if context.is_video else ""
if delivery_contract:
lines.append(f"【当前预设必须贯穿脚本与出片】{delivery_contract}")
@@ -3389,6 +3484,51 @@ def apply_click_swap_plan_card(
third_end = max(second_end + 1, round(total * 0.75))
third_end = min(third_end, total - 1)
second_end = min(second_end, third_end - 1)
if click_swap_mode(conversation) == "character":
return {
**card,
"usp": f"同一角色在日常场景中按「{sequence}」变色换款",
"points": [
"锁定同一角色、同一生活场景与光线",
"角色自然使用或展示商品,按确认顺序换款",
f"严格按「{sequence}」逐款展示,结尾给出全款式收束",
],
"timeline": [
{
"start": 0,
"end": first_end,
"stage": "日常开场",
"desc": "同一角色在日常使用场景中亮相,商品作为手头物件自然入画。",
},
{
"start": first_end,
"end": second_end,
"stage": "首次换款",
"desc": "角色保持同一身份与场景,商品切换到下一款,颜色/款式变化清楚。",
},
{
"start": second_end,
"end": third_end,
"stage": "按序换款",
"desc": f"按「{sequence}」继续换款;人物五官、发型、身形、基础服装与场景不变。",
},
{
"start": third_end,
"end": total,
"stage": "全款式收束",
"desc": "同一角色与场景下给出全款式收束,不改成口播带货腔或复杂剧情。",
},
],
"matrix": {
"shots": 4,
"rows": [
{"point": "角色一致", "hits": [1, 2, 3, 4]},
{"point": "日常场景", "hits": [1, 2, 3, 4]},
{"point": "款式顺序", "hits": [1, 2, 3, 4]},
],
},
"voice_chars": [0, 0],
}
return {
**card,
"usp": f"手指逐次点击,商品按「{sequence}」在原位连续换款",
@@ -4094,6 +4234,65 @@ def _elicit_payload_for_fields(
}
def _asset_ask_already_resolved(conversation: CreationConversation, fields: list[dict]) -> str | None:
"""素材类 ask_user 若本轮/会话已确认过,返回跳过原因;避免「你来推荐」后同款闸门再弹一次。"""
if not fields:
return None
field = fields[0] if isinstance(fields[0], dict) else {}
types = [t for t in (field.get("asset_types") or infer_field_types(field) or []) if t in TYPE_LABELS]
key = str(field.get("key") or "").strip().lower()
if not types:
if key in {"character", "person", "model", "product", "scene", "asset"}:
types = [key if key != "person" else "character"]
elif field.get("type") != "asset":
return None
else:
types = ["product"]
memory = conversation.memory if isinstance(conversation.memory, dict) else {}
if any(t in {"character", "model", "person"} for t in types):
if person_identity_ready(conversation):
return (
"出镜角色图已锁定或正在生成。不要再 ask_user 追问角色;直接继续创作。"
)
if "product" in types:
if memory.get("product_source_resolved") or has_locked_product_reference(conversation):
return (
"商品来源已确认(含本地上传或「你来推荐」)。不要再 ask_user 追问商品;直接继续创作。"
)
# 最近一条同类型闸门若已 submitted,也视为已答(防同轮或紧邻轮重复弹)。
recent = conversation.messages.filter(kind=CreationMessage.Kind.ELICIT).order_by("-seq")[:6]
for message in recent:
payload = message.payload or {}
if not payload.get("submitted"):
continue
if payload.get("phase") not in {"gate", "pick"} and payload.get("interaction") not in {
"chat", "asset_picker", "person_source_gate"
}:
continue
pending = payload.get("pending_fields") or payload.get("fields") or []
for item in pending:
if not isinstance(item, dict):
continue
item_types = [t for t in (item.get("asset_types") or infer_field_types(item) or []) if t]
if item.get("key") == "_asset_gate":
# pending_fields 才是真实素材类型
continue
if set(item_types) & set(types):
return (
f"刚刚已确认过「{'/'.join(types)}」素材选择。不要重复弹出同一问题;直接继续创作。"
)
# gate 的 pending_fields
for item in (payload.get("pending_fields") or []):
if not isinstance(item, dict):
continue
item_types = [t for t in (item.get("asset_types") or infer_field_types(item) or []) if t]
if set(item_types) & set(types) and payload.get("answers"):
return (
f"刚刚已确认过「{'/'.join(types)}」素材选择。不要重复弹出同一问题;直接继续创作。"
)
return None
def _dispatch_tool(
context: AgentContext, name: str, args: dict, *, allow_pick: bool = False
) -> tuple[dict, bool]:
@@ -4105,6 +4304,9 @@ def _dispatch_tool(
fields = _coerce_fields(args.get("fields"))
if not fields:
return {"payload": {"error": "fields 不合法,请重新组织问题"}}, False
skip_reason = _asset_ask_already_resolved(context.conversation, fields)
if skip_reason:
return {"payload": {"skipped": True, "reason": "already_resolved", "note": skip_reason}}, False
payload = _elicit_payload_for_fields(fields, allow_pick=allow_pick, is_video=context.is_video)
display_field = (payload.get("fields") or fields)[0]
message = append_message(
@@ -4142,6 +4344,13 @@ def _dispatch_tool(
return {"payload": _run_search_library(context, args)}, False
if name == "write_strategy":
if context.is_video and click_swap_needs_mode(context.conversation):
gate = append_click_swap_mode_gate(context.conversation)
set_video_gate_stage(context.conversation, "clarify")
return {
"payload": {"asked": True, "field": "click_swap_mode"},
"_events": [{"type": "message", "message": _message_payload(gate)}],
}, True
if context.is_video and click_swap_needs_sequence(context.conversation):
gate = append_click_swap_sequence_gate(context.conversation)
set_video_gate_stage(context.conversation, "clarify")
@@ -4209,6 +4418,13 @@ def _dispatch_tool(
}, True
if name == "write_plan":
if context.is_video and click_swap_needs_mode(context.conversation):
gate = append_click_swap_mode_gate(context.conversation)
set_video_gate_stage(context.conversation, "clarify")
return {
"payload": {"asked": True, "field": "click_swap_mode"},
"_events": [{"type": "message", "message": _message_payload(gate)}],
}, True
video_prompt = str(args.get("video_prompt") or "").strip()
if not video_prompt:
return {"payload": {"error": "video_prompt 不能为空,请把完整出片指令写进去"}}, False
@@ -4226,7 +4442,7 @@ def _dispatch_tool(
"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)
video_prompt = apply_video_preset_prompt(context.conversation.preset, video_prompt, click_swap_mode=click_swap_mode(context.conversation))
if is_click_swap_preset(context.conversation.preset):
video_prompt = (
f"{video_prompt}\n\n【商家确认的换款顺序】{click_swap_sequence(context.conversation)}\n"
@@ -4314,7 +4530,7 @@ def _dispatch_tool(
"payload": {"asked": True, "field": "sku_sequence"},
"_events": [{"type": "message", "message": _message_payload(gate)}],
}, True
video_prompt = apply_video_preset_prompt(context.conversation.preset, video_prompt)
video_prompt = apply_video_preset_prompt(context.conversation.preset, video_prompt, click_swap_mode=click_swap_mode(context.conversation))
video_prompt = apply_product_voice_visual_guard(context.conversation, video_prompt)
video_prompt = apply_product_reality_guard(video_prompt)
video_prompt = apply_video_platform_safety_guard(video_prompt)