修改全能创作

This commit is contained in:
Azmat@qq.com
2026-09-15 17:45:28 +08:00
parent 243a3aa54b
commit d4f282bfa9
8 changed files with 1142 additions and 212 deletions
+316 -17
View File
@@ -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