全能创作:换款双形态与素材闸门防跳过
本地上传优先于商品库推荐;点击换款支持只出手/角色日常;角色未锁定禁止进核心卖点,「你来推荐」空库不再假完成。
This commit is contained in:
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user