全能创作:定妆/台词/换款与剧情方向硬约束,资源可下载

修角色定妆修改不重出图、角色图混入商品、宠物旁白腔、换款同点复读、剧情反转方向漂移,并补会话资源下载与外观/空消息闸门。
This commit is contained in:
Azmat@qq.com
2026-09-20 14:52:51 +08:00
parent f2e8a5f3c1
commit fb3b2904ae
10 changed files with 1149 additions and 142 deletions
+1
View File
@@ -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
+290 -29
View File
@@ -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(
+89 -20
View File
@@ -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}"
+279 -4
View File
@@ -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
+95 -14
View File
@@ -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 的二次发送)