优化全能创作部分优化脚本
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 {
|
||||
|
||||
@@ -14,8 +14,10 @@ VIDEO_PRESETS: dict[str, str] = {
|
||||
"商品不能在开头硬推,要等冲突立住了再自然介入。禁止把普通不便夸成严重后果。"
|
||||
),
|
||||
"商品拟人广告": (
|
||||
"把商品拟人化成有性格的角色,用它的动作、表情和情绪推进。轻快有趣,不说教。"
|
||||
"拟人化不能牺牲商品真实外观 —— 材质、颜色、结构必须和参考图一致。"
|
||||
"把商品当成有性格的角色,但默认采用无脸拟人:商品本体保持真实完整,不在瓶身、包装或机身上新增卡通五官。"
|
||||
"性格通过整个商品轻微倾斜、转向、弹跳、进退,配合镜头、光影、环境反应和音效来表达。"
|
||||
"商品需要说台词时使用画外角色声,商品本体不做口型。整体轻快有趣、精致克制,不是儿童贴纸玩具风。"
|
||||
"只有用户明确说要可见的卡通五官时才采用有脸拟人。商品材质、颜色、结构和包装必须与参考图一致。"
|
||||
),
|
||||
"达人口播种草": (
|
||||
"真人出镜口播,生活化语气,像朋友分享而不是念广告稿。开头一句话建立停留理由,"
|
||||
|
||||
@@ -71,6 +71,9 @@ def model_resolutions(model_config) -> set[str]:
|
||||
RATIOS = {"21:9", "16:9", "4:3", "1:1", "3:4", "9:16"}
|
||||
RESOLUTIONS = {"480p", "720p", "1080p", "4k"}
|
||||
MODES = {"universal", "keyframe"}
|
||||
# 全能创作 / 分镜路用语义 type(character/product/…);对火山仍是参考图。
|
||||
# 旧逻辑只认 image/video/audio,会把商品/角色图静默 skipped → 成片只靠文案「发明」包装。
|
||||
IMAGE_LIKE_REF_TYPES = {"image", "character", "product", "scene", "model", "asset"}
|
||||
IN_FLIGHT_STATUSES = (
|
||||
AITask.Status.CREATED, # 视频复刻审核中也占并发,避免连点刷出一堆待审任务
|
||||
AITask.Status.RESERVED,
|
||||
@@ -300,7 +303,9 @@ def build_content_items(
|
||||
|
||||
# 三库引用(模块4 · 4.1):资产库 / 模特库 / 商品库 挑出来的东西最终都是一行 Asset,
|
||||
# 所以后端只认一种 source=asset,三个库的差别全在前端的 picker 上。
|
||||
if source == "asset" and ref.get("asset_id"):
|
||||
# 全能创作 resolve_refs / 前端常带 type=character|product 且 source 缺省为 upload,
|
||||
# 只要有 asset_id 就按 Asset 解析(含审核态 → asset://),避免直链分支把语义 type skipped。
|
||||
if ref.get("asset_id") and source in {"asset", "upload", ""}:
|
||||
asset = Asset.objects.filter(id=ref["asset_id"], team=team, is_deleted=False).first()
|
||||
if asset is None:
|
||||
raise ValueError(f"素材「{label or '未命名'}」不存在或已被删除")
|
||||
@@ -326,8 +331,9 @@ def build_content_items(
|
||||
|
||||
# 直传素材(已上传 TOS 的直链)。resolved_url 可覆盖为 asset://(官方模特跨团队等)
|
||||
push_url = str(ref.get("resolved_url") or url)
|
||||
if ref_type == "image":
|
||||
if ref_type in IMAGE_LIKE_REF_TYPES:
|
||||
# 参考图模式下所有图 role 必须 reference_image;keyframe 用 first_frame/last_frame
|
||||
# character/product/scene/model/asset 与 image 同等对待(全能创作语义 type)
|
||||
effective_role = "reference_image" if mode == "universal" else (role or "first_frame")
|
||||
asset_type = _push("image", push_url, effective_role)
|
||||
elif ref_type == "video":
|
||||
@@ -356,6 +362,13 @@ def build_content_items(
|
||||
# @label 替换:按 label 长度降序,防子串吞噬
|
||||
ordered = sorted(label_to_placeholder.items(), key=lambda kv: len(kv[0]), reverse=True)
|
||||
api_prompt = _format_prompt_for_ark(prompt, ordered)
|
||||
# 自由创作 / 全能创作是整段一次出片(没有「第 2 段」),开场先验强 ——
|
||||
# 挂上与专业创作首镜相同的正面洁净指令;最终 enforce_no_embedded_captions 还会改写口播并挂禁令。
|
||||
from .services import OPENING_SHOT_DIRECTIVE, rewrite_speech_as_audio_only
|
||||
|
||||
if OPENING_SHOT_DIRECTIVE not in api_prompt:
|
||||
api_prompt = f"{OPENING_SHOT_DIRECTIVE}\n{api_prompt}"
|
||||
api_prompt = rewrite_speech_as_audio_only(api_prompt)
|
||||
|
||||
return {
|
||||
"content_items": content_items,
|
||||
@@ -480,7 +493,7 @@ def submit_free_video(*, team, user, params: dict) -> AITask:
|
||||
|
||||
if mode == "keyframe":
|
||||
roles = [str(r.get("role") or "") for r in references]
|
||||
if any(str(r.get("type") or "image") != "image" for r in references):
|
||||
if any(str(r.get("type") or "image") not in IMAGE_LIKE_REF_TYPES for r in references):
|
||||
raise ValueError("首尾帧模式仅支持图片素材")
|
||||
if "first_frame" not in roles:
|
||||
raise ValueError("首尾帧模式需要提供首帧图片")
|
||||
|
||||
@@ -277,6 +277,7 @@ def _asset_reference(asset, type_: str, label: str) -> dict | None:
|
||||
"url": url,
|
||||
"type": type_,
|
||||
"label": label,
|
||||
"source": "asset",
|
||||
"asset_id": str(asset.id),
|
||||
"review_status": asset.review_status,
|
||||
"review_remote_id": asset.review_remote_id,
|
||||
@@ -355,7 +356,7 @@ def _product_reference(product) -> dict | None:
|
||||
cover = _product_cover_url(product)
|
||||
return (
|
||||
{"url": cover, "type": "product", "label": product.title,
|
||||
"asset_id": "", "review_status": "", "review_remote_id": ""}
|
||||
"source": "upload", "asset_id": "", "review_status": "", "review_remote_id": ""}
|
||||
if cover else None
|
||||
)
|
||||
|
||||
|
||||
@@ -948,6 +948,35 @@ _CAPTION_FIELD_INLINE_RE = re.compile(rf"[,,;;、]?\s*{_CAPTION_WORDS}\s*[
|
||||
_CAPTION_CLAUSE_RE = re.compile(rf"[,,;;、]?\s*[^,,;;。\n]*{_CAPTION_WORDS}[^,,;;。\n]*[。]?")
|
||||
|
||||
|
||||
_SPEECH_LINE_RE = re.compile(
|
||||
r"^(\s*)(口播|旁白|台词|对白|解说)\s*[::]\s*(.+)$"
|
||||
)
|
||||
_SPEECH_AUDIO_ONLY_PREFIX = "人声(仅音频,由人物口型与配音表达,不出现在画面上):"
|
||||
|
||||
|
||||
def rewrite_speech_as_audio_only(prompt: str) -> str:
|
||||
"""把「口播:/旁白:/台词:」改写成「人声(仅音频…)」抬头。
|
||||
|
||||
全能创作 / 自由创作的 video_prompt 常按秒级分镜写「口播:…」。
|
||||
裸摆口播原文时,出片模型极易把这段可见文本烧成画面字幕 —— 专业创作
|
||||
已用同一抬头(_segment_script_text);这里对所有入口统一改写。
|
||||
已带「仅音频」抬头的行不重复包一层。
|
||||
"""
|
||||
out: list[str] = []
|
||||
for line in (prompt or "").split("\n"):
|
||||
if "仅音频" in line and "人声" in line:
|
||||
out.append(line)
|
||||
continue
|
||||
m = _SPEECH_LINE_RE.match(line)
|
||||
if m:
|
||||
indent, _kind, speech = m.group(1), m.group(2), m.group(3).strip()
|
||||
if speech:
|
||||
out.append(f"{indent}{_SPEECH_AUDIO_ONLY_PREFIX}{speech}")
|
||||
continue
|
||||
out.append(line)
|
||||
return "\n".join(out)
|
||||
|
||||
|
||||
def strip_caption_directives(prompt: str) -> str:
|
||||
"""把正文里所有会让模型联想到「画面文字」的字样抹掉 —— 无论它是要求加字幕还是声明没有字幕。
|
||||
模型不区分肯定否定,看到「字幕」这个词本身就更容易画出来,所以一律清掉,
|
||||
@@ -966,15 +995,18 @@ def strip_caption_directives(prompt: str) -> str:
|
||||
|
||||
|
||||
def enforce_no_embedded_captions(prompt: str) -> str:
|
||||
"""所有视频入口(专业创作 / 一键成片 / 自由创作 / 视频复刻 / 提炼改写)最终汇入这里:
|
||||
先洗掉正文里的字幕字段,再把最高优先级的无字幕禁令置于提示词第一行。
|
||||
"""所有视频入口(专业创作 / 一键成片 / 自由创作 / 视频复刻 / 提炼改写 / 全能创作)最终汇入这里:
|
||||
先把「口播:」改写成仅音频抬头,再洗掉字幕字段,最后挂最高优先级无字幕禁令。
|
||||
首镜最容易被模型自动加标题,所以不能再把禁令放在长提示词末尾。"""
|
||||
# 顺序要紧:先摘掉可能已存在的规则本身,再洗正文,最后统一拼回去。
|
||||
# 反过来的话,清洗器会把规则里「不要字幕…」那半句也当成字幕字样吃掉 → 摘不干净 → 规则拼两遍
|
||||
# (重复 = 负面词频翻倍 = 更容易出字幕,正是本次要根治的毛病)。
|
||||
base = (prompt or "").replace(NO_EMBEDDED_CAPTIONS_REQUIREMENT, "").replace(NO_EMBEDDED_CAPTIONS_TAIL, "")
|
||||
base = base.replace(OPENING_SHOT_DIRECTIVE, "")
|
||||
base = rewrite_speech_as_audio_only(base)
|
||||
base = strip_caption_directives(base).strip()
|
||||
# 首帧最容易被模型自动加标题页 → 规则放开头;末位权重高 → 结尾补一句中性的正面复述。
|
||||
# 口播改写后再挂洁净规则,避免「口播:」原文被模型烧成字幕。
|
||||
return f"{NO_EMBEDDED_CAPTIONS_REQUIREMENT}\n{base}\n{NO_EMBEDDED_CAPTIONS_TAIL}".strip()
|
||||
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import json
|
||||
from unittest.mock import patch
|
||||
|
||||
from django.test import TestCase
|
||||
from django.test import SimpleTestCase, TestCase
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
from apps.accounts.models import Team, TeamMember, User
|
||||
@@ -25,9 +25,14 @@ from .creation_agent import (
|
||||
_merge_tool_call_deltas,
|
||||
build_messages,
|
||||
get_creation_chat_model,
|
||||
has_creative_intent,
|
||||
is_continue_intent,
|
||||
is_pure_chitchat,
|
||||
session_has_creative_context,
|
||||
stream_creation_agent,
|
||||
submit_confirmed_image,
|
||||
submit_confirmed_video,
|
||||
tool_schemas,
|
||||
video_duration,
|
||||
video_model_name,
|
||||
wanted_asset_pick,
|
||||
@@ -134,17 +139,19 @@ class FieldCoercionTests(TestCase):
|
||||
self.assertEqual(fields, []) # 没选项的单选是废卡
|
||||
|
||||
def test_text_and_asset_fields_get_their_defaults(self):
|
||||
fields = _coerce_fields([
|
||||
text_fields = _coerce_fields([
|
||||
{"key": "slogan", "label": "想突出哪句话?", "type": "text"},
|
||||
])
|
||||
asset_fields = _coerce_fields([
|
||||
{"key": "who", "label": "用哪个模特?", "type": "asset"},
|
||||
])
|
||||
self.assertEqual(fields[0]["placeholder"], "")
|
||||
self.assertIn("product", fields[1]["asset_types"])
|
||||
self.assertEqual(text_fields[0]["placeholder"], "")
|
||||
self.assertIn("product", asset_fields[0]["asset_types"])
|
||||
|
||||
def test_unknown_type_and_over_limit_are_trimmed(self):
|
||||
raw = [{"key": f"k{i}", "label": "x", "type": "text"} for i in range(5)]
|
||||
raw.append({"key": "bad", "label": "x", "type": "dropdown"})
|
||||
self.assertEqual(len(_coerce_fields(raw)), 3) # 一次最多问 3 项
|
||||
self.assertEqual(len(_coerce_fields(raw)), 1) # 聊天式追问一次只问 1 项
|
||||
|
||||
def test_product_text_or_single_is_coerced_to_asset_card(self):
|
||||
fields = _coerce_fields([
|
||||
@@ -185,6 +192,8 @@ class ParamPickIntentTests(TestCase):
|
||||
|
||||
class AskUserTests(CreationAgentBaseTests):
|
||||
def test_saying_change_duration_injects_param_card(self):
|
||||
self.conversation.mode = CreationConversation.Mode.VIDEO
|
||||
self.conversation.save(update_fields=["mode"])
|
||||
events, _ = self._run([_text_chunks("你想改成多长?")], text="我想改时长")
|
||||
elicit = [e for e in events if e.get("type") == "message" and e["message"]["kind"] == "elicit"]
|
||||
self.assertEqual(len(elicit), 1)
|
||||
@@ -195,8 +204,42 @@ class AskUserTests(CreationAgentBaseTests):
|
||||
events, _ = self._run([_text_chunks("换成哪个商品?直接选或填名字都行:")], text="我想修改商品")
|
||||
elicit = [e for e in events if e.get("type") == "message" and e["message"]["kind"] == "elicit"]
|
||||
self.assertEqual(len(elicit), 1)
|
||||
self.assertEqual(elicit[0]["message"]["payload"]["fields"][0]["type"], "asset")
|
||||
self.assertEqual(elicit[0]["message"]["payload"]["fields"][0]["asset_types"], ["product"])
|
||||
payload = elicit[0]["message"]["payload"]
|
||||
self.assertEqual(payload["interaction"], "asset_picker")
|
||||
self.assertEqual(payload["phase"], "pick")
|
||||
self.assertEqual(payload["fields"][0]["type"], "asset")
|
||||
self.assertEqual(payload["fields"][0]["asset_types"], ["product"])
|
||||
self.assertEqual(payload["fields"][0]["label"], "这条要展示哪款商品?")
|
||||
self.assertEqual(elicit[0]["message"]["text"], "这条要展示哪款商品?")
|
||||
|
||||
def test_requesting_the_card_uses_the_previous_asset_context(self):
|
||||
append_message(self.conversation, role="user", text="帮我做一条商品短片")
|
||||
append_message(
|
||||
self.conversation,
|
||||
role="assistant",
|
||||
kind=CreationMessage.Kind.ELICIT,
|
||||
text="这条要展示哪款商品?",
|
||||
payload={
|
||||
"interaction": "chat",
|
||||
"fields": [{
|
||||
"key": "product",
|
||||
"label": "这条要展示哪款商品?",
|
||||
"type": "asset",
|
||||
"asset_types": ["product"],
|
||||
}],
|
||||
"submitted": True,
|
||||
"answers": {"product": "你发一下卡片我选择"},
|
||||
},
|
||||
)
|
||||
events, _ = self._run([_text_chunks("我把商品卡片发出来。")], text="你发一下卡片我选择")
|
||||
cards = [
|
||||
event["message"] for event in events
|
||||
if event.get("type") == "message"
|
||||
and event["message"]["kind"] == "elicit"
|
||||
and event["message"]["payload"].get("interaction") == "asset_picker"
|
||||
]
|
||||
self.assertEqual(len(cards), 1)
|
||||
self.assertEqual(cards[0]["payload"]["fields"][0]["asset_types"], ["product"])
|
||||
|
||||
def test_ask_user_emits_elicit_card_and_stops_the_loop(self):
|
||||
events, fake = self._run([
|
||||
@@ -209,6 +252,11 @@ class AskUserTests(CreationAgentBaseTests):
|
||||
|
||||
elicit = [e for e in events if e.get("type") == "message" and e["message"]["kind"] == "elicit"]
|
||||
self.assertEqual(len(elicit), 1)
|
||||
self.assertEqual(elicit[0]["message"]["payload"]["interaction"], "chat")
|
||||
self.assertEqual(
|
||||
elicit[0]["message"]["text"],
|
||||
"想要什么调性? 直接用一句话告诉我就行,不用整理成完整需求。",
|
||||
)
|
||||
self.assertEqual(len(elicit[0]["message"]["payload"]["fields"][0]["options"]), 2)
|
||||
# 反问必须中断循环,否则模型会自问自答
|
||||
self.assertEqual(len(fake.calls), 1)
|
||||
@@ -398,6 +446,136 @@ class SendEndpointTests(TestCase):
|
||||
response = self.client.post(f"/api/ai/creations/{self.conversation.id}/send/", {}, format="json")
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
def test_typed_reply_answers_chat_question_and_continues(self):
|
||||
product = Product.objects.create(team=self.team, created_by=self.user, title="净颜精华")
|
||||
append_message(self.conversation, role="user", text="做一张轻松有趣的商品海报")
|
||||
question = append_message(
|
||||
self.conversation,
|
||||
role="assistant",
|
||||
kind=CreationMessage.Kind.ELICIT,
|
||||
text="这次主推哪个商品?",
|
||||
payload={
|
||||
"interaction": "asset_picker",
|
||||
"fields": [{
|
||||
"key": "product",
|
||||
"label": "这次主推哪个商品?",
|
||||
"type": "asset",
|
||||
"required": True,
|
||||
"asset_types": ["product"],
|
||||
}],
|
||||
"submitted": False,
|
||||
"answers": {},
|
||||
},
|
||||
)
|
||||
fake = FakeProvider([_text_chunks("那就把反差感落在商品登场上。")])
|
||||
|
||||
with patch("apps.ai.creation_agent.build_provider", return_value=fake):
|
||||
response = self.client.post(
|
||||
f"/api/ai/creations/{self.conversation.id}/send/",
|
||||
{"kind": "text", "text": f"就用{product.title}吧"},
|
||||
format="json",
|
||||
)
|
||||
list(response.streaming_content)
|
||||
|
||||
question.refresh_from_db()
|
||||
self.conversation.refresh_from_db()
|
||||
self.assertTrue(question.payload["submitted"])
|
||||
self.assertEqual(question.payload["answers"], {"product": product.title})
|
||||
self.assertEqual(
|
||||
self.conversation.messages.filter(role="user").order_by("-seq").first().text,
|
||||
f"就用{product.title}吧",
|
||||
)
|
||||
self.assertTrue(any(
|
||||
ref.get("type") == "product" and str(ref.get("id")) == str(product.id)
|
||||
for ref in self.conversation.pinned_refs
|
||||
))
|
||||
tool_names = {tool["function"]["name"] for tool in fake.calls[0]["extra_body"]["tools"]}
|
||||
self.assertIn("generate_image", tool_names)
|
||||
|
||||
def test_delegate_reply_picks_one_product_without_showing_a_list(self):
|
||||
product = Product.objects.create(team=self.team, created_by=self.user, title="净颜精华")
|
||||
question = append_message(
|
||||
self.conversation,
|
||||
role="assistant",
|
||||
kind=CreationMessage.Kind.ELICIT,
|
||||
text="先定主商品:这条要展示哪款商品?",
|
||||
payload={
|
||||
"interaction": "asset_picker",
|
||||
"fields": [{
|
||||
"key": "product",
|
||||
"label": "这条要展示哪款商品?",
|
||||
"type": "asset",
|
||||
"required": True,
|
||||
"asset_types": ["product"],
|
||||
}],
|
||||
"submitted": False,
|
||||
"answers": {},
|
||||
},
|
||||
)
|
||||
fake = FakeProvider([_text_chunks("我用它来组织这条片。")])
|
||||
|
||||
with patch("apps.ai.creation_agent.build_provider", return_value=fake):
|
||||
response = self.client.post(
|
||||
f"/api/ai/creations/{self.conversation.id}/send/",
|
||||
{"kind": "text", "text": "你来定"},
|
||||
format="json",
|
||||
)
|
||||
list(response.streaming_content)
|
||||
|
||||
question.refresh_from_db()
|
||||
self.conversation.refresh_from_db()
|
||||
self.assertEqual(question.payload["answers"], {"product": product.title})
|
||||
self.assertTrue(any(
|
||||
ref.get("type") == "product" and str(ref.get("id")) == str(product.id)
|
||||
for ref in self.conversation.pinned_refs
|
||||
))
|
||||
assistant_text = " ".join(
|
||||
message.text for message in self.conversation.messages.filter(role="assistant")
|
||||
if message.text
|
||||
)
|
||||
self.assertNotIn("可选商品", assistant_text)
|
||||
|
||||
def test_typed_param_reply_maps_to_option_value(self):
|
||||
self.conversation.mode = CreationConversation.Mode.VIDEO
|
||||
self.conversation.params = {"duration": "5 秒"}
|
||||
self.conversation.save(update_fields=["mode", "params"])
|
||||
question = append_message(
|
||||
self.conversation,
|
||||
role="assistant",
|
||||
kind=CreationMessage.Kind.ELICIT,
|
||||
text="改成多长?",
|
||||
payload={
|
||||
"interaction": "chat",
|
||||
"fields": [{
|
||||
"key": "duration",
|
||||
"label": "改成多长?",
|
||||
"type": "single",
|
||||
"required": True,
|
||||
"options": [
|
||||
{"value": "5 秒", "label": "5 秒"},
|
||||
{"value": "10 秒", "label": "10 秒"},
|
||||
{"value": "15 秒", "label": "15 秒"},
|
||||
],
|
||||
}],
|
||||
"submitted": False,
|
||||
"answers": {},
|
||||
},
|
||||
)
|
||||
fake = FakeProvider([_text_chunks("我按十秒重新排镜头。")])
|
||||
|
||||
with patch("apps.ai.creation_agent.build_provider", return_value=fake):
|
||||
response = self.client.post(
|
||||
f"/api/ai/creations/{self.conversation.id}/send/",
|
||||
{"kind": "text", "text": "改成 10 秒吧"},
|
||||
format="json",
|
||||
)
|
||||
list(response.streaming_content)
|
||||
|
||||
question.refresh_from_db()
|
||||
self.conversation.refresh_from_db()
|
||||
self.assertEqual(question.payload["answers"], {"duration": "10 秒"})
|
||||
self.assertEqual(self.conversation.params["duration"], "10 秒")
|
||||
|
||||
def test_answering_an_elicit_card_marks_it_submitted(self):
|
||||
card = append_message(
|
||||
self.conversation, role="assistant", kind=CreationMessage.Kind.ELICIT,
|
||||
@@ -417,6 +595,8 @@ class SendEndpointTests(TestCase):
|
||||
card.refresh_from_db()
|
||||
self.assertTrue(card.payload["submitted"])
|
||||
self.assertEqual(card.payload["answers"], {"tone": "warm"})
|
||||
# 选择已经显示在卡片里,不能再复制成一条伪造的用户气泡。
|
||||
self.assertFalse(self.conversation.messages.filter(role="user").exists())
|
||||
|
||||
def test_elicit_product_choice_pins_the_product(self):
|
||||
"""对话里点选商品只回 answers 时,也必须钉进 pinned_refs,否则出片带不上商品图。"""
|
||||
@@ -480,6 +660,61 @@ class SendEndpointTests(TestCase):
|
||||
# 重复提交会让同一个问题在上下文里出现两次答案
|
||||
self.assertEqual(response.status_code, 409)
|
||||
|
||||
def test_skipping_asset_picker_continues_without_fake_user_bubble(self):
|
||||
"""点「先按当前想法做」应继续创作,不能把系统文案冒充成用户消息再停住。"""
|
||||
append_message(self.conversation, role="user", text="帮我做一张有性格的商品海报")
|
||||
card = append_message(
|
||||
self.conversation,
|
||||
role="assistant",
|
||||
kind=CreationMessage.Kind.ELICIT,
|
||||
payload={
|
||||
"phase": "gate",
|
||||
"fields": [{
|
||||
"key": "_asset_gate",
|
||||
"label": "选好商品能让画面更准确。要现在从素材库里挑一个吗?",
|
||||
"type": "single",
|
||||
"options": [
|
||||
{"value": "send", "label": "选择商品"},
|
||||
{"value": "skip", "label": "先按当前想法做"},
|
||||
],
|
||||
}],
|
||||
"pending_fields": [{
|
||||
"key": "product", "label": "选哪个商品?", "type": "asset",
|
||||
"asset_types": ["product"],
|
||||
}],
|
||||
"submitted": False,
|
||||
"answers": {},
|
||||
},
|
||||
)
|
||||
fake = FakeProvider([_text_chunks("行,那我先按拟人海报方向往下做。")])
|
||||
before_users = self.conversation.messages.filter(role="user").count()
|
||||
|
||||
with patch("apps.ai.creation_agent.build_provider", return_value=fake):
|
||||
response = self.client.post(
|
||||
f"/api/ai/creations/{self.conversation.id}/send/",
|
||||
{"kind": "elicit_answer", "reply_to": str(card.id),
|
||||
"answers": {"_asset_gate": "skip"}},
|
||||
format="json",
|
||||
)
|
||||
events = _events(
|
||||
chunk.decode("utf-8") if isinstance(chunk, bytes) else chunk
|
||||
for chunk in response.streaming_content
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(self.conversation.messages.filter(role="user").count(), before_users)
|
||||
self.assertFalse(any(
|
||||
event.get("type") == "message" and event["message"]["role"] == "user"
|
||||
for event in events
|
||||
))
|
||||
self.assertTrue(fake.calls)
|
||||
tool_names = {tool["function"]["name"] for tool in fake.calls[0]["extra_body"]["tools"]}
|
||||
self.assertIn("generate_image", tool_names)
|
||||
self.assertNotIn("有需要再说", "".join(
|
||||
event["message"].get("text", "")
|
||||
for event in events if event.get("type") == "message"
|
||||
))
|
||||
|
||||
|
||||
class VideoPlanAndConfirmTests(CreationAgentBaseTests):
|
||||
"""视频链路:策略卡 → 方案卡 → 确认闸门 → 出片(契约 §0)。"""
|
||||
@@ -576,6 +811,51 @@ class VideoPlanAndConfirmTests(CreationAgentBaseTests):
|
||||
self.assertEqual(params["aspect_ratio"], "9:16")
|
||||
self.assertTrue(params["generate_audio"])
|
||||
|
||||
def test_personified_product_uses_offscreen_voice_without_changing_packaging(self):
|
||||
self.conversation.preset = "商品拟人广告"
|
||||
self.conversation.save(update_fields=["preset"])
|
||||
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-product-voice",
|
||||
)
|
||||
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("全部可见表面保持原有设计", prompt)
|
||||
|
||||
def test_explicit_cartoon_face_request_can_override_the_default(self):
|
||||
self.conversation.preset = "商品拟人广告"
|
||||
self.conversation.save(update_fields=["preset"])
|
||||
append_message(self.conversation, role="user", text="我就要精华瓶长出卡通眼睛和嘴巴")
|
||||
original = "精华瓶眨眼开口说话"
|
||||
card = append_message(
|
||||
self.conversation, role="assistant", kind=CreationMessage.Kind.CONFIRM,
|
||||
payload={"video_prompt": original, "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-product-face",
|
||||
)
|
||||
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.assertEqual(prompt, original)
|
||||
|
||||
def test_confirm_without_stored_prompt_reports_instead_of_submitting(self):
|
||||
card = append_message(
|
||||
self.conversation, role="assistant", kind=CreationMessage.Kind.CONFIRM,
|
||||
@@ -794,6 +1074,18 @@ class PresetGuidanceTests(CreationAgentBaseTests):
|
||||
# 光有名字模型只能靠猜,拍法约束必须一起给
|
||||
self.assertIn("人物面部和身形必须全程一致", system)
|
||||
|
||||
def test_product_personification_defaults_to_faceless_expression(self):
|
||||
conversation = CreationConversation.objects.create(
|
||||
team=self.team, created_by=self.user, mode="video", preset="商品拟人广告", params={},
|
||||
)
|
||||
fake = FakeProvider([_text_chunks("开始设计")])
|
||||
with patch("apps.ai.creation_agent.build_provider", return_value=fake):
|
||||
list(stream_creation_agent(conversation=conversation, user=self.user,
|
||||
text="开始", model_config=self.model))
|
||||
system = fake.calls[0]["messages"][0]["content"]
|
||||
self.assertIn("默认采用无脸拟人", system)
|
||||
self.assertIn("商品需要说台词时使用画外角色声", system)
|
||||
|
||||
def test_unknown_preset_degrades_to_name_only(self):
|
||||
conversation = CreationConversation.objects.create(
|
||||
team=self.team, created_by=self.user, mode="video", preset="前端新加的卡", params={},
|
||||
@@ -869,4 +1161,93 @@ class CreationChatModelTests(CreationAgentBaseTests):
|
||||
is_default=True,
|
||||
)
|
||||
picked = get_creation_chat_model(None)
|
||||
self.assertEqual(picked.id, seed.id)
|
||||
# 测试迁移已经可能种过同名 Seed 2.1,重点是编排层选到该模型家族,
|
||||
# 不是强行命中本测试后建的重复记录。
|
||||
self.assertEqual(picked.name, seed.name)
|
||||
|
||||
|
||||
class PureChitchatTests(SimpleTestCase):
|
||||
"""打招呼不能触发整理方案 / write_plan。"""
|
||||
|
||||
def test_greetings_are_chitchat(self):
|
||||
for text in ("hi", "Hi!", "你好", "在吗", "嗯", "好的", "ok", "谢谢"):
|
||||
self.assertTrue(is_pure_chitchat(text), text)
|
||||
|
||||
def test_creative_asks_are_not_chitchat(self):
|
||||
for text in ("做条带货视频", "hi 帮我改个方案", "换个商品", "重新写方案"):
|
||||
self.assertFalse(is_pure_chitchat(text), text)
|
||||
|
||||
|
||||
class CreativeIntentTests(SimpleTestCase):
|
||||
"""闲聊不出方案工具;明确要做片才放开。"""
|
||||
|
||||
def test_idle_chat_has_no_creative_intent(self):
|
||||
for text in ("hi", "今天天气不错", "这个功能怎么用", "你们支持哪些模型", "随便聊聊"):
|
||||
self.assertFalse(has_creative_intent(text), text)
|
||||
|
||||
def test_refs_alone_do_not_count(self):
|
||||
self.assertFalse(has_creative_intent("看看", [{"type": "product", "id": "1"}]))
|
||||
self.assertFalse(has_creative_intent("", [{"type": "product", "id": "1"}]))
|
||||
|
||||
def test_creative_asks_count(self):
|
||||
for text in (
|
||||
"做条带货视频",
|
||||
"帮我出个方案",
|
||||
"重新写方案",
|
||||
"出一张主图",
|
||||
"改成 9:16",
|
||||
"把商品塑造为有性格的拟人角色,创作一条轻快、有趣的短广告。",
|
||||
):
|
||||
self.assertTrue(has_creative_intent(text), text)
|
||||
|
||||
def test_short_continue_phrases_are_explicit_continuations(self):
|
||||
for text in ("继续", "接着做", "往下做吧", "就按这个", "直接来"):
|
||||
self.assertTrue(is_continue_intent(text), text)
|
||||
|
||||
for text in ("好的", "嗯", "先不用", "今天怎么样"):
|
||||
self.assertFalse(is_continue_intent(text), text)
|
||||
|
||||
|
||||
class ToolGateTests(CreationAgentBaseTests):
|
||||
def test_empty_conversation_greeting_is_human_and_actionable(self):
|
||||
events = _events(stream_creation_agent(
|
||||
conversation=self.conversation,
|
||||
user=self.user,
|
||||
text="在吗",
|
||||
model_config=self.model,
|
||||
))
|
||||
replies = [
|
||||
event["message"]["text"]
|
||||
for event in events
|
||||
if event.get("type") == "message" and event["message"]["role"] == "assistant"
|
||||
]
|
||||
self.assertEqual(replies, ["我在。想做什么,直接丢一句想法给我就行。"])
|
||||
self.assertNotIn("有需要再说", replies[0])
|
||||
|
||||
def test_idle_chat_hides_plan_tools(self):
|
||||
fake = FakeProvider([_text_chunks("哈哈,有需要再说")])
|
||||
with patch("apps.ai.creation_agent.build_provider", return_value=fake):
|
||||
list(stream_creation_agent(
|
||||
conversation=self.conversation, user=self.user,
|
||||
text="今天天气不错", model_config=self.model,
|
||||
))
|
||||
self.assertTrue(fake.calls)
|
||||
names = {t["function"]["name"] for t in fake.calls[0]["extra_body"]["tools"]}
|
||||
self.assertNotIn("write_strategy", names)
|
||||
self.assertNotIn("write_plan", names)
|
||||
self.assertNotIn("ask_user", names) # 闲聊不给追问卡,免得推销出片
|
||||
self.assertIn("search_library", names)
|
||||
|
||||
def test_continue_after_existing_brief_keeps_creation_tools(self):
|
||||
append_message(self.conversation, role="user", text="帮我做一张商品主图")
|
||||
self.assertTrue(session_has_creative_context(self.conversation))
|
||||
fake = FakeProvider([_text_chunks("那就接着往下做。")])
|
||||
with patch("apps.ai.creation_agent.build_provider", return_value=fake):
|
||||
list(stream_creation_agent(
|
||||
conversation=self.conversation,
|
||||
user=self.user,
|
||||
text="继续",
|
||||
model_config=self.model,
|
||||
))
|
||||
names = {tool["function"]["name"] for tool in fake.calls[0]["extra_body"]["tools"]}
|
||||
self.assertIn("generate_image", names)
|
||||
|
||||
@@ -110,7 +110,25 @@ class BuildContentItemsTests(TestCase):
|
||||
self.assertEqual(roles, ["reference_image", "reference_image", "reference_video"])
|
||||
self.assertEqual(built["video_duration_total"], 3.0)
|
||||
|
||||
def test_semantic_types_character_product_not_skipped(self):
|
||||
"""全能创作 references 用 type=character/product,不能再静默 skipped。"""
|
||||
refs = [
|
||||
{"url": "http://x/person.png", "type": "character", "label": "男生", "source": "upload"},
|
||||
{"url": "http://x/bottle.png", "type": "product", "label": "100nomur", "source": "upload"},
|
||||
{"url": "http://x/tri.png", "type": "product", "label": "100nomur三视图", "source": "upload"},
|
||||
]
|
||||
built = build_content_items(team=self.team, prompt="@男生 拿着 @100nomur", mode="universal", references=refs)
|
||||
self.assertEqual(built["image_n"], 3)
|
||||
self.assertEqual(len(built["content_items"]), 3)
|
||||
self.assertEqual(
|
||||
[i.get("role") for i in built["content_items"]],
|
||||
["reference_image", "reference_image", "reference_image"],
|
||||
)
|
||||
self.assertIn("图片1", built["api_prompt"])
|
||||
self.assertIn("图片2", built["api_prompt"])
|
||||
|
||||
def test_keyframe_roles(self):
|
||||
|
||||
refs = [
|
||||
{"url": "http://x/f.png", "type": "image", "role": "first_frame"},
|
||||
{"url": "http://x/l.png", "type": "image", "role": "last_frame"},
|
||||
|
||||
@@ -5,6 +5,7 @@ from apps.ai.services import (
|
||||
NO_EMBEDDED_CAPTIONS_TAIL,
|
||||
OPENING_SHOT_DIRECTIVE,
|
||||
enforce_no_embedded_captions,
|
||||
rewrite_speech_as_audio_only,
|
||||
strip_caption_directives,
|
||||
)
|
||||
|
||||
@@ -90,6 +91,24 @@ class VideoCaptionPolicyTests(SimpleTestCase):
|
||||
self.assertTrue(prompt.startswith(NO_EMBEDDED_CAPTIONS_REQUIREMENT))
|
||||
|
||||
|
||||
|
||||
def test_rewrites_koubo_as_audio_only(self):
|
||||
"""全能创作「口播:」必须改成仅音频抬头,否则模型会把口播原文烧成字幕。"""
|
||||
raw = "0-3s:近景\n口播:这开俩小时车,困得眼睛都快粘一块了。"
|
||||
cleaned = rewrite_speech_as_audio_only(raw)
|
||||
self.assertNotIn("口播:", cleaned)
|
||||
self.assertIn("人声(仅音频,由人物口型与配音表达,不出现在画面上):这开俩小时车,困得眼睛都快粘一块了。", cleaned)
|
||||
prompt = enforce_no_embedded_captions(raw)
|
||||
self.assertNotIn("口播:", prompt)
|
||||
self.assertIn("仅音频", prompt)
|
||||
self.assertTrue(prompt.startswith(NO_EMBEDDED_CAPTIONS_REQUIREMENT))
|
||||
|
||||
def test_audio_only_prefix_not_double_wrapped(self):
|
||||
line = "人声(仅音频,由人物口型与配音表达,不出现在画面上):已经包过"
|
||||
self.assertEqual(rewrite_speech_as_audio_only(line), line)
|
||||
|
||||
|
||||
|
||||
class OpeningShotDirectiveTests(SimpleTestCase):
|
||||
"""实测:一条片子出四段,只有开场那段带字幕,后三段干净 —— 模型在「电商口播开场」语境下
|
||||
有「钩子 = 打一行大字」的强先验。开场段单独给一条正面指令把它掰回来。"""
|
||||
|
||||
+179
-17
@@ -1,4 +1,5 @@
|
||||
import logging
|
||||
import re
|
||||
import uuid
|
||||
|
||||
from django.db import transaction
|
||||
@@ -21,7 +22,7 @@ from apps.products.models import Product
|
||||
|
||||
from .generation_errors import classify_generation_error, public_error_for_task
|
||||
from .creation import append_message, sync_generating_messages
|
||||
from .creation_agent import apply_confirm_params, apply_session_params, stream_creation_agent, submit_confirmed_image, submit_confirmed_video
|
||||
from .creation_agent import apply_confirm_params, apply_session_params, stream_creation_agent, submit_confirmed_image, submit_confirmed_video, _sse, _message_payload
|
||||
from .mentions import TYPE_LABELS, VALID_TYPES, refs_from_elicit_answers, search_mentions
|
||||
from .models import AITask, CreationConversation, CreationMessage, ImageConversation, ModelConfig
|
||||
from .serializers import (
|
||||
@@ -38,6 +39,84 @@ from .services import enqueue_standalone_images
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _normalise_chat_choice(value: str) -> str:
|
||||
return re.sub(r"[^0-9a-zA-Z一-鿿]+", "", str(value or "")).lower()
|
||||
|
||||
|
||||
def _chat_answer_for_field(field: dict, text: str, team=None):
|
||||
"""把自然输入映射回字段值;认不出时保留原话,让 Agent 自己理解。"""
|
||||
raw = str(text or "").strip()
|
||||
if field.get("type") == "asset" and team is not None:
|
||||
answer_norm = _normalise_chat_choice(raw)
|
||||
types = [item for item in (field.get("asset_types") or []) if item in TYPE_LABELS] or None
|
||||
hits = search_mentions(team, q="", types=types, limit=50)
|
||||
if re.search(r"(你来定|你定|你帮我定|你帮我选|帮我挑|没想好|随便|都行)", raw):
|
||||
# 用户把选择权交给 Agent 时静默选一个可用素材,不把整份库甩回聊天区。
|
||||
if hits:
|
||||
return str(hits[0].get("name") or raw)
|
||||
contained = [
|
||||
hit for hit in hits
|
||||
if _normalise_chat_choice(hit.get("name") or "")
|
||||
and _normalise_chat_choice(hit.get("name") or "") in answer_norm
|
||||
]
|
||||
if contained:
|
||||
# 「就用净颜精华吧」也能绑定净颜精华;同名片段优先取名字更完整的。
|
||||
contained.sort(key=lambda hit: len(_normalise_chat_choice(hit.get("name") or "")), reverse=True)
|
||||
return str(contained[0].get("name") or raw)
|
||||
if field.get("type") != "single":
|
||||
if field.get("type") == "multi":
|
||||
answer_norm = _normalise_chat_choice(raw)
|
||||
matched = []
|
||||
for option in field.get("options") or []:
|
||||
value = str(option.get("value") or "")
|
||||
label = str(option.get("label") or value)
|
||||
token = _normalise_chat_choice(label)
|
||||
if token and token in answer_norm:
|
||||
matched.append(value)
|
||||
return matched or [raw]
|
||||
return raw
|
||||
|
||||
options = [item for item in (field.get("options") or []) if isinstance(item, dict)]
|
||||
answer_norm = _normalise_chat_choice(raw)
|
||||
for option in sorted(
|
||||
options,
|
||||
key=lambda item: len(_normalise_chat_choice(item.get("label") or item.get("value") or "")),
|
||||
reverse=True,
|
||||
):
|
||||
value = str(option.get("value") or "")
|
||||
label_norm = _normalise_chat_choice(option.get("label") or value)
|
||||
value_norm = _normalise_chat_choice(value)
|
||||
if answer_norm in {label_norm, value_norm} or (
|
||||
label_norm and label_norm in answer_norm
|
||||
) or (
|
||||
value_norm and value_norm in answer_norm
|
||||
):
|
||||
return value
|
||||
# 「10」也能命中「10 秒」。同一个数字对应多个模型名时不擅自猜。
|
||||
answer_digits = re.findall(r"\d+(?:\.\d+)?", raw)
|
||||
numeric_matches = []
|
||||
if answer_digits:
|
||||
for option in options:
|
||||
label = str(option.get("label") or option.get("value") or "")
|
||||
if re.findall(r"\d+(?:\.\d+)?", label) == answer_digits:
|
||||
numeric_matches.append(str(option.get("value") or label))
|
||||
if len(numeric_matches) == 1:
|
||||
return numeric_matches[0]
|
||||
return raw
|
||||
|
||||
|
||||
def _pending_chat_question(conversation: CreationConversation) -> CreationMessage | None:
|
||||
"""找最近一条未回答的新式追问;卡片既可点选,也兼容直接输入。"""
|
||||
candidates = conversation.messages.filter(
|
||||
kind=CreationMessage.Kind.ELICIT
|
||||
).order_by("-seq")[:20]
|
||||
for message in candidates:
|
||||
payload = message.payload or {}
|
||||
if payload.get("interaction") in {"chat", "asset_picker"} and not payload.get("submitted"):
|
||||
return message
|
||||
return None
|
||||
|
||||
|
||||
class GenerateImageView(APIView):
|
||||
"""独立生图(不绑项目)· 图片创作/模特图/平台套图共用 —— **异步**。
|
||||
|
||||
@@ -1380,9 +1459,48 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
kind = str(request.data.get("kind") or "text")
|
||||
text = str(request.data.get("text") or "").strip()
|
||||
refs = request.data.get("refs") or []
|
||||
record_user_message = True
|
||||
force_creative_turn = False
|
||||
continuation_instruction = ""
|
||||
if not isinstance(refs, list):
|
||||
return JsonResponse({"detail": "refs 必须是数组"}, status=400)
|
||||
|
||||
if kind == "text" and text:
|
||||
pending = _pending_chat_question(conversation)
|
||||
if pending is not None:
|
||||
payload = dict(pending.payload or {})
|
||||
fields = [item for item in (payload.get("fields") or []) if isinstance(item, dict)]
|
||||
if fields:
|
||||
field = fields[0]
|
||||
answers = {
|
||||
str(field.get("key") or "answer"):
|
||||
_chat_answer_for_field(field, text, conversation.team)
|
||||
}
|
||||
payload["answers"] = answers
|
||||
payload["submitted"] = True
|
||||
payload["answered_via"] = "chat"
|
||||
pending.payload = payload
|
||||
pending.save(update_fields=["payload", "updated_at"])
|
||||
|
||||
params_changed = apply_session_params(conversation, fields, answers)
|
||||
existing = {
|
||||
(item.get("type"), str(item.get("id")))
|
||||
for item in refs if isinstance(item, dict)
|
||||
}
|
||||
for extra in refs_from_elicit_answers(conversation.team, fields, answers):
|
||||
mark = (extra.get("type"), str(extra.get("id")))
|
||||
if mark not in existing:
|
||||
refs.append(extra)
|
||||
existing.add(mark)
|
||||
|
||||
force_creative_turn = True
|
||||
continuation_instruction = (
|
||||
"用户刚用输入框回答了你上一句问题。直接基于这条真实回答继续原任务;"
|
||||
"不要复述答案,不要回复收到,也不要问要不要继续或要不要生成。"
|
||||
)
|
||||
if params_changed:
|
||||
continuation_instruction += " 会话参数已更新,旧方案作废,按新参数重新产出。"
|
||||
|
||||
if kind == "confirm":
|
||||
reply_to = str(request.data.get("reply_to") or "").strip()
|
||||
card = conversation.messages.filter(
|
||||
@@ -1451,22 +1569,63 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
payload["submitted"] = True
|
||||
card.payload = payload
|
||||
card.save(update_fields=["payload", "updated_at"])
|
||||
labels = {f["key"]: f["label"] for f in payload.get("fields", [])}
|
||||
text = ";".join(
|
||||
f"{labels.get(k, k)}:{'、'.join(v) if isinstance(v, list) else v}"
|
||||
for k, v in answers.items()
|
||||
)
|
||||
if apply_session_params(conversation, payload.get("fields") or [], answers):
|
||||
text = f"{text}。请按新的会话参数重新写方案,旧方案作废"
|
||||
# 点选商品/角色必须钉成 Ref:模型常把选项做成单选文字,前端只回 answers。
|
||||
refs = list(refs)
|
||||
existing = {(item.get("type"), str(item.get("id"))) for item in refs if isinstance(item, dict)}
|
||||
for extra in refs_from_elicit_answers(conversation.team, payload.get("fields") or [], answers):
|
||||
mark = (extra.get("type"), str(extra.get("id")))
|
||||
if mark in existing:
|
||||
continue
|
||||
refs.append(extra)
|
||||
existing.add(mark)
|
||||
|
||||
# 素材选择闸门:用户愿意时再展开列表;跳过则直接沿原任务继续。
|
||||
if payload.get("phase") == "gate":
|
||||
choice = str(answers.get("_asset_gate") or "").strip()
|
||||
if choice == "send":
|
||||
pending = payload.get("pending_fields") or []
|
||||
pick = append_message(
|
||||
conversation,
|
||||
role="assistant",
|
||||
kind=CreationMessage.Kind.ELICIT,
|
||||
payload={
|
||||
"phase": "pick",
|
||||
"fields": pending,
|
||||
"submitted": False,
|
||||
"answers": {},
|
||||
},
|
||||
)
|
||||
|
||||
def _gate_send_stream():
|
||||
yield _sse({"type": "message", "message": _message_payload(pick)})
|
||||
yield _sse({"type": "done"})
|
||||
|
||||
response = StreamingHttpResponse(
|
||||
_gate_send_stream(), content_type="text/event-stream"
|
||||
)
|
||||
response["Cache-Control"] = "no-cache"
|
||||
response["X-Accel-Buffering"] = "no"
|
||||
return response
|
||||
# 卡片本身已经记录了用户的选择。不要再伪造一条黑色用户气泡;
|
||||
# 继续原任务,并明确告诉模型不要再次追问同一项素材。
|
||||
text = ""
|
||||
record_user_message = False
|
||||
force_creative_turn = True
|
||||
continuation_instruction = (
|
||||
"用户刚选择暂不添加这项素材。接受这个选择,按会话里已有的需求和合理默认继续原任务。"
|
||||
"先用一句自然的话承接,随后直接推进创作;不要再次追问同一素材,不要只说收到或有需要再说。"
|
||||
)
|
||||
else:
|
||||
params_changed = apply_session_params(conversation, payload.get("fields") or [], answers)
|
||||
# 点选商品/角色必须钉成 Ref:模型常把选项做成单选文字,前端只回 answers。
|
||||
refs = list(refs)
|
||||
existing = {(item.get("type"), str(item.get("id"))) for item in refs if isinstance(item, dict)}
|
||||
for extra in refs_from_elicit_answers(conversation.team, payload.get("fields") or [], answers):
|
||||
mark = (extra.get("type"), str(extra.get("id")))
|
||||
if mark in existing:
|
||||
continue
|
||||
refs.append(extra)
|
||||
existing.add(mark)
|
||||
# 答案已显示在追问卡里,不再复制成一条用户消息。让 Agent 直接往下做。
|
||||
text = ""
|
||||
record_user_message = False
|
||||
force_creative_turn = True
|
||||
continuation_instruction = (
|
||||
"用户已经完成刚才的选择。直接基于卡片答案继续原任务,不要复述选项,不要只回复收到。"
|
||||
)
|
||||
if params_changed:
|
||||
continuation_instruction += " 会话参数已经更新,旧方案作废,按新参数重新产出方案。"
|
||||
elif not text and not refs:
|
||||
return JsonResponse({"detail": "消息不能为空"}, status=400)
|
||||
|
||||
@@ -1485,6 +1644,9 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
text=text,
|
||||
refs=refs,
|
||||
model_config=model_config,
|
||||
record_user_message=record_user_message,
|
||||
force_creative_turn=force_creative_turn,
|
||||
continuation_instruction=continuation_instruction,
|
||||
)
|
||||
response = StreamingHttpResponse(stream, content_type="text/event-stream")
|
||||
response["Cache-Control"] = "no-cache"
|
||||
|
||||
Reference in New Issue
Block a user