优化全能创作部分优化脚本
This commit is contained in:
@@ -29,7 +29,7 @@ from .creation import append_message, pin_refs
|
||||
from .creation_presets import preset_guidance
|
||||
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, resolve_text_model
|
||||
from .services import build_provider, get_default_model, get_seed_text_model, resolve_text_model
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -80,7 +80,7 @@ video_prompt 必须能被出片模型直接执行,按时间轴写秒级分镜,
|
||||
3. 运镜(手持跟拍/推近/拉远/横摇/环绕/固定 —— 每段至少一个运镜词)
|
||||
4. 动作(谁、哪只手、对什么、做什么;要连贯可拍,禁止「展示质感」等抽象词)
|
||||
5. 信息变化(这几秒画面上多了/变了什么)
|
||||
- 口播原文单独写清(可用「口播:…」),字数贴近会话时长:大约 5.0–5.7 字/秒,
|
||||
- 人声原文单独写清,必须用「人声(仅音频,由人物口型与配音表达,不出现在画面上):…」,禁止写成「口播:…」(裸写口播会被出片模型烧成字幕);字数贴近会话时长:大约 5.0–5.7 字/秒,
|
||||
15 秒约 75–85 字;太短撑不满,太长会赶。
|
||||
- 钩子段画面不要「对着镜头说话」静态开场;前 15 个口播字禁止「大家好/今天分享/给你们推荐」。
|
||||
- 全片只围绕一个具体情境推进一个主卖点;卖点要有看得见的证据(质地/前后变化/用法结果)。
|
||||
@@ -88,11 +88,53 @@ video_prompt 必须能被出片模型直接执行,按时间轴写秒级分镜,
|
||||
- **不要写字幕/花字/标题贴片/弹幕/角标/水印/购物浮层**,也不要写「无字幕」
|
||||
(否定说法也容易把字画上屏)。口播只存在于声音;包装上原有印刷字除外。
|
||||
- 已 @ 的角色/商品/场景参考图会自动附上,不要在 prompt 里重描长相;以图锁定性别年龄服装外形。
|
||||
- 用户说「商品说话 / 商品自述 / 商品拟人」时,默认采用**无脸拟人**:商品声音是画外角色声,
|
||||
商品本体不做口型、不新增卡通五官;性格靠整体倾斜、转向、弹跳、进退、镜头和音效表达。
|
||||
只有用户明确要求可见的卡通五官时才例外。
|
||||
- 同一场戏保持地点、光线、服装连续;要换环境就明确写下一时间段切换。
|
||||
"""
|
||||
|
||||
|
||||
|
||||
_PRODUCT_VOICE_HINT_RE = re.compile(
|
||||
r"(商品|产品|包装|瓶|机身).{0,8}(说话|开口|自述|拟人)"
|
||||
r"|拟人.{0,8}(商品|产品|包装|瓶|机身)"
|
||||
)
|
||||
_VISIBLE_PRODUCT_FACE_RE = re.compile(
|
||||
r"(商品|产品|包装|瓶|机身).{0,12}(卡通)?(五官|眼睛|眼珠|嘴巴|嘴|口型)"
|
||||
r"|(卡通)?(五官|眼睛|眼珠|嘴巴|嘴|口型).{0,12}(商品|产品|包装|瓶|机身)"
|
||||
r"|(给|让).{0,12}(长出|加上|出现).{0,6}(卡通)?(五官|眼睛|眼珠|嘴巴|嘴|口型)"
|
||||
)
|
||||
PRODUCT_VOICE_VISUAL_GUARD = (
|
||||
"【商品角色表现·最高优先级】商品始终是参考图里的真实物件。包装正面、瓶身、机身和全部可见表面"
|
||||
"保持原有设计,只保留参考图本来就有的标签、图案与结构。拟人感完全通过整个商品轻微倾斜、"
|
||||
"转向、弹跳、进退,配合镜头、光影、环境反应和音效表达。商品台词采用画外角色声,声源不在"
|
||||
"画面内,商品保持完整物件形态;场景里的其他物件也保持真实原貌。整体采用精致实拍广告质感"
|
||||
"与克制幽默。前文若有改造商品外观的动作描述,统一改成商品整体运动的对应表达。"
|
||||
)
|
||||
|
||||
|
||||
def apply_product_voice_visual_guard(conversation: CreationConversation, prompt: str) -> str:
|
||||
"""商品拟人默认不长脸;用户明确要求可见卡通五官时尊重其创作选择。"""
|
||||
base = str(prompt or "").strip()
|
||||
recent_user_text = " ".join(
|
||||
conversation.messages.filter(
|
||||
role=CreationMessage.Role.USER,
|
||||
kind=CreationMessage.Kind.TEXT,
|
||||
).order_by("-seq").values_list("text", flat=True)[:12]
|
||||
)
|
||||
if _VISIBLE_PRODUCT_FACE_RE.search(recent_user_text):
|
||||
return base
|
||||
needs_guard = (
|
||||
conversation.preset == "商品拟人广告"
|
||||
or bool(_PRODUCT_VOICE_HINT_RE.search(recent_user_text))
|
||||
or bool(_PRODUCT_VOICE_HINT_RE.search(base))
|
||||
)
|
||||
if not needs_guard or PRODUCT_VOICE_VISUAL_GUARD in base:
|
||||
return base
|
||||
return f"{base}\n{PRODUCT_VOICE_VISUAL_GUARD}".strip()
|
||||
|
||||
|
||||
class AgentError(Exception):
|
||||
"""Agent 循环里的业务错误,已经是可以直接给用户看的中文。"""
|
||||
|
||||
@@ -128,8 +170,105 @@ _ASSET_PICK_PATTERNS = (
|
||||
)
|
||||
|
||||
|
||||
_CHITCHAT_RE = re.compile(
|
||||
r"^\s*("
|
||||
r"hi|hello|hey|yo|hola|"
|
||||
r"你好呀?|您好|嗨|哈喽|嘿|"
|
||||
r"在吗|在不在|有人吗|"
|
||||
r"早+|早安|早上好|午安|晚安|"
|
||||
r"嗯+|哦+|噢+|额+|呃+|"
|
||||
r"好的?|行|可以|ok(?:ay)?|thanks?|thank\s*you|谢谢了?|感谢|"
|
||||
r"收到|知道了|明白了|了解"
|
||||
r")[\s!!.。~~??…]*$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
def is_pure_chitchat(user_text: str) -> bool:
|
||||
"""纯打招呼 / 应答,没有创作意图。这类消息绝不能触发 write_strategy / write_plan。"""
|
||||
text = (user_text or "").strip()
|
||||
if not text or len(text) > 24:
|
||||
return False
|
||||
return bool(_CHITCHAT_RE.match(text))
|
||||
|
||||
|
||||
# 明确要做内容才算出方案/出图。闲聊、吐槽、问功能都不算。
|
||||
_CREATIVE_INTENT_RE = re.compile(
|
||||
r"("
|
||||
r"帮我做|帮我拍|帮我出|帮我写|帮我改|帮我生成|"
|
||||
r"创作[一两]?[条个张]?|创作(?:一条|个|短)?|"
|
||||
r"做[一两]?[条个张](?:视频|片|图|广告)?|拍[一两]?[条个](?:视频|片|广告)?|"
|
||||
r"出[一两]?[条个张](?:视频|片|图|广告)?|出片|出图|出方案|写方案|改方案|重写方案|重新写|"
|
||||
r"生成(?:一下|一张|几张|一条)?(?:视频|图|片|广告|脚本)?|做条|做个片|短视频|带货视频|短广告|广告片|带货片|"
|
||||
r"换卖点|改卖点|换剧情|改剧情|重做|重新出|按这个出|确认出片|"
|
||||
r"分镜|脚本|口播稿|storyboard|拟人|"
|
||||
r"(改|换|修改|更换).{0,8}(时长|秒数|比例|尺寸|画幅|分辨率|清晰度|模型)|"
|
||||
r"改成\s*\d+\s*秒|改成\s*\d+\s*[::]\s*\d+|改成.{0,8}(竖屏|横屏|比例|分辨率)"
|
||||
r")",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
# 创作 brief 常见搭配:动词 + 成片名词(「创作一条短广告」)
|
||||
_CREATIVE_VERB_RE = re.compile(r"创作|制作|拍摄|生成|做|拍|出|写|弄|来一条|来个")
|
||||
_CREATIVE_NOUN_RE = re.compile(r"视频|短片|短视频|广告|带货|出片|分镜|脚本|口播|主图|海报|成片")
|
||||
|
||||
|
||||
def has_creative_intent(user_text: str, refs: list | None = None) -> bool:
|
||||
"""用户本轮是否明确要做片/出图/改方案。
|
||||
|
||||
没意图时不把 write_strategy / write_plan / generate_image 塞进 tools,
|
||||
避免模型把闲聊「整理」成方案卡。仅 @ 素材不算意图。
|
||||
"""
|
||||
text = (user_text or "").strip()
|
||||
if not text:
|
||||
return False
|
||||
if is_pure_chitchat(text):
|
||||
return False
|
||||
if _CREATIVE_INTENT_RE.search(text):
|
||||
return True
|
||||
# 「把商品…创作一条…短广告」这类 brief:同时有创作动词和成片名词
|
||||
if len(text) >= 8 and _CREATIVE_VERB_RE.search(text) and _CREATIVE_NOUN_RE.search(text):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
_CONTINUE_INTENT_RE = re.compile(
|
||||
r"^\s*(继续|继续做|接着|接着做|往下做|开始吧|开做吧|就这样|就按这个|按这个来|照这个做|直接做|直接来)[吧啊呀呢。.!!]*\s*$"
|
||||
)
|
||||
|
||||
|
||||
def is_continue_intent(user_text: str) -> bool:
|
||||
"""已有创作上下文时,这些短句是在授权继续,不是闲聊。"""
|
||||
return bool(_CONTINUE_INTENT_RE.match((user_text or "").strip()))
|
||||
|
||||
|
||||
def session_has_creative_context(conversation: CreationConversation) -> bool:
|
||||
"""会话里是否已有可继续的创作进度(钉了素材 / 出过策略或方案)。
|
||||
|
||||
这个信号只用于判断一句短回复是否承接创作,不拿来主动追问流程确认。
|
||||
"""
|
||||
if conversation.pinned_refs:
|
||||
return True
|
||||
if conversation.messages.filter(
|
||||
kind__in=(
|
||||
CreationMessage.Kind.STRATEGY,
|
||||
CreationMessage.Kind.PLAN,
|
||||
CreationMessage.Kind.PROMPT_FILE,
|
||||
CreationMessage.Kind.CONFIRM,
|
||||
)
|
||||
).exists():
|
||||
return True
|
||||
# 追问发生在策略卡之前时,会话里可能还没有任何结构化产物。原始 brief 本身
|
||||
# 就是创作上下文;不认它的话,刷新后一句「继续」会被当成空闲聊天。
|
||||
recent_user_texts = conversation.messages.filter(
|
||||
role=CreationMessage.Role.USER,
|
||||
kind=CreationMessage.Kind.TEXT,
|
||||
).order_by("-seq").values_list("text", flat=True)[:8]
|
||||
return any(has_creative_intent(item) for item in recent_user_texts)
|
||||
|
||||
|
||||
def wanted_asset_pick(user_text: str, refs: list | None) -> str | None:
|
||||
"""用户说「改商品」却没 @ 时,应弹出素材卡,而不是让他自己填名字。"""
|
||||
"""用户说「改商品」却没点名时,直接发对应素材选择卡。"""
|
||||
if refs:
|
||||
return None
|
||||
text = user_text or ""
|
||||
@@ -139,6 +278,34 @@ def wanted_asset_pick(user_text: str, refs: list | None) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
def requested_asset_card_from_context(
|
||||
conversation: CreationConversation,
|
||||
user_text: str,
|
||||
) -> str | None:
|
||||
"""「发一下卡片我选」没有说类型时,沿用最近一次素材追问的类型。"""
|
||||
text = str(user_text or "")
|
||||
wants_card = re.search(
|
||||
r"(发|打开|展示|看看|看下|给我).{0,10}(卡片|列表|商品库|素材库)"
|
||||
r"|(卡片|列表).{0,10}(选|选择|看看|看下)",
|
||||
text,
|
||||
)
|
||||
if not wants_card:
|
||||
return None
|
||||
recent = conversation.messages.filter(
|
||||
kind=CreationMessage.Kind.ELICIT
|
||||
).order_by("-seq")[:8]
|
||||
for message in recent:
|
||||
payload = message.payload or {}
|
||||
fields = payload.get("pending_fields") or payload.get("fields") or []
|
||||
for field in fields:
|
||||
if not isinstance(field, dict):
|
||||
continue
|
||||
inferred = infer_field_types(field)
|
||||
if len(inferred) == 1 and inferred[0] in TYPE_LABELS:
|
||||
return inferred[0]
|
||||
return None
|
||||
|
||||
|
||||
|
||||
VIDEO_MODELS = ["Seedance 2.5", "Seedance 2.0", "Seedance 2.0 Fast", "Seedance 2.0 Mini"]
|
||||
IMAGE_MODELS = ["Seedream5.0", "YQ image2"]
|
||||
@@ -150,11 +317,11 @@ SESSION_PARAM_KEYS = ("duration", "ratio", "resolution", "video_model", "count")
|
||||
_PARAM_TO_STORED = {"video_model": "model"}
|
||||
|
||||
_PARAM_PICK_LABEL = {
|
||||
"duration": "改成多长?",
|
||||
"ratio": "改成什么比例?",
|
||||
"resolution": "改成什么分辨率?",
|
||||
"video_model": "换成哪个模型?",
|
||||
"count": "出几张?",
|
||||
"duration": "想改成多少秒?",
|
||||
"ratio": "想换成什么画幅?比如 9:16 竖屏或 16:9 横屏。",
|
||||
"resolution": "想换成什么清晰度?直接说 480p、720p 或 1080p 就行。",
|
||||
"video_model": "想换哪个模型?直接告诉我模型名就行。",
|
||||
"count": "这次想出几张?",
|
||||
}
|
||||
|
||||
|
||||
@@ -172,7 +339,8 @@ def _param_options(key: str, is_video: bool) -> list[str]:
|
||||
|
||||
def session_param_fields(keys: list[str], is_video: bool) -> list[dict]:
|
||||
fields = []
|
||||
for key in keys[:3]:
|
||||
# 对话式追问一次只问一件事,避免又退化成参数问卷。
|
||||
for key in keys[:1]:
|
||||
if key not in _PARAM_PICK_LABEL:
|
||||
continue
|
||||
options = [{"value": item, "label": item} for item in _param_options(key, is_video)]
|
||||
@@ -283,27 +451,29 @@ def apply_confirm_params(conversation, incoming: dict | None) -> tuple[dict, boo
|
||||
|
||||
# ---------------------------------------------------------------- 工具 schema
|
||||
|
||||
def tool_schemas(context: AgentContext) -> list[dict]:
|
||||
def tool_schemas(context: AgentContext, *, allow_plan: bool = True) -> list[dict]:
|
||||
"""给模型看的工具清单。图片会话不暴露 generate_video,反之亦然 ——
|
||||
会话 mode 是定死的(契约 §0),把不该用的工具摆出来只会诱导模型走错路。"""
|
||||
会话 mode 是定死的(契约 §0),把不该用的工具摆出来只会诱导模型走错路。
|
||||
allow_plan=False 时隐藏 write_strategy / write_plan / generate_image,闲聊用不出来。"""
|
||||
tools = [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "ask_user",
|
||||
"description": (
|
||||
"缺少必要信息时反问用户。会在对话里渲染成可点选/可填写的卡片。"
|
||||
"缺少必要信息时向用户追问。"
|
||||
"只在信息**确实缺失且无法合理推断**时用;能自己定的就自己定,别把用户当填表机器。"
|
||||
"用户要选/改/换商品、角色、模特、场景时**必须**调这个工具,"
|
||||
"type 用 asset 并填 asset_types;禁止只在气泡里问「换成哪个」。"
|
||||
"一次最多问 3 项。"
|
||||
"type 用 asset 并填 asset_types,系统会直接显示可视化素材卡让用户点选。"
|
||||
"不要先问用户是否需要卡片或列表。其他类型才显示为普通聊天问题。"
|
||||
"一次只问 1 项,禁止问「要不要继续」「要不要生成」「是否开始创作」这类流程问题。"
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"fields": {
|
||||
"type": "array",
|
||||
"maxItems": 3,
|
||||
"maxItems": 1,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -352,6 +522,10 @@ def tool_schemas(context: AgentContext) -> list[dict]:
|
||||
},
|
||||
},
|
||||
]
|
||||
if not allow_plan:
|
||||
# 闲聊轮次不给 ask_user,避免模型追问「要不要出片」;
|
||||
# 换商品/改参数仍由 wanted_asset_pick / wanted_param_keys 兜底追问。
|
||||
return [t for t in tools if t.get("function", {}).get("name") == "search_library"]
|
||||
if context.is_video:
|
||||
tools.append({
|
||||
"type": "function",
|
||||
@@ -359,7 +533,7 @@ def tool_schemas(context: AgentContext) -> list[dict]:
|
||||
"name": "write_strategy",
|
||||
"description": (
|
||||
"写「创作策略理解」卡:说清这条片给谁看、他为什么会信、你想让他信什么、整体创作方向。"
|
||||
"在动手写方案之前调它一次,让用户先确认你理解对了。"
|
||||
"仅当用户明确要做片/出方案时,在 write_plan 之前调一次;打招呼或闲聊不要调。"
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
@@ -378,8 +552,8 @@ def tool_schemas(context: AgentContext) -> list[dict]:
|
||||
"function": {
|
||||
"name": "write_plan",
|
||||
"description": (
|
||||
"写「视频最终方案」卡并请用户确认。**这是出片前的最后一步**,调完会等用户点确认,"
|
||||
"确认后平台直接按 video_prompt 出片,你不会再有插话机会。"
|
||||
"写「视频最终方案」卡并请用户确认。**仅当用户明确要做片/出方案/改方案时调用**;"
|
||||
"打招呼或闲聊不要调。调完会等用户点确认,确认后平台直接按 video_prompt 出片。"
|
||||
"video_prompt 按系统里的「出片脚本写法」写成口播秒级分镜(专业创作同口径),不要只写大纲。"
|
||||
"先调 write_strategy 再调它。"
|
||||
),
|
||||
@@ -412,7 +586,7 @@ def tool_schemas(context: AgentContext) -> list[dict]:
|
||||
"description": (
|
||||
"交给出片模型的完整口播带货指令(对齐专业创作口径)。"
|
||||
"必须含:总时长与画幅、整体光线色调、按 0-Ns 分段的秒级分镜"
|
||||
"(每段写清景别/机位/运镜/具体动作/信息变化)、口播原文、一个主卖点与可见证据、口语 CTA。"
|
||||
"(每段写清景别/机位/运镜/具体动作/信息变化)、人声(仅音频抬头)原文、一个主卖点与可见证据、口语 CTA。禁止「口播:」字样。"
|
||||
"禁止字幕/花字/贴片及「无字幕」字样;禁止详情页腔与「大家好」开场。"
|
||||
"已 @ 素材会自动作参考图,勿重描长相。"
|
||||
),
|
||||
@@ -451,7 +625,7 @@ def _coerce_fields(raw) -> list[dict]:
|
||||
"""把模型给的 fields 规整成契约 §2 的 Field。脏数据丢弃而不是抛 ——
|
||||
模型偶尔漏个 type 不该让整条对话崩掉。"""
|
||||
fields: list[dict] = []
|
||||
for item in (raw or [])[:3]:
|
||||
for item in (raw or [])[:1]:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
key = str(item.get("key") or "").strip()
|
||||
@@ -635,6 +809,7 @@ 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_product_voice_visual_guard(context.conversation, prompt)
|
||||
submit = {
|
||||
"prompt": prompt,
|
||||
"feature": "omni_create",
|
||||
@@ -759,8 +934,10 @@ def submit_confirmed_image(*, conversation: CreationConversation, user, confirm_
|
||||
|
||||
|
||||
def get_creation_chat_model(requested: ModelConfig | None = None) -> ModelConfig | None:
|
||||
"""全能创作对话模型:走平台文本解析,DeepSeek 一律换成 Seed 2.1 Pro。"""
|
||||
return resolve_text_model(requested)
|
||||
"""全能创作编排固定优先 Seed 2.1 Pro;显式传入的可用模型仍尊重用户选择。"""
|
||||
if requested is not None:
|
||||
return resolve_text_model(requested)
|
||||
return get_seed_text_model() or resolve_text_model(None)
|
||||
|
||||
|
||||
def _creation_model_sees_images(model_config: ModelConfig | None) -> bool:
|
||||
@@ -853,7 +1030,7 @@ def _attach_ref_images(messages: list[dict], image_urls: list[str]) -> list[dict
|
||||
return out
|
||||
|
||||
|
||||
def build_system_prompt(context: AgentContext) -> str:
|
||||
def build_system_prompt(context: AgentContext, *, allow_plan: bool = True, has_context: bool = False) -> str:
|
||||
conversation = context.conversation
|
||||
params = conversation.params or {}
|
||||
kind = "视频" if context.is_video else "图片"
|
||||
@@ -862,16 +1039,30 @@ def build_system_prompt(context: AgentContext) -> str:
|
||||
f"本次会话产出的是**{kind}**,这一点在整个会话里不会改变 —— 用户要另一种就请他新开一个创作。",
|
||||
"",
|
||||
"【怎么说话】",
|
||||
"- 说人话,像个有经验的同事,不要写成客服话术或需求确认清单。",
|
||||
"- 不要复述用户刚说过的话,不要说「好的,我明白了」这种空句。",
|
||||
"- 一次只推进一步。",
|
||||
"- 说人话,像个懂创作、会一起把事做完的同事;自然、简短、有判断,不要写成客服话术或需求确认清单。",
|
||||
"- 禁止用「好的」「收到」「明白了」「有需要再说」「我将为你」起手;这些句子没有信息量,也很像机器人。",
|
||||
"- 不要逐字复述用户刚说过的话。需要承接时,只说你的判断或下一步,例如「这个方向能做,我先把反转落在商品登场上。」",
|
||||
"- 一次只推进一步,但不要把能直接做的事停在寒暄、确认或客套话上。",
|
||||
"- 缺信息时一次只问一个真正影响结果的问题,不要把对话做成问卷。",
|
||||
"- 商品、模特、角色、场景这类有库内素材可选的内容,直接发可视化选择卡让用户点;不要先问「要不要发列表」。",
|
||||
"- 调性、受众、文案、时长等其他信息才用聊天追问,问题末尾顺手告诉用户下一句怎么回。",
|
||||
"- 用户刚回答过你的问题时,直接沿着答案继续;不要复述答案,也不要额外回一句「收到」。",
|
||||
"- 用户选择暂不提供某项素材时,把它当成明确授权:按已有信息和合理默认继续。除非任务客观上无法完成,否则不要再次追问同一素材。",
|
||||
"- 用户说「你来定」「你帮我选」「随便」「都行」时,就是授权你做专业判断;直接选合理方案继续,不要把选择题再抛回去。",
|
||||
"- 禁止问「要不要继续」「要不要生成」「是否开始创作」这类流程问题。用户已经提出创作需求且信息够了,就直接写策略/方案;真正扣积分前另有确认卡。",
|
||||
"- 用户打招呼或闲聊(hi / 你好 / 在吗 / 你在干什么 / 嗯 / 好的 / ok):"
|
||||
" **禁止**调用 write_strategy、write_plan、generate_image,不要「整理方案」或直接开写脚本。",
|
||||
"- 会话里**还没有**商品/方向时:自然地告诉用户可以直接丢一句想法,别硬推销,也别用客服式结束语。",
|
||||
"- 会话里**已有**商品或方向时:针对用户这句话本身自然回应;别用「要现在生成还是先调细节」把工作又抛回给用户。",
|
||||
"- 没有明确创作指令时不要自己写策略卡/方案卡,也不要主动追问流程确认;等用户给出具体想法或修改。",
|
||||
"",
|
||||
"【什么时候反问】",
|
||||
"- 只有信息**确实缺失且无法合理推断**时才调用 ask_user;能自己定的就自己定。",
|
||||
"- ask_user 一次只问一件事。type=asset 会显示素材选择卡;其他类型显示普通聊天问题,让用户直接输入。",
|
||||
"- 商品是谁、给谁看、什么调性 —— 这些缺了会直接影响成片,值得问。",
|
||||
"- 让用户选商品/角色/模特/场景时,ask_user 必须用 type=asset 并填 asset_types。",
|
||||
"- 用户说「改商品」「换角色」却没点名是哪个:立刻 ask_user 出点选卡,禁止只在气泡里问「换成哪个、填个名字」。用户不必 @。",
|
||||
"- 用户说改时长/模型/比例/分辨率:立刻 ask_user,type=single 给出选项;选完后旧方案作废,必须按新参数重新 write_plan。不要让用户去点底部菜单。",
|
||||
"- 用户说「改商品」「换角色」却没点名是哪个:立刻 ask_user 发对应素材卡,不要再用文字追问名称。",
|
||||
"- 用户说改时长/模型/比例/分辨率但没给新值:立刻 ask_user,type=single 提供可识别的候选值;聊天里只显示问题,用户直接输入。回答后旧方案作废,必须按新参数重新 write_plan。",
|
||||
"- 光线、构图、镜头这些专业判断是你的活,不要反过来问用户。",
|
||||
]
|
||||
if context.is_video:
|
||||
@@ -884,7 +1075,7 @@ def build_system_prompt(context: AgentContext) -> str:
|
||||
lo = max(1, int(dur * 5.0))
|
||||
hi = max(lo, min(85, int(dur * 5.7)))
|
||||
lines.append(f"- 当前按约 {dur} 秒出片,口播建议 {lo}–{hi} 字;write_plan 的 voice_chars 填这个区间。")
|
||||
lines.append("- 写方案时必须调用 write_plan;video_prompt 按上面的秒级分镜规范写满,不要只给大纲。")
|
||||
lines.append("- 只有用户明确要做片、出方案、改方案、换卖点/剧情时才调用 write_strategy / write_plan;闲聊与打招呼绝对不要。真要写方案时必须先 write_strategy 再 write_plan,video_prompt 按上面的秒级分镜规范写满,不要只给大纲,也禁止只回「好的,有需要再说」。")
|
||||
else:
|
||||
lines.extend([
|
||||
"",
|
||||
@@ -892,10 +1083,26 @@ def build_system_prompt(context: AgentContext) -> str:
|
||||
"- 决定出图时必须调用 generate_image,不要只口头说「我这就出图」。",
|
||||
"- 一次用户消息只出一轮;张数用会话已定参数,不要自己加张。",
|
||||
])
|
||||
if not allow_plan:
|
||||
lines.extend([
|
||||
"",
|
||||
"【本轮闸门】",
|
||||
"- 用户本轮没有明确说「去做/出方案」。没有 write_strategy / write_plan / generate_image。",
|
||||
"- **禁止**本轮直接写出策略卡或方案卡。",
|
||||
])
|
||||
if has_context:
|
||||
lines.extend([
|
||||
"- 会话已有素材或方向:针对用户本轮实际说的话自然回应;不要问要不要继续、要不要生成。",
|
||||
"- 禁止只回「在呢」「有需要随时招呼」「收到」这种空话。",
|
||||
])
|
||||
else:
|
||||
lines.extend([
|
||||
"- 会话还没有创作进度:短回一句,告诉用户直接说想做什么即可;不要用客服式结束语,也不要硬推销出片。",
|
||||
])
|
||||
if params:
|
||||
meta = "、".join(f"{k}:{v}" for k, v in params.items() if v)
|
||||
if meta:
|
||||
lines.append(f"\n【会话已定参数】{meta}(出片按这套;用户要改就出选项卡,选完必须重写方案)")
|
||||
lines.append(f"\n【会话已定参数】{meta}(出片按这套;用户改动后必须按新值重写方案)")
|
||||
if conversation.preset:
|
||||
# 只给名字模型只能靠猜;把这个预设的拍法约束一起给它
|
||||
guidance = preset_guidance(conversation.preset)
|
||||
@@ -929,14 +1136,25 @@ def build_system_prompt(context: AgentContext) -> str:
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def build_messages(context: AgentContext) -> list[dict]:
|
||||
def _answer_label(field: dict, raw) -> str:
|
||||
"""追问卡的内部值翻成人能读的标签,避免把 UUID / gate key 喂回模型。"""
|
||||
values = raw if isinstance(raw, list) else [raw]
|
||||
options = {
|
||||
str(item.get("value")): str(item.get("label") or item.get("value") or "")
|
||||
for item in (field.get("options") or [])
|
||||
if isinstance(item, dict)
|
||||
}
|
||||
return "、".join(options.get(str(value), str(value)) for value in values if value not in (None, ""))
|
||||
|
||||
|
||||
def build_messages(context: AgentContext, *, allow_plan: bool = True, has_context: bool = False) -> list[dict]:
|
||||
"""会话历史 → 模型消息。只喂对模型有意义的:文字、追问和它的答案、生成过什么。
|
||||
策略卡/方案卡这类结构化产物压成一句话,原样塞 JSON 只会挤爆上下文。
|
||||
|
||||
长会话只喂最近 KEEP_RECENT_MESSAGES 条原文,更早的靠 system 里的【前情提要】
|
||||
——摘要在 compress_memory() 里生成,不在这里现算。
|
||||
"""
|
||||
messages = [{"role": "system", "content": build_system_prompt(context)}]
|
||||
messages = [{"role": "system", "content": build_system_prompt(context, allow_plan=allow_plan, has_context=has_context)}]
|
||||
history = list(context.conversation.messages.all())
|
||||
if len(history) > COMPRESS_AFTER_MESSAGES:
|
||||
history = history[-KEEP_RECENT_MESSAGES:]
|
||||
@@ -945,14 +1163,37 @@ def build_messages(context: AgentContext) -> list[dict]:
|
||||
if message.text.strip():
|
||||
messages.append({"role": message.role, "content": message.text})
|
||||
elif message.kind == CreationMessage.Kind.ELICIT:
|
||||
answers = (message.payload or {}).get("answers") or {}
|
||||
labels = {f["key"]: f["label"] for f in (message.payload or {}).get("fields", [])}
|
||||
payload = message.payload or {}
|
||||
answers = payload.get("answers") or {}
|
||||
fields = [item for item in (payload.get("fields") or []) if isinstance(item, dict)]
|
||||
field_by_key = {str(item.get("key") or ""): item for item in fields}
|
||||
if payload.get("interaction") == "chat" or (
|
||||
payload.get("interaction") == "asset_picker"
|
||||
and payload.get("answered_via") == "chat"
|
||||
):
|
||||
question = message.text.strip() or str((fields[0] if fields else {}).get("label") or "").strip()
|
||||
if question:
|
||||
messages.append({"role": "assistant", "content": question})
|
||||
# 聊天式回答本身就是下一条 TEXT user 消息,这里不再合成一条伪造答案。
|
||||
continue
|
||||
if answers:
|
||||
joined = ";".join(f"{labels.get(k, k)} → {v}" for k, v in answers.items())
|
||||
messages.append({"role": "assistant", "content": f"(我问了用户几个问题)"})
|
||||
messages.append({"role": "user", "content": f"(用户回答){joined}"})
|
||||
if payload.get("phase") == "gate":
|
||||
choice = str(answers.get("_asset_gate") or "")
|
||||
if choice == "skip":
|
||||
answer_text = "(用户选择暂不添加这项素材,要求按现有信息继续原任务)"
|
||||
else:
|
||||
answer_text = "(用户希望打开素材列表继续选择)"
|
||||
else:
|
||||
joined = ";".join(
|
||||
f"{field_by_key.get(str(key), {}).get('label') or key}:"
|
||||
f"{_answer_label(field_by_key.get(str(key), {}), value)}"
|
||||
for key, value in answers.items()
|
||||
)
|
||||
answer_text = f"(用户完成了刚才的选择:{joined})"
|
||||
messages.append({"role": "assistant", "content": "(我请用户补充了一项会影响创作的信息)"})
|
||||
messages.append({"role": "user", "content": answer_text})
|
||||
else:
|
||||
messages.append({"role": "assistant", "content": "(我向用户提了问题,还没收到回答)"})
|
||||
messages.append({"role": "assistant", "content": "(我正在等用户回答刚才的问题)"})
|
||||
elif message.kind in (CreationMessage.Kind.GENERATING, CreationMessage.Kind.RESULT):
|
||||
prompt = (message.payload or {}).get("prompt") or ""
|
||||
messages.append({"role": "assistant", "content": f"(我生成了一版,prompt:{prompt[:200]})"})
|
||||
@@ -1005,6 +1246,9 @@ def stream_creation_agent(
|
||||
text: str,
|
||||
refs: list[dict] | None = None,
|
||||
model_config: ModelConfig | None = None,
|
||||
record_user_message: bool = True,
|
||||
force_creative_turn: bool = False,
|
||||
continuation_instruction: str = "",
|
||||
) -> Iterator[str]:
|
||||
"""一条用户消息 → SSE 流。生成器,由 StreamingHttpResponse 逐帧下发。"""
|
||||
refs = refs or []
|
||||
@@ -1015,10 +1259,26 @@ def stream_creation_agent(
|
||||
|
||||
context = AgentContext(conversation=conversation, user=user, model_config=model_config)
|
||||
try:
|
||||
user_message = None
|
||||
with transaction.atomic():
|
||||
pin_refs(conversation, refs)
|
||||
user_message = append_message(conversation, role="user", text=text, refs=refs)
|
||||
yield _sse({"type": "message", "message": _message_payload(user_message)})
|
||||
if record_user_message:
|
||||
user_message = append_message(conversation, role="user", text=text, refs=refs)
|
||||
if user_message is not None:
|
||||
yield _sse({"type": "message", "message": _message_payload(user_message)})
|
||||
# 空会话里的纯打招呼:短回一句就够。已有商品/方向时走模型,
|
||||
# 针对用户实际说的话自然回应,但不给方案工具。
|
||||
has_context = session_has_creative_context(conversation)
|
||||
if record_user_message and is_pure_chitchat(text) and not refs and not has_context:
|
||||
reply = append_message(
|
||||
conversation,
|
||||
role="assistant",
|
||||
text="我在。想做什么,直接丢一句想法给我就行。",
|
||||
)
|
||||
yield _sse({"type": "message", "message": _message_payload(reply)})
|
||||
yield _sse({"type": "done"})
|
||||
return
|
||||
|
||||
model_config = _prefer_vision_text_model(model_config, conversation.team, conversation.pinned_refs or [])
|
||||
context.model_config = model_config
|
||||
|
||||
@@ -1035,9 +1295,18 @@ def stream_creation_agent(
|
||||
# 用户消息已经先回显了,所以这一小段等待不会看起来像卡住。
|
||||
compress_memory(context)
|
||||
|
||||
allow_plan = (
|
||||
force_creative_turn
|
||||
or has_creative_intent(text, refs)
|
||||
or (has_context and is_continue_intent(text))
|
||||
)
|
||||
provider = build_provider(model_config)
|
||||
messages = build_messages(context)
|
||||
tools = tool_schemas(context)
|
||||
messages = build_messages(context, allow_plan=allow_plan, has_context=has_context)
|
||||
if continuation_instruction.strip():
|
||||
# 卡片答案已经在历史里,这里仅给本轮一个不落库的执行指令。这样既不会多出
|
||||
# 一条伪造的用户气泡,也不会让模型把「跳过素材」误判成闲聊后停住。
|
||||
messages.append({"role": "user", "content": continuation_instruction.strip()})
|
||||
tools = tool_schemas(context, allow_plan=allow_plan)
|
||||
|
||||
for _round in range(MAX_TOOL_ROUNDS):
|
||||
text_buffer: list[str] = []
|
||||
@@ -1060,17 +1329,15 @@ def stream_creation_agent(
|
||||
|
||||
said = "".join(text_buffer).strip()
|
||||
calls = [tool_buffer[i] for i in sorted(tool_buffer) if tool_buffer[i].get("name")]
|
||||
|
||||
if said:
|
||||
bubble = append_message(conversation, role="assistant", text=said)
|
||||
yield _sse({"type": "message", "message": _message_payload(bubble)})
|
||||
|
||||
fallback_fields = None
|
||||
if not calls:
|
||||
pick = wanted_asset_pick(text, refs)
|
||||
pick = (
|
||||
wanted_asset_pick(text, refs)
|
||||
or requested_asset_card_from_context(conversation, text)
|
||||
)
|
||||
param_keys = wanted_param_keys(text, is_video=context.is_video)
|
||||
fields = None
|
||||
if pick:
|
||||
fields = [{
|
||||
fallback_fields = [{
|
||||
"key": pick,
|
||||
"label": _ASSET_PICK_LABEL[pick],
|
||||
"type": "asset",
|
||||
@@ -1078,9 +1345,18 @@ def stream_creation_agent(
|
||||
"asset_types": [pick],
|
||||
}]
|
||||
elif param_keys:
|
||||
fields = session_param_fields(param_keys, context.is_video)
|
||||
if fields:
|
||||
result, _stop = _dispatch_tool(context, "ask_user", {"fields": fields})
|
||||
fallback_fields = session_param_fields(param_keys, context.is_video)
|
||||
|
||||
# ask_user 自己会落一条可追踪的聊天问题。模型同时吐出的过渡文案不再
|
||||
# 另存一条,否则界面会连续出现两遍几乎相同的问题。
|
||||
asks_user = bool(fallback_fields) or any(call.get("name") == "ask_user" for call in calls)
|
||||
if said and not asks_user:
|
||||
bubble = append_message(conversation, role="assistant", text=said)
|
||||
yield _sse({"type": "message", "message": _message_payload(bubble)})
|
||||
|
||||
if not calls:
|
||||
if fallback_fields:
|
||||
result, _stop = _dispatch_tool(context, "ask_user", {"fields": fallback_fields})
|
||||
for event in result.get("_events", []):
|
||||
yield _sse(event)
|
||||
break
|
||||
@@ -1138,6 +1414,47 @@ _TOOL_LABELS = {
|
||||
}
|
||||
|
||||
|
||||
_ASSET_CARD_LABELS = {
|
||||
"product": "这条要展示哪款商品?",
|
||||
"character": "这条想用哪个角色?",
|
||||
"model": "这条想用哪位模特?",
|
||||
"scene": "这条想放在哪个场景里?",
|
||||
"asset": "这一步要使用哪项素材?",
|
||||
}
|
||||
|
||||
|
||||
def _guided_elicit_text(field: dict) -> str:
|
||||
"""追问既要问清一件事,也要让用户知道下一句怎么回。"""
|
||||
label = str(field.get("label") or "这项你想怎么定?").strip()
|
||||
if field.get("type") == "asset":
|
||||
return label
|
||||
if str(field.get("key") or "") in SESSION_PARAM_KEYS:
|
||||
return label
|
||||
return f"{label} 直接用一句话告诉我就行,不用整理成完整需求。"
|
||||
|
||||
|
||||
|
||||
def _elicit_payload_for_fields(fields: list[dict]) -> dict:
|
||||
"""素材问题直接出选择卡;其他问题继续走普通聊天。"""
|
||||
field = dict(fields[0])
|
||||
if field.get("type") == "asset":
|
||||
asset_types = [item for item in (field.get("asset_types") or []) if item in _ASSET_CARD_LABELS]
|
||||
field["label"] = _ASSET_CARD_LABELS[asset_types[0]] if len(asset_types) == 1 else _ASSET_CARD_LABELS["asset"]
|
||||
return {
|
||||
"interaction": "asset_picker",
|
||||
"phase": "pick",
|
||||
"fields": [field],
|
||||
"submitted": False,
|
||||
"answers": {},
|
||||
}
|
||||
return {
|
||||
"interaction": "chat",
|
||||
"fields": [field],
|
||||
"submitted": False,
|
||||
"answers": {},
|
||||
}
|
||||
|
||||
|
||||
def _dispatch_tool(context: AgentContext, name: str, args: dict) -> tuple[dict, bool]:
|
||||
"""执行一个工具。返回 (结果, 是否中断循环)。
|
||||
|
||||
@@ -1147,10 +1464,13 @@ def _dispatch_tool(context: AgentContext, name: str, args: dict) -> tuple[dict,
|
||||
fields = _coerce_fields(args.get("fields"))
|
||||
if not fields:
|
||||
return {"payload": {"error": "fields 不合法,请重新组织问题"}}, False
|
||||
payload = _elicit_payload_for_fields(fields)
|
||||
display_field = (payload.get("fields") or fields)[0]
|
||||
message = append_message(
|
||||
context.conversation, role="assistant",
|
||||
kind=CreationMessage.Kind.ELICIT,
|
||||
payload={"fields": fields, "submitted": False, "answers": {}},
|
||||
text=_guided_elicit_text(display_field),
|
||||
payload=payload,
|
||||
)
|
||||
# 反问一旦发出就必须停,等人回答。继续跑等于自问自答。
|
||||
return {
|
||||
|
||||
Reference in New Issue
Block a user