优化全能创作部分优化脚本

This commit is contained in:
Azmat@qq.com
2026-09-10 18:59:08 +08:00
parent 0d56fdb299
commit 43f80a4d90
12 changed files with 1223 additions and 95 deletions
+374 -54
View File
@@ -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 {