diff --git a/core/backend/apps/ai/creation.py b/core/backend/apps/ai/creation.py index 1a1c9ca..9ee1f40 100644 --- a/core/backend/apps/ai/creation.py +++ b/core/backend/apps/ai/creation.py @@ -474,6 +474,7 @@ def finish_generating_message(message: CreationMessage, *, assets: list[dict], m memory["person_source"] = "platform_generate" memory["person_source_pending"] = False memory["person_source_ready"] = True + memory["person_confirm_pending"] = True memory["person_model_id"] = str(model.id) conversation.memory = memory conversation.status = CreationConversation.Status.RUNNING diff --git a/core/backend/apps/ai/creation_agent.py b/core/backend/apps/ai/creation_agent.py index e7da0d7..eb35fa5 100644 --- a/core/backend/apps/ai/creation_agent.py +++ b/core/backend/apps/ai/creation_agent.py @@ -32,6 +32,8 @@ from .creation_presets import ( PLOT_TWIST_STORY_DEPTH_OPTIONS, apply_image_preset_prompt, apply_plot_twist_story_contract, + apply_plot_twist_direction_contract, + format_plot_twist_direction_contract, apply_video_preset_prompt, is_click_swap_preset, plot_twist_story_depth, @@ -110,6 +112,36 @@ def is_plot_twist_conversation(conversation: CreationConversation) -> bool: return conversation.mode == CreationConversation.Mode.VIDEO and conversation.preset == PLOT_TWIST_PRESET + +def plot_twist_selected_direction(conversation: CreationConversation) -> dict: + """读取用户已选剧情方向(结构化优先,兼容旧会话只有 title/detail)。""" + memory = conversation.memory if isinstance(conversation.memory, dict) else {} + payload = memory.get("plot_twist_story_direction_payload") + if isinstance(payload, dict) and any( + str(payload.get(k) or "").strip() for k in ("title", "conflict", "product_role", "reversal", "detail") + ): + return { + "title": str(payload.get("title") or memory.get("plot_twist_story_direction") or "").strip(), + "conflict": str(payload.get("conflict") or "").strip(), + "product_role": str(payload.get("product_role") or "").strip(), + "reversal": str(payload.get("reversal") or "").strip(), + "tone": str(payload.get("tone") or "").strip(), + "detail": str(memory.get("plot_twist_story_direction_detail") or "").strip(), + } + title = str(memory.get("plot_twist_story_direction") or "").strip() + detail = str(memory.get("plot_twist_story_direction_detail") or "").strip() + if not title and not detail: + return {} + return { + "title": title, + "conflict": "", + "product_role": "", + "reversal": "", + "tone": "", + "detail": detail, + } + + def is_pain_point_conversation(conversation: CreationConversation) -> bool: return conversation.mode == CreationConversation.Mode.VIDEO and conversation.preset == PAIN_POINT_PRESET @@ -756,7 +788,7 @@ def append_click_swap_sequence_gate(conversation: CreationConversation) -> Creat hint = "先确认要切换的款式和展示顺序。角色会在日常场景中按这个顺序变色换款。" placeholder = "例如:曜石黑 → 象牙白 → 樱花粉" else: - hint = "先确认要切换的款式和展示顺序。后续每次手指点击都会严格按这个顺序原位换款。" + hint = "先确认要切换的款式和展示顺序。后续按这个顺序用手指点击触发换款即可,不必强调每次点同一位置。" placeholder = "例如:黑色 → 白色 → 樱花粉" return append_message( conversation, @@ -837,7 +869,7 @@ def apply_click_swap_mode(conversation: CreationConversation, mode: str) -> str: if value == "finger": return ( "商家已选择【只出手指出镜】。不要生成角色定妆图,不要追问出镜人物;" - "后续只围绕固定机位 + 手指点击 + 商品原位换款推进。" + "后续只围绕固定机位 + 手指点击触发换款推进;分镜不要反复写「每次点击同一位置」。" ) return ( "商家已选择【角色在日常场景中换款】。下一步先锁定出镜角色来源;" @@ -845,13 +877,32 @@ def apply_click_swap_mode(conversation: CreationConversation, mode: str) -> str: ) +def _character_only_appearance_text(raw: str) -> str: + """定妆图只保留角色外观描述,去掉商品/卖点/带货指令,避免图里跑出商品。""" + text = str(raw or "").strip() + if not text: + return "" + text = re.sub( + r"(?:商品|产品|品牌|品名|卖点|空气炸锅|猫粮|狗粮|包装|瓶身|机身|SKU|链接|下单|购买)[^。!?\n]{0,40}", + " ", + text, + flags=re.IGNORECASE, + ) + text = re.sub(r"[\s,,、;;]{2,}", ",", text).strip(",,。;; ") + return text[:500] + + def submit_generated_person_reference( *, conversation: CreationConversation, user, appearance_prompt: str = "", ) -> CreationMessage: - """先生成独立人物/宠物角色定妆参考,完成后再由 creation.py 自动建模特并锁定。""" + """先生成独立人物/宠物角色定妆参考,完成后再由 creation.py 自动建模特并锁定。 + + 定妆图 prompt 只写角色外观,不写入商品名/卖点/用户创作简述,也不带商品参考图, + 否则出图模型常会把商品画进角色照。 + """ from .services import enqueue_standalone_images recent_user = ( @@ -862,13 +913,19 @@ def submit_generated_person_reference( if getattr(conversation, "created_at", None) else [] ) - brief = "\n".join(reversed([item.strip() for item in recent_user if item and item.strip()]))[:700] - appearance_prompt = (appearance_prompt or "").strip()[:500] + # 仅用于推断宠物品种;绝不拼进最终定妆 prompt。 + 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] + appearance_only = _character_only_appearance_text(raw_appearance) is_pet = is_pet_preset(conversation.preset) + no_product = ( + "画面中只允许出现角色本身与简洁背景,禁止出现任何商品、包装、瓶罐、袋装、纸盒、" + "电子产品、食品零食、广告道具或手持卖品;不要商品特写,不要把商品画进画面。" + ) if is_pet: - # 智能推断宠物类别:用户指定 > 商品及需求上下文 > 默认萌宠 - context_text = f"{appearance_prompt} {brief} {conversation.preset}" + # 智能推断宠物类别:用户指定 > 商品及需求上下文 > 默认萌宠(只影响品种提示,不写入商品名) + context_text = f"{raw_appearance} {context_brief} {conversation.preset}" for ref in locked_product_references(conversation): context_text += " " + str(ref.get("name") or "") if any(w in context_text for w in ("狗", "犬", "咬胶", "磨牙", "骨头", "汪", "狗粮", "犬粮", "幼犬", "成犬", "中大型犬", "小型犬")): @@ -885,11 +942,10 @@ def submit_generated_person_reference( f"可搭配精致简约的拟人小配饰(如可爱小领结、小方巾或背带,契合萌宠调性)," f"写实摄影风格,电影级柔和摄影棚光影,简洁纯净背景," f"画面中只出现这一只可爱的宠物动物主角,绝对不要出现真人人类!不要文字、水印、拼图或变形。" + f"{no_product}" ) - if appearance_prompt: - prompt += f" 用户指定的宠物外观与品种:{appearance_prompt}。严格保留这些宠物外观要求。" - if brief: - prompt += f" 参考用户需求:{brief}" + if appearance_only: + prompt += f" 用户指定的宠物外观与品种:{appearance_only}。严格保留这些宠物外观要求。" label = "正在生成宠物角色参考" else: prompt = ( @@ -897,13 +953,10 @@ def submit_generated_person_reference( "只出现一位成年人物,正面或轻微三分之四角度,中近景,表情自然," "五官、发型、肤色、身形和服装细节清晰,简洁中性背景,写实摄影," "不要文字、水印、拼图、多人、遮挡脸部或夸张滤镜。" + f"{no_product}" ) - if appearance_prompt: - prompt += f" 用户指定的人物外观:{appearance_prompt}。严格保留这些外观要求。" - if conversation.preset: - prompt += f" 适配视频预设:{conversation.preset}。" - if brief: - prompt += f" 参考用户需求:{brief}" + if appearance_only: + prompt += f" 用户指定的人物外观:{appearance_only}。严格保留这些外观要求。" label = "正在生成人物参考" tasks = enqueue_standalone_images( @@ -938,6 +991,7 @@ def submit_generated_person_reference( ) + def _step_confirm_payload(step: str) -> dict: return { "interaction": "step_confirm", @@ -1046,6 +1100,9 @@ def emit_prompt_gate( return [] prompt = apply_product_voice_visual_guard(conversation, prompt) prompt = apply_product_reality_guard(prompt) + prompt = apply_clothing_video_guard(conversation, prompt) + prompt = apply_product_appearance_guard(conversation, prompt) + prompt = apply_pet_dialogue_guard(conversation, prompt) prompt = apply_video_platform_safety_guard(prompt) messages = [] prompt_file = append_message( @@ -1085,6 +1142,9 @@ def emit_final_confirm_gate( return None prompt = apply_product_voice_visual_guard(conversation, prompt) prompt = apply_product_reality_guard(prompt) + prompt = apply_clothing_video_guard(conversation, prompt) + prompt = apply_product_appearance_guard(conversation, prompt) + prompt = apply_pet_dialogue_guard(conversation, prompt) prompt = apply_video_platform_safety_guard(prompt) credits = 0 if context is not None: @@ -1195,8 +1255,11 @@ video_prompt 是交给出片模型的完整制作文件,不是方案摘要、 - 商品演示先过“真实用途与状态”检查:只展示商品在其已知用途和已提供事实范围内的正常、完整状态; 禁止无依据出现破损、漏液、渗水、失效、异常变形、脏污,或把商品拿去做超出用途的压力测试。 不确定防水、防漏、承重、耐热、容量、材质等关键能力时,不编造测试和结果;要么问用户,要么改用可观察的正常使用动作。 +- 服装/服饰类商品:人物从第一镜起就已经穿着当前商品,只拍上身效果、版型、面料、走动与搭配; + 不要写穿衣服、套袖、扣扣、拉拉链、脱换过程等穿戴动作镜头。换款用瞬间切换或已穿好的下一套展示。 - 画面中不出现新增字幕、花字、标题贴片、弹幕、角标、水印、购物浮层或说明性文字;口播只存在于声音,包装本身原有印刷字除外。 - 结尾单列「全片一致性与禁用项」:用正向、可执行的句子重申角色、商品、场景、光线、服装/材质的连续性。 + 有商品参考图时,商品颜色与外形必须写明「以参考商品图为准」,禁止另写冲突颜色。 - 用户说「商品说话 / 商品自述 / 商品拟人」时,默认无脸拟人:商品声音是画外角色声,商品本体不做口型、不新增卡通五官;性格只通过整体倾斜、转向、弹跳、进退、镜头和音效表达。只有用户明确要求可见卡通五官时才例外。 - 审核安全必须在第一次生成时完成,不能依赖提交前清洗。没有锁定人物素材时,人物只写「成年女性 / 成年男性 / 成年人」,不要自创精确年龄区间,不使用带幼态联想的称呼、音色或人设。服装写日常得体,默认平视、自然俯拍或尊重主体的正面构图,不强调身体局部。 - 最终 video_prompt 只使用正向安全描述。不要把平台风险类别、禁用词或用户原始高风险措辞逐项写进 Prompt,否定句、免责声明和「禁止出现某词」也不能照抄;先在内部把冲突改写成成年人之间积极、友善的日常互动,再输出改写后的可拍内容。 @@ -1232,6 +1295,45 @@ PRODUCT_REALITY_GUARD = ( "材质等能力拍成已被验证的结果。若关键信息不足,使用可观察的正常操作替代夸张测试。" ) +# 服装类视频禁止「穿衣过程」镜头:模特从第一帧就已穿着商品,只展示上身效果。 +CLOTHING_VIDEO_NO_DRESSING_GUARD = ( + "【服装类镜头约束·最高优先级】人物从第一镜起就已经穿着当前服装商品," + "只展示上身效果、版型线条、面料质感、走动垂坠与场景搭配。" + "不要出现穿衣服、往身上套、伸袖、套头、拉拉链、扣纽扣、系腰带、脱衣换装过程等穿戴动作镜头;" + "换款或换装用瞬间切换、转场或已穿好的下一套直接展示,不要拍穿衣过程。" +) + +# 已锁定商品参考图时:文字描述不得改写商品颜色/外形;与参考图冲突时以图为准。 +PRODUCT_APPEARANCE_GUARD = ( + "【商品外观锁定·最高优先级】商品颜色、材质、外形、面板、按键、把手与整体结构必须与用户上传的商品参考图完全一致。" + "禁止把参考图里的商品改成其他颜色或近似款(例如把黑色写成白色、把深色写成浅色)。" + "若前文颜色或外形描述与参考图冲突,一律以参考图为准;不确定时只写「严格按参考商品图外观」,不要猜测颜色。" +) + +PET_DIALOGUE_GUARD = ( + "【宠物拟人台词·最高优先级】片中人声必须是出镜宠物的第一人称说话,像这只宠物自己在开口:" + "短句、口语、带性格,可用贴合物种的自称;声音从宠物角色发出(可配合口型或明确的角色声)。" + "禁止旁白解说、第三人称讲述、达人口播腔、画外音旁白,或「今天给大家介绍/测评」类带货开场。" +) + +_CLOTHING_CATEGORY_HINTS = ( + "服饰", "女装", "男装", "服装", "内衣", "裤装", "裙装", "上衣", "外套", "鞋服", + "连衣裙", "半身裙", "衬衫", "卫衣", "毛衣", "夹克", "大衣", "羽绒服", "风衣", + "牛仔裤", "阔腿裤", "休闲裤", "西装", "套装", "吊带", "背心", "T恤", "t恤", + "短袖", "长袖", "针织", "打底", "睡衣", "家居服", "泳装", "泳衣", +) + +_CLOTHING_DRESSING_REWRITES: tuple[tuple[str, str], ...] = ( + (r"把(?:这款|该|那件|这件)?(?:衣服|服装|外套|上衣|裙子|裤子)?穿上", "已穿着该服装展示上身效果"), + (r"(?:开始|正在|慢慢)?穿(?:上|好)?(?:这款|该|那件|这件)?(?:衣服|服装|外套|上衣|裙子|裤子)", "已穿着该服装自然展示"), + (r"穿衣服(?:的)?(?:过程|镜头|动作)?", "已穿好服装的上身展示"), + (r"(?:套上|换上|穿进)(?:袖子|衣袖|外套|上衣|裙子|裤子|衣服)", "已穿着该服装"), + (r"(?:拉上|拉开)拉链(?:穿上|穿好)?", "已穿着该服装,拉链细节特写"), + (r"(?:扣上|解开)纽扣", "已穿着该服装,门襟与纽扣细节特写"), + (r"脱下(?:旧衣|旧的)?(?:衣服|外套|上衣)?(?:再|后)?(?:换|穿)", "瞬间切换为已穿好的下一套"), + (r"换装过程中.{0,12}(?:穿|套|脱)", "瞬间切换为已穿好的下一套"), +) + # 这一层不是替代平台审核,而是在脚本落成最终 video_prompt 前先把最容易被视频模型 # 拦截的明确高风险表述改成等价的安全叙事。模型仍会收到下方完整约束,避免只靠关键词替换。 VIDEO_PLATFORM_SAFETY_GUARD = ( @@ -1308,6 +1410,88 @@ def apply_product_reality_guard(prompt: str) -> str: return f"{base}\n{PRODUCT_REALITY_GUARD}".strip() +def _clothing_context_blob(conversation: CreationConversation | None, prompt: str = "") -> str: + """汇总商品名、品类、预设与 prompt,用于判断是否服装类视频。""" + parts = [str(prompt or "")] + if conversation is None: + return "\n".join(parts) + parts.append(str(conversation.preset or "")) + memory = conversation.memory if isinstance(conversation.memory, dict) else {} + for key in ("product_name", "product_brand", "product_brand_and_name", "selling_point", "pending_video_prompt"): + parts.append(str(memory.get(key) or "")) + for ref in locked_product_references(conversation): + if not isinstance(ref, dict): + continue + parts.append(str(ref.get("name") or "")) + parts.append(str(ref.get("category") or "")) + parts.append(str(ref.get("title") or "")) + return "\n".join(parts) + + +def is_clothing_video_context(conversation: CreationConversation | None, prompt: str = "") -> bool: + """服装/服饰类视频:含鱼眼换装预设,或商品名/品类命中服装关键词。""" + if conversation is not None and str(conversation.preset or "").strip() == "鱼眼换装": + return True + blob = _clothing_context_blob(conversation, prompt) + lower = blob.lower() + return any(hint.lower() in lower for hint in _CLOTHING_CATEGORY_HINTS) + + +def apply_clothing_video_guard( + conversation: CreationConversation | None, + prompt: str, +) -> str: + """服装类视频去掉穿衣过程镜头,改为已穿好上身展示;可重复调用。""" + base = str(prompt or "").strip() + if not base or not is_clothing_video_context(conversation, base): + return base + for pattern, replacement in _CLOTHING_DRESSING_REWRITES: + base = re.sub(pattern, replacement, base) + if CLOTHING_VIDEO_NO_DRESSING_GUARD not in base: + base = f"{base}\n{CLOTHING_VIDEO_NO_DRESSING_GUARD}".strip() + return base + + +def apply_product_appearance_guard( + conversation: CreationConversation | None, + prompt: str, +) -> str: + """已锁定商品参考图时,出图/出片指令追加外观锁定,避免文字把黑商品写成白。""" + base = str(prompt or "").strip() + if not base or conversation is None: + return base + if not has_locked_product_reference(conversation): + return base + if PRODUCT_APPEARANCE_GUARD in base: + return base + return f"{base}\n{PRODUCT_APPEARANCE_GUARD}".strip() + + +def apply_pet_dialogue_guard( + conversation: CreationConversation | None, + prompt: str, +) -> str: + """AI 宠物拟人:台词必须是宠物第一人称,禁止旁白/口播/画外音感。""" + base = str(prompt or "").strip() + if not base or conversation is None: + return base + if not is_pet_preset(conversation.preset): + return base + # 轻量改写常见旁白开场,避免最终模型照念 + rewrites = ( + (r"今天给大家介绍", "我今天发现"), + (r"给大家推荐", "我超喜欢"), + (r"旁白[::]", "宠物说:"), + (r"画外音[::]", "宠物说:"), + (r"口播[::]", "宠物说:"), + ) + for pattern, replacement in rewrites: + base = re.sub(pattern, replacement, base) + if PET_DIALOGUE_GUARD not in base: + base = f"{base}\n{PET_DIALOGUE_GUARD}".strip() + return base + + def apply_video_platform_safety_guard(prompt: str) -> str: """将最终出片指令收束为较不易触发视频模型审核的安全版本,且可重复调用。""" base = str(prompt or "").strip() @@ -1714,6 +1898,9 @@ def apply_restart_intent(conversation: CreationConversation) -> int: memory.pop("product_source_resolved", None) memory.pop("pain_point_direction_ready", None) memory.pop("pain_point_direction", None) + memory.pop("plot_twist_story_direction", None) + memory.pop("plot_twist_story_direction_detail", None) + memory.pop("plot_twist_story_direction_payload", None) memory.pop("click_swap_ready", None) memory.pop("click_swap_sequence", None) memory.pop("click_swap_mode", None) @@ -2195,7 +2382,9 @@ def tool_schemas(context: AgentContext, *, allow_plan: bool = True) -> list[dict "name": "generate_image", "description": ( "生成图片。prompt 必须是完整、可独立执行的画面描述(主体/动作/环境/光线/构图/风格)," - "不要写成对用户说的话。已 @ 引用的素材会自动作为参考图带上,不用在 prompt 里重复描述它们的外观。" + "不要写成对用户说的话。已 @ 引用的素材会自动作为参考图带上。" + "若已锁定商品参考图:商品颜色、材质、外形必须与参考图一致;看不清就写「严格按参考商品图外观」," + "禁止凭印象编造颜色(例如把黑色空气炸锅写成白色)。人物参考图同理,外貌以图为准。" ), "parameters": { "type": "object", @@ -2588,6 +2777,7 @@ def _run_generate_image(context: AgentContext, args: dict) -> tuple[dict, list]: if not prompt: raise AgentError("生成失败:模型没有给出画面描述") prompt = apply_image_preset_prompt(context.conversation.preset, prompt) + prompt = apply_product_appearance_guard(context.conversation, prompt) params = context.conversation.params or {} resolved = resolve_refs(context.team, context.conversation.pinned_refs or []) @@ -2619,12 +2809,19 @@ def _video_submit_params(context: AgentContext, prompt: str) -> tuple[dict, list 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_clothing_video_guard(context.conversation, prompt) + prompt = apply_product_appearance_guard(context.conversation, prompt) + prompt = apply_pet_dialogue_guard(context.conversation, prompt) prompt = apply_video_platform_safety_guard(prompt) prompt = apply_plot_twist_story_contract( context.conversation.preset, active_plot_twist_story_depth(context.conversation), prompt, ) + if is_plot_twist_conversation(context.conversation): + selected = plot_twist_selected_direction(context.conversation) + if selected: + prompt = apply_plot_twist_direction_contract(prompt, **selected) prompt = apply_person_identity_guard(prompt, resolved.references) duration = resolve_smart_video_duration(context.conversation, prompt=prompt) # resolve_smart_video_duration 可能为了完整脚本切到支持长时长的模型,必须重新取参数。 @@ -2871,7 +3068,8 @@ def _attach_ref_images(messages: list[dict], image_urls: list[str]) -> list[dict return messages note = ( f"【参考图·请亲眼看】下面 {len(image_urls)} 张是用户锁定的素材。" - "人物的性别、年龄段、发型、服装必须以图为准;看不清再问用户,禁止凭文件名猜测性别。" + "人物的性别、年龄段、发型、服装必须以图为准;商品的颜色、材质、外形、面板与结构也必须以图为准。" + "看不清再问用户;禁止凭文件名、品类常识或默认印象猜测性别或把商品写成别的颜色(例如把黑色写成白色)。" ) out = [dict(message) for message in messages] index = next((i for i in range(len(out) - 1, -1, -1) if out[i].get("role") == "user"), None) @@ -3025,6 +3223,8 @@ def build_system_prompt(context: AgentContext, *, allow_plan: bool = True, has_c "【出图】", "- 决定出图时必须调用 generate_image,不要只口头说「我这就出图」。", "- 一次用户消息只出一轮;张数用会话已定参数,不要自己加张。", + "- 已锁定商品参考图时:prompt 里商品颜色、材质、外形必须与参考图一致;" + "看不清就写「严格按参考商品图外观」,禁止凭印象编造颜色。", ]) if not allow_plan: lines.extend([ @@ -3065,9 +3265,16 @@ def build_system_prompt(context: AgentContext, *, allow_plan: bool = True, has_c else: lines.append(plot_twist_story_contract(depth)) memory = conversation.memory if isinstance(conversation.memory, dict) else {} - selected_direction = str(memory.get("plot_twist_story_direction") or "").strip() - if selected_direction: - lines.append(f"用户已选剧情方向:{selected_direction}。直接围绕此方向写策略,不要重发方向卡。") + selected = plot_twist_selected_direction(conversation) + if selected: + contract = format_plot_twist_direction_contract(**{ + k: selected.get(k, "") for k in ("title", "conflict", "product_role", "reversal", "tone", "detail") + }) + lines.append(contract) + lines.append( + "已选定剧情方向:策略卡的创作方向、方案时间轴和 video_prompt 必须严格按上方冲突→商品作用→反转展开;" + "禁止另起一套常见带货桥段;不要重发方向卡。" + ) else: lines.append( "【强制下一步】现在必须调用 present_story_directions,直接展示 3 张完整剧情方向卡。" @@ -3095,6 +3302,29 @@ def build_system_prompt(context: AgentContext, *, allow_plan: bool = True, has_c 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}") + if context.is_video and is_clothing_video_context(conversation): + lines.append( + "【服装类视频硬性要求】人物从第一镜起就已经穿着商品;只拍上身效果、版型、面料与搭配。" + "禁止写穿衣服、套袖、扣扣、拉拉链、脱换过程等穿戴动作镜头;换款用瞬间切换或已穿好的下一套。" + ) + if context.is_video and is_click_swap_preset(conversation.preset) and click_swap_mode(conversation) != "character": + lines.append( + "【点击换款分镜硬性要求】换款由手指点击触发即可;撰写 video_prompt / 方案分镜时," + "禁止反复写「每次点击同一位置 / 点同一点 / 手指点在完全相同的位置」;" + "各镜重点写款式变化与节奏,点击动作点到为止。" + ) + if context.is_video and is_pet_preset(conversation.preset): + lines.append( + "【宠物拟人台词硬性要求】策略、方案和 video_prompt 里的人声必须是宠物第一人称开口," + "像这只宠物自己在说话;禁止旁白、口播腔、画外音解说或第三人称讲述。" + ) + if context.is_video: + lines.append( + "【角色定妆图硬性要求】生成人物/宠物定妆参考图时,prompt 只写角色外貌、发型、服装气质与表情;" + "禁止写商品名、卖点、包装或使用动作;出图画面中不得出现任何商品、瓶罐、袋装或手持卖品。" + "用户对已出定妆图提出修改(如短发、御姐气质)时,必须真正重新提交定妆出图;" + "禁止只在聊天里写「我重新生成…prompt:…」而不调用出图。" + ) resolved = resolve_refs(context.team, conversation.pinned_refs or []) if resolved.facts: @@ -3103,7 +3333,8 @@ def build_system_prompt(context: AgentContext, *, allow_plan: bool = True, has_c lines.append( "以上素材的参考图会附给你看,出片时也会自动锁人锁物。" "人物性别、年龄段、发型、服装、商品颜色外形必须以图为准;" - "图上看不清或没附图时,必须问用户,禁止凭文件名猜测男女。" + "禁止凭品类常识或默认印象把商品写成别的颜色(例如参考图是黑色却写成白色)。" + "图上看不清或没附图时,必须问用户,禁止凭文件名猜测男女或颜色。" ) memory = conversation.memory or {} people = locked_person_references(conversation) @@ -3531,10 +3762,10 @@ def apply_click_swap_plan_card( } return { **card, - "usp": f"手指逐次点击,商品按「{sequence}」在原位连续换款", + "usp": f"手指点击触发,商品按「{sequence}」连续换款", "points": [ - "固定机位、背景、光线与商品中心位置", - "每次换款都由一次清楚的手指点击触发", + "固定机位、背景与光线,商品构图稳定", + "换款由手指点击触发,分镜不反复写同一点击位置", f"严格按「{sequence}」逐款展示,结尾给出全款式总览", ], "timeline": [ @@ -3542,19 +3773,19 @@ def apply_click_swap_plan_card( "start": 0, "end": first_end, "stage": "首款定帧", - "desc": "固定机位建立首款,商品位置、尺寸、角度和背景作为后续唯一基准。", + "desc": "固定机位建立首款,机位、背景与商品构图作为后续基准。", }, { "start": first_end, "end": second_end, "stage": "首次点击换款", - "desc": "手指清晰点击商品,接触瞬间在原位 match cut 为下一款。", + "desc": "手指点击商品触发换款,接触后以干净 match cut 切到下一款;不写点击同一位置。", }, { "start": second_end, "end": third_end, "stage": "按序连续换款", - "desc": f"按「{sequence}」继续一触一换;机位、构图、商品比例和光线完全不变。", + "desc": f"按「{sequence}」继续点击触发换款;机位、构图与光线保持稳定,不反复描写点击位置。", }, { "start": third_end, @@ -4369,6 +4600,15 @@ def _dispatch_tool( "_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 { + "payload": { + "error": ( + "剧情反转带货必须先选定剧情方向:调用 present_story_directions 展示 3 张方向卡," + "等用户选择后再写策略。不得自行编造未选中的故事桥段。" + ) + } + }, False if context.is_video and is_pain_point_conversation(context.conversation) and not memory.get("selling_point_ready"): return { "payload": { @@ -4450,12 +4690,25 @@ def _dispatch_tool( ) video_prompt = apply_product_voice_visual_guard(context.conversation, video_prompt) video_prompt = apply_product_reality_guard(video_prompt) + video_prompt = apply_clothing_video_guard(context.conversation, video_prompt) + video_prompt = apply_product_appearance_guard(context.conversation, video_prompt) + video_prompt = apply_pet_dialogue_guard(context.conversation, video_prompt) video_prompt = apply_video_platform_safety_guard(video_prompt) video_prompt = apply_plot_twist_story_contract( context.conversation.preset, active_plot_twist_story_depth(context.conversation), video_prompt, ) + if is_plot_twist_conversation(context.conversation): + selected = plot_twist_selected_direction(context.conversation) + if selected: + video_prompt = apply_plot_twist_direction_contract(video_prompt, **selected) + else: + return { + "payload": { + "error": "剧情反转带货必须先选定剧情方向,再写方案。请先 present_story_directions 让用户选择。" + } + }, False card = _coerce_plan_card_args(args if isinstance(args, dict) else {}) if not card["usp"] or not card["points"]: return { @@ -4533,12 +4786,19 @@ def _dispatch_tool( 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_clothing_video_guard(context.conversation, video_prompt) + video_prompt = apply_product_appearance_guard(context.conversation, video_prompt) + video_prompt = apply_pet_dialogue_guard(context.conversation, video_prompt) video_prompt = apply_video_platform_safety_guard(video_prompt) video_prompt = apply_plot_twist_story_contract( context.conversation.preset, active_plot_twist_story_depth(context.conversation), video_prompt, ) + if is_plot_twist_conversation(context.conversation): + selected = plot_twist_selected_direction(context.conversation) + if selected: + video_prompt = apply_plot_twist_direction_contract(video_prompt, **selected) prompt_messages = emit_prompt_gate(context.conversation, video_prompt) if not prompt_messages: return {"payload": {"error": "无法准备出片指令"}}, False @@ -4559,6 +4819,7 @@ def _dispatch_tool( if not prompt: return {"payload": {"error": "生成失败:模型没有给出画面描述"}}, False prompt = apply_image_preset_prompt(context.conversation.preset, prompt) + prompt = apply_product_appearance_guard(context.conversation, prompt) # 出图也走确认卡:用户先看当前模型/比例/张数,点了才提交。 credits = estimate_image_credits(context) confirm = append_message( diff --git a/core/backend/apps/ai/creation_presets.py b/core/backend/apps/ai/creation_presets.py index 3af97a3..ab28309 100644 --- a/core/backend/apps/ai/creation_presets.py +++ b/core/backend/apps/ai/creation_presets.py @@ -11,8 +11,9 @@ from __future__ import annotations CLICK_SWAP_PRESETS = frozenset({"点击换款", "点触换款", "多色商品换款"}) _CLICK_SWAP_FINGER_GUIDANCE = ( "形态:只出手指出镜。固定机位的商品点击换款短片。必须先确认要展示的颜色/款式/SKU 及顺序," - "画面只围绕同一件商品的逐款切换:商品始终居中、大小与角度不变,背景、灯光、机位不变。" - "每次画面中的手指点击/轻触商品后,下一款在原位立即完成干净的 match cut 切换。" + "画面只围绕同一件商品的逐款切换:机位、背景与灯光稳定,商品构图稳定。" + "用手指点击/轻触触发换款,接触后以干净 match cut 切到下一款。" + "撰写分镜时不要反复写「每次点击同一位置 / 点同一点」;点击动作点到为止,重点写款式变化与节奏。" "禁止完整人物出镜、禁止生成角色定妆图、禁止改成口播/剧情/使用教程;最后才用同一构图做全款式收束。" ) _CLICK_SWAP_CHARACTER_GUIDANCE = ( @@ -61,7 +62,8 @@ VIDEO_PRESETS: dict[str, str] = { ), "鱼眼换装": ( "鱼眼/广角近距离透视,连续换装节奏。**人物面部和身形必须全程一致**," - "只有服装在变。每次换装用一个明确动作触发。" + "只有服装在变。人物从第一镜起就已经穿着当前套装;换装用甩头、抬手、转身等触发瞬间切换到已穿好的下一套," + "禁止拍穿衣服、套袖、拉拉链等穿戴过程。" ), "点击换款": _CLICK_SWAP_GUIDANCE, "多色商品换款": _CLICK_SWAP_GUIDANCE, @@ -82,6 +84,8 @@ VIDEO_PRESETS: dict[str, str] = { "AI 宠物拟人": ( "先确定宠物的外观、性格和可执行动作,再设计一段轻巧剧情。宠物与商品互动要符合商品真实结构和正常操作方式;" "趣味来自角色反应和剧情,不把商品改造成不真实的玩具或凭空增加能力。" + "有台词时必须是宠物第一人称开口(像这只宠物在说话),口语、性格化、短句;" + "禁止旁白解说、达人口播腔、画外音旁白或第三人称讲述。" ), "人物与音色替换": ( "先理解原视频的镜头、人物、动作、台词、画外音、字幕、场景和节奏。只替换用户指定的人物、声音或两者;" @@ -182,6 +186,64 @@ def apply_plot_twist_story_contract(name: str, story_depth: str, prompt: str) -> return base return f"{base}\n\n{contract}" + +def format_plot_twist_direction_contract( + *, + title: str = "", + conflict: str = "", + product_role: str = "", + reversal: str = "", + tone: str = "", + detail: str = "", +) -> str: + """把用户选定的剧情方向写成出片/方案硬约束。""" + title = str(title or "").strip() + conflict = str(conflict or "").strip() + product_role = str(product_role or "").strip() + reversal = str(reversal or "").strip() + tone = str(tone or "").strip() + detail = str(detail or "").strip() + if not title and not detail and not any((conflict, product_role, reversal)): + return "" + lines = ["【剧情反转方向·强制执行·最高优先级】"] + if title: + lines.append(f"已选方向标题:{title}") + if conflict: + lines.append(f"开场冲突必须是:{conflict}") + if product_role: + lines.append(f"商品在剧情中的作用必须是:{product_role}") + if reversal: + lines.append(f"最终反转必须是:{reversal}") + if tone: + lines.append(f"情绪调性:{tone}") + if detail and not any((conflict, product_role, reversal)): + lines.append(f"方向细节:{detail}") + lines.append( + "策略、方案时间轴和 video_prompt 必须严格沿用上述冲突→商品作用→反转;" + "禁止改成另一套常见带货故事(例如擅自换成「出门忘了喂宠/忘记带东西」等默认桥段)," + "除非用户所选方向本身就是该桥段。" + ) + return "\n".join(lines) + + +def apply_plot_twist_direction_contract(prompt: str, *, title: str = "", conflict: str = "", product_role: str = "", reversal: str = "", tone: str = "", detail: str = "") -> str: + """确定性把已选剧情方向写入方案/出片 prompt,避免模型另起炉灶。""" + base = str(prompt or "").strip() + contract = format_plot_twist_direction_contract( + title=title, + conflict=conflict, + product_role=product_role, + reversal=reversal, + tone=tone, + detail=detail, + ) + if not base or not contract: + return base + if "【剧情反转方向·强制执行·最高优先级】" in base: + return base + return f"{base}\n\n{contract}" + + # 预设除了决定最终 Prompt,也决定 Agent 在素材整理、追问与生成前检查时的工作重点。 # 这段进入系统提示词,避免把预设退化成一句风格修饰词。 VIDEO_PRESET_WORKFLOWS: dict[str, str] = { @@ -191,14 +253,14 @@ VIDEO_PRESET_WORKFLOWS: dict[str, str] = { "商品拟人广告": "先确认商品外观和性格表达方式;默认无脸拟人,商品保持真实完整,台词用画外声。", "达人口播种草": "优先确认人物、多人出镜关系、真实体验和主卖点;生成前核对口播字数能在时长内说完,每个卖点都有画面证明。", "商品图一键成片": "优先从商品参考图锁定外观;自动补场景和动作,但不替换或改变用户商品图里的结构、颜色和包装。", - "鱼眼换装": "优先确认人物参考、服装套数和展示顺序;生成前核对脸、身形、场景稳定,只有服装随动作切换。", - "点击换款": "先让用户确认颜色/款式/SKU 和切换顺序;脚本只能是固定机位下手指逐次点击、商品原位换款,禁止转成剧情或口播。", - "多色商品换款": "先让用户确认颜色/款式/SKU 和切换顺序;脚本只能是固定机位下手指逐次点击、商品原位换款,禁止转成剧情或口播。", - "点触换款": "先让用户确认颜色/款式/SKU 和切换顺序;脚本只能是固定机位下手指逐次点击、商品原位换款,禁止转成剧情或口播。", + "鱼眼换装": "优先确认人物参考、服装套数和展示顺序;生成前核对脸、身形、场景稳定,只有服装瞬间切换;人物始终已穿好当前套,不写穿衣过程。", + "点击换款": "先让用户确认颜色/款式/SKU 和切换顺序;脚本只能是固定机位 + 手指点击触发换款;分镜不要反复写「每次点击同一位置」,禁止转成剧情或口播。", + "多色商品换款": "先让用户确认颜色/款式/SKU 和切换顺序;脚本只能是固定机位 + 手指点击触发换款;分镜不要反复写「每次点击同一位置」,禁止转成剧情或口播。", + "点触换款": "先让用户确认颜色/款式/SKU 和切换顺序;脚本只能是固定机位 + 手指点击触发换款;分镜不要反复写「每次点击同一位置」,禁止转成剧情或口播。", "探店漫游": "优先确认门店动线与主推项目;用连续移动串联入口、环境、细节和服务,不做碎片化硬切。", "品牌质感大片": "优先确认品牌气质、材质和商品主卖点;由 Agent 决定光线和镜头,不把专业选择反复抛给用户。", "前后对比实测": "优先确认同一对象的前、中、后素材或可比较条件;生成前核对对比不夸大且没有缺失关键阶段。", - "AI 宠物拟人": "优先确认宠物角色参考、性格和商品互动方式;生成前核对宠物、商品结构和使用动作前后一致。", + "AI 宠物拟人": "优先确认宠物角色参考、性格和商品互动方式;台词必须是宠物自己说的第一人称,禁止旁白/口播/画外音感;生成前核对宠物、商品结构和使用动作前后一致。", "人物与音色替换": "先确认原视频、替换对象和替换范围;未经要求不得改变原视频的构图、动作、商品、场景或节奏。", } @@ -235,26 +297,30 @@ VIDEO_PRESET_DELIVERY_CONTRACTS: dict[str, str] = { ), "鱼眼换装": ( "【预设执行层·鱼眼换装】使用近距离鱼眼/广角透视和稳定的同一机位;人物脸、身形、发型、场景、光线连续一致。" - "每一套服装由一个清晰的身体动作触发切换,按用户提供顺序完整展示;镜头的变化来自动作和节奏,不额外编复杂营销剧情。" + "人物始终已穿着当前套装;每一套由甩头、抬手、转身等动作触发瞬间切换到已穿好的下一套,按用户提供顺序完整展示。" + "禁止穿衣、套袖、扣扣、拉拉链等穿戴过程镜头;镜头变化来自触发动作与节奏,不额外编复杂营销剧情。" ), "点击换款": ( "【预设执行层·点击换款·强制】这不是口播片、剧情片或普通商品展示片。" - "使用单一固定机位和同一背景,商品全程处于完全相同的中心位置、尺寸、角度与透视。" - "每个切换节点都必须清楚拍到一根手指轻触/点击商品,接触瞬间通过原位 match cut 换成下一款;" + "使用单一固定机位和同一背景,商品构图、尺寸与角度保持稳定。" + "换款由手指轻触/点击触发,接触后以干净 match cut 切到下一款;" + "分镜不要反复写「每次点击同一位置 / 点同一点」,点击动作点到为止,重点写款式变化。" "不改场景、不改机位、不运镜、不说话、不出现完整人物、不演剧情、不演示使用步骤。" "各款必须按用户确认的顺序逐一出现,不自行发明颜色或款式;结尾停在干净的全款式总览。" ), "多色商品换款": ( "【预设执行层·点击换款·强制】这不是口播片、剧情片或普通商品展示片。" - "使用单一固定机位和同一背景,商品全程处于完全相同的中心位置、尺寸、角度与透视。" - "每个切换节点都必须清楚拍到一根手指轻触/点击商品,接触瞬间通过原位 match cut 换成下一款;" + "使用单一固定机位和同一背景,商品构图、尺寸与角度保持稳定。" + "换款由手指轻触/点击触发,接触后以干净 match cut 切到下一款;" + "分镜不要反复写「每次点击同一位置 / 点同一点」,点击动作点到为止,重点写款式变化。" "不改场景、不改机位、不运镜、不说话、不出现完整人物、不演剧情、不演示使用步骤。" "各款必须按用户确认的顺序逐一出现,不自行发明颜色或款式;结尾停在干净的全款式总览。" ), "点触换款": ( "【预设执行层·点击换款·强制】这不是口播片、剧情片或普通商品展示片。" - "使用单一固定机位和同一背景,商品全程处于完全相同的中心位置、尺寸、角度与透视。" - "每个切换节点都必须清楚拍到一根手指轻触/点击商品,接触瞬间通过原位 match cut 换成下一款;" + "使用单一固定机位和同一背景,商品构图、尺寸与角度保持稳定。" + "换款由手指轻触/点击触发,接触后以干净 match cut 切到下一款;" + "分镜不要反复写「每次点击同一位置 / 点同一点」,点击动作点到为止,重点写款式变化。" "不改场景、不改机位、不运镜、不说话、不出现完整人物、不演剧情、不演示使用步骤。" "各款必须按用户确认的顺序逐一出现,不自行发明颜色或款式;结尾停在干净的全款式总览。" ), @@ -273,6 +339,8 @@ VIDEO_PRESET_DELIVERY_CONTRACTS: dict[str, str] = { "AI 宠物拟人": ( "【预设执行层·AI 宠物拟人】宠物外观、体型、毛色和性格前后一致;趣味来自拟人动作与真实反应。" "宠物和商品的互动必须符合商品真实结构与正常用法,商品不变成玩具或获得不存在的能力;剧情里至少有一个可见卖点证据。" + "人声必须是宠物角色第一人称台词(我/本汪/本喵等贴合物种的口吻),像宠物对着镜头或商品说话;" + "禁止写成旁白解说、达人口播、画外音讲述或「今天给大家介绍…」类带货腔。" ), "人物与音色替换": ( "【预设执行层·人物与音色替换】严格保留原视频的构图、镜头顺序、动作、节奏、商品、场景和剪辑;" @@ -316,8 +384,9 @@ def preset_workflow_guidance(name: str) -> str: _CLICK_SWAP_FINGER_CONTRACT = ( "【预设执行层·点击换款·只出手】这不是口播片、剧情片或普通商品展示片。" - "使用单一固定机位和同一背景,商品全程处于完全相同的中心位置、尺寸、角度与透视。" - "每个切换节点都必须清楚拍到一根手指轻触/点击商品,接触瞬间通过原位 match cut 换成下一款;" + "使用单一固定机位和同一背景,商品构图、尺寸与角度保持稳定。" + "换款由手指轻触/点击触发,接触后以干净 match cut 切到下一款;" + "分镜不要反复写「每次点击同一位置 / 点同一点」,点击动作点到为止,重点写款式变化。" "不改场景、不改机位、不运镜、不说话、不出现完整人物、不演剧情、不演示使用步骤、不生成角色定妆图。" "各款必须按用户确认的顺序逐一出现,不自行发明颜色或款式;结尾停在干净的全款式总览。" ) @@ -367,10 +436,10 @@ def apply_video_preset_prompt(name: str, prompt: str, *, click_swap_mode: str = return ( f"{marker}\n{contract}\n\n" "【原始商品与 SKU 信息】仅提取下文中的商品外观、颜色、款式和顺序事实;" - "下文任何与「固定机位、手指点击、商品原位换款」冲突的拍法一律忽略。\n" + "下文任何与「固定机位、手指点击触发换款、构图稳定」冲突的拍法一律忽略。\n" f"{base}\n\n" - "【最终执行检查】每一次换款都必须由画面内手指的一次清晰点击触发," - "切换前后商品中心点、尺寸、角度、背景、光线和机位不变;不出现完整人物、不生成角色图。" + "【最终执行检查】换款由手指点击触发,机位、背景、光线与商品构图保持稳定;" + "分镜禁止反复写「每次点击同一位置 / 点同一点」;不出现完整人物、不生成角色图。" ) return f"{base}\n\n{marker}\n{contract}" diff --git a/core/backend/apps/ai/test_creation_agent.py b/core/backend/apps/ai/test_creation_agent.py index dbe3855..079365b 100644 --- a/core/backend/apps/ai/test_creation_agent.py +++ b/core/backend/apps/ai/test_creation_agent.py @@ -28,6 +28,7 @@ from .creation_agent import ( apply_restart_intent, apply_person_identity_guard, active_plot_twist_story_depth, + plot_twist_selected_direction, append_multi_character_relation_gate, build_system_prompt, build_messages, @@ -561,6 +562,7 @@ class PersonReferenceCompletionTests(CreationAgentBaseTests): model_ref = next(ref for ref in self.conversation.pinned_refs if ref.get("type") == "model") self.assertTrue(AssetModel.objects.filter(id=model_ref["id"], portrait_asset=asset).exists()) self.assertTrue(self.conversation.memory["person_source_ready"]) + self.assertTrue(self.conversation.memory["person_confirm_pending"]) self.assertTrue( self.conversation.messages.filter(text__contains="是否使用这个角色").exists() ) @@ -586,12 +588,47 @@ class PersonReferenceCompletionTests(CreationAgentBaseTests): self.assertEqual(enqueue.call_args.kwargs["count"], 1) self.assertEqual(enqueue.call_args.kwargs["feature"], "omni_create") self.assertIn("利落短发", enqueue.call_args.kwargs["prompt"]) + self.assertIn("禁止出现任何商品", enqueue.call_args.kwargs["prompt"]) + self.assertNotIn("参考用户需求", enqueue.call_args.kwargs["prompt"]) self.conversation.refresh_from_db() self.assertEqual( self.conversation.memory["person_prompt"], "25 岁左右女性,利落短发,干练通勤风", ) + def test_person_reference_prompt_excludes_product_brief(self): + """定妆图 prompt 不得把近期带货/商品对话拼进去,避免角色图里出现商品。""" + from .creation import append_message + + append_message( + self.conversation, + role="user", + kind=CreationMessage.Kind.TEXT, + text="帮我卖黑色空气炸锅,强调大容量和易清洗", + ) + task = AITask.objects.create( + team=self.team, + created_by=self.user, + task_type=AITask.Type.PERSON_IMAGE, + model_config=self.model, + idempotency_key="k-person-no-product", + ) + with patch("apps.ai.services.enqueue_standalone_images", return_value=[task]) as enqueue: + submit_generated_person_reference( + conversation=self.conversation, + user=self.user, + appearance_prompt="25 岁亚洲女性,黑色短发,米色针织衫", + ) + + prompt = enqueue.call_args.kwargs["prompt"] + self.assertIn("黑色短发", prompt) + self.assertIn("禁止出现任何商品", prompt) + self.assertNotIn("空气炸锅", prompt) + self.assertNotIn("大容量", prompt) + self.assertNotIn("参考用户需求", prompt) + self.assertIsNone(enqueue.call_args.kwargs.get("product_id")) + self.assertFalse(enqueue.call_args.kwargs.get("reference_product")) + class SendEndpointTests(TestCase): def setUp(self): @@ -608,6 +645,100 @@ class SendEndpointTests(TestCase): response = self.client.post(f"/api/ai/creations/{self.conversation.id}/send/", {}, format="json") self.assertEqual(response.status_code, 400) + def test_product_info_brand_answer_is_not_rejected_as_empty(self): + """第一次填品牌品名会清空 text 走 continuation,不能误报消息不能为空。""" + from .creation_agent import append_product_info_gate + from unittest.mock import patch + + append_product_info_gate(self.conversation) + with patch("apps.ai.views.begin_agent_planning", return_value=True), patch( + "apps.ai.views.team_agent_busy", return_value=False + ), patch("apps.ai.tasks.run_creation_agent_turn_task") as task: + task.apply_async = lambda *a, **k: None + task.delay = lambda *a, **k: None + response = self.client.post( + f"/api/ai/creations/{self.conversation.id}/send/", + {"kind": "text", "text": "品牌是美的,品名空气炸锅K60"}, + format="json", + ) + self.assertNotEqual(response.status_code, 400, response.content) + self.assertIn(response.status_code, (200, 202)) + self.conversation.refresh_from_db() + memory = self.conversation.memory or {} + self.assertTrue(memory.get("product_info_resolved")) + self.assertIn("美的", str(memory.get("product_brand_and_name") or "")) + + def test_person_appearance_revision_regenerates_while_confirm_pending(self): + """定妆确认阶段输入「短发,御姐」必须真正重出图,不能只回假 prompt 文案。""" + from unittest.mock import patch + + self.conversation.mode = CreationConversation.Mode.VIDEO + self.conversation.memory = { + "person_source": "platform_generate", + "person_source_ready": True, + "person_confirm_pending": True, + "person_prompt": "长发女性,通勤风", + "person_model_id": "11111111-1111-1111-1111-111111111111", + } + self.conversation.pinned_refs = [{ + "type": "model", + "id": "11111111-1111-1111-1111-111111111111", + "name": "旧角色", + }] + self.conversation.save(update_fields=["mode", "memory", "pinned_refs", "updated_at"]) + + generating = append_message( + self.conversation, + role="assistant", + kind=CreationMessage.Kind.GENERATING, + payload={"kind": "person_reference"}, + ) + with patch("apps.ai.views.submit_generated_person_reference") as submit: + submit.return_value = generating + response = self.client.post( + f"/api/ai/creations/{self.conversation.id}/send/", + {"kind": "text", "text": "短发,御姐"}, + format="json", + ) + + self.assertEqual(response.status_code, 202, response.content) + submit.assert_called_once() + appearance = submit.call_args.kwargs["appearance_prompt"] + self.assertIn("短发", appearance) + self.assertIn("御姐", appearance) + self.assertIn("长发女性", appearance) + self.conversation.refresh_from_db() + self.assertFalse(self.conversation.memory.get("person_confirm_pending")) + self.assertNotIn( + "11111111-1111-1111-1111-111111111111", + [str(ref.get("id")) for ref in (self.conversation.pinned_refs or [])], + ) + + def test_person_confirm_clears_pending_flag(self): + from unittest.mock import patch + + self.conversation.mode = CreationConversation.Mode.VIDEO + self.conversation.memory = { + "person_source": "platform_generate", + "person_source_ready": True, + "person_confirm_pending": True, + "person_model_id": "22222222-2222-2222-2222-222222222222", + } + self.conversation.save(update_fields=["mode", "memory", "updated_at"]) + with patch("apps.ai.views.begin_agent_planning", return_value=True), patch( + "apps.ai.views.team_agent_busy", return_value=False + ), patch("apps.ai.tasks.run_creation_agent_turn_task") as task: + task.apply_async = lambda *a, **k: None + task.delay = lambda *a, **k: None + response = self.client.post( + f"/api/ai/creations/{self.conversation.id}/send/", + {"kind": "text", "text": "使用这个角色继续创作"}, + format="json", + ) + self.assertIn(response.status_code, (200, 202), response.content) + self.conversation.refresh_from_db() + self.assertFalse(self.conversation.memory.get("person_confirm_pending")) + def test_person_source_upload_is_pinned_before_agent_continues(self): self.conversation.mode = CreationConversation.Mode.VIDEO self.conversation.save(update_fields=["mode", "updated_at"]) @@ -1331,6 +1462,73 @@ class VideoPlanAndConfirmTests(CreationAgentBaseTests): self.assertEqual(len(directions), 3) self.assertTrue(all(item.get("conflict") and item.get("product_role") and item.get("reversal") for item in directions)) + def test_plot_twist_direction_is_locked_into_plan_prompt(self): + """已选剧情方向必须写进方案 video_prompt,不能漂成另一套默认带货故事。""" + from apps.ai.creation_presets import apply_plot_twist_direction_contract + from apps.ai.views import _plot_twist_direction_continuation + + self.conversation.mode = CreationConversation.Mode.VIDEO + self.conversation.preset = "剧情反转带货" + self.conversation.memory = { + "plot_twist_story_depth": "30s", + "person_source_ready": True, + "selling_point_ready": True, + "selling_point_mode": "manual", + "selling_point": "定时定量", + "stage": "strategy", + "strategy_confirmed": True, + } + self.conversation.params = {"duration": "30 秒"} + self.conversation.save(update_fields=["mode", "preset", "memory", "params", "updated_at"]) + + payload = { + "directions": [{ + "id": "misunderstanding", + "title": "误会翻盘", + "conflict": "邻居误以为主人长期不喂宠,上门质问。", + "product_role": "自动喂食器到点出粮,成为当场证据。", + "reversal": "邻居看到出粮瞬间,误会当场解除。", + "tone": "轻喜剧", + }] + } + _plot_twist_direction_continuation(self.conversation, payload, "misunderstanding") + self.conversation.refresh_from_db() + selected = plot_twist_selected_direction(self.conversation) + self.assertEqual(selected["title"], "误会翻盘") + self.assertIn("邻居误以为", selected["conflict"]) + + system = build_system_prompt( + AgentContext(conversation=self.conversation, user=self.user, model_config=self.model) + ) + self.assertIn("【剧情反转方向·强制执行·最高优先级】", system) + self.assertIn("邻居误以为", system) + + drifted = ( + "0-5秒男主高铁站发现忘了喂猫;5-12秒想象猫挨饿;" + "12-22秒回家发现喂食器已自动出粮;22-30秒抚摸猫咪收束。" + ) + fake = FakeProvider([ + _tool_chunks("write_plan", self._plan_args(video_prompt=drifted)), + _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, + )) + plan = next( + event["message"] for event in events + if event.get("type") == "message" and event["message"]["kind"] == "plan" + ) + prompt = str((plan.get("payload") or {}).get("video_prompt") or "") + self.assertIn("【剧情反转方向·强制执行·最高优先级】", prompt) + self.assertIn("邻居误以为", prompt) + self.assertIn("误会当场解除", prompt) + # 即使模型写了漂移故事,平台也会把已选方向硬约束追加进去 + self.assertIn("禁止改成另一套常见带货故事", prompt) + def test_pain_point_list_becomes_clickable_and_choice_is_the_selling_point(self): product = Product.objects.create(team=self.team, created_by=self.user, title="舒缓面霜") self.conversation.mode = CreationConversation.Mode.VIDEO @@ -1914,9 +2112,10 @@ class VideoPlanAndConfirmTests(CreationAgentBaseTests): self.assertIsNotNone(message) self.assertIn("【视频预设】多色商品换款", prompt) self.assertIn("【预设执行层·点击换款·强制】", prompt) - self.assertIn("手指轻触/点击商品", prompt) + self.assertIn("手指轻触/点击", prompt) self.assertIn("不演剧情", prompt) - self.assertIn("原位 match cut", prompt) + self.assertIn("干净 match cut", prompt) + self.assertIn("不要反复写「每次点击同一位置", prompt) def test_personified_product_uses_offscreen_voice_without_changing_packaging(self): self.conversation.preset = "商品拟人广告" @@ -2300,7 +2499,8 @@ class PresetGuidanceTests(CreationAgentBaseTests): self.assertIn("当前预设的工作重点", system) self.assertIn("确认颜色/款式/SKU 和切换顺序", system) - self.assertIn("固定机位下手指逐次点击", system) + self.assertIn("固定机位 + 手指点击触发换款", system) + self.assertIn("不要反复写「每次点击同一位置」", system) self.assertIn("禁止转成剧情或口播", system) def test_video_preset_is_injected_into_the_actual_generation_prompt(self): @@ -2325,7 +2525,8 @@ class PresetGuidanceTests(CreationAgentBaseTests): self.assertIn("【预设执行层·点击换款·强制】", prompt) self.assertIn("原始商品与 SKU 信息", prompt) self.assertIn("冲突的拍法一律忽略", prompt) - self.assertIn("每一次换款都必须由画面内手指的一次清晰点击触发", prompt) + self.assertIn("换款由手指点击触发", prompt) + self.assertIn("分镜禁止反复写「每次点击同一位置", prompt) self.assertEqual(apply_video_preset_prompt("点击换款", prompt), prompt) def test_every_video_preset_has_a_delivery_contract(self): @@ -2343,6 +2544,80 @@ class PresetGuidanceTests(CreationAgentBaseTests): # 重复走闸门不会把同一条约束堆进 Prompt 两遍。 self.assertEqual(apply_product_reality_guard(prompt), prompt) + def test_pet_dialogue_guard_requires_first_person_pet_voice(self): + from .creation_agent import apply_pet_dialogue_guard, PET_DIALOGUE_GUARD + + conversation = CreationConversation.objects.create( + team=self.team, created_by=self.user, mode="video", preset="AI 宠物拟人", params={}, + ) + prompt = apply_pet_dialogue_guard( + conversation, + "旁白:今天给大家介绍这款猫粮,超好吃。", + ) + self.assertIn("宠物拟人台词", prompt) + self.assertNotIn("今天给大家介绍", prompt) + self.assertIn("我今天发现", prompt) + self.assertEqual(apply_pet_dialogue_guard(conversation, prompt), prompt) + + other = CreationConversation.objects.create( + team=self.team, created_by=self.user, mode="video", preset="达人口播种草", params={}, + ) + self.assertNotIn( + PET_DIALOGUE_GUARD, + apply_pet_dialogue_guard(other, "今天给大家介绍这款猫粮"), + ) + + def test_product_appearance_guard_locks_color_to_reference(self): + from .creation_agent import apply_product_appearance_guard, PRODUCT_APPEARANCE_GUARD + + conversation = CreationConversation.objects.create( + team=self.team, created_by=self.user, mode="image", params={}, + pinned_refs=[{"type": "asset", "id": "1", "name": "空气炸锅", "category": "product"}], + ) + prompt = apply_product_appearance_guard( + conversation, + "厨房里放着一台白色空气炸锅,柔光棚拍。", + ) + self.assertIn("商品外观锁定", prompt) + self.assertIn("以参考图为准", prompt) + self.assertEqual(apply_product_appearance_guard(conversation, prompt), prompt) + + bare = CreationConversation.objects.create( + team=self.team, created_by=self.user, mode="image", params={}, + pinned_refs=[], + ) + self.assertNotIn( + PRODUCT_APPEARANCE_GUARD, + apply_product_appearance_guard(bare, "厨房里放着一台白色空气炸锅"), + ) + + def test_clothing_video_guard_blocks_dressing_shots(self): + from .creation_agent import apply_clothing_video_guard + + conversation = CreationConversation.objects.create( + team=self.team, created_by=self.user, mode="video", preset="达人口播种草", params={}, + pinned_refs=[{"type": "product", "id": "1", "name": "法式收腰连衣裙", "category": "女装"}], + ) + prompt = apply_clothing_video_guard( + conversation, + "0-3秒:女主把这件衣服穿上,再拉上拉链。", + ) + self.assertIn("服装类镜头约束", prompt) + self.assertNotIn("把这件衣服穿上", prompt) + self.assertNotIn("拉上拉链", prompt) + self.assertIn("已穿着", prompt) + self.assertEqual(apply_clothing_video_guard(conversation, prompt), prompt) + + def test_clothing_video_guard_skips_non_apparel(self): + from .creation_agent import apply_clothing_video_guard, CLOTHING_VIDEO_NO_DRESSING_GUARD + + conversation = CreationConversation.objects.create( + team=self.team, created_by=self.user, mode="video", preset="真实使用演示", params={}, + pinned_refs=[{"type": "product", "id": "1", "name": "暖岚保温杯", "category": "杯具"}], + ) + prompt = apply_clothing_video_guard(conversation, "拍一条保温杯日常使用演示") + self.assertNotIn(CLOTHING_VIDEO_NO_DRESSING_GUARD, prompt) + def test_video_platform_safety_guard_rewrites_high_risk_story_terms(self): from .creation_agent import apply_video_platform_safety_guard diff --git a/core/backend/apps/ai/views.py b/core/backend/apps/ai/views.py index 8a50629..926ecf2 100644 --- a/core/backend/apps/ai/views.py +++ b/core/backend/apps/ai/views.py @@ -258,23 +258,43 @@ def _plot_twist_direction_continuation( ) if isinstance(direction, dict): title = str(direction.get("title") or choice).strip() + conflict = str(direction.get("conflict") or "").strip() + product_role = str(direction.get("product_role") or "").strip() + reversal = str(direction.get("reversal") or "").strip() + tone = str(direction.get("tone") or "").strip() detail = ";".join( - str(direction.get(key) or "").strip() - for key in ("conflict", "product_role", "reversal", "tone") - if str(direction.get(key) or "").strip() + part for part in (conflict, product_role, reversal, tone) if part ) + payload_dir = { + "id": str(direction.get("id") or "").strip(), + "title": title, + "conflict": conflict, + "product_role": product_role, + "reversal": reversal, + "tone": tone, + } else: title = choice.strip() or "用户自定义方向" + conflict = product_role = reversal = tone = "" detail = title + payload_dir = { + "id": "", + "title": title, + "conflict": "", + "product_role": "", + "reversal": "", + "tone": "", + } memory = dict(conversation.memory or {}) memory["plot_twist_story_direction"] = title memory["plot_twist_story_direction_detail"] = detail + memory["plot_twist_story_direction_payload"] = payload_dir conversation.memory = memory conversation.save(update_fields=["memory", "updated_at"]) return ( f"用户已选择剧情方向【{title}】。方向细节:{detail}。" - "现在只调用 write_strategy 写创作策略卡,必须沿用该冲突、商品作用与反转;" - "不要再展示方向卡、不要复述选择、不要直接写方案或出片。" + "现在只调用 write_strategy 写创作策略卡,策略的创作方向与后续方案/分镜必须严格沿用该冲突、商品作用与反转," + "不得改写成另一套常见带货故事;不要再展示方向卡、不要复述选择、不要直接写方案或出片。" ) @@ -2245,6 +2265,12 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet): memory["product_info_resolved"] = True if clean_ans and not any(kw in clean_ans for kw in ("按图片", "设计品牌", "不用再问")): memory["product_brand_and_name"] = clean_ans + bm = re.search(r"品牌[::是为]?\s*([^\n,,。!!]+)", clean_ans) + nm = re.search(r"(?:品名|商品名|产品名)[::是为]?\s*([^\n,,。!!]+)", clean_ans) + if bm: + memory["product_brand"] = bm.group(1).strip() + if nm: + memory["product_name"] = nm.group(1).strip() for r in (conversation.pinned_refs or []): if isinstance(r, dict) and r.get("type") in ("asset", "product"): r["name"] = clean_ans @@ -2255,8 +2281,9 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet): continuation_instruction = "用户要求直接根据图片内容设计品牌与品名推进创作。直接基于图片外观特征推进方案,不要重复追问品牌。" conversation.memory = memory conversation.save(update_fields=["memory", "pinned_refs", "updated_at"]) - text = "" - record_user_message = False + # 保留用户原文气泡,便于回看刚填的品牌/品名;不要清空 text, + # 否则会落到「消息不能为空」且对话里看不到这次回答。 + force_creative_turn = True else: continuation_instruction = ( "用户刚用输入框回答了你上一句问题。直接基于这条真实回答继续原任务;" @@ -2265,26 +2292,78 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet): if params_changed: continuation_instruction += " 会话参数已更新,旧方案作废,按新参数重新产出。" - # 用户对刚生成的角色给出确认/重刷反馈 + # 用户对刚生成的角色给出确认/重刷/外观修改反馈 clean_text = text.strip() - if re.search(r"^(使用这[个位只]角色|就用这[个位只]角色|就用[她他它]|满意|确认使用|合适|可以)[继续创作。!! ]*$", clean_text) or clean_text in ("使用这个角色继续创作", "使用这个宠物角色继续创作"): + memory_now = dict(conversation.memory or {}) + person_confirm_pending = bool(memory_now.get("person_confirm_pending")) + is_platform_person = memory_now.get("person_source") == "platform_generate" + confirmed_person = ( + bool(re.search( + r"^(使用这[个位只]角色|就用这[个位只]角色|就用[她他它]|满意|确认使用|合适|可以)[继续创作。!! ]*$", + clean_text, + )) + or clean_text in ( + "使用这个角色继续创作", + "使用这个宠物角色继续创作", + ) + or (person_confirm_pending and clean_text in ("继续", "继续创作")) + ) + regenerate_button = bool(re.search( + r"^(重新生成|再生成|重刷|换一个|换位|不满意).{0,6}(角色|人物|模特|宠物)?[。!! ]*$", + clean_text, + )) + upload_person = bool(re.search( + r"(上传|发一张|发个).{0,8}(人物|角色|模特|宠物)|我上传", + clean_text, + )) + if confirmed_person: + if person_confirm_pending: + memory_now["person_confirm_pending"] = False + conversation.memory = memory_now + conversation.save(update_fields=["memory", "updated_at"]) force_creative_turn = True continuation_instruction = ( "用户已确认使用当前生成的角色出镜。直接基于该角色继续推进创作方案" "(若尚未选商品则确定商品,已选好商品则开始写创作策略和方案),不要再次追问角色来源。" ) - elif re.search(r"^(重新生成|再生成|重刷|换一个|换位|不满意).{0,6}(角色|人物|模特|宠物)?[。!! ]*$", clean_text) and (conversation.memory or {}).get("person_source") == "platform_generate": - person_prompt = str((conversation.memory or {}).get("person_prompt") or "").strip() + elif is_platform_person and (regenerate_button or (person_confirm_pending and clean_text and not upload_person)): + prev_prompt = str(memory_now.get("person_prompt") or "").strip() + if regenerate_button: + appearance_prompt = prev_prompt + elif prev_prompt: + appearance_prompt = f"{prev_prompt};按用户最新要求调整:{clean_text}" + else: + appearance_prompt = clean_text + # 卸掉上一张平台定妆锁定,避免新旧角色叠在 pinned_refs 里 + old_model_id = str(memory_now.get("person_model_id") or "").strip() + if old_model_id: + conversation.pinned_refs = [ + ref for ref in (conversation.pinned_refs or []) + if not ( + isinstance(ref, dict) + and ref.get("type") in {"model", "character"} + and str(ref.get("id") or "") == old_model_id + ) + ] + memory_now["person_confirm_pending"] = False + memory_now.pop("person_source_ready", None) + memory_now.pop("person_model_id", None) + conversation.memory = memory_now + conversation.save(update_fields=["memory", "pinned_refs", "updated_at"]) try: + user_msg = append_message(conversation, role="user", text=clean_text) generating = submit_generated_person_reference( conversation=conversation, user=request.user, - appearance_prompt=person_prompt, + appearance_prompt=appearance_prompt, ) return JsonResponse({ "conversation_id": str(conversation.id), "agent_status": conversation.agent_status, - "messages": [CreationMessageSerializer(generating).data], + "messages": [ + CreationMessageSerializer(user_msg).data, + CreationMessageSerializer(generating).data, + ], }, status=202) except Exception: pass @@ -2775,7 +2854,9 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet): ) if params_changed: continuation_instruction += " 会话参数已经更新,旧方案作废,按新参数重新产出方案。" - elif not text and not refs: + elif not text and not refs and not (force_creative_turn or continuation_instruction): + # 追问卡(品牌品名/点选闸门等)会把正文清空、改走 continuation; + # 这时没有 text/refs 也是合法推进,不能误报「消息不能为空」。 return JsonResponse({"detail": "消息不能为空"}, status=400) # 同一账号同时只允许一个整理方案 turn(含本会话已在 planning 的二次发送) diff --git a/core/backend/skills/ecommerce-video-script/references/category-playbook.md b/core/backend/skills/ecommerce-video-script/references/category-playbook.md index 6a5ee21..ec8b5f5 100644 --- a/core/backend/skills/ecommerce-video-script/references/category-playbook.md +++ b/core/backend/skills/ecommerce-video-script/references/category-playbook.md @@ -36,8 +36,8 @@ - **语气**:穿搭分享、身材/场景导向,强调上身效果。 - **卖点侧重**:版型显瘦/显高、面料舒适、好搭配、场合适配、尺码建议。 -- **常用露出**:上身使用中、面料细节特写、多场景/多角度展示。 -- **违规雷区**:材质成分虚标;"显瘦 20 斤"等夸张数字;绝对化用语。 +- **常用露出**:已穿着上身效果、面料细节特写、走动垂坠、多场景/多角度展示;换款用瞬间切换。 +- **违规雷区**:材质成分虚标;"显瘦 20 斤"等夸张数字;绝对化用语;**不要写穿衣服、套袖、扣扣、拉拉链等穿戴过程镜头**。 - **示例**:「这个版型是真的藏肉,梨形身材穿上腿一下显直,通勤约会都能穿。」 ## 家居日用 diff --git a/core/frontend/src/components/overlays.tsx b/core/frontend/src/components/overlays.tsx index 4b35bad..0c28094 100644 --- a/core/frontend/src/components/overlays.tsx +++ b/core/frontend/src/components/overlays.tsx @@ -1,6 +1,6 @@ import { useEffect, useState, type ReactNode } from "react"; import { createPortal } from "react-dom"; -import { CheckCircle2, Inbox, Music, Shield, Trash2, X } from "lucide-react"; +import { CheckCircle2, Download, Inbox, Music, Shield, Trash2, X } from "lucide-react"; // 抽屉 / 弹窗 / 全屏播放器必须挂到 document.body。 // 写在页面树里会被顶栏(sticky + z-index)盖住:搜索、余额、铃铛会浮在抽屉上。 @@ -55,12 +55,13 @@ export function useOverlayTransition(open: boolean, onClose?: () => void, durati // 通用媒体预览灯箱:点击图片放大 / 点击视频弹窗播放 / 点击音频弹窗试听(复用 .np-lightbox 样式) // 背景点击 / Esc / 关闭键都可关闭;点媒体本身不关闭。 -export function MediaLightbox({ open, src, kind, name, close }: { +export function MediaLightbox({ open, src, kind, name, close, onDownload }: { open: boolean; src: string; kind?: "image" | "video" | "audio"; name?: string; close: () => void; + onDownload?: () => void; }) { useBodyScrollLock(open && !!src); useEffect(() => { @@ -73,7 +74,12 @@ export function MediaLightbox({ open, src, kind, name, close }: { // 挂到 body:浮层才能盖住顶栏(搜索 / 余额 / 铃铛)和侧栏 return createPortal(