添加艾特角色功能

This commit is contained in:
Azmat@qq.com
2026-09-17 18:31:29 +08:00
parent 5df038c635
commit abccf4393a
18 changed files with 2769 additions and 182 deletions
+274 -7
View File
@@ -79,6 +79,24 @@ _PERSON_VISUAL_RE = re.compile(
r"(人物|角色|模特|主角|达人|主播|出镜|口播|女生|女性|男生|男性|"
r"女主|男主|年轻人|手模|手部|真人|换装|穿搭|剧情|短剧)"
)
_CAST_RELATION_RE = re.compile(
r"(共同出镜|一起出镜|一同出镜|同框|都(?:要|会|需)?出镜|全部(?:角色)?出镜|"
r"(?:一起|一同)(?:拍|演)|全员出镜|(?:三个|三位|两位|两个|大家)(?:都要|一起)|"
r"轮流出镜|分别出镜|主讲|主角|主演|辅助出镜|配角|只(?:用|留|要)|单人出镜)"
)
_CAST_RELATION_AUTO_RE = re.compile(r"(你来定|你安排|你决定|随便|都行)")
_PRODUCT_REQUIRED_PRESETS = {
"痛点解决演示",
PLOT_TWIST_PRESET,
"达人口播种草",
"点击换款",
"多色商品换款",
"点触换款",
"商品拟人广告",
"商品图一键成片",
"前后对比实测",
"AI 宠物拟人",
}
def is_plot_twist_conversation(conversation: CreationConversation) -> bool:
@@ -351,6 +369,167 @@ def has_locked_person_reference(conversation: CreationConversation) -> bool:
)
def locked_person_references(conversation: CreationConversation) -> list[dict]:
"""返回去重后的已锁定人物,顺序与用户添加顺序一致。"""
people: list[dict] = []
seen: set[tuple[str, str]] = set()
for ref in conversation.pinned_refs or []:
if not isinstance(ref, dict) or ref.get("type") not in {"model", "character"} or not ref.get("id"):
continue
key = (str(ref.get("type")), str(ref.get("id")))
if key in seen:
continue
seen.add(key)
people.append(ref)
return people
def _cast_relation_signature(conversation: CreationConversation) -> str:
return "|".join(
f"{ref.get('type')}:{ref.get('id')}"
for ref in locked_person_references(conversation)
)
def cast_relation_options(conversation: CreationConversation) -> list[dict[str, str]]:
"""把当前已锁定人物直接变成一键选择项,不再让用户重复输入名称。"""
options = [{"value": "all_together", "label": "全部共同出镜"}]
for ref in locked_person_references(conversation):
name = str(ref.get("name") or "未命名角色").split(" · ")[0].strip() or "未命名角色"
options.append({
"value": f"lead:{ref.get('type')}:{ref.get('id')}",
"label": f"{name}主讲",
})
return options
def apply_cast_relation_choice(conversation: CreationConversation, choice: str) -> str:
"""保存多角色标签选择,并返回可直接喂给 Agent 的完整人物关系。"""
raw = str(choice or "").strip()
people = locked_person_references(conversation)
signature = _cast_relation_signature(conversation)
relation = ""
if raw == "all_together":
relation = "全部已锁定角色共同出镜"
elif raw.startswith("lead:"):
selected = next(
(
ref for ref in people
if raw == f"lead:{ref.get('type')}:{ref.get('id')}"
),
None,
)
if selected is not None:
name = str(selected.get("name") or "未命名角色").split(" · ")[0].strip() or "未命名角色"
relation = f"{name}作为主讲,其余已锁定角色辅助出镜"
if not relation:
return ""
memory = dict(conversation.memory or {})
memory["cast_relation"] = relation
memory["cast_relation_ref_signature"] = signature
memory.pop("cast_relation_pending_signature", None)
conversation.memory = memory
conversation.save(update_fields=["memory", "updated_at"])
return relation
def _normalized_cast_relation(conversation: CreationConversation, user_text: str) -> str:
"""把简短回答补成不会丢角色的出镜安排。"""
answer = str(user_text or "").strip()
if _CAST_RELATION_AUTO_RE.search(answer):
return "由系统安排一位主讲,其余已锁定角色辅助出镜;全部角色都保留"
if re.search(
r"(共同出镜|一起出镜|一同出镜|同框|都(?:要|会|需)?出镜|全部(?:角色)?出镜|"
r"(?:一起|一同)(?:拍|演)|全员出镜|(?:三个|三位|两位|两个|大家)(?:都要|一起))",
answer,
):
return "全部已锁定角色共同出镜"
if not _CAST_RELATION_RE.search(answer):
# 闸门明确提示「有主讲就直接说角色名」;用户只回名字时,补全语义。
names = [str(ref.get("name") or "").strip() for ref in locked_person_references(conversation)]
aliases = {
alias
for name in names
for alias in (name, name.split(" · ")[0].strip())
if alias
}
if any(answer == alias or answer in alias or alias in answer for alias in aliases):
return f"{answer}作为主讲,其余已锁定角色辅助出镜"
return answer
def multi_character_relation_needs_clarification(
conversation: CreationConversation,
user_text: str = "",
) -> bool:
"""多人物已添加但关系不明时,先确认共同出镜还是主讲+辅助。
用人物 Ref 签名记录答案:后续新增/替换人物会重新确认,原班角色继续改稿时
不会反复追问。若当前消息本身已经写明关系,则直接采纳并放行。
"""
people = locked_person_references(conversation)
if len(people) < 2:
return False
signature = _cast_relation_signature(conversation)
memory = dict(conversation.memory or {})
if memory.get("cast_relation_ref_signature") == signature and memory.get("cast_relation"):
return False
text = str(user_text or "").strip()
if memory.get("cast_relation_pending_signature") == signature:
if not text:
return True
memory["cast_relation"] = _normalized_cast_relation(conversation, text)
memory["cast_relation_ref_signature"] = signature
memory.pop("cast_relation_pending_signature", None)
conversation.memory = memory
conversation.save(update_fields=["memory", "updated_at"])
return False
if _CAST_RELATION_RE.search(text) or _CAST_RELATION_AUTO_RE.search(text):
memory["cast_relation"] = _normalized_cast_relation(conversation, text)
memory["cast_relation_ref_signature"] = signature
memory.pop("cast_relation_pending_signature", None)
conversation.memory = memory
conversation.save(update_fields=["memory", "updated_at"])
return False
return True
def append_multi_character_relation_gate(conversation: CreationConversation) -> CreationMessage:
"""保留全部人物,只询问人物之间的出镜关系。"""
people = locked_person_references(conversation)
signature = _cast_relation_signature(conversation)
memory = dict(conversation.memory or {})
memory["cast_relation_pending_signature"] = signature
conversation.memory = memory
conversation.save(update_fields=["memory", "updated_at"])
count = len(people)
question = (
f"这次已经添加了 {count} 位角色,我都会保留。"
"直接选择共同出镜,或点一位角色作为主讲,其余角色会辅助出镜。"
)
return append_message(
conversation,
role="assistant",
kind=CreationMessage.Kind.ELICIT,
text=question,
payload={
"interaction": "chat",
"topic": "cast_relation",
"fields": [{
"key": "cast_relation",
"label": question,
"type": "single",
"required": True,
"options": cast_relation_options(conversation),
}],
"submitted": False,
"answers": {},
},
)
def video_needs_person_source(conversation: CreationConversation, user_text: str = "") -> bool:
"""需要真人/角色的视频在写策略前必须先锁定人物来源。"""
if conversation.mode != CreationConversation.Mode.VIDEO or has_locked_person_reference(conversation):
@@ -367,13 +546,44 @@ def video_needs_person_source(conversation: CreationConversation, user_text: str
return bool(_PERSON_VISUAL_RE.search("\n".join([user_text, pending_prompt, *recent])))
def has_locked_product_reference(conversation: CreationConversation) -> bool:
return any(
isinstance(ref, dict) and ref.get("type") == "product" and ref.get("id")
for ref in (conversation.pinned_refs or [])
)
def creation_needs_product_source(conversation: CreationConversation, user_text: str = "") -> bool:
"""明确的商品类预设缺少结构化商品时,先索取而不是把普通素材猜成商品。
自由创作仍由 Agent 的 ask_user 规则和无输出兜底负责自然追问,避免抢在模型
已有澄清问题之前重复弹卡;预设创作则由平台确定性保证商品前置。
"""
if has_locked_product_reference(conversation):
return False
memory = conversation.memory if isinstance(conversation.memory, dict) else {}
if memory.get("product_source_resolved"):
return False
return conversation.preset in _PRODUCT_REQUIRED_PRESETS
def append_person_source_gate(conversation: CreationConversation) -> CreationMessage:
"""可视化的人物来源闸门;三个选项分别进文件、模特库和生图流程。"""
product_name = ""
for ref in (conversation.pinned_refs or []):
if isinstance(ref, dict) and ref.get("type") == "product":
product_name = str(ref.get("name") or "").split(" · ")[0].strip()
break
prompt_text = (
f"商品已选定【{product_name}】。这条视频想由哪位角色/达人出镜?选定后,所有镜头和分段都会锁定同一位人物。"
if product_name else
"先确定这条视频的出镜人物。选定后,所有镜头和分段都会锁定同一位人物。"
)
return append_message(
conversation,
role="assistant",
kind=CreationMessage.Kind.ELICIT,
text="先确定这条视频的出镜人物。选定后,所有镜头和分段都会锁定同一位人物。",
text=prompt_text,
payload={
"interaction": "person_source_gate",
"fields": [{
@@ -737,7 +947,7 @@ video_prompt 是交给出片模型的完整制作文件,不是方案摘要、
禁止无依据出现破损、漏液、渗水、失效、异常变形、脏污,或把商品拿去做超出用途的压力测试。
不确定防水、防漏、承重、耐热、容量、材质等关键能力时,不编造测试和结果;要么问用户,要么改用可观察的正常使用动作。
- 画面中不出现新增字幕、花字、标题贴片、弹幕、角标、水印、购物浮层或说明性文字;口播只存在于声音,包装本身原有印刷字除外。
- 结尾单列「全片一致性与约束」:用正向、可执行的句子重申角色、商品、场景、光线、服装/材质的连续性。
- 结尾单列「全片一致性与禁用项」:用正向、可执行的句子重申角色、商品、场景、光线、服装/材质的连续性。
- 用户说「商品说话 / 商品自述 / 商品拟人」时,默认无脸拟人:商品声音是画外角色声,商品本体不做口型、不新增卡通五官;性格只通过整体倾斜、转向、弹跳、进退、镜头和音效表达。只有用户明确要求可见卡通五官时才例外。
- 审核安全必须在第一次生成时完成,不能依赖提交前清洗。没有锁定人物素材时,人物只写「成年女性 / 成年男性 / 成年人」,不要自创精确年龄区间,不使用带幼态联想的称呼、音色或人设。服装写日常得体,默认平视、自然俯拍或尊重主体的正面构图,不强调身体局部。
- 最终 video_prompt 只使用正向安全描述。不要把平台风险类别、禁用词或用户原始高风险措辞逐项写进 Prompt,否定句、免责声明和「禁止出现某词」也不能照抄;先在内部把冲突改写成成年人之间积极、友善的日常互动,再输出改写后的可拍内容。
@@ -1242,6 +1452,7 @@ def apply_restart_intent(conversation: CreationConversation) -> int:
memory.pop("selling_point_ready", None)
memory.pop("selling_point_mode", None)
memory.pop("selling_point", None)
memory.pop("product_source_resolved", None)
memory.pop("pain_point_direction_ready", None)
memory.pop("pain_point_direction", None)
memory.pop("click_swap_ready", None)
@@ -1950,8 +2161,8 @@ def segment_video_prompt(prompt: str, segment: dict, total_duration: int) -> str
return (
f"{prompt.strip()}\n\n"
f"【分段出片约束】这是整支 {total_duration} 秒视频的第 {index} 段,只生成 {start}{end} 秒的内容。"
"仅呈现这一时间段对应的情节与镜头。必须继续使用参考图锁定的同一位人物"
"不得在本段重新设计、随机替换或改变其五官、发型、年龄、身形和服装。"
"仅呈现这一时间段对应的情节与镜头。必须继续使用参考图锁定的原有人物阵容与身份关系"
"不得在本段重新设计、随机替换、遗漏人物或改变各自的五官、发型、年龄、身形和服装。"
"承接上一段的动作、商品、场景和光线,"
"为下一段留出自然动作衔接;不要重演完整故事,不要添加字幕、文字、角标或水印。"
)
@@ -1970,11 +2181,21 @@ def apply_person_identity_guard(prompt: str, references: list[dict]) -> str:
if not indexes:
return prompt
labels = "".join(f"参考图{index}" for index in indexes)
if len(indexes) == 1:
identity_rule = (
"整片及所有分段、远景、近景、转场都必须保持同一人;五官比例、脸型、发型、"
"肤色、年龄、身形、手部特征和基础服装不得漂移。不得换人、随机造人、合并成新面孔,"
"不得因镜头或光线变化而改变身份。"
)
else:
identity_rule = (
"这些参考图分别对应不同角色。整片及所有分段、远景、近景、转场都必须保持每位角色各自的"
"五官比例、脸型、发型、肤色、年龄、身形、手部特征和基础服装;人物不得互换、遗漏、"
"随机替换或合并成新面孔,也不得因镜头或光线变化而改变任何角色身份。"
)
return (
f"{prompt.strip()}\n\n【人物一致性硬约束】{labels}定义本片的固定出镜人物。"
"整片及所有分段、远景、近景、转场都必须保持同一人;五官比例、脸型、发型、"
"肤色、年龄、身形、手部特征和基础服装不得漂移。不得换人、随机造人、合并成新面孔,"
"不得因镜头或光线变化而改变身份。"
f"{identity_rule}"
)
@@ -2452,6 +2673,8 @@ def build_system_prompt(context: AgentContext, *, allow_plan: bool = True, has_c
"- ask_user 一次只问一件事。type=asset 会以类Agent自然追问轻量询问是否发送列表或由你推荐;其他类型显示普通聊天问题,让用户直接输入。",
"- 商品是谁、给谁看、什么调性 —— 这些缺了会直接影响成片,值得问。",
"- 让用户选商品/角色/模特/场景时,ask_user 必须用 type=asset 并填 asset_types。",
"- 用户已经添加多位角色并分别描述造型时,这些角色默认都属于本次创作,不得擅自删掉或要求用户从中三选一。"
" 如果人物关系没说清,只确认‘共同出镜’还是‘一位主讲、其余辅助’;只有用户明确说只用一位时才缩减角色。",
"- 用户说「改商品」「换角色」却没点名是哪个:立刻 ask_user 启动素材确认,不要强行出大卡片。",
"- 用户说改时长/模型/比例/分辨率但没给新值:立刻 ask_user,type=single 提供可识别的候选值;聊天里只显示问题,用户直接输入。回答后旧方案作废,必须按新参数重新 write_plan。",
"- 光线、构图、镜头这些专业判断是你的活,不要反过来问用户。",
@@ -2601,6 +2824,20 @@ def build_system_prompt(context: AgentContext, *, allow_plan: bool = True, has_c
"图上看不清或没附图时,必须问用户,禁止凭文件名猜测男女。"
)
memory = conversation.memory or {}
people = locked_person_references(conversation)
if len(people) > 1:
names = "".join(str(ref.get("name") or "未命名角色") for ref in people)
relation = str(memory.get("cast_relation") or "").strip()
keep_rule = (
"按用户明确要求缩减出镜角色,但未出镜的人物素材仍保留在会话中。"
if re.search(r"(只用|只留|只要|单人出镜)", relation)
else "这些角色默认都必须保留。"
)
lines.append(f"\n【多角色锁定】本次已添加 {len(people)} 位角色:{names}{keep_rule}")
if relation:
lines.append(f"已确认出镜安排:{relation}。后续策略、脚本和分镜必须遵守,不得再次询问同一问题。")
else:
lines.append("出镜关系尚未确认:只询问共同出镜还是主讲+辅助,不得问‘三位选哪一位’。")
if memory.get("summary"):
lines.append(f"\n【前情提要】{memory['summary']}")
artifacts = memory.get("artifacts") or []
@@ -3101,6 +3338,25 @@ def iter_creation_agent_events(
yield {"type": "done"}
return
# 商品类创作先锁定结构化商品。普通上传始终只是素材,不能靠图片内容猜成商品。
if creation_needs_product_source(conversation, text):
result, _stop = _dispatch_tool(
context,
"ask_user",
{"fields": [{
"key": "product",
"label": "这条内容要使用哪个商品?",
"type": "asset",
"required": True,
"asset_types": ["product"],
}]},
allow_pick=False,
)
for event in result.get("_events", []):
yield event
yield {"type": "done"}
return
# 点击换款的款式清单和顺序是脚本事实,不能让模型自行猜色号或把预设改成普通展示片。
if click_swap_needs_sequence(conversation):
question = append_click_swap_sequence_gate(conversation)
@@ -3122,6 +3378,17 @@ def iter_creation_agent_events(
yield {"type": "done"}
return
# 已经添加多位人物时,人物都属于本次 brief。只确认他们如何出镜,不能让
# 模型把「多角色 + 各自造型」误读成候选人列表并强迫用户三选一。
if context.is_video and multi_character_relation_needs_clarification(conversation, text):
question = append_multi_character_relation_gate(conversation)
set_video_gate_stage(conversation, "clarify")
conversation.agent_status = CreationConversation.AgentStatus.AWAITING_USER
conversation.save(update_fields=["agent_status", "updated_at"])
yield {"type": "message", "message": _message_payload(question)}
yield {"type": "done"}
return
# 明确要商品/角色/场景列表时,直接生成真实选择卡,绝不先让模型念出素材名称。
requested_card = requested_asset_card_from_context(conversation, text)
if requested_card:
+6 -3
View File
@@ -45,7 +45,9 @@ VIDEO_PRESETS: dict[str, str] = {
),
"达人口播种草": (
"真人出镜口播,生活化语气,像朋友分享而不是念广告稿。开头一句话建立停留理由,"
"中段讲清使用场景和一个核心卖点,结尾给明确行动理由。禁止万能主播腔和空卖点。"
"中段讲清使用场景和一个核心卖点,结尾给明确行动理由。"
"只有一位人物时全片锁定同一人;用户已添加多位角色时保留全部,并固定共同出镜或主讲+辅助关系。"
"禁止万能主播腔和空卖点。"
),
"商品图一键成片": (
"从商品参考图出发建立镜头语言:主图定调 → 补场景 → 补使用动作 → 收尾。"
@@ -181,7 +183,7 @@ VIDEO_PRESET_WORKFLOWS: dict[str, str] = {
"真实使用演示": "优先核对商品真实用途、关键步骤和可证实卖点;不为氛围而增加不合理测试。",
"剧情反转带货": "先让用户选故事深度(15秒快节奏反转、30秒轻剧情带货、60秒完整短剧带货或智能推荐),再给三个与时长匹配的剧情方向;商品必须成为解决问题、解除误会、证明事实、回收伏笔或完成翻盘的关键。",
"商品拟人广告": "先确认商品外观和性格表达方式;默认无脸拟人,商品保持真实完整,台词用画外声。",
"达人口播种草": "优先确认达人/人物、真实体验和主卖点;生成前核对口播字数能在时长内说完,每个卖点都有画面证明。",
"达人口播种草": "优先确认人物、多人出镜关系、真实体验和主卖点;生成前核对口播字数能在时长内说完,每个卖点都有画面证明。",
"商品图一键成片": "优先从商品参考图锁定外观;自动补场景和动作,但不替换或改变用户商品图里的结构、颜色和包装。",
"鱼眼换装": "优先确认人物参考、服装套数和展示顺序;生成前核对脸、身形、场景稳定,只有服装随动作切换。",
"点击换款": "先让用户确认颜色/款式/SKU 和切换顺序;脚本只能是固定机位下手指逐次点击、商品原位换款,禁止转成剧情或口播。",
@@ -216,7 +218,8 @@ VIDEO_PRESET_DELIVERY_CONTRACTS: dict[str, str] = {
"性格由整体运动、镜头、环境反应和画外角色声表达。每次拟人动作都要符合物理状态,趣味服务于一个真实卖点。"
),
"达人口播种草": (
"【预设执行层·达人口播种草】固定一位可信真人,在真实生活场景中自然口播。"
"【预设执行层·达人口播种草】固定已经确认的人物与出镜关系:只有一位人物时全片锁定同一位可信真人"
"已添加多位角色时保留全部角色,主讲身份与辅助角色不得在镜头间互换。真实生活场景中自然口播。"
"结构为:前 2–3 秒具体痛点或体验钩子 → 真实使用/质地/细节证明 → 一句个人感受 → 克制收束。"
"口播像朋友分享,所有卖点必须被动作或特写佐证;禁止万能主播腔、堆砌卖点、画面字幕和夸张承诺。"
),
+9 -3
View File
@@ -129,7 +129,7 @@ def _refresh_processing_free_asset(free_asset: FreeAsset) -> bool:
return False
def _guard_asset_reference(asset: Asset, label: str) -> None:
def _guard_asset_reference(asset: Asset, label: str, ref_type: str = "") -> None:
"""三库引用前的审核闸(模块4 · 4.2)。
平台生成的资产免审直接过;用户上传的必须真过一遍审核才能当生成参考
@@ -140,7 +140,13 @@ def _guard_asset_reference(asset: Asset, label: str) -> None:
"""
from apps.assets.review import poll_asset_review, reference_review_state, wait_upload_review
state = reference_review_state(asset)
# Seedance 对人物类参考图不区分「用户上传」还是「平台生成」:只要像真人,普通 URL
# 都可能触发 PrivacyInformation。人物语义或审核类资产必须先登记,再以 asset:// 引用。
require_registered = (
ref_type in {"model", "character"}
or asset.category in Asset.REVIEW_CATEGORIES
)
state = reference_review_state(asset, require_registered=require_registered)
if state == "processing":
poll_asset_review(asset)
state = reference_review_state(asset)
@@ -314,7 +320,7 @@ def build_content_items(
asset = Asset.objects.filter(id=ref["asset_id"], team=team, is_deleted=False).first()
if asset is None:
raise ValueError(f"素材「{label or '未命名'}」不存在或已被删除")
_guard_asset_reference(asset, label)
_guard_asset_reference(asset, label, ref_type)
from .services import _asset_preview_url, _seedance_ref_url
raw_url = _asset_preview_url(asset)
+6
View File
@@ -3925,6 +3925,12 @@ def run_standalone_image_task(*, task_id: str) -> None:
),
)
AssetFile.objects.create(asset=asset, object_key=stored.object_key, bucket=stored.bucket, content_type=stored.content_type, size_bytes=stored.size_bytes, is_primary=True)
# 全能创作「平台帮忙生成」的人物稍后会作为 Seedance 参考图。平台生成不等于
# 可用普通 URL 绕过真人素材校验:提前登记火山素材库,出片时才能使用 asset://。
if asset_category == Asset.Category.MODEL_PORTRAIT:
from apps.assets.review import submit_asset_for_review
transaction.on_commit(lambda a=asset: submit_asset_for_review(a))
except Exception as exc: # noqa: BLE001 — 失败要退费并把错误记进 AITask 供前端轮询读取;不向上抛(避免 celery 重试二次扣费)
task.status = AITask.Status.FAILED
task.error_message = str(exc)
+223 -1
View File
@@ -28,6 +28,7 @@ from .creation_agent import (
apply_restart_intent,
apply_person_identity_guard,
active_plot_twist_story_depth,
append_multi_character_relation_gate,
build_system_prompt,
build_messages,
default_reply_hint,
@@ -599,6 +600,62 @@ class SendEndpointTests(TestCase):
for ref in self.conversation.pinned_refs
))
@override_settings(CREATION_AGENT_INLINE=False, CREATION_AGENT_TASK_QUEUE="airshelf.local")
def test_cast_relation_label_click_saves_lead_and_continues_without_text_input(self):
product = Product.objects.create(team=self.team, created_by=self.user, title="蓝牙耳机")
characters = [
Asset.objects.create(
team=self.team,
created_by=self.user,
name=name,
asset_type=Asset.Type.IMAGE,
source=Asset.Source.UPLOAD,
category=Asset.Category.PERSON,
)
for name in ("南卡", "红发男生", "戴墨镜女生")
]
self.conversation.mode = CreationConversation.Mode.VIDEO
self.conversation.preset = "达人口播种草"
self.conversation.pinned_refs = [
{"type": "product", "id": str(product.id), "name": product.title},
*[
{"type": "character", "id": str(character.id), "name": character.name}
for character in characters
],
]
self.conversation.save(update_fields=["mode", "preset", "pinned_refs", "updated_at"])
card = append_multi_character_relation_gate(self.conversation)
lead_value = card.payload["fields"][0]["options"][1]["value"]
user_message_count = self.conversation.messages.filter(role="user").count()
with patch("apps.ai.views.begin_agent_planning", return_value=True), \
patch("apps.ai.views.run_creation_agent_turn_task") as task:
response = self.client.post(
f"/api/ai/creations/{self.conversation.id}/send/",
{
"kind": "elicit_answer",
"reply_to": str(card.id),
"answers": {"cast_relation": lead_value},
},
format="json",
)
self.assertIn(response.status_code, (200, 202))
card.refresh_from_db()
self.conversation.refresh_from_db()
self.assertTrue(card.payload["submitted"])
self.assertEqual(card.payload["answers"]["cast_relation"], lead_value)
self.assertEqual(
self.conversation.memory["cast_relation"],
"南卡作为主讲,其余已锁定角色辅助出镜",
)
self.assertNotIn("cast_relation_pending_signature", self.conversation.memory)
self.assertEqual(self.conversation.messages.filter(role="user").count(), user_message_count)
self.assertTrue(task.apply_async.called)
turn_kwargs = task.apply_async.call_args.kwargs["kwargs"]
self.assertFalse(turn_kwargs["record_user_message"])
self.assertIn("南卡作为主讲", turn_kwargs["continuation_instruction"])
def test_click_swap_sequence_answer_is_saved_before_agent_continues(self):
self.conversation.mode = CreationConversation.Mode.VIDEO
self.conversation.preset = "点击换款"
@@ -901,6 +958,46 @@ class SendEndpointTests(TestCase):
message.text for message in self.conversation.messages.filter(role="assistant")
))
@override_settings(CREATION_AGENT_INLINE=False, CREATION_AGENT_TASK_QUEUE="airshelf.local")
def test_skipping_required_product_gate_does_not_ask_for_product_again(self):
self.conversation.mode = CreationConversation.Mode.VIDEO
self.conversation.preset = "达人口播种草"
self.conversation.save(update_fields=["mode", "preset", "updated_at"])
card = append_message(
self.conversation,
role="assistant",
kind=CreationMessage.Kind.ELICIT,
payload={
"interaction": "chat",
"phase": "gate",
"fields": [{"key": "_asset_gate", "type": "single"}],
"pending_fields": [{
"key": "product",
"type": "asset",
"asset_types": ["product"],
}],
"submitted": False,
"answers": {},
},
)
with patch("apps.ai.views.begin_agent_planning", return_value=True), \
patch("apps.ai.views.run_creation_agent_turn_task") as task:
response = self.client.post(
f"/api/ai/creations/{self.conversation.id}/send/",
{
"kind": "elicit_answer",
"reply_to": str(card.id),
"answers": {"_asset_gate": "skip"},
},
format="json",
)
self.assertEqual(response.status_code, 202)
self.assertTrue(task.apply_async.called)
self.conversation.refresh_from_db()
self.assertTrue((self.conversation.memory or {}).get("product_source_resolved"))
def test_gate_emits_conversational_question_not_direct_pick_card(self):
"""用户发消息缺商品时,先出轻量对话闸门询问是否发列表,不直接出大卡片。"""
fake = FakeProvider([_tool_chunks("ask_user", {"fields": [
@@ -1022,6 +1119,16 @@ class VideoPlanAndConfirmTests(CreationAgentBaseTests):
args.update(overrides)
return args
def _pin_product(self, title="测试商品"):
product = Product.objects.create(team=self.team, created_by=self.user, title=title)
self.conversation.pinned_refs = [{
"type": "product",
"id": str(product.id),
"name": product.title,
}]
self.conversation.save(update_fields=["pinned_refs", "updated_at"])
return product
def test_plot_twist_preset_requires_story_depth_before_creative_output(self):
self.conversation.preset = "剧情反转带货"
self.conversation.params = {**self.conversation.params, "duration": "智能时长"}
@@ -1079,6 +1186,7 @@ class VideoPlanAndConfirmTests(CreationAgentBaseTests):
self.assertIn("17-24 秒商品在转折点", prompt)
def test_plot_twist_always_displays_three_direction_cards(self):
self._pin_product("剧情测试商品")
self.conversation.preset = "剧情反转带货"
self.conversation.params = {**self.conversation.params, "duration": "30 秒"}
self.conversation.memory = {"person_source_ready": True}
@@ -1189,6 +1297,7 @@ class VideoPlanAndConfirmTests(CreationAgentBaseTests):
self.assertEqual(len(fake.calls), 1)
def test_plain_strategy_prose_is_recovered_as_strategy_card(self):
self._pin_product("小熊婴儿湿巾")
self.conversation.preset = "痛点解决演示"
self.conversation.memory = {
"selling_point_ready": True,
@@ -1285,6 +1394,7 @@ class VideoPlanAndConfirmTests(CreationAgentBaseTests):
self.assertEqual(len(fake.calls), 1)
def test_click_swap_requires_sequence_before_calling_model(self):
self._pin_product("三色通勤包")
self.conversation.preset = "点击换款"
self.conversation.memory = {}
self.conversation.save(update_fields=["preset", "memory", "updated_at"])
@@ -1307,6 +1417,7 @@ class VideoPlanAndConfirmTests(CreationAgentBaseTests):
self.assertEqual(fake.calls, [])
def test_click_swap_plan_overrides_conflicting_generic_script(self):
self._pin_product("三色通勤包")
self.conversation.preset = "点击换款"
self.conversation.memory = {
"click_swap_ready": True,
@@ -1425,6 +1536,7 @@ class VideoPlanAndConfirmTests(CreationAgentBaseTests):
self.assertEqual(len(fake.calls), 1)
def test_person_video_requires_a_source_before_the_provider_runs(self):
self._pin_product("控油粉饼")
self.conversation.preset = "达人口播种草"
self.conversation.save(update_fields=["preset", "updated_at"])
fake = FakeProvider([_text_chunks("这一轮不应调用模型")])
@@ -1448,6 +1560,109 @@ class VideoPlanAndConfirmTests(CreationAgentBaseTests):
)
self.assertEqual(fake.calls, [])
def test_multiple_characters_are_kept_and_relation_is_clarified_before_model_runs(self):
product = Product.objects.create(team=self.team, created_by=self.user, title="蓝牙耳机")
characters = [
Asset.objects.create(
team=self.team,
created_by=self.user,
name=name,
asset_type=Asset.Type.IMAGE,
source=Asset.Source.UPLOAD,
category=Asset.Category.PERSON,
)
for name in ("南卡", "红发男生", "戴墨镜女生")
]
self.conversation.preset = "达人口播种草"
self.conversation.pinned_refs = [
{"type": "product", "id": str(product.id), "name": product.title},
*[
{"type": "character", "id": str(character.id), "name": character.name}
for character in characters
],
]
self.conversation.save(update_fields=["preset", "pinned_refs", "updated_at"])
fake = FakeProvider([_text_chunks("这一轮不应调用模型")])
with patch("apps.ai.creation_agent.build_provider", return_value=fake):
events = _events(stream_creation_agent(
conversation=self.conversation,
user=self.user,
text=(
"@南卡 这个角色衣服红色,@红发男生 头发红色,"
"@戴墨镜女生 戴墨镜,来用这个商品"
),
model_config=self.model,
))
card = next(
event["message"] for event in events
if event.get("type") == "message" and event["message"]["kind"] == "elicit"
)
self.assertEqual(card["payload"]["topic"], "cast_relation")
self.assertIn("3 位角色,我都会保留", card["text"])
self.assertIn("共同出镜", card["text"])
self.assertIn("点一位角色作为主讲", card["text"])
self.assertNotIn("选哪一位", card["text"])
self.assertEqual(card["payload"]["fields"][0]["type"], "single")
self.assertEqual(
[option["label"] for option in card["payload"]["fields"][0]["options"]],
["全部共同出镜", "南卡主讲", "红发男生主讲", "戴墨镜女生主讲"],
)
self.assertEqual(fake.calls, [])
follow_up = FakeProvider([_text_chunks("这个安排能做,我按三位角色继续整理。")])
with patch("apps.ai.creation_agent.build_provider", return_value=follow_up):
_events(stream_creation_agent(
conversation=self.conversation,
user=self.user,
text="南卡",
model_config=self.model,
))
self.conversation.refresh_from_db()
self.assertEqual(
(self.conversation.memory or {}).get("cast_relation"),
"南卡作为主讲,其余已锁定角色辅助出镜",
)
self.assertEqual(len(follow_up.calls), 1)
system = follow_up.calls[0]["messages"][0]["content"]
self.assertIn("这些角色默认都必须保留", system)
self.assertIn("南卡作为主讲,其余已锁定角色辅助出镜", system)
self.assertIn("不得再次询问同一问题", system)
self.assertIn("已添加多位角色时保留全部角色", system)
self.assertNotIn("固定一位可信的真人", system)
def test_commerce_preset_requests_product_and_does_not_treat_raw_asset_as_product(self):
material = Asset.objects.create(
team=self.team,
created_by=self.user,
name="未分类参考图",
asset_type=Asset.Type.IMAGE,
source=Asset.Source.UPLOAD,
)
self.conversation.preset = "达人口播种草"
self.conversation.save(update_fields=["preset", "updated_at"])
fake = FakeProvider([_text_chunks("这一轮不应调用模型")])
with patch("apps.ai.creation_agent.build_provider", return_value=fake):
events = _events(stream_creation_agent(
conversation=self.conversation,
user=self.user,
text="做一条自然的种草视频",
refs=[{"type": "asset", "id": str(material.id), "name": material.name}],
model_config=self.model,
))
card = next(
event["message"] for event in events
if event.get("type") == "message" and event["message"]["kind"] == "elicit"
)
self.assertEqual(card["payload"].get("interaction"), "chat")
self.assertEqual(card["payload"].get("phase"), "gate")
self.assertEqual(card["payload"]["pending_fields"][0]["asset_types"], ["product"])
self.assertEqual(fake.calls, [])
def test_sixty_second_segments_reuse_full_prompt_and_same_person_reference(self):
portrait = Asset.objects.create(
team=self.team,
@@ -1519,7 +1734,7 @@ class VideoPlanAndConfirmTests(CreationAgentBaseTests):
self.assertEqual(params["prompt"].count("字幕"), 1)
self.assertIn("【人物一致性硬约束】", params["prompt"])
self.assertIn("【预设执行层·达人口播种草】", params["prompt"])
self.assertIn("必须继续使用参考图锁定的同一位人物", params["prompt"])
self.assertIn("必须继续使用参考图锁定的原有人物阵容与身份关系", params["prompt"])
def test_plan_without_video_prompt_is_rejected_without_emitting_cards(self):
fake = FakeProvider([
@@ -1690,6 +1905,9 @@ class VideoParamParsingTests(TestCase):
])
self.assertIn("参考图1、参考图2", prompt)
self.assertNotIn("参考图3定义本片的固定出镜人物", prompt)
self.assertIn("分别对应不同角色", prompt)
self.assertIn("人物不得互换、遗漏", prompt)
self.assertNotIn("必须保持同一人", prompt)
def test_model_label_maps_to_volcano_name(self):
self.assertEqual(video_model_name({"model": "Seedance 2.0 Fast"}), "doubao-seedance-2-0-fast-260128")
@@ -1916,8 +2134,10 @@ class PresetGuidanceTests(CreationAgentBaseTests):
self.assertIn("人物面部和身形必须全程一致", system)
def test_product_personification_defaults_to_faceless_expression(self):
product = Product.objects.create(team=self.team, created_by=self.user, title="拟人测试商品")
conversation = CreationConversation.objects.create(
team=self.team, created_by=self.user, mode="video", preset="商品拟人广告", params={},
pinned_refs=[{"type": "product", "id": str(product.id), "name": product.title}],
)
fake = FakeProvider([_text_chunks("开始设计")])
with patch("apps.ai.creation_agent.build_provider", return_value=fake):
@@ -2222,6 +2442,7 @@ class RestartSendEndpointTests(TestCase):
"stage": "done",
"strategy_confirmed": True,
"pending_video_prompt": "旧出片指令",
"product_source_resolved": True,
},
)
self.client = APIClient()
@@ -2279,6 +2500,7 @@ class RestartSendEndpointTests(TestCase):
self.assertEqual(get_video_gate_stage(self.conversation), "clarify")
self.assertFalse((self.conversation.memory or {}).get("strategy_confirmed"))
self.assertNotIn("pending_video_prompt", self.conversation.memory or {})
self.assertNotIn("product_source_resolved", self.conversation.memory or {})
self.assertTrue(turn_kwargs["force_creative_turn"])
self.assertIn("重新来", turn_kwargs["continuation_instruction"])
self.assertIn("模特库", turn_kwargs["continuation_instruction"])
@@ -50,6 +50,17 @@ class ReferenceReviewStateTests(TestCase):
asset = _asset(self.team, source=Asset.Source.AI_GENERATED)
self.assertEqual(reference_review_state(asset), "allowed")
def test_platform_generated_person_must_be_registered_for_seedance(self):
asset = _asset(
self.team,
source=Asset.Source.AI_GENERATED,
category=Asset.Category.MODEL_PORTRAIT,
)
self.assertEqual(
reference_review_state(asset, require_registered=True),
"unsubmitted",
)
def test_upload_needs_active(self):
self.assertEqual(reference_review_state(_asset(self.team, source=Asset.Source.UPLOAD)), "unsubmitted")
self.assertEqual(
@@ -114,6 +125,28 @@ class AssetReferenceBuildTests(TestCase):
built = self._build(asset)
self.assertEqual(built["content_items"][0]["image_url"]["url"], "asset://asset-abc")
def test_platform_generated_person_is_registered_before_video(self):
asset = _asset(
self.team,
source=Asset.Source.AI_GENERATED,
category=Asset.Category.MODEL_PORTRAIT,
)
def activate(target, **_kwargs):
target.review_status = "active"
target.review_remote_id = "asset-platform-person"
target.save(update_fields=["review_status", "review_remote_id"])
return "active"
with patch("apps.assets.review.wait_upload_review", side_effect=activate) as wait:
built = self._build(asset)
wait.assert_called_once()
self.assertEqual(
built["content_items"][0]["image_url"]["url"],
"asset://asset-platform-person",
)
def test_unreviewed_upload_waits_and_allows_when_active(self):
asset = _asset(self.team, source=Asset.Source.UPLOAD)
with patch("apps.assets.review.wait_upload_review", return_value="active") as wait:
@@ -242,6 +242,28 @@ class StandaloneSingleImageRoutingTests(TestCase):
self.assertEqual(self.ledger_count(task, CreditLedger.Type.RESERVE), 1)
self.assertEqual(self.ledger_count(task, CreditLedger.Type.CHARGE), 1)
def test_generated_model_portrait_is_submitted_for_video_review(self):
primary = self.model(self.provider("portrait-primary", 100), "portrait-model")
task = enqueue_standalone_images(
team=self.team,
user=self.user,
prompt="生成一位成年电商模特定妆参考",
mode="model",
count=1,
ratio="portrait",
image_model=f"{primary.provider.name}:{primary.name}",
feature="omni_create",
dispatch=False,
)[0]
with patch("apps.assets.review.submit_asset_for_review") as submit_review:
with self.captureOnCommitCallbacks(execute=True):
run_standalone_image_task(task_id=str(task.id))
asset = Asset.objects.get(origin_task=task)
self.assertEqual(asset.category, Asset.Category.MODEL_PORTRAIT)
submit_review.assert_called_once_with(asset)
def test_primary_retry_then_dynamic_candidate_success_has_one_charge(self):
primary = self.model(self.provider("single-fallback-primary", 100), "primary", base_cost="0.25")
candidate = self.model(self.provider("volcano", 10), "candidate", outbound=False, base_cost="0.75")
+51 -7
View File
@@ -38,6 +38,7 @@ from .creation import (
from .creation_agent import (
_ASSET_CARD_LABELS,
_RESTART_CONTINUATION,
apply_cast_relation_choice,
apply_pain_point_direction,
apply_confirm_params,
apply_restart_intent,
@@ -288,6 +289,14 @@ def _store_click_swap_sequence(conversation: CreationConversation, value: str) -
)
def _mark_product_source_resolved(conversation: CreationConversation) -> None:
"""用户选择跳过、自动推荐或直接描述商品后,不重复弹同一个商品闸门。"""
memory = dict(conversation.memory or {})
memory["product_source_resolved"] = True
conversation.memory = memory
conversation.save(update_fields=["memory", "updated_at"])
_STEP_CONTINUE_INSTRUCTIONS = {
"strategy": (
"用户已确认创作策略。现在只调用 write_plan 写方案卡(含完整 video_prompt 存档);"
@@ -1928,6 +1937,7 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
pending_fields = [item for item in (payload.get("pending_fields") or []) if isinstance(item, dict)]
primary_field = pending_fields[0] if pending_fields else {}
asset_types = [t for t in (primary_field.get("asset_types") or []) if t in TYPE_LABELS] or ["product"]
is_product_gate = "product" in asset_types
# 1. 用户明确在打字说「发列表/发给我/发卡片/打开列表」
if _WANTS_CARD_RE.search(text):
@@ -1981,6 +1991,8 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
payload["answered_via"] = "chat"
pending.payload = payload
pending.save(update_fields=["payload", "updated_at"])
if is_product_gate:
_mark_product_source_resolved(conversation)
force_creative_turn = True
if hits:
@@ -1999,6 +2011,8 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
payload["answered_via"] = "chat"
pending.payload = payload
pending.save(update_fields=["payload", "updated_at"])
if is_product_gate:
_mark_product_source_resolved(conversation)
force_creative_turn = True
continuation_instruction = (
@@ -2037,6 +2051,8 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
payload["answered_via"] = "chat"
pending.payload = payload
pending.save(update_fields=["payload", "updated_at"])
if is_product_gate:
_mark_product_source_resolved(conversation)
force_creative_turn = True
else:
@@ -2212,6 +2228,17 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
sequence = str(answers.get("sku_sequence") or "").strip()
if not sequence:
return JsonResponse({"detail": "请填写要展示的款式和切换顺序"}, status=400)
if payload.get("topic") == "cast_relation":
choice = str(answers.get("cast_relation") or "").strip()
valid_choices = {
str(option.get("value") or "")
for field in (payload.get("fields") or [])
if isinstance(field, dict) and field.get("key") == "cast_relation"
for option in (field.get("options") or [])
if isinstance(option, dict)
}
if not choice or choice not in valid_choices:
return JsonResponse({"detail": "请选择共同出镜或一位主讲角色"}, status=400)
payload["answers"] = answers
payload["submitted"] = True
card.payload = payload
@@ -2278,6 +2305,20 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
record_user_message = False
force_creative_turn = True
continuation_instruction = _store_click_swap_sequence(conversation, sequence)
elif payload.get("topic") == "cast_relation":
relation = apply_cast_relation_choice(
conversation,
str(answers.get("cast_relation") or ""),
)
if not relation:
return JsonResponse({"detail": "这个角色选项已经失效,请重新选择"}, status=400)
text = ""
record_user_message = False
force_creative_turn = True
continuation_instruction = (
f"商家已确认出镜安排:【{relation}】。保留全部已锁定角色,直接继续原任务;"
"后续策略、脚本、分镜和视频分段都严格保持该人物关系,不要再次追问。"
)
elif payload.get("interaction") == "person_source_gate":
source = str(answers.get("person_source") or "").strip()
if source == "platform_generate":
@@ -2322,10 +2363,12 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
)
elif payload.get("phase") == "gate":
choice = str(answers.get("_asset_gate") or "").strip()
pending = payload.get("pending_fields") or []
primary_field = pending[0] if pending else {}
gate_types = [item for item in (primary_field.get("asset_types") or []) if item in TYPE_LABELS] or ["product"]
is_product_gate = "product" in gate_types
if choice == "send":
pending = payload.get("pending_fields") or []
primary_field = pending[0] if pending else {}
types = [t for t in (primary_field.get("asset_types") or []) if t in TYPE_LABELS] or ["product"]
types = gate_types
pick_field = dict(primary_field)
pick_field["label"] = _ASSET_CARD_LABELS.get(
types[0], _ASSET_CARD_LABELS.get("asset", "请选择素材")
@@ -2352,10 +2395,7 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
"messages": [CreationMessageSerializer(pick).data],
}, status=200)
elif choice == "auto":
pending = payload.get("pending_fields") or []
primary_field = pending[0] if pending else {}
types = [item for item in (primary_field.get("asset_types") or []) if item in TYPE_LABELS] or ["product"]
hits = search_mentions(conversation.team, q="", types=types, limit=1)
hits = search_mentions(conversation.team, q="", types=gate_types, limit=1)
if hits:
refs = list(refs)
refs.append(hits[0])
@@ -2369,12 +2409,16 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
text = ""
record_user_message = False
force_creative_turn = True
if is_product_gate:
_mark_product_source_resolved(conversation)
else:
# 卡片本身已经记录了用户的选择。不要再伪造一条黑色用户气泡;
# 继续原任务,并明确告诉模型不要再次追问同一项素材。
text = ""
record_user_message = False
force_creative_turn = True
if is_product_gate:
_mark_product_source_resolved(conversation)
continuation_instruction = (
"用户刚选择暂不添加这项素材。接受这个选择,按会话里已有的需求和合理默认继续原任务。"
"先用一句自然的话承接,随后直接推进创作;不要再次追问同一素材,不要只说收到或有需要再说。"