修改全能创作
This commit is contained in:
@@ -27,7 +27,18 @@ from django.core.serializers.json import DjangoJSONEncoder
|
||||
from django.db import transaction
|
||||
|
||||
from .creation import append_message, pin_refs
|
||||
from .creation_presets import apply_image_preset_prompt, preset_guidance, preset_workflow_guidance
|
||||
from .creation_presets import (
|
||||
PLOT_TWIST_PRESET,
|
||||
PLOT_TWIST_STORY_DEPTH_OPTIONS,
|
||||
apply_image_preset_prompt,
|
||||
apply_plot_twist_story_contract,
|
||||
apply_video_preset_prompt,
|
||||
plot_twist_story_depth,
|
||||
plot_twist_story_contract,
|
||||
preset_guidance,
|
||||
preset_workflow_guidance,
|
||||
video_preset_delivery_contract,
|
||||
)
|
||||
from .mentions import TYPE_LABELS, infer_field_types, resolve_refs, search_mentions
|
||||
from .models import CreationConversation, CreationMessage, ModelConfig
|
||||
from .services import build_provider, get_default_model, get_seed_text_model, resolve_text_model
|
||||
@@ -41,8 +52,7 @@ MAX_TOOL_ROUNDS = 8
|
||||
MAX_BILLED_GENERATIONS = 1
|
||||
|
||||
# 视频闸门阶段(落在 conversation.memory.stage;resume 靠它)
|
||||
# prompt 仅兼容历史会话。新视频链路在方案确认后直接转入出片确认,Prompt 始终由平台在后台维护。
|
||||
# clarify → strategy → plan → confirm → done
|
||||
# clarify → strategy → plan → prompt → confirm → done
|
||||
VIDEO_GATE_STAGES = ("clarify", "strategy", "plan", "prompt", "confirm", "done")
|
||||
_STEP_CONFIRM_LABELS = {
|
||||
"strategy": "创作策略已写好。确认后继续写方案;要改就点「我想改」或直接说改哪里。",
|
||||
@@ -51,6 +61,146 @@ _STEP_CONFIRM_LABELS = {
|
||||
}
|
||||
|
||||
|
||||
def is_plot_twist_conversation(conversation: CreationConversation) -> bool:
|
||||
return conversation.mode == CreationConversation.Mode.VIDEO and conversation.preset == PLOT_TWIST_PRESET
|
||||
|
||||
|
||||
def active_plot_twist_story_depth(conversation: CreationConversation) -> str:
|
||||
"""时长是最终事实来源;用户中途改时长后,故事结构必须随之切换。"""
|
||||
from_duration = plot_twist_story_depth(str((conversation.params or {}).get("duration") or ""))
|
||||
if from_duration and from_duration["value"] != "smart":
|
||||
return str(from_duration["value"])
|
||||
memory = conversation.memory if isinstance(conversation.memory, dict) else {}
|
||||
return str(memory.get("plot_twist_story_depth") or "").strip()
|
||||
|
||||
|
||||
def set_plot_twist_story_depth(conversation: CreationConversation, value: str) -> dict | None:
|
||||
"""卡片或自然语言选时长后,同步会话顶部参数与最终 Prompt 的故事结构。"""
|
||||
if not is_plot_twist_conversation(conversation):
|
||||
return None
|
||||
depth = plot_twist_story_depth(value)
|
||||
if depth is None:
|
||||
return None
|
||||
memory = dict(conversation.memory or {})
|
||||
params = dict(conversation.params or {})
|
||||
memory["plot_twist_story_depth"] = depth["value"]
|
||||
if depth["duration"]:
|
||||
params["duration"] = depth["duration"]
|
||||
conversation.memory = memory
|
||||
conversation.params = params
|
||||
conversation.save(update_fields=["memory", "params", "updated_at"])
|
||||
return depth
|
||||
|
||||
|
||||
def append_plot_twist_story_depth_question(conversation: CreationConversation) -> CreationMessage:
|
||||
return append_message(
|
||||
conversation,
|
||||
role="assistant",
|
||||
kind=CreationMessage.Kind.ELICIT,
|
||||
text="你希望这支剧情带货视频做到什么程度?",
|
||||
payload={
|
||||
"interaction": "plot_twist_story_depth",
|
||||
"fields": [
|
||||
{
|
||||
"key": "story_depth",
|
||||
"label": "故事深度选择",
|
||||
"type": "single",
|
||||
"required": True,
|
||||
"options": [
|
||||
{"value": item["value"], "label": f"{item['label']}|{item['summary']}"}
|
||||
for item in PLOT_TWIST_STORY_DEPTH_OPTIONS
|
||||
],
|
||||
}
|
||||
],
|
||||
"submitted": False,
|
||||
"answers": {},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _plot_twist_direction_fallback(conversation: CreationConversation) -> list[dict]:
|
||||
"""模型遗漏方向卡工具时的平台兜底,不能只留下「我准备了三个方向」的空话。"""
|
||||
refs = [item for item in (conversation.pinned_refs or []) if isinstance(item, dict)]
|
||||
product = next((str(item.get("name") or "").strip() for item in refs if item.get("type") == "product"), "这款商品")
|
||||
return [
|
||||
{
|
||||
"id": "misunderstanding",
|
||||
"title": "误会翻盘",
|
||||
"conflict": "主角把眼前的麻烦误判成无解,情绪不断升级。",
|
||||
"product_role": f"{product} 作为关键证据或解决工具,在最需要时自然出现。",
|
||||
"reversal": "原来问题的答案一直在眼前,误会被当场化解。",
|
||||
"tone": "轻喜剧、反差感强",
|
||||
},
|
||||
{
|
||||
"id": "last_chance",
|
||||
"title": "最后一次机会",
|
||||
"conflict": "主角已经试过常规办法仍然失败,只剩最后一次选择。",
|
||||
"product_role": f"{product} 承担最后一次可验证的尝试,完整展示正常使用过程。",
|
||||
"reversal": "看似失败的局面被扭转,结果恰好印证核心卖点。",
|
||||
"tone": "紧张后释然、转化更强",
|
||||
},
|
||||
{
|
||||
"id": "foreshadowing",
|
||||
"title": "伏笔回收",
|
||||
"conflict": "开头埋下一个不起眼的细节,人物关系或目标因此受阻。",
|
||||
"product_role": f"{product} 先作为日常细节出现,后半段成为推动结局的关键线索。",
|
||||
"reversal": "前面的细节被回收,观众才发现结局早有铺垫。",
|
||||
"tone": "温暖小反转、故事感更强",
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
def _coerce_plot_twist_directions(raw) -> list[dict]:
|
||||
"""把模型给的三条方向收成可直接渲染的卡片字段。"""
|
||||
if not isinstance(raw, list):
|
||||
return []
|
||||
directions: list[dict] = []
|
||||
for index, item in enumerate(raw[:3], start=1):
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
title = str(item.get("title") or item.get("name") or "").strip()
|
||||
conflict = str(item.get("conflict") or item.get("setup") or "").strip()
|
||||
product_role = str(item.get("product_role") or item.get("product") or "").strip()
|
||||
reversal = str(item.get("reversal") or item.get("twist") or "").strip()
|
||||
tone = str(item.get("tone") or item.get("feel") or "").strip()
|
||||
if not all((title, conflict, product_role, reversal)):
|
||||
continue
|
||||
directions.append({
|
||||
"id": str(item.get("id") or f"direction_{index}").strip() or f"direction_{index}",
|
||||
"title": title,
|
||||
"conflict": conflict,
|
||||
"product_role": product_role,
|
||||
"reversal": reversal,
|
||||
"tone": tone or "剧情带货",
|
||||
})
|
||||
return directions if len(directions) == 3 else []
|
||||
|
||||
|
||||
def _append_plot_twist_direction_question(context: "AgentContext", directions: list[dict]) -> CreationMessage:
|
||||
return append_message(
|
||||
context.conversation,
|
||||
role="assistant",
|
||||
kind=CreationMessage.Kind.ELICIT,
|
||||
text="选一个剧情方向,我会按它写成完整方案。",
|
||||
payload={
|
||||
"interaction": "plot_twist_directions",
|
||||
"directions": directions,
|
||||
"fields": [{
|
||||
"key": "story_direction",
|
||||
"label": "三个剧情反转方向",
|
||||
"type": "single",
|
||||
"required": True,
|
||||
"options": [
|
||||
{"value": item["id"], "label": item["title"]}
|
||||
for item in directions
|
||||
],
|
||||
}],
|
||||
"submitted": False,
|
||||
"answers": {},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def get_video_gate_stage(conversation: CreationConversation) -> str:
|
||||
memory = conversation.memory if isinstance(conversation.memory, dict) else {}
|
||||
stage = str(memory.get("stage") or "clarify").strip()
|
||||
@@ -478,6 +628,17 @@ def text_already_guides(text: str) -> bool:
|
||||
return bool(_GUIDANCE_MARKER_RE.search((text or "").strip()))
|
||||
|
||||
|
||||
_NUMERIC_REPLY_INSTRUCTION_RE = re.compile(
|
||||
r"(?:你|请)?\s*(?:直接)?(?:回复|选择|选)\s*(?:数字|编号)?\s*"
|
||||
r"1\s*(?:[/、,,]\s*2)(?:\s*(?:[/、,,]\s*3))?[^。!?!\n]*(?:[。!?!]|$)"
|
||||
)
|
||||
|
||||
|
||||
def strip_numeric_reply_instruction(text: str) -> str:
|
||||
"""选择器会把 1/2/3 直接做成按钮,正文不再要求用户手输数字。"""
|
||||
return _NUMERIC_REPLY_INSTRUCTION_RE.sub("", text or "").strip()
|
||||
|
||||
|
||||
def default_reply_hint(
|
||||
conversation: CreationConversation | None = None,
|
||||
*,
|
||||
@@ -776,7 +937,7 @@ VIDEO_MODELS = ["Seedance 2.5", "Seedance 2.0", "Seedance 2.0 Fast", "Seedance 2
|
||||
IMAGE_MODELS = ["Seedream5.0", "YQ image2"]
|
||||
RATIOS = ["16:9", "9:16", "4:3", "3:4", "1:1"]
|
||||
RESOLUTIONS = ["480p", "720p", "1080p"]
|
||||
VIDEO_DURATIONS = ["智能时长", "4 秒", "5 秒", "6 秒", "8 秒", "10 秒", "12 秒", "15 秒", "30 秒"]
|
||||
VIDEO_DURATIONS = ["智能时长", "4 秒", "5 秒", "6 秒", "8 秒", "10 秒", "12 秒", "15 秒", "30 秒", "60 秒"]
|
||||
IMAGE_COUNTS = ["1 张", "2 张", "4 张", "8 张"]
|
||||
SESSION_PARAM_KEYS = ("duration", "ratio", "resolution", "video_model", "count")
|
||||
_PARAM_TO_STORED = {"video_model": "model"}
|
||||
@@ -865,6 +1026,9 @@ def apply_session_params(conversation, fields, answers: dict) -> bool:
|
||||
if changed:
|
||||
conversation.params = current
|
||||
conversation.save(update_fields=["params", "updated_at"])
|
||||
if is_plot_twist_conversation(conversation):
|
||||
# 用户在参数卡里改了时长,剧情结构也必须立即跟着切换。
|
||||
set_plot_twist_story_depth(conversation, str(current.get("duration") or ""))
|
||||
return changed
|
||||
|
||||
|
||||
@@ -911,6 +1075,9 @@ def apply_confirm_params(conversation, incoming: dict | None) -> tuple[dict, boo
|
||||
if changed:
|
||||
conversation.params = current
|
||||
conversation.save(update_fields=["params", "updated_at"])
|
||||
if is_plot_twist_conversation(conversation):
|
||||
# 在确认卡改时长也要切换故事契约;随后视图会要求重写旧方案。
|
||||
set_plot_twist_story_depth(conversation, str(current.get("duration") or ""))
|
||||
return snapshot_session_params(conversation), duration_changed
|
||||
|
||||
|
||||
@@ -993,6 +1160,41 @@ def tool_schemas(context: AgentContext, *, allow_plan: bool = True) -> list[dict
|
||||
# 换商品/改参数仍由 wanted_asset_pick / wanted_param_keys 兜底追问。
|
||||
return [t for t in tools if t.get("function", {}).get("name") == "search_library"]
|
||||
if context.is_video:
|
||||
if is_plot_twist_conversation(context.conversation) and active_plot_twist_story_depth(context.conversation):
|
||||
tools.append({
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "present_story_directions",
|
||||
"description": (
|
||||
"剧情反转带货预设在用户选定故事深度后,先调用此工具展示 3 个可点击的剧情方向。"
|
||||
"每条都必须有不同的冲突、商品承担的实际作用、反转和情绪;不能只说‘我准备了三个方向’。"
|
||||
"用户点击其一后才可 write_strategy;本工具调用后必须停下来等待选择。"
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"directions": {
|
||||
"type": "array",
|
||||
"minItems": 3,
|
||||
"maxItems": 3,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": {"type": "string"},
|
||||
"title": {"type": "string", "description": "短标题,不超过 10 个字"},
|
||||
"conflict": {"type": "string", "description": "人物处境与开场冲突"},
|
||||
"product_role": {"type": "string", "description": "商品如何自然推进剧情"},
|
||||
"reversal": {"type": "string", "description": "最终反转如何回收"},
|
||||
"tone": {"type": "string", "description": "情绪与转化倾向"},
|
||||
},
|
||||
"required": ["title", "conflict", "product_role", "reversal", "tone"],
|
||||
},
|
||||
},
|
||||
},
|
||||
"required": ["directions"],
|
||||
},
|
||||
},
|
||||
})
|
||||
tools.append({
|
||||
"type": "function",
|
||||
"function": {
|
||||
@@ -1233,7 +1435,7 @@ def video_duration(params: dict) -> int:
|
||||
digits = "".join(ch for ch in raw if ch.isdigit())
|
||||
if not digits:
|
||||
return SMART_DURATION
|
||||
return max(4, min(int(digits), 30))
|
||||
return max(4, min(int(digits), 60))
|
||||
|
||||
|
||||
def _image_count(params: dict, raw) -> int:
|
||||
@@ -1306,8 +1508,14 @@ def _video_submit_params(context: AgentContext, prompt: str) -> tuple[dict, list
|
||||
它已经排好 角色 → 场景 → 商品 的顺序,那正是出片模型 @图N 的语义依据。"""
|
||||
params = context.conversation.params or {}
|
||||
resolved = resolve_refs(context.team, context.conversation.pinned_refs or [])
|
||||
prompt = apply_video_preset_prompt(context.conversation.preset, prompt)
|
||||
prompt = apply_product_voice_visual_guard(context.conversation, prompt)
|
||||
prompt = apply_product_reality_guard(prompt)
|
||||
prompt = apply_plot_twist_story_contract(
|
||||
context.conversation.preset,
|
||||
active_plot_twist_story_depth(context.conversation),
|
||||
prompt,
|
||||
)
|
||||
submit = {
|
||||
"prompt": prompt,
|
||||
"feature": "omni_create",
|
||||
@@ -1562,7 +1770,9 @@ def build_system_prompt(context: AgentContext, *, allow_plan: bool = True, has_c
|
||||
"",
|
||||
"【每轮必须给引导】",
|
||||
"- 每一轮回复结束时,用户必须知道下一步怎么做:要么调用 ask_user 弹出可选项,"
|
||||
"要么在文字里明确告诉用户该回复什么(例如「请回复:1… 2…」或「直接说想改的卖点」)。",
|
||||
"要么在文字里明确告诉用户该回复什么(例如「直接说想改的卖点」)。",
|
||||
"- 当你给出 2–3 个方向时,必须调用 ask_user 生成可点击选项;正文只解释各方向,"
|
||||
"绝不要求用户回复数字、编号或 1/2/3。",
|
||||
"- 禁止只丢一段解释/分析就结束、让用户不知道该回什么。",
|
||||
"- 用户已经给出可直接执行的图片需求(主体、场景或氛围已足够)时,直接调用 generate_image 进入确认卡;"
|
||||
"不要只描述你准备怎么拍,再让用户继续补一句。",
|
||||
@@ -1601,8 +1811,8 @@ def build_system_prompt(context: AgentContext, *, allow_plan: bool = True, has_c
|
||||
hi = max(lo, min(85, int(dur * 5.7)))
|
||||
lines.append(f"- 当前按约 {dur} 秒出片,口播建议 {lo}–{hi} 字;write_plan 的 voice_chars 填这个区间。")
|
||||
lines.append(
|
||||
"- 视频 4 步闸门(不可同轮连跳):①缺信息 ask_user 停 → ②write_strategy 停等确认 → "
|
||||
"③用户确认后 write_plan 停等确认 → ④方案确认后平台在后台整理完整出片指令,并直接出现积分确认卡。"
|
||||
"- 视频 5 步闸门(不可同轮连跳):①缺信息 ask_user 停 → ②write_strategy 停等确认 → "
|
||||
"③用户确认后 write_plan 停等确认 → ④方案确认后展示完整出片指令并停等确认 → ⑤再显示积分确认卡。"
|
||||
)
|
||||
lines.append(
|
||||
"- 只有用户明确要做片、出方案、改方案、换卖点/剧情时才调用 write_strategy / write_plan;"
|
||||
@@ -1657,12 +1867,36 @@ def build_system_prompt(context: AgentContext, *, allow_plan: bool = True, has_c
|
||||
# 只给名字模型只能靠猜;把这个预设的拍法约束一起给它
|
||||
guidance = preset_guidance(conversation.preset)
|
||||
lines.append(f"\n【创作预设】{conversation.preset}")
|
||||
if context.is_video:
|
||||
lines.append(
|
||||
"预设必须贯穿本次对话:据此判断该补问哪些必要事实和素材、给什么创意选择、"
|
||||
"如何写脚本/分镜、商品如何出现、生成前检查什么;不能把预设当成最后才附加的一行风格词。"
|
||||
)
|
||||
if guidance:
|
||||
lines.append(guidance)
|
||||
lines.append("用户选了这个预设,就按它的拍法来;要偏离得先问过用户。")
|
||||
if is_plot_twist_conversation(conversation):
|
||||
depth = active_plot_twist_story_depth(conversation)
|
||||
if not depth:
|
||||
lines.append("剧情反转带货尚未选择故事深度:必须先调用 ask_user 让用户选 15 秒、30 秒、60 秒或智能推荐;此时禁止给剧情方向、策略或方案。")
|
||||
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}。直接围绕此方向写策略,不要重发方向卡。")
|
||||
else:
|
||||
lines.append(
|
||||
"【强制下一步】现在必须调用 present_story_directions,直接展示 3 张完整剧情方向卡。"
|
||||
"每张卡写清冲突、商品如何推进剧情、反转和情绪;不得只在文字中说‘我准备了三个方向’,"
|
||||
"不得先 write_strategy / write_plan,也不要让用户输入编号。"
|
||||
)
|
||||
workflow_guidance = preset_workflow_guidance(conversation.preset) if context.is_video else ""
|
||||
if workflow_guidance:
|
||||
lines.append(f"【当前预设的工作重点】{workflow_guidance}")
|
||||
delivery_contract = video_preset_delivery_contract(conversation.preset) if context.is_video else ""
|
||||
if delivery_contract:
|
||||
lines.append(f"【当前预设必须贯穿脚本与出片】{delivery_contract}")
|
||||
|
||||
resolved = resolve_refs(context.team, conversation.pinned_refs or [])
|
||||
if resolved.facts:
|
||||
@@ -2039,6 +2273,20 @@ def iter_creation_agent_events(
|
||||
yield {"type": "done"}
|
||||
return
|
||||
|
||||
# 剧情反转预设必须先选故事深度。平台直接落选择卡,不能把这一步交给模型猜,
|
||||
# 否则默认 15 秒会吞掉 30/60 秒该有的人物关系和冲突发展。
|
||||
if is_plot_twist_conversation(conversation) and not active_plot_twist_story_depth(conversation):
|
||||
explicit_depth = plot_twist_story_depth(text)
|
||||
if explicit_depth is not None and explicit_depth["value"] != "smart":
|
||||
set_plot_twist_story_depth(conversation, explicit_depth["value"])
|
||||
else:
|
||||
question = append_plot_twist_story_depth_question(conversation)
|
||||
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
|
||||
|
||||
model_config = _prefer_vision_text_model(model_config, conversation.team, conversation.pinned_refs or [])
|
||||
context.model_config = model_config
|
||||
|
||||
@@ -2095,7 +2343,7 @@ def iter_creation_agent_events(
|
||||
elif kind == "tool_call":
|
||||
_merge_tool_call_deltas(tool_buffer, chunk.get("tool_calls"))
|
||||
|
||||
said = "".join(text_buffer).strip()
|
||||
said = strip_numeric_reply_instruction("".join(text_buffer))
|
||||
calls = [tool_buffer[i] for i in sorted(tool_buffer) if tool_buffer[i].get("name")]
|
||||
fallback_fields = None
|
||||
allow_pick = False
|
||||
@@ -2131,6 +2379,28 @@ def iter_creation_agent_events(
|
||||
"asset_types": ["product"],
|
||||
}]
|
||||
|
||||
# 方向卡是剧情反转预设的固定入口。模型偶尔只会说「我准备了三个方向」而忘了调工具,
|
||||
# 此处直接补上可点击卡,不能让用户面对一段空话再自己追问。
|
||||
memory = conversation.memory if isinstance(conversation.memory, dict) else {}
|
||||
needs_plot_twist_directions = (
|
||||
not calls
|
||||
and allow_plan
|
||||
and is_plot_twist_conversation(conversation)
|
||||
and bool(active_plot_twist_story_depth(conversation))
|
||||
and not str(memory.get("plot_twist_story_direction") or "").strip()
|
||||
)
|
||||
if needs_plot_twist_directions:
|
||||
result, _stop = _dispatch_tool(
|
||||
context,
|
||||
"present_story_directions",
|
||||
{"directions": _plot_twist_direction_fallback(conversation)},
|
||||
)
|
||||
for event in result.get("_events", []):
|
||||
yield event
|
||||
if event.get("type") == "message":
|
||||
turn_has_gate = True
|
||||
break
|
||||
|
||||
# ask_user 自己会落一条可追踪的聊天问题。模型同时吐出的过渡文案不再
|
||||
# 另存一条,否则界面会连续出现两遍几乎相同的问题。
|
||||
asks_user = bool(fallback_fields) or any(call.get("name") == "ask_user" for call in calls)
|
||||
@@ -2352,6 +2622,7 @@ def run_creation_agent_turn(
|
||||
|
||||
_TOOL_LABELS = {
|
||||
"ask_user": "向你确认",
|
||||
"present_story_directions": "整理剧情方向",
|
||||
"search_library": "查找素材",
|
||||
"generate_image": "生成图片",
|
||||
"write_strategy": "梳理创作策略",
|
||||
@@ -2476,6 +2747,23 @@ def _dispatch_tool(
|
||||
"_events": [{"type": "message", "message": _message_payload(message)}],
|
||||
}, True
|
||||
|
||||
if name == "present_story_directions":
|
||||
if not is_plot_twist_conversation(context.conversation):
|
||||
return {"payload": {"error": "当前会话不是剧情反转带货预设"}}, False
|
||||
directions = _coerce_plot_twist_directions(args.get("directions"))
|
||||
if not directions:
|
||||
return {
|
||||
"payload": {
|
||||
"error": "必须提供恰好 3 个完整剧情方向,每个都要有标题、冲突、商品作用、反转和情绪。"
|
||||
}
|
||||
}, False
|
||||
message = _append_plot_twist_direction_question(context, directions)
|
||||
set_video_gate_stage(context.conversation, "clarify")
|
||||
return {
|
||||
"payload": {"presented": True, "count": 3},
|
||||
"_events": [{"type": "message", "message": _message_payload(message)}],
|
||||
}, True
|
||||
|
||||
if name == "search_library":
|
||||
return {"payload": _run_search_library(context, args)}, False
|
||||
|
||||
@@ -2515,8 +2803,14 @@ def _dispatch_tool(
|
||||
video_prompt = str(args.get("video_prompt") or "").strip()
|
||||
if not video_prompt:
|
||||
return {"payload": {"error": "video_prompt 不能为空,请把完整出片指令写进去"}}, False
|
||||
video_prompt = apply_video_preset_prompt(context.conversation.preset, video_prompt)
|
||||
video_prompt = apply_product_voice_visual_guard(context.conversation, video_prompt)
|
||||
video_prompt = apply_product_reality_guard(video_prompt)
|
||||
video_prompt = apply_plot_twist_story_contract(
|
||||
context.conversation.preset,
|
||||
active_plot_twist_story_depth(context.conversation),
|
||||
video_prompt,
|
||||
)
|
||||
card = _coerce_plan_card_args(args if isinstance(args, dict) else {})
|
||||
if not card["usp"] or not card["points"]:
|
||||
return {
|
||||
@@ -2548,7 +2842,7 @@ def _dispatch_tool(
|
||||
kind=CreationMessage.Kind.PLAN, payload=plan_payload,
|
||||
)
|
||||
events.append({"type": "message", "message": _message_payload(plan)})
|
||||
# video_prompt 只存后台。用户确认方案后直接进入积分确认,不展示内部 Prompt。
|
||||
# video_prompt 先作为方案产物存档;用户确认方案后会展示指令文件供确认。
|
||||
set_video_gate_stage(
|
||||
context.conversation, "plan", pending_video_prompt=video_prompt
|
||||
)
|
||||
@@ -2565,18 +2859,23 @@ def _dispatch_tool(
|
||||
video_prompt = get_pending_video_prompt(context.conversation)
|
||||
if not video_prompt:
|
||||
return {"payload": {"error": "video_prompt 不能为空,请把完整出片指令写进去"}}, False
|
||||
video_prompt = apply_video_preset_prompt(context.conversation.preset, video_prompt)
|
||||
video_prompt = apply_product_voice_visual_guard(context.conversation, video_prompt)
|
||||
video_prompt = apply_product_reality_guard(video_prompt)
|
||||
set_video_gate_stage(context.conversation, "prompt", pending_video_prompt=video_prompt)
|
||||
confirm = emit_final_confirm_gate(context.conversation, context=context)
|
||||
if confirm is None:
|
||||
return {"payload": {"error": "无法准备出片确认"}}, False
|
||||
video_prompt = apply_plot_twist_story_contract(
|
||||
context.conversation.preset,
|
||||
active_plot_twist_story_depth(context.conversation),
|
||||
video_prompt,
|
||||
)
|
||||
prompt_messages = emit_prompt_gate(context.conversation, video_prompt)
|
||||
if not prompt_messages:
|
||||
return {"payload": {"error": "无法准备出片指令"}}, False
|
||||
events = [
|
||||
{"type": "message", "message": _message_payload(confirm)},
|
||||
{"type": "credits", "estimated": int((confirm.payload or {}).get("estimated_credits") or 0)},
|
||||
{"type": "message", "message": _message_payload(message)}
|
||||
for message in prompt_messages
|
||||
]
|
||||
return {
|
||||
"payload": {"awaiting_confirmation": True, "prepared": True},
|
||||
"payload": {"awaiting_step": "prompt", "prepared": True},
|
||||
"_events": events,
|
||||
}, True
|
||||
|
||||
|
||||
@@ -23,8 +23,11 @@ VIDEO_PRESETS: dict[str, str] = {
|
||||
"用户没给关键功能或使用边界时,只问一次缺失事实;其余镜头用可观察的正常动作补齐。"
|
||||
),
|
||||
"剧情反转带货": (
|
||||
"轻剧情短片。必须有一个具体的困境场景 → 意外转折 → 商品成为解决问题的关键道具。"
|
||||
"商品不能在开头硬推,要等冲突立住了再自然介入。禁止把普通不便夸成严重后果。"
|
||||
"剧情反转带货不是给商品套一个故事壳。商品必须作为解决困境、解除误会、证明事实、完成翻盘或回收伏笔的关键物,"
|
||||
"并在最终 Prompt、镜头、动作、对白和反转结果中反复可见地承担这个作用。"
|
||||
"不得在故事结束后突然停下来介绍商品,也不能只在最后两秒放商品图。"
|
||||
"必须先确定故事深度,再按所选 15 秒、30 秒或 60 秒的结构写人物关系、冲突铺垫、商品介入时机、反转和情绪落点;"
|
||||
"禁止把同一条 15 秒故事机械加长或缩短。"
|
||||
),
|
||||
"商品拟人广告": (
|
||||
"把商品当成有性格的角色,但默认采用无脸拟人:商品本体保持真实完整,不在瓶身、包装或机身上新增卡通五官。"
|
||||
@@ -75,12 +78,105 @@ VIDEO_PRESETS: dict[str, str] = {
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
PLOT_TWIST_PRESET = "剧情反转带货"
|
||||
|
||||
# 值同时是会话记忆中的稳定标识;label 则是用户在对话里看到的短说明。
|
||||
PLOT_TWIST_STORY_DEPTH_OPTIONS = (
|
||||
{
|
||||
"value": "15s",
|
||||
"label": "15秒|快节奏反转",
|
||||
"duration": "15 秒",
|
||||
"summary": "强冲突快速抓人,商品尽早介入并完成反转。",
|
||||
},
|
||||
{
|
||||
"value": "30s",
|
||||
"label": "30秒|轻剧情带货",
|
||||
"duration": "30 秒",
|
||||
"summary": "补足人物处境和矛盾铺垫,让商品在转折点自然发挥作用。",
|
||||
},
|
||||
{
|
||||
"value": "60s",
|
||||
"label": "60秒|完整短剧带货",
|
||||
"duration": "60 秒",
|
||||
"summary": "建立人物关系与持续矛盾,商品作为伏笔或关键线索完成翻盘。",
|
||||
},
|
||||
{
|
||||
"value": "smart",
|
||||
"label": "智能推荐",
|
||||
"duration": "",
|
||||
"summary": "结合商品卖点、素材和剧情空间推荐时长,仍由你最终决定。",
|
||||
},
|
||||
)
|
||||
|
||||
_PLOT_TWIST_DEPTH_BY_VALUE = {item["value"]: item for item in PLOT_TWIST_STORY_DEPTH_OPTIONS}
|
||||
|
||||
|
||||
def plot_twist_story_depth(value: str) -> dict | None:
|
||||
"""兼容卡片 value、展示文案和自然语言里带的秒数。"""
|
||||
raw = str(value or "").strip()
|
||||
if raw in _PLOT_TWIST_DEPTH_BY_VALUE:
|
||||
return _PLOT_TWIST_DEPTH_BY_VALUE[raw]
|
||||
if "60" in raw:
|
||||
return _PLOT_TWIST_DEPTH_BY_VALUE["60s"]
|
||||
if "30" in raw:
|
||||
return _PLOT_TWIST_DEPTH_BY_VALUE["30s"]
|
||||
if "15" in raw:
|
||||
return _PLOT_TWIST_DEPTH_BY_VALUE["15s"]
|
||||
if "智能" in raw or "推荐" in raw:
|
||||
return _PLOT_TWIST_DEPTH_BY_VALUE["smart"]
|
||||
return None
|
||||
|
||||
|
||||
def plot_twist_story_contract(value: str) -> str:
|
||||
"""真实写入出片 Prompt 的硬约束,确保预设不会退化成一行风格词。"""
|
||||
depth = plot_twist_story_depth(value)
|
||||
if depth is None:
|
||||
return ""
|
||||
if depth["value"] == "15s":
|
||||
return (
|
||||
"【剧情反转带货·15秒快节奏反转·强制执行】只保留一条主冲突,主角 1 人、最多 1 名辅助人物。"
|
||||
"0-3 秒强冲突或意外,3-6 秒问题升级,6-11 秒商品以正常使用动作介入并证明一个核心卖点,"
|
||||
"11-15 秒完成结果反转与自然行动引导。商品必须在中段前出现;对白短、直接、有记忆点;"
|
||||
"禁止复杂背景、支线、重复卖点和与商品无关的镜头。"
|
||||
)
|
||||
if depth["value"] == "30s":
|
||||
return (
|
||||
"【剧情反转带货·30秒轻剧情带货·强制执行】必须交代人物处境,可设置两人互动。"
|
||||
"0-4 秒抛出结果预告或冲突钩子,4-10 秒建立处境,10-17 秒让矛盾升级或一次错误尝试,"
|
||||
"17-24 秒商品在转折点以完整正常使用过程介入,24-28 秒回收反转与人物反应,28-30 秒自然收束。"
|
||||
"商品承担改变局面的实际作用;反转既服务剧情也证明卖点,禁止为了凑时长重复同一卖点。"
|
||||
)
|
||||
if depth["value"] == "60s":
|
||||
return (
|
||||
"【剧情反转带货·60秒完整短剧带货·强制执行】必须有人物关系、持续发展的矛盾、一次铺垫和一次回收。"
|
||||
"0-5 秒高强度钩子,5-15 秒交代关系和处境,15-28 秒矛盾逐步升级,28-38 秒主角面临选择或第一次失败,"
|
||||
"38-48 秒商品作为关键线索、工具、证据或关系转折点推进剧情,48-56 秒完成主要反转和情绪释放,"
|
||||
"56-60 秒回扣商品价值并自然转化。商品可前置为伏笔但必须在后半段真正改变结局;"
|
||||
"禁止用重复对白、无意义空镜或硬插卖点填满时长。"
|
||||
)
|
||||
return (
|
||||
"【剧情反转带货·智能推荐】先根据商品卖点、已有素材和剧情空间推荐 15、30 或 60 秒之一并说明理由;"
|
||||
"随后必须让用户选择实际时长,未确认前不得写剧情方向、策略、方案或出片指令。"
|
||||
)
|
||||
|
||||
|
||||
def apply_plot_twist_story_contract(name: str, story_depth: str, prompt: str) -> str:
|
||||
"""在最终出片指令里确定性追加所选故事深度,避免模型漏写关键结构。"""
|
||||
base = str(prompt or "").strip()
|
||||
if (name or "").strip() != PLOT_TWIST_PRESET or not base:
|
||||
return base
|
||||
contract = plot_twist_story_contract(story_depth)
|
||||
if not contract or contract in base:
|
||||
return base
|
||||
return f"{base}\n\n{contract}"
|
||||
|
||||
# 预设除了决定最终 Prompt,也决定 Agent 在素材整理、追问与生成前检查时的工作重点。
|
||||
# 这段进入系统提示词,避免把预设退化成一句风格修饰词。
|
||||
VIDEO_PRESET_WORKFLOWS: dict[str, str] = {
|
||||
"痛点解决演示": "先确认商品真实解决的具体问题与正常用法;生成前核对痛点、过程和结果都有可见证据。",
|
||||
"真实使用演示": "优先核对商品真实用途、关键步骤和可证实卖点;不为氛围而增加不合理测试。",
|
||||
"剧情反转带货": "优先确认人物关系或冲突;缺剧情时给 2–3 个可选方向,商品必须成为解决问题或反转的关键。",
|
||||
"剧情反转带货": "先让用户选故事深度(15秒快节奏反转、30秒轻剧情带货、60秒完整短剧带货或智能推荐),再给三个与时长匹配的剧情方向;商品必须成为解决问题、解除误会、证明事实、回收伏笔或完成翻盘的关键。",
|
||||
"商品拟人广告": "先确认商品外观和性格表达方式;默认无脸拟人,商品保持真实完整,台词用画外声。",
|
||||
"达人口播种草": "优先确认达人/人物、真实体验和主卖点;生成前核对口播字数能在时长内说完,每个卖点都有画面证明。",
|
||||
"商品图一键成片": "优先从商品参考图锁定外观;自动补场景和动作,但不替换或改变用户商品图里的结构、颜色和包装。",
|
||||
@@ -94,6 +190,70 @@ VIDEO_PRESET_WORKFLOWS: dict[str, str] = {
|
||||
"人物与音色替换": "先确认原视频、替换对象和替换范围;未经要求不得改变原视频的构图、动作、商品、场景或节奏。",
|
||||
}
|
||||
|
||||
# 这一层不是给聊天看的装饰文字,而是实际写入 video_prompt 的制作约束。
|
||||
# 预设示例片的效果主要来自结构、镜头和素材处理方式,不能只追加一句风格名。
|
||||
VIDEO_PRESET_DELIVERY_CONTRACTS: dict[str, str] = {
|
||||
"痛点解决演示": (
|
||||
"【预设执行层·痛点解决演示】全片严格按‘真实困扰 → 商品进入 → 正常使用过程 → 可见结果 → 自然收束’推进。"
|
||||
"开头先让目标用户看懂具体困扰;中段完整拍清产品的真实操作和一个可观察证据;"
|
||||
"结尾只回扣已经被画面证明的价值。不得虚构压力测试、绝对功效、价格或促销。"
|
||||
),
|
||||
"真实使用演示": (
|
||||
"【预设执行层·真实使用演示】像一镜到底可复核的日常实拍:先交代真实场景,再按正确用途完成步骤,"
|
||||
"用近景展示材质、动作或结果。商品全程保持正常完整;不以破损、漏液、渗水、超载或超出用途的测试制造戏剧性。"
|
||||
),
|
||||
"剧情反转带货": (
|
||||
"【预设执行层·剧情反转带货】人物关系、冲突、情绪变化和反转必须在每个关键镜头连续推进。"
|
||||
"商品不是摆拍道具:必须充当解决问题的工具、解除误会的证据、关系变化的礼物或回收伏笔的关键物;"
|
||||
"商品出现、使用和结果必须直接改变剧情走向,禁止剧情结束后再硬插卖点。"
|
||||
),
|
||||
"商品拟人广告": (
|
||||
"【预设执行层·商品拟人广告】商品本体保持真实包装、颜色、材质和结构;默认不长五官、不做口型。"
|
||||
"性格由整体运动、镜头、环境反应和画外角色声表达。每次拟人动作都要符合物理状态,趣味服务于一个真实卖点。"
|
||||
),
|
||||
"达人口播种草": (
|
||||
"【预设执行层·达人口播种草】固定一位可信的真人,在真实生活场景中自然口播。"
|
||||
"结构为:前 2–3 秒具体痛点或体验钩子 → 真实使用/质地/细节证明 → 一句个人感受 → 克制收束。"
|
||||
"口播像朋友分享,所有卖点必须被动作或特写佐证;禁止万能主播腔、堆砌卖点、画面字幕和夸张承诺。"
|
||||
),
|
||||
"商品图一键成片": (
|
||||
"【预设执行层·商品图一键成片】以用户商品图作为外观唯一事实源:先用主图确立商品,再补与真实用途匹配的场景、手部动作和细节特写,"
|
||||
"最后以干净的产品收束。包装文字、颜色、比例和结构全程稳定,不能用 AI 生成的相似商品替代参考图。"
|
||||
),
|
||||
"鱼眼换装": (
|
||||
"【预设执行层·鱼眼换装】使用近距离鱼眼/广角透视和稳定的同一机位;人物脸、身形、发型、场景、光线连续一致。"
|
||||
"每一套服装由一个清晰的身体动作触发切换,按用户提供顺序完整展示;镜头的变化来自动作和节奏,不额外编复杂营销剧情。"
|
||||
),
|
||||
"多色商品换款": (
|
||||
"【预设执行层·多色商品换款】先建立统一构图、比例、机位和光线,再以点击、触碰、转动或同一连续动作逐款切换。"
|
||||
"每个颜色/SKU 都要清晰出现且顺序可辨,商品尺寸和位置不得漂移;最后给出全部款式同框总览。"
|
||||
),
|
||||
"点触换款": (
|
||||
"【预设执行层·点触换款】先建立统一构图、比例、机位和光线,再以点击、触碰、转动或同一连续动作逐款切换。"
|
||||
"每个颜色/SKU 都要清晰出现且顺序可辨,商品尺寸和位置不得漂移;最后给出全部款式同框总览。"
|
||||
),
|
||||
"探店漫游": (
|
||||
"【预设执行层·探店漫游】用一条连续空间动线讲清入口、环境、关键细节和主推服务/商品。"
|
||||
"镜头移动要有明确去向,人物或旁白承担导览;环境、服务和商品在同一体验中自然出现,不做碎片化硬切或虚假热闹。"
|
||||
),
|
||||
"品牌质感大片": (
|
||||
"【预设执行层·品牌质感大片】少信息、高控制:围绕一个商品主角,以材质、光线、留白和镜头节奏建立品牌气质。"
|
||||
"用真实可见的表面、边缘光、环境反射和干净构图表达高级感;禁止无意义的漂浮特效、过度 CG 和堆叠卖点。"
|
||||
),
|
||||
"前后对比实测": (
|
||||
"【预设执行层·前后对比实测】同一对象、同一条件、同一机位依次呈现使用前、正常使用过程和使用后。"
|
||||
"对比必须由用户素材或可见事实支持,画面明确说明比较对象;禁止伪造夸张效果、偷换条件或把未验证功效拍成结论。"
|
||||
),
|
||||
"AI 宠物拟人": (
|
||||
"【预设执行层·AI 宠物拟人】宠物外观、体型、毛色和性格前后一致;趣味来自拟人动作与真实反应。"
|
||||
"宠物和商品的互动必须符合商品真实结构与正常用法,商品不变成玩具或获得不存在的能力;剧情里至少有一个可见卖点证据。"
|
||||
),
|
||||
"人物与音色替换": (
|
||||
"【预设执行层·人物与音色替换】严格保留原视频的构图、镜头顺序、动作、节奏、商品、场景和剪辑;"
|
||||
"只替换用户明确指定的人物、声音或两者。替换人物的脸、身形、服装和新声音要全片一致,未授权内容一律不改。"
|
||||
),
|
||||
}
|
||||
|
||||
IMAGE_PRESETS: dict[str, str] = {
|
||||
"电影感": "电影级摄影质感,宽容度高,强烈但自然的明暗层次,深邃阴影,柔和高光,冷暖色彩对比,轻微青橙调,真实环境光,局部轮廓光,适度暗角,细腻胶片颗粒,低饱和高级色调,电影调色,富有叙事感,真实摄影质感,避免过度HDR。",
|
||||
"日式清新": "日系清新摄影,高明度,低对比,柔和自然光,干净通透的空气感,白色与浅色占比高,低饱和淡彩,轻微偏冷色温,柔和肤色与高光,阴影浅淡,画面轻盈,曝光稍高但保留细节,清透自然,简洁治愈。",
|
||||
@@ -128,6 +288,24 @@ def preset_workflow_guidance(name: str) -> str:
|
||||
return VIDEO_PRESET_WORKFLOWS.get((name or "").strip(), "")
|
||||
|
||||
|
||||
def video_preset_delivery_contract(name: str) -> str:
|
||||
"""预设名 → 最终视频生成阶段必须执行的结构与镜头约束。"""
|
||||
return VIDEO_PRESET_DELIVERY_CONTRACTS.get((name or "").strip(), "")
|
||||
|
||||
|
||||
def apply_video_preset_prompt(name: str, prompt: str) -> str:
|
||||
"""把视频预设确定性并入最终出片指令,避免只依赖对话模型主动复述。"""
|
||||
base = str(prompt or "").strip()
|
||||
preset = (name or "").strip()
|
||||
contract = video_preset_delivery_contract(preset)
|
||||
if not base or not contract:
|
||||
return base
|
||||
marker = f"【视频预设】{preset}"
|
||||
if marker in base:
|
||||
return base
|
||||
return f"{base}\n\n{marker}\n{contract}"
|
||||
|
||||
|
||||
def apply_image_preset_prompt(name: str, prompt: str) -> str:
|
||||
"""将已选图片预设的风格字段确定性并入实际出图指令,不依赖 Agent 自行复述。"""
|
||||
base = str(prompt or "").strip()
|
||||
|
||||
@@ -25,6 +25,7 @@ from .creation_agent import (
|
||||
_image_count,
|
||||
_merge_tool_call_deltas,
|
||||
apply_restart_intent,
|
||||
active_plot_twist_story_depth,
|
||||
build_system_prompt,
|
||||
build_messages,
|
||||
default_reply_hint,
|
||||
@@ -38,6 +39,8 @@ from .creation_agent import (
|
||||
is_restart_intent,
|
||||
session_has_creative_context,
|
||||
stream_creation_agent,
|
||||
set_plot_twist_story_depth,
|
||||
strip_numeric_reply_instruction,
|
||||
submit_confirmed_image,
|
||||
submit_confirmed_video,
|
||||
text_already_guides,
|
||||
@@ -846,6 +849,87 @@ class VideoPlanAndConfirmTests(CreationAgentBaseTests):
|
||||
args.update(overrides)
|
||||
return args
|
||||
|
||||
def test_plot_twist_preset_requires_story_depth_before_creative_output(self):
|
||||
self.conversation.preset = "剧情反转带货"
|
||||
self.conversation.params = {**self.conversation.params, "duration": "智能时长"}
|
||||
self.conversation.memory = {}
|
||||
self.conversation.save(update_fields=["preset", "params", "memory", "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,
|
||||
))
|
||||
|
||||
question = next(
|
||||
event["message"] for event in events
|
||||
if event.get("type") == "message"
|
||||
and event["message"]["kind"] == "elicit"
|
||||
)
|
||||
self.assertEqual(question["payload"].get("interaction"), "plot_twist_story_depth")
|
||||
self.assertEqual(
|
||||
[option["value"] for option in question["payload"]["fields"][0]["options"]],
|
||||
["15s", "30s", "60s", "smart"],
|
||||
)
|
||||
self.conversation.refresh_from_db()
|
||||
self.assertEqual(self.conversation.agent_status, "awaiting_user")
|
||||
self.assertEqual(fake.calls, [])
|
||||
|
||||
def test_plot_twist_depth_syncs_duration_and_is_written_into_final_prompt(self):
|
||||
self.conversation.preset = "剧情反转带货"
|
||||
self.conversation.save(update_fields=["preset", "updated_at"])
|
||||
depth = set_plot_twist_story_depth(self.conversation, "30s")
|
||||
self.assertEqual(depth["duration"], "30 秒")
|
||||
self.conversation.refresh_from_db()
|
||||
self.assertEqual(active_plot_twist_story_depth(self.conversation), "30s")
|
||||
|
||||
card = append_message(
|
||||
self.conversation, role="assistant", kind=CreationMessage.Kind.CONFIRM,
|
||||
payload={"video_prompt": "女主发现早餐来不及准备", "submitted": False},
|
||||
)
|
||||
task = AITask.objects.create(
|
||||
team=self.team, created_by=self.user, task_type=AITask.Type.FREE_VIDEO,
|
||||
model_config=self.model, idempotency_key="k-plot-twist-depth",
|
||||
)
|
||||
with patch("apps.ai.free_video.submit_free_video", return_value=task) as submit:
|
||||
message, error = submit_confirmed_video(
|
||||
conversation=self.conversation, user=self.user, confirm_message=card
|
||||
)
|
||||
prompt = submit.call_args.kwargs["params"]["prompt"]
|
||||
|
||||
self.assertEqual(error, "")
|
||||
self.assertIsNotNone(message)
|
||||
self.assertIn("30秒轻剧情带货", prompt)
|
||||
self.assertIn("17-24 秒商品在转折点", prompt)
|
||||
|
||||
def test_plot_twist_always_displays_three_direction_cards(self):
|
||||
self.conversation.preset = "剧情反转带货"
|
||||
self.conversation.params = {**self.conversation.params, "duration": "30 秒"}
|
||||
self.conversation.save(update_fields=["preset", "params", "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"].get("interaction"), "plot_twist_directions")
|
||||
directions = card["payload"].get("directions") or []
|
||||
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_image_tool_is_hidden_and_video_tools_offered(self):
|
||||
fake = FakeProvider([_text_chunks("先聊聊")])
|
||||
with patch("apps.ai.creation_agent.build_provider", return_value=fake):
|
||||
@@ -900,14 +984,13 @@ class VideoPlanAndConfirmTests(CreationAgentBaseTests):
|
||||
self.assertEqual(elicits[-1]["payload"].get("step"), "plan")
|
||||
self.conversation.refresh_from_db()
|
||||
self.assertEqual((self.conversation.memory or {}).get("stage"), "plan")
|
||||
self.assertEqual(
|
||||
(self.conversation.memory or {}).get("pending_video_prompt"),
|
||||
"0-3秒 近景手持商品…",
|
||||
)
|
||||
pending_prompt = (self.conversation.memory or {}).get("pending_video_prompt") or ""
|
||||
self.assertTrue(pending_prompt.startswith("0-3秒 近景手持商品…"))
|
||||
self.assertIn("不出现破损、漏液、渗水", pending_prompt)
|
||||
# 方案闸门停下,不能同轮出 Prompt/积分卡
|
||||
self.assertEqual(len(fake.calls), 1)
|
||||
|
||||
def test_write_prompt_keeps_internal_prompt_and_emits_confirm(self):
|
||||
def test_write_prompt_emits_document_and_requires_prompt_confirmation(self):
|
||||
fake = FakeProvider([
|
||||
_tool_chunks("write_prompt", {"video_prompt": "0-3秒 近景手持商品…"}),
|
||||
_text_chunks("不该跑到这一轮"),
|
||||
@@ -916,10 +999,15 @@ class VideoPlanAndConfirmTests(CreationAgentBaseTests):
|
||||
events = _events(stream_creation_agent(conversation=self.conversation, user=self.user,
|
||||
text="做条视频", model_config=self.model))
|
||||
kinds = [e["message"]["kind"] for e in events if e.get("type") == "message"]
|
||||
self.assertNotIn("prompt_file", kinds)
|
||||
self.assertIn("confirm", kinds)
|
||||
self.assertIn("prompt_file", kinds)
|
||||
self.assertNotIn("confirm", kinds)
|
||||
elicit = next(
|
||||
e["message"] for e in events
|
||||
if e.get("type") == "message" and e["message"]["kind"] == "elicit"
|
||||
)
|
||||
self.assertEqual(elicit["payload"].get("step"), "prompt")
|
||||
self.conversation.refresh_from_db()
|
||||
self.assertEqual((self.conversation.memory or {}).get("stage"), "confirm")
|
||||
self.assertEqual((self.conversation.memory or {}).get("stage"), "prompt")
|
||||
self.assertEqual(len(fake.calls), 1)
|
||||
|
||||
def test_strategy_and_plan_same_round_stops_after_strategy(self):
|
||||
@@ -986,13 +1074,37 @@ class VideoPlanAndConfirmTests(CreationAgentBaseTests):
|
||||
|
||||
self.assertEqual(error, "")
|
||||
self.assertEqual(message.kind, CreationMessage.Kind.GENERATING)
|
||||
self.assertEqual(params["prompt"], "0-3秒 近景手持商品…")
|
||||
self.assertTrue(params["prompt"].startswith("0-3秒 近景手持商品…"))
|
||||
self.assertIn("不出现破损、漏液、渗水", params["prompt"])
|
||||
# 顶栏参数直接用,label 要翻成火山真名
|
||||
self.assertEqual(params["model"], "doubao-seedance-2-5-260628")
|
||||
self.assertEqual(params["duration"], 15)
|
||||
self.assertEqual(params["aspect_ratio"], "9:16")
|
||||
self.assertTrue(params["generate_audio"])
|
||||
|
||||
def test_confirmed_video_keeps_selected_preset_contract(self):
|
||||
self.conversation.preset = "多色商品换款"
|
||||
self.conversation.save(update_fields=["preset", "updated_at"])
|
||||
card = append_message(
|
||||
self.conversation, role="assistant", kind=CreationMessage.Kind.CONFIRM,
|
||||
payload={"video_prompt": "手指点击后展示三种颜色的同款耳机", "submitted": False},
|
||||
)
|
||||
task = AITask.objects.create(
|
||||
team=self.team, created_by=self.user, task_type=AITask.Type.FREE_VIDEO,
|
||||
model_config=self.model, idempotency_key="k-multi-sku-contract",
|
||||
)
|
||||
with patch("apps.ai.free_video.submit_free_video", return_value=task) as submit:
|
||||
message, error = submit_confirmed_video(
|
||||
conversation=self.conversation, user=self.user, confirm_message=card
|
||||
)
|
||||
prompt = submit.call_args.kwargs["params"]["prompt"]
|
||||
|
||||
self.assertEqual(error, "")
|
||||
self.assertIsNotNone(message)
|
||||
self.assertIn("【视频预设】多色商品换款", prompt)
|
||||
self.assertIn("每个颜色/SKU 都要清晰出现", prompt)
|
||||
self.assertIn("最后给出全部款式同框总览", prompt)
|
||||
|
||||
def test_personified_product_uses_offscreen_voice_without_changing_packaging(self):
|
||||
self.conversation.preset = "商品拟人广告"
|
||||
self.conversation.save(update_fields=["preset"])
|
||||
@@ -1036,7 +1148,8 @@ class VideoPlanAndConfirmTests(CreationAgentBaseTests):
|
||||
|
||||
self.assertEqual(error, "")
|
||||
self.assertIsNotNone(message)
|
||||
self.assertEqual(prompt, original)
|
||||
self.assertTrue(prompt.startswith(original))
|
||||
self.assertIn("不出现破损、漏液、渗水", prompt)
|
||||
|
||||
def test_confirm_without_stored_prompt_reports_instead_of_submitting(self):
|
||||
card = append_message(
|
||||
@@ -1057,8 +1170,8 @@ class VideoParamParsingTests(TestCase):
|
||||
self.assertEqual(video_duration({"duration": "15 秒"}), 15)
|
||||
self.assertEqual(video_duration({"duration": "智能时长"}), SMART_DURATION)
|
||||
self.assertEqual(video_duration({}), SMART_DURATION)
|
||||
# 火山单次最长 30 秒,超了要夹住而不是让 submit 报错
|
||||
self.assertEqual(video_duration({"duration": "99 秒"}), 30)
|
||||
# 当前视频工作流支持完整短剧,超出预设上限时仍需夹住。
|
||||
self.assertEqual(video_duration({"duration": "99 秒"}), 60)
|
||||
|
||||
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")
|
||||
@@ -1304,6 +1417,22 @@ class PresetGuidanceTests(CreationAgentBaseTests):
|
||||
self.assertIn("所有 SKU 都已进入时间轴", system)
|
||||
self.assertIn("比例、位置和机位保持一致", system)
|
||||
|
||||
def test_video_preset_is_injected_into_the_actual_generation_prompt(self):
|
||||
from .creation_presets import apply_video_preset_prompt
|
||||
|
||||
prompt = apply_video_preset_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):
|
||||
from .creation_presets import VIDEO_PRESETS, VIDEO_PRESET_DELIVERY_CONTRACTS
|
||||
|
||||
self.assertEqual(set(VIDEO_PRESETS), set(VIDEO_PRESET_DELIVERY_CONTRACTS))
|
||||
self.assertTrue(all(text.strip() for text in VIDEO_PRESET_DELIVERY_CONTRACTS.values()))
|
||||
|
||||
def test_product_reality_guard_is_added_to_final_prompt(self):
|
||||
from .creation_agent import apply_product_reality_guard
|
||||
|
||||
@@ -1631,12 +1760,17 @@ class ToolGateTests(CreationAgentBaseTests):
|
||||
class ReplyGuidanceTests(CreationAgentBaseTests):
|
||||
"""每轮回复必须给引导:系统提示有规则,散文收束会挂 reply_hint。"""
|
||||
|
||||
def test_numbered_reply_instruction_is_removed_from_text(self):
|
||||
text = "三个方向都能做。\n你直接回复数字1/2/3选就行,想改细节也可以直接说。"
|
||||
self.assertEqual(strip_numeric_reply_instruction(text), "三个方向都能做。")
|
||||
|
||||
def test_system_prompt_contains_turn_guidance_rule(self):
|
||||
context = AgentContext(conversation=self.conversation, user=self.user, model_config=self.model)
|
||||
prompt = build_system_prompt(context, allow_plan=True, has_context=False)
|
||||
self.assertIn("【每轮必须给引导】", prompt)
|
||||
self.assertIn("ask_user", prompt)
|
||||
self.assertIn("该回复什么", prompt)
|
||||
self.assertIn("绝不要求用户回复数字", prompt)
|
||||
|
||||
def test_system_prompt_requires_production_grade_video_prompt_document(self):
|
||||
self.conversation.mode = CreationConversation.Mode.VIDEO
|
||||
|
||||
@@ -39,7 +39,9 @@ from .creation_agent import (
|
||||
apply_confirm_params,
|
||||
apply_restart_intent,
|
||||
apply_session_params,
|
||||
emit_prompt_gate,
|
||||
emit_final_confirm_gate,
|
||||
set_plot_twist_story_depth,
|
||||
is_greeting,
|
||||
is_restart_intent,
|
||||
restore_gated_step_after_cancel,
|
||||
@@ -167,6 +169,56 @@ def _pending_chat_question(conversation: CreationConversation) -> CreationMessag
|
||||
return None
|
||||
|
||||
|
||||
def _plot_twist_depth_continuation(depth: dict) -> str:
|
||||
"""让故事深度卡的回答直接进入对应的创作分支。"""
|
||||
if depth.get("value") == "smart":
|
||||
return (
|
||||
"用户选择智能推荐。先根据商品卖点、已有素材和剧情空间推荐 15、30 或 60 秒其中一个,"
|
||||
"说明一句推荐理由,再调用 ask_user 让用户最终选择实际时长;未确认实际时长前不要给剧情方向、策略或方案。"
|
||||
)
|
||||
return (
|
||||
f"用户已选择:{depth['label']}。立刻按这个故事深度给出 3 个明显不同的剧情方向,"
|
||||
"每个方向必须写人物关系、开场冲突、商品如何进入剧情、商品承担的作用、最终反转、情绪和偏故事/偏转化;"
|
||||
"然后调用 ask_user 让用户点击选择或输入自己的想法。不得按默认 15 秒偷换结构。"
|
||||
)
|
||||
|
||||
|
||||
def _plot_twist_direction_continuation(
|
||||
conversation: CreationConversation,
|
||||
payload: dict,
|
||||
choice: str,
|
||||
) -> str:
|
||||
"""保存方向卡的真实内容,再让模型进入策略步骤。"""
|
||||
direction = next(
|
||||
(
|
||||
item for item in (payload.get("directions") or [])
|
||||
if isinstance(item, dict)
|
||||
and (str(item.get("id") or "") == choice or str(item.get("title") or "") == choice)
|
||||
),
|
||||
None,
|
||||
)
|
||||
if isinstance(direction, dict):
|
||||
title = str(direction.get("title") or choice).strip()
|
||||
detail = ";".join(
|
||||
str(direction.get(key) or "").strip()
|
||||
for key in ("conflict", "product_role", "reversal", "tone")
|
||||
if str(direction.get(key) or "").strip()
|
||||
)
|
||||
else:
|
||||
title = choice.strip() or "用户自定义方向"
|
||||
detail = title
|
||||
memory = dict(conversation.memory or {})
|
||||
memory["plot_twist_story_direction"] = title
|
||||
memory["plot_twist_story_direction_detail"] = detail
|
||||
conversation.memory = memory
|
||||
conversation.save(update_fields=["memory", "updated_at"])
|
||||
return (
|
||||
f"用户已选择剧情方向【{title}】。方向细节:{detail}。"
|
||||
"现在只调用 write_strategy 写创作策略卡,必须沿用该冲突、商品作用与反转;"
|
||||
"不要再展示方向卡、不要复述选择、不要直接写方案或出片。"
|
||||
)
|
||||
|
||||
|
||||
_STEP_CONTINUE_INSTRUCTIONS = {
|
||||
"strategy": (
|
||||
"用户已确认创作策略。现在只调用 write_plan 写方案卡(含完整 video_prompt 存档);"
|
||||
@@ -248,20 +300,17 @@ def _handle_step_confirm_answer(
|
||||
return None, True, _STEP_CONTINUE_INSTRUCTIONS["strategy"]
|
||||
|
||||
if step == "plan":
|
||||
confirm = emit_final_confirm_gate(conversation)
|
||||
if confirm is None:
|
||||
# 兼容旧会话或缓存丢失:让 agent 补齐后台出片指令,再直接给出积分确认。
|
||||
prompt_messages = emit_prompt_gate(conversation)
|
||||
if not prompt_messages:
|
||||
# 兼容旧会话或缓存丢失:让 agent 补齐出片指令,再交给用户确认。
|
||||
return None, True, _STEP_CONTINUE_INSTRUCTIONS["plan"]
|
||||
conversation.agent_status = CreationConversation.AgentStatus.AWAITING_USER
|
||||
conversation.save(update_fields=["agent_status", "updated_at"])
|
||||
credits = int((confirm.payload or {}).get("estimated_credits") or 0)
|
||||
body = {
|
||||
"conversation_id": str(conversation.id),
|
||||
"agent_status": conversation.agent_status,
|
||||
"messages": [CreationMessageSerializer(confirm).data],
|
||||
"messages": [CreationMessageSerializer(message).data for message in prompt_messages],
|
||||
}
|
||||
if credits:
|
||||
body["estimated_credits"] = credits
|
||||
return JsonResponse(body, status=200), False, ""
|
||||
|
||||
if step == "prompt":
|
||||
@@ -1733,6 +1782,41 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
if short is not None:
|
||||
return short
|
||||
# 用户原文仍落库,方便回看;continuation 已带反馈
|
||||
elif payload.get("interaction") == "plot_twist_story_depth":
|
||||
# 故事深度卡既可点选,也允许用户直接输入「30 秒」之类的自然回答。
|
||||
depth = set_plot_twist_story_depth(conversation, text)
|
||||
if depth is not None:
|
||||
payload["answers"] = {"story_depth": depth["value"]}
|
||||
payload["submitted"] = True
|
||||
payload["answered_via"] = "chat"
|
||||
pending.payload = payload
|
||||
pending.save(update_fields=["payload", "updated_at"])
|
||||
text = ""
|
||||
record_user_message = False
|
||||
force_creative_turn = True
|
||||
continuation_instruction = _plot_twist_depth_continuation(depth)
|
||||
elif payload.get("interaction") == "plot_twist_directions":
|
||||
options = [item for item in (payload.get("directions") or []) if isinstance(item, dict)]
|
||||
chosen = next(
|
||||
(
|
||||
item for item in options
|
||||
if str(item.get("title") or "") in text or str(item.get("id") or "") == text
|
||||
),
|
||||
None,
|
||||
)
|
||||
choice = str((chosen or {}).get("id") or text).strip()
|
||||
if choice:
|
||||
payload["answers"] = {"story_direction": choice}
|
||||
payload["submitted"] = True
|
||||
payload["answered_via"] = "chat"
|
||||
pending.payload = payload
|
||||
pending.save(update_fields=["payload", "updated_at"])
|
||||
text = ""
|
||||
record_user_message = False
|
||||
force_creative_turn = True
|
||||
continuation_instruction = _plot_twist_direction_continuation(
|
||||
conversation, payload, choice
|
||||
)
|
||||
elif payload.get("phase") == "gate":
|
||||
pending_fields = [item for item in (payload.get("pending_fields") or []) if isinstance(item, dict)]
|
||||
primary_field = pending_fields[0] if pending_fields else {}
|
||||
@@ -1973,6 +2057,27 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
# 素材选择闸门:用户愿意时再展开列表;跳过则直接沿原任务继续。
|
||||
if payload.get("interaction") == "step_confirm":
|
||||
pass # 已在上面处理
|
||||
elif payload.get("interaction") == "plot_twist_story_depth":
|
||||
depth = set_plot_twist_story_depth(
|
||||
conversation,
|
||||
str(answers.get("story_depth") or ""),
|
||||
)
|
||||
if depth is None:
|
||||
return JsonResponse({"detail": "请选择一个有效的故事深度"}, status=400)
|
||||
text = ""
|
||||
record_user_message = False
|
||||
force_creative_turn = True
|
||||
continuation_instruction = _plot_twist_depth_continuation(depth)
|
||||
elif payload.get("interaction") == "plot_twist_directions":
|
||||
choice = str(answers.get("story_direction") or "").strip()
|
||||
if not choice:
|
||||
return JsonResponse({"detail": "请选择一个剧情方向"}, status=400)
|
||||
text = ""
|
||||
record_user_message = False
|
||||
force_creative_turn = True
|
||||
continuation_instruction = _plot_twist_direction_continuation(
|
||||
conversation, payload, choice
|
||||
)
|
||||
elif payload.get("phase") == "gate":
|
||||
choice = str(answers.get("_asset_gate") or "").strip()
|
||||
if choice == "send":
|
||||
|
||||
Reference in New Issue
Block a user