解决已发现问题
This commit is contained in:
@@ -33,6 +33,7 @@ from .creation_presets import (
|
||||
apply_image_preset_prompt,
|
||||
apply_plot_twist_story_contract,
|
||||
apply_video_preset_prompt,
|
||||
is_click_swap_preset,
|
||||
plot_twist_story_depth,
|
||||
plot_twist_story_contract,
|
||||
preset_guidance,
|
||||
@@ -41,7 +42,13 @@ from .creation_presets import (
|
||||
)
|
||||
from .mentions import TYPE_LABELS, infer_field_types, resolve_refs, search_mentions
|
||||
from .models import CreationConversation, CreationMessage, ModelConfig
|
||||
from .services import build_provider, get_default_model, get_seed_text_model, resolve_text_model
|
||||
from .services import (
|
||||
build_provider,
|
||||
enforce_no_embedded_captions,
|
||||
get_default_model,
|
||||
get_seed_text_model,
|
||||
resolve_text_model,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -54,17 +61,89 @@ MAX_BILLED_GENERATIONS = 1
|
||||
# 视频闸门阶段(落在 conversation.memory.stage;resume 靠它)
|
||||
# clarify → strategy → plan → prompt → confirm → done
|
||||
VIDEO_GATE_STAGES = ("clarify", "strategy", "plan", "prompt", "confirm", "done")
|
||||
PAIN_POINT_PRESET = "痛点解决演示"
|
||||
PAIN_POINT_DIRECTION_KEY = "pain_point_direction"
|
||||
_STEP_CONFIRM_LABELS = {
|
||||
"strategy": "创作策略已写好。确认后继续写方案;要改就点「我想改」或直接说改哪里。",
|
||||
"plan": "视频方案已写好。确认后我会整理出片细节,并带你确认生成参数;要改就点「我想改」或直接说改哪里。",
|
||||
"prompt": "出片指令已整理好。确认后核对参数并生成;要改就点「我想改」或直接说改哪里。",
|
||||
}
|
||||
|
||||
_PERSON_SOURCE_PRESETS = {
|
||||
"痛点解决演示",
|
||||
PLOT_TWIST_PRESET,
|
||||
"达人口播种草",
|
||||
"鱼眼换装",
|
||||
}
|
||||
_PERSON_VISUAL_RE = re.compile(
|
||||
r"(人物|角色|模特|主角|达人|主播|出镜|口播|女生|女性|男生|男性|"
|
||||
r"女主|男主|年轻人|手模|手部|真人|换装|穿搭|剧情|短剧)"
|
||||
)
|
||||
|
||||
|
||||
def is_plot_twist_conversation(conversation: CreationConversation) -> bool:
|
||||
return conversation.mode == CreationConversation.Mode.VIDEO and conversation.preset == PLOT_TWIST_PRESET
|
||||
|
||||
|
||||
def is_pain_point_conversation(conversation: CreationConversation) -> bool:
|
||||
return conversation.mode == CreationConversation.Mode.VIDEO and conversation.preset == PAIN_POINT_PRESET
|
||||
|
||||
|
||||
def is_pain_point_direction_payload(payload: dict) -> bool:
|
||||
fields = [item for item in (payload.get("fields") or []) if isinstance(item, dict)]
|
||||
return bool(fields and str(fields[0].get("key") or "") == PAIN_POINT_DIRECTION_KEY)
|
||||
|
||||
|
||||
def apply_pain_point_direction(
|
||||
conversation: CreationConversation,
|
||||
payload: dict,
|
||||
choice: str,
|
||||
) -> str:
|
||||
"""把已选方向直接作为本轮核心痛点/卖点,后续不再重复追问核心卖点。"""
|
||||
fields = [item for item in (payload.get("fields") or []) if isinstance(item, dict)]
|
||||
options = (fields[0].get("options") or []) if fields else []
|
||||
selected = next(
|
||||
(
|
||||
item for item in options
|
||||
if isinstance(item, dict)
|
||||
and str(item.get("value") or "") == str(choice or "")
|
||||
),
|
||||
None,
|
||||
)
|
||||
direction = str((selected or {}).get("label") or choice or "").strip()
|
||||
memory = dict(conversation.memory or {})
|
||||
memory["pain_point_direction_ready"] = True
|
||||
memory["pain_point_direction"] = direction
|
||||
memory["selling_point_ready"] = True
|
||||
memory["selling_point_mode"] = "manual"
|
||||
memory["selling_point"] = direction
|
||||
conversation.memory = memory
|
||||
conversation.save(update_fields=["memory", "updated_at"])
|
||||
return (
|
||||
f"商家已选择痛点方向:【{direction}】。这个选择同时就是本轮要突出的核心痛点与核心卖点。"
|
||||
"现在只调用 write_strategy 写创作策略,并让痛点、正常使用过程和可见结果都围绕它展开;"
|
||||
"不要再询问核心卖点,不要复述选择,不要直接写方案或出片。"
|
||||
)
|
||||
|
||||
|
||||
def pain_point_direction_options_from_text(text: str) -> list[dict[str, str]]:
|
||||
"""模型偶尔只输出三条列表而忘记 ask_user;把列表确定性转成可点击选项。"""
|
||||
items: list[str] = []
|
||||
for line in str(text or "").splitlines():
|
||||
match = re.match(r"^\s*(?:[-*+•]|[1-3][.、.)])\s*(.+?)\s*$", line)
|
||||
if not match:
|
||||
continue
|
||||
label = re.sub(r"\*\*", "", match.group(1)).strip()
|
||||
if label and label not in items:
|
||||
items.append(label)
|
||||
if len(items) != 3:
|
||||
return []
|
||||
return [
|
||||
{"value": f"direction_{index}", "label": label}
|
||||
for index, label in enumerate(items, start=1)
|
||||
]
|
||||
|
||||
|
||||
def active_plot_twist_story_depth(conversation: CreationConversation) -> str:
|
||||
"""时长是最终事实来源;用户中途改时长后,故事结构必须随之切换。"""
|
||||
from_duration = plot_twist_story_depth(str((conversation.params or {}).get("duration") or ""))
|
||||
@@ -260,6 +339,146 @@ def append_selling_point_gate(conversation: CreationConversation) -> CreationMes
|
||||
)
|
||||
|
||||
|
||||
def has_locked_person_reference(conversation: CreationConversation) -> bool:
|
||||
"""人物一致性的前提是会话里有可解析的人物 Ref。
|
||||
|
||||
本地上传人物图由前端标记为 character,模特库则是 model;普通 asset
|
||||
不能被默认当人,否则商品图/场景图会误跳过这个闸门。
|
||||
"""
|
||||
return any(
|
||||
isinstance(ref, dict) and ref.get("type") in {"model", "character"} and ref.get("id")
|
||||
for ref in (conversation.pinned_refs or [])
|
||||
)
|
||||
|
||||
|
||||
def video_needs_person_source(conversation: CreationConversation, user_text: str = "") -> bool:
|
||||
"""需要真人/角色的视频在写策略前必须先锁定人物来源。"""
|
||||
if conversation.mode != CreationConversation.Mode.VIDEO or has_locked_person_reference(conversation):
|
||||
return False
|
||||
memory = conversation.memory if isinstance(conversation.memory, dict) else {}
|
||||
if memory.get("person_source_ready") or memory.get("person_source_pending"):
|
||||
return False
|
||||
if conversation.preset in _PERSON_SOURCE_PRESETS:
|
||||
return True
|
||||
recent = list(
|
||||
conversation.messages.order_by("-seq").values_list("text", flat=True)[:12]
|
||||
)
|
||||
pending_prompt = str(memory.get("pending_video_prompt") or "")
|
||||
return bool(_PERSON_VISUAL_RE.search("\n".join([user_text, pending_prompt, *recent])))
|
||||
|
||||
|
||||
def append_person_source_gate(conversation: CreationConversation) -> CreationMessage:
|
||||
"""可视化的人物来源闸门;三个选项分别进文件、模特库和生图流程。"""
|
||||
return append_message(
|
||||
conversation,
|
||||
role="assistant",
|
||||
kind=CreationMessage.Kind.ELICIT,
|
||||
text="先确定这条视频的出镜人物。选定后,所有镜头和分段都会锁定同一位人物。",
|
||||
payload={
|
||||
"interaction": "person_source_gate",
|
||||
"fields": [{
|
||||
"key": "person_source",
|
||||
"label": "选择人物来源",
|
||||
"type": "single",
|
||||
"required": True,
|
||||
"options": [
|
||||
{"value": "local_upload", "label": "本地上传"},
|
||||
{"value": "model_library", "label": "从模特库选择"},
|
||||
{"value": "platform_generate", "label": "平台帮忙生成"},
|
||||
],
|
||||
}],
|
||||
"submitted": False,
|
||||
"answers": {},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def click_swap_sequence(conversation: CreationConversation) -> str:
|
||||
memory = conversation.memory if isinstance(conversation.memory, dict) else {}
|
||||
return str(memory.get("click_swap_sequence") or "").strip()
|
||||
|
||||
|
||||
def click_swap_needs_sequence(conversation: CreationConversation) -> bool:
|
||||
"""点击换款只有在商家明确款式和顺序后才能写脚本。"""
|
||||
if conversation.mode != CreationConversation.Mode.VIDEO:
|
||||
return False
|
||||
if not is_click_swap_preset(conversation.preset):
|
||||
return False
|
||||
memory = conversation.memory if isinstance(conversation.memory, dict) else {}
|
||||
return not bool(memory.get("click_swap_ready") and click_swap_sequence(conversation))
|
||||
|
||||
|
||||
def append_click_swap_sequence_gate(conversation: CreationConversation) -> CreationMessage:
|
||||
return append_message(
|
||||
conversation,
|
||||
role="assistant",
|
||||
kind=CreationMessage.Kind.ELICIT,
|
||||
text="先确认要切换的款式和展示顺序。后续每次手指点击都会严格按这个顺序原位换款。",
|
||||
payload={
|
||||
"interaction": "click_swap_sku_gate",
|
||||
"fields": [{
|
||||
"key": "sku_sequence",
|
||||
"label": "款式与切换顺序",
|
||||
"type": "text",
|
||||
"required": True,
|
||||
"placeholder": "例如:黑色 → 白色 → 樱花粉",
|
||||
}],
|
||||
"submitted": False,
|
||||
"answers": {},
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def submit_generated_person_reference(*, conversation: CreationConversation, user) -> CreationMessage:
|
||||
"""先生成独立人物定妆参考,完成后再由 creation.py 自动建模特并锁定。"""
|
||||
from .services import enqueue_standalone_images
|
||||
|
||||
recent_user = list(
|
||||
conversation.messages.filter(role=CreationMessage.Role.USER)
|
||||
.order_by("-seq").values_list("text", flat=True)[:6]
|
||||
)
|
||||
brief = "\n".join(reversed([item.strip() for item in recent_user if item and item.strip()]))[:700]
|
||||
prompt = (
|
||||
"为短视频生成一张可反复用于锁定身份的真人模特定妆参考图。"
|
||||
"只出现一位成年人物,正面或轻微三分之四角度,中近景,表情自然,"
|
||||
"五官、发型、肤色、身形和服装细节清晰,简洁中性背景,写实摄影,"
|
||||
"不要文字、水印、拼图、多人、遮挡脸部或夸张滤镜。"
|
||||
)
|
||||
if conversation.preset:
|
||||
prompt += f" 适配视频预设:{conversation.preset}。"
|
||||
if brief:
|
||||
prompt += f" 参考用户需求:{brief}"
|
||||
tasks = enqueue_standalone_images(
|
||||
team=conversation.team,
|
||||
user=user,
|
||||
prompt=prompt,
|
||||
mode="model",
|
||||
count=1,
|
||||
ratio="portrait",
|
||||
feature="omni_create",
|
||||
)
|
||||
task = tasks[0]
|
||||
memory = dict(conversation.memory or {})
|
||||
memory["person_source"] = "platform_generate"
|
||||
memory["person_source_pending"] = True
|
||||
conversation.memory = memory
|
||||
conversation.status = CreationConversation.Status.RUNNING
|
||||
conversation.agent_status = CreationConversation.AgentStatus.IDLE
|
||||
conversation.save(update_fields=["memory", "status", "agent_status", "updated_at"])
|
||||
return append_message(
|
||||
conversation,
|
||||
role="assistant",
|
||||
kind=CreationMessage.Kind.GENERATING,
|
||||
payload={
|
||||
"task_id": str(task.id),
|
||||
"kind": "person_reference",
|
||||
"prompt": prompt,
|
||||
"label": "正在生成人物参考",
|
||||
},
|
||||
task=task,
|
||||
)
|
||||
|
||||
|
||||
def _step_confirm_payload(step: str) -> dict:
|
||||
return {
|
||||
"interaction": "step_confirm",
|
||||
@@ -472,7 +691,9 @@ IMAGE_MODEL_BY_LABEL = {
|
||||
# 不能把每条片都悄悄压成 15 秒。
|
||||
SMART_DURATION = 15
|
||||
_SMART_DURATION_RE = re.compile(
|
||||
r"(?<!\d)(\d{1,2}(?:\.\d+)?)\s*(?:-|—|–|~|至|到)\s*(\d{1,2}(?:\.\d+)?)\s*秒?"
|
||||
# 结尾必须明确带「秒/s」。原来的「秒?」会把“18–22岁”误当成 22 秒。
|
||||
r"(?<!\d)(\d{1,2}(?:\.\d+)?)\s*(?:-|—|–|~|至|到)\s*(\d{1,2}(?:\.\d+)?)\s*(?:秒|s)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_TOTAL_DURATION_RE = re.compile(
|
||||
r"(?:总时长|成片时长|时长)\s*[::]?\s*(\d{1,2}(?:\.\d+)?)\s*秒",
|
||||
@@ -516,9 +737,11 @@ video_prompt 是交给出片模型的完整制作文件,不是方案摘要、
|
||||
禁止无依据出现破损、漏液、渗水、失效、异常变形、脏污,或把商品拿去做超出用途的压力测试。
|
||||
不确定防水、防漏、承重、耐热、容量、材质等关键能力时,不编造测试和结果;要么问用户,要么改用可观察的正常使用动作。
|
||||
- 画面中不出现新增字幕、花字、标题贴片、弹幕、角标、水印、购物浮层或说明性文字;口播只存在于声音,包装本身原有印刷字除外。
|
||||
- 结尾单列「全片一致性与禁用项」:重申角色、商品、场景、光线、服装/材质的连续性,以及本片最重要的画面禁用项。
|
||||
- 结尾单列「全片一致性与约束」:用正向、可执行的句子重申角色、商品、场景、光线、服装/材质的连续性。
|
||||
- 用户说「商品说话 / 商品自述 / 商品拟人」时,默认无脸拟人:商品声音是画外角色声,商品本体不做口型、不新增卡通五官;性格只通过整体倾斜、转向、弹跳、进退、镜头和音效表达。只有用户明确要求可见卡通五官时才例外。
|
||||
- 平台安全优先于戏剧冲突:不要撰写或照抄暴力伤害、血腥、裸体或性暗示、违法犯罪、危险挑战、政治敏感、仇恨歧视、真实名人/IP 模仿等内容。若用户原意涉及上述内容,保留原来的情绪转折和商品卖点,改成成年人之间安全、非暴力、无违法的日常误会、压力或竞争,并采用原创人物与场景。儿童只能出现在安全的日常家庭场景,绝不参与危险、成人或营销煽动情节。
|
||||
- 审核安全必须在第一次生成时完成,不能依赖提交前清洗。没有锁定人物素材时,人物只写「成年女性 / 成年男性 / 成年人」,不要自创精确年龄区间,不使用带幼态联想的称呼、音色或人设。服装写日常得体,默认平视、自然俯拍或尊重主体的正面构图,不强调身体局部。
|
||||
- 最终 video_prompt 只使用正向安全描述。不要把平台风险类别、禁用词或用户原始高风险措辞逐项写进 Prompt,否定句、免责声明和「禁止出现某词」也不能照抄;先在内部把冲突改写成成年人之间积极、友善的日常互动,再输出改写后的可拍内容。
|
||||
- 平台安全优先于戏剧冲突:若原意不适合直接出片,只保留情绪转折和真实商品卖点,改成成年人之间的日常误会、压力或良性竞争,并采用原创人物与场景;不要在输出中复述被替换掉的原情节。
|
||||
- 不写医疗诊断、治愈/根治、绝对效果、虚构权威背书或夸大功效;把卖点改为真实可见的使用动作、材质细节和日常体验。最终 video_prompt 必须是可直接提交审核和出片的安全版本。
|
||||
"""
|
||||
|
||||
@@ -553,12 +776,10 @@ PRODUCT_REALITY_GUARD = (
|
||||
# 这一层不是替代平台审核,而是在脚本落成最终 video_prompt 前先把最容易被视频模型
|
||||
# 拦截的明确高风险表述改成等价的安全叙事。模型仍会收到下方完整约束,避免只靠关键词替换。
|
||||
VIDEO_PLATFORM_SAFETY_GUARD = (
|
||||
"【平台安全出片约束·最高优先级】全片仅呈现安全、合法、适合公开传播的原创商业内容。"
|
||||
"不出现或暗示暴力伤害、血腥、自残、裸体、性内容、违法犯罪、赌博、毒品、武器、危险挑战、"
|
||||
"政治敏感、仇恨歧视、真实名人或受保护 IP 模仿。若原剧情有冲突,只用成年人之间安全、"
|
||||
"非暴力的日常误会、压力或竞争来表达,并以沟通或轻松反转收束。儿童仅可出现在安全的日常家庭场景,"
|
||||
"不得参与危险、成人或煽动性情节。商品功效只写已知事实和可见使用体验,不作医疗、治愈、"
|
||||
"绝对化或虚构权威承诺。"
|
||||
"【平台安全出片约束·最高优先级】全片采用健康、友善、合法且适合公开传播的原创商业表达。"
|
||||
"出镜人物均明确为二十二岁以上成年人,着装与镜头语言自然得体,构图保持尊重。"
|
||||
"所有情节通过日常互动、积极沟通和轻松表达推进,并以正向结果收束。"
|
||||
"商品只呈现已知事实、正常用途和可观察的使用体验,所有描述保持客观克制。"
|
||||
)
|
||||
_VIDEO_PLATFORM_SAFETY_REWRITES: tuple[tuple[re.Pattern[str], str], ...] = (
|
||||
(
|
||||
@@ -566,7 +787,7 @@ _VIDEO_PLATFORM_SAFETY_REWRITES: tuple[tuple[re.Pattern[str], str], ...] = (
|
||||
r"暴打|殴打|群殴|互殴|动手伤人|打伤|捅伤|刺伤|砍伤|见血|鲜血(?:直流)?|血泊|"
|
||||
r"杀人|杀死|自杀|自残|割腕|跳楼|爆炸|绑架|虐待"
|
||||
),
|
||||
"强烈情绪冲突以安全、非暴力方式化解",
|
||||
"强烈情绪冲突通过沟通与日常误会化解",
|
||||
),
|
||||
(
|
||||
re.compile(r"裸体|裸露|色情|性爱|性行为|性暗示|挑逗|床戏"),
|
||||
@@ -574,7 +795,7 @@ _VIDEO_PLATFORM_SAFETY_REWRITES: tuple[tuple[re.Pattern[str], str], ...] = (
|
||||
),
|
||||
(
|
||||
re.compile(r"吸毒|毒品交易|贩毒|赌博|赌钱|诈骗|抢劫|偷窃|枪战|持枪|开枪"),
|
||||
"不涉及违法或危险行为的日常情节",
|
||||
"合规、稳妥的日常情节",
|
||||
),
|
||||
(
|
||||
re.compile(r"治愈|根治|药到病除|包治|抗癌|无副作用|百分之百(?:有效|治愈)|永久(?:有效|瘦)"),
|
||||
@@ -584,6 +805,18 @@ _VIDEO_PLATFORM_SAFETY_REWRITES: tuple[tuple[re.Pattern[str], str], ...] = (
|
||||
re.compile(r"(?:模仿|复刻|扮演|像).{0,16}(?:明星|艺人|名人|网红|演员)"),
|
||||
"采用原创人物设定与表演",
|
||||
),
|
||||
(
|
||||
re.compile(r"(?<!\d)18\s*(?:-|—|–|~|至|到)\s*22\s*岁\s*(?:软甜)?少女音"),
|
||||
"二十二岁以上成年女性的清甜自然声线",
|
||||
),
|
||||
(
|
||||
re.compile(r"(?<!\d)18\s*(?:-|—|–|~|至|到)\s*22\s*岁"),
|
||||
"二十二岁以上",
|
||||
),
|
||||
(re.compile(r"少女"), "年轻成年女性"),
|
||||
(re.compile(r"甜妹"), "甜美风成年女性"),
|
||||
(re.compile(r"低角度轻微仰拍"), "略低于视线的正面拍摄"),
|
||||
(re.compile(r"低角度鱼眼机位"), "正面鱼眼机位"),
|
||||
)
|
||||
|
||||
|
||||
@@ -774,31 +1007,75 @@ def default_reply_options(
|
||||
]
|
||||
if has_context:
|
||||
text = str(assistant_text or "")
|
||||
if re.search(r"颜色|色号|色彩|SKU|款式|几种", text, re.IGNORECASE):
|
||||
# 创作描述常同时出现商品、人物、场景、颜色。快捷回复只看末尾真正交给用户
|
||||
# 决定的部分,避免正文里的普通名词抢走最后一句的意图。
|
||||
paragraphs = [part.strip() for part in re.split(r"\n+", text) if part.strip()]
|
||||
source = paragraphs[-1] if paragraphs else text
|
||||
sentences = [part.strip() for part in re.split(r"(?<=[。!?!?])", source) if part.strip()]
|
||||
guidance = "".join(sentences[-2:])[-240:] if sentences else source[-240:]
|
||||
asks_for_detail = bool(re.search(
|
||||
r"(?:额外|另外|其他|重点).{0,12}(?:突出|强调).{0,12}(?:细节|重点|卖点)|"
|
||||
r"(?:细节|重点|卖点).{0,12}(?:突出|强调)",
|
||||
guidance,
|
||||
re.IGNORECASE,
|
||||
))
|
||||
asks_for_color_order = bool(re.search(
|
||||
r"配色.{0,12}(?:顺序|排序|调换|调整)|(?:调换|调整).{0,12}配色",
|
||||
guidance,
|
||||
re.IGNORECASE,
|
||||
))
|
||||
if asks_for_detail and asks_for_color_order:
|
||||
return [
|
||||
{"label": "补充突出细节", "text": "我想补充需要额外突出的细节"},
|
||||
{"label": "调整配色顺序", "text": "我想调整配色的展示顺序"},
|
||||
{"label": "按当前描述继续", "text": "没有其他调整,按当前描述继续"},
|
||||
]
|
||||
if re.search(r"颜色|色号|色彩|配色|SKU|款式|几种|展示顺序", guidance, re.IGNORECASE):
|
||||
return [
|
||||
{"label": "补充颜色和顺序", "text": "我来补充每个颜色和展示顺序"},
|
||||
{"label": "上传各款实物图", "text": "我补充各颜色/款式的实物图"},
|
||||
{"label": "先按当前主款做", "text": "先按当前主款做,其他颜色后面再补"},
|
||||
]
|
||||
if re.search(r"商品|产品|主推|哪款", text, re.IGNORECASE):
|
||||
if re.search(
|
||||
r"(?:哪款|哪个|什么).{0,8}(?:商品|产品)|"
|
||||
r"(?:商品|产品).{0,12}(?:选择|选|换|更换|主推|想推|要推)|"
|
||||
r"(?:选择|选|换|更换|主推|想推|要推).{0,12}(?:商品|产品)",
|
||||
guidance,
|
||||
re.IGNORECASE,
|
||||
):
|
||||
return [
|
||||
{"label": "发商品列表", "text": "把商品列表发给我选"},
|
||||
{"label": "我直接说商品名", "text": "我直接告诉你商品名"},
|
||||
{"label": "你来推荐", "text": "你根据当前需求推荐一款"},
|
||||
]
|
||||
if re.search(r"人物|角色|模特|出镜", text, re.IGNORECASE):
|
||||
if re.search(
|
||||
r"(?:哪位|哪个|什么|选择|选|换|更换|调整|修改|改).{0,12}(?:人物|角色|模特|出镜)|"
|
||||
r"(?:人物|角色|模特|出镜).{0,12}(?:哪位|哪个|选择|选|换|更换|调整|修改|改)",
|
||||
guidance,
|
||||
re.IGNORECASE,
|
||||
):
|
||||
return [
|
||||
{"label": "上传人物图", "text": "我上传人物参考图"},
|
||||
{"label": "由你设定角色", "text": "你先帮我设定一个合适的角色"},
|
||||
{"label": "不需要人物", "text": "这条先不需要人物出镜"},
|
||||
]
|
||||
if re.search(r"场景|地点|背景|在哪", text, re.IGNORECASE):
|
||||
if re.search(
|
||||
r"(?:哪里|哪儿|什么|选择|选|换|更换|调整|修改|改).{0,12}(?:场景|地点|背景)|"
|
||||
r"(?:场景|地点|背景).{0,12}(?:哪里|哪儿|选择|选|换|更换|调整|修改|改)",
|
||||
guidance,
|
||||
re.IGNORECASE,
|
||||
):
|
||||
return [
|
||||
{"label": "上传场景图", "text": "我上传场景参考图"},
|
||||
{"label": "你来推荐场景", "text": "你按商品和预设推荐场景"},
|
||||
{"label": "用干净日常场景", "text": "先用干净自然的日常场景"},
|
||||
]
|
||||
if re.search(r"卖点|功能|效果|优惠|价格", text, re.IGNORECASE):
|
||||
if re.search(
|
||||
r"(?:补充|选择|选|换|更换|调整|修改|改|突出|强调).{0,12}(?:卖点|功能|效果|优惠|价格)|"
|
||||
r"(?:卖点|功能|效果|优惠|价格).{0,12}(?:补充|选择|选|换|更换|调整|修改|改|突出|强调)",
|
||||
guidance,
|
||||
re.IGNORECASE,
|
||||
):
|
||||
return [
|
||||
{"label": "补充真实卖点", "text": "我来补充商品真实卖点"},
|
||||
{"label": "从素材里判断", "text": "先根据我上传的素材判断可表达的卖点"},
|
||||
@@ -965,6 +1242,10 @@ def apply_restart_intent(conversation: CreationConversation) -> int:
|
||||
memory.pop("selling_point_ready", None)
|
||||
memory.pop("selling_point_mode", None)
|
||||
memory.pop("selling_point", None)
|
||||
memory.pop("pain_point_direction_ready", None)
|
||||
memory.pop("pain_point_direction", None)
|
||||
memory.pop("click_swap_ready", None)
|
||||
memory.pop("click_swap_sequence", None)
|
||||
conversation.memory = memory
|
||||
conversation.save(update_fields=["memory", "updated_at"])
|
||||
|
||||
@@ -1181,6 +1462,15 @@ def confirm_param_options(is_video: bool) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def _normalize_confirm_duration(value) -> tuple[str, int | str]:
|
||||
"""确认卡只按实际秒数判断时长变化,兼容「8秒 / 8 秒」等历史格式。"""
|
||||
raw = str(value or "").strip()
|
||||
match = re.search(r"\d+(?:\.\d+)?", raw)
|
||||
if match:
|
||||
return "seconds", int(float(match.group()))
|
||||
return "label", re.sub(r"\s+", "", raw).lower()
|
||||
|
||||
|
||||
def apply_confirm_params(conversation, incoming: dict | None) -> tuple[dict, bool]:
|
||||
"""确认卡上改的参数写回会话。返回 (最新 params, 视频时长是否变了)。"""
|
||||
current = dict(conversation.params or {})
|
||||
@@ -1192,11 +1482,13 @@ def apply_confirm_params(conversation, incoming: dict | None) -> tuple[dict, boo
|
||||
value = str(raw or "").strip()
|
||||
if not value or current.get(key) == value:
|
||||
continue
|
||||
if key == "duration" and _normalize_confirm_duration(current.get(key)) == _normalize_confirm_duration(value):
|
||||
continue
|
||||
current[key] = value
|
||||
changed = True
|
||||
duration_changed = (
|
||||
conversation.mode == CreationConversation.Mode.VIDEO
|
||||
and str(current.get("duration") or "") != old_duration
|
||||
and _normalize_confirm_duration(current.get("duration")) != _normalize_confirm_duration(old_duration)
|
||||
and bool(str(current.get("duration") or ""))
|
||||
and bool(old_duration)
|
||||
)
|
||||
@@ -1330,6 +1622,7 @@ def tool_schemas(context: AgentContext, *, allow_plan: bool = True) -> list[dict
|
||||
"description": (
|
||||
"写「创作策略理解」卡:说清这条片给谁看、他为什么会信、你想让他信什么、整体创作方向。"
|
||||
"四个字段都必须写具体非空文案,禁止空字符串。"
|
||||
"策略从第一稿就使用健康、正向、明确成年的人物与情节表达,不要复述需要规避的原始措辞。"
|
||||
"调完会停下来等用户确认或提出修改,不要同轮接着 write_plan。"
|
||||
"仅当用户明确要做片/出方案时调用;打招呼或闲聊不要调。"
|
||||
),
|
||||
@@ -1357,6 +1650,7 @@ def tool_schemas(context: AgentContext, *, allow_plan: bool = True) -> list[dict
|
||||
"用户确认方案后由平台展示 Prompt。"
|
||||
"video_prompt 按系统里的「制作级交付」写成完整 Prompt 文件:必须有整体规则、"
|
||||
"声音/灯光/场景/参考素材锁定、逐镜四行细节和一致性收束,不要只写大纲。"
|
||||
"第一稿必须已经可直接过平台审核:只写正向安全描述,不要输出风险词清单或否定式免责声明。"
|
||||
"先有已确认的 write_strategy,再调它。"
|
||||
),
|
||||
"parameters": {
|
||||
@@ -1390,6 +1684,7 @@ def tool_schemas(context: AgentContext, *, allow_plan: bool = True) -> list[dict
|
||||
"时长任务、标题、风格、镜头语言、视觉美术、色彩材质、打光、剪辑、声音、场景、主体参考、逐镜脚本、一致性收束。"
|
||||
"每一镜严格包含拍法/画面内容/主体或产品露出/声音四行;15 秒至少 4 镜,含人声原文、拟音和 BGM 节奏。"
|
||||
"已 @ 素材标明用途与需锁定的特征;禁止把口播做成画面文字。"
|
||||
"人物必须明确为成年人且构图得体;只写正向可拍内容,不列风险词或禁用词。"
|
||||
),
|
||||
},
|
||||
},
|
||||
@@ -1655,11 +1950,34 @@ def segment_video_prompt(prompt: str, segment: dict, total_duration: int) -> str
|
||||
return (
|
||||
f"{prompt.strip()}\n\n"
|
||||
f"【分段出片约束】这是整支 {total_duration} 秒视频的第 {index} 段,只生成 {start}–{end} 秒的内容。"
|
||||
"仅呈现这一时间段对应的情节与镜头,承接上一段的角色、服装、商品、场景和光线,"
|
||||
"仅呈现这一时间段对应的情节与镜头。必须继续使用参考图锁定的同一位人物,"
|
||||
"不得在本段重新设计、随机替换或改变其五官、发型、年龄、身形和服装。"
|
||||
"承接上一段的动作、商品、场景和光线,"
|
||||
"为下一段留出自然动作衔接;不要重演完整故事,不要添加字幕、文字、角标或水印。"
|
||||
)
|
||||
|
||||
|
||||
def apply_person_identity_guard(prompt: str, references: list[dict]) -> str:
|
||||
"""把解析后的人物参考编号写进最终出片 Prompt。
|
||||
|
||||
resolve_refs 会把人物排在最前,但仍按真实位置计算 @图N,避免混入
|
||||
场景/商品后编号指错。
|
||||
"""
|
||||
indexes = [
|
||||
index for index, item in enumerate(references or [], start=1)
|
||||
if isinstance(item, dict) and item.get("type") in {"model", "character"}
|
||||
]
|
||||
if not indexes:
|
||||
return prompt
|
||||
labels = "、".join(f"参考图{index}" for index in indexes)
|
||||
return (
|
||||
f"{prompt.strip()}\n\n【人物一致性硬约束】{labels}定义本片的固定出镜人物。"
|
||||
"整片及所有分段、远景、近景、转场都必须保持同一人;五官比例、脸型、发型、"
|
||||
"肤色、年龄、身形、手部特征和基础服装不得漂移。不得换人、随机造人、合并成新面孔,"
|
||||
"不得因镜头或光线变化而改变身份。"
|
||||
)
|
||||
|
||||
|
||||
def video_duration(params: dict, *, prompt: str = "", timeline: list[dict] | None = None) -> int:
|
||||
"""显式时长优先;智能时长从方案/Prompt 推导,完全缺失才回退 15 秒。"""
|
||||
raw = str(params.get("duration") or "")
|
||||
@@ -1804,6 +2122,7 @@ def _video_submit_params(context: AgentContext, prompt: str) -> tuple[dict, list
|
||||
active_plot_twist_story_depth(context.conversation),
|
||||
prompt,
|
||||
)
|
||||
prompt = apply_person_identity_guard(prompt, resolved.references)
|
||||
duration = resolve_smart_video_duration(context.conversation, prompt=prompt)
|
||||
# resolve_smart_video_duration 可能为了完整脚本切到支持长时长的模型,必须重新取参数。
|
||||
params = context.conversation.params or {}
|
||||
@@ -1818,6 +2137,9 @@ def _video_submit_params(context: AgentContext, prompt: str) -> tuple[dict, list
|
||||
"generate_audio": True,
|
||||
"references": resolved.references,
|
||||
}
|
||||
if any(item.get("type") in {"model", "character"} for item in resolved.references):
|
||||
# 长视频的多个任务共用同一个确定性 seed,减少两段各自随机采样导致的脸部/服装漂移。
|
||||
submit["seed"] = int(str(context.conversation.id).replace("-", "")[:8], 16)
|
||||
return submit, resolved.references
|
||||
|
||||
|
||||
@@ -1901,10 +2223,19 @@ def submit_confirmed_video(*, conversation: CreationConversation, user, confirm_
|
||||
tasks = []
|
||||
try:
|
||||
for segment in segments:
|
||||
segment_prompt = (
|
||||
segment_video_prompt(submit["prompt"], segment, total_duration)
|
||||
if len(segments) > 1
|
||||
else submit["prompt"]
|
||||
)
|
||||
segment_submit = {
|
||||
**submit,
|
||||
"duration": int(segment["duration"]),
|
||||
"prompt": segment_video_prompt(prompt, segment, total_duration) if len(segments) > 1 else submit["prompt"],
|
||||
# 长视频也必须从完整的最终 Prompt 分段。以前这里误用原始 prompt,
|
||||
# 会丢掉预设约束、商品安全约束和人物锁定,是 60s 前后换人的直接原因。
|
||||
# 分段之后再执行一次统一清洗:移除模型/用户写进正文的任何屏显指令,
|
||||
# 并让每个独立视频任务的首尾都带最高优先级画面洁净约束。
|
||||
"prompt": enforce_no_embedded_captions(segment_prompt),
|
||||
"extra_payload": {
|
||||
"omni_segment": {
|
||||
"index": segment["index"],
|
||||
@@ -2135,6 +2466,7 @@ def build_system_prompt(context: AgentContext, *, allow_plan: bool = True, has_c
|
||||
"- 可以自己决定转场、灯光、普通镜头细节;商品功能、规格、价格、活动、功效和关键使用边界不能猜,缺失时一次只问一项。",
|
||||
"- 用户上传的人物、商品、服装和场景优先作为参考;如确实需要额外生成角色、场景或道具,先说明用途和预计积分,等用户确认。",
|
||||
"- 出片前检查:主卖点都有画面或台词证据、口播能在时长内说完、脚本中每位人物/商品/服装/场景都有对应素材、商品正常使用、全片一致、所有 SKU 都已安排。内容超出时长时建议删减、延长或拆分,而不是硬塞。",
|
||||
"- 安全检查前置到创作第一稿:人物默认明确为成年人,服装与构图得体;策略、方案和 video_prompt 只写改写后的正向可拍内容,不复述风险情节,不罗列平台禁用词,也不写否定式免责声明。",
|
||||
])
|
||||
# 按会话时长给出口播字数锚点(与专业创作 narration_limit 同口径)
|
||||
try:
|
||||
@@ -2237,6 +2569,21 @@ def build_system_prompt(context: AgentContext, *, allow_plan: bool = True, has_c
|
||||
"每张卡写清冲突、商品如何推进剧情、反转和情绪;不得只在文字中说‘我准备了三个方向’,"
|
||||
"不得先 write_strategy / write_plan,也不要让用户输入编号。"
|
||||
)
|
||||
if is_pain_point_conversation(conversation):
|
||||
memory = conversation.memory if isinstance(conversation.memory, dict) else {}
|
||||
selected_direction = str(memory.get("pain_point_direction") or "").strip()
|
||||
if selected_direction:
|
||||
lines.append(
|
||||
f"用户已选择痛点方向:【{selected_direction}】。这个方向就是已确认的核心卖点;"
|
||||
"直接围绕它写策略,不得再询问核心卖点。"
|
||||
)
|
||||
else:
|
||||
lines.append(
|
||||
"【强制下一步·痛点方向三选一】先根据商品事实与可见素材整理恰好 3 个明显不同、可被画面证明的痛点方向。"
|
||||
"必须调用 ask_user:field key 固定为 pain_point_direction,type=single,options 恰好 3 项;"
|
||||
"每个 label 直接写完整的‘具体困扰 + 商品正常使用后可见结果’,让用户点击即选中。"
|
||||
"不得只在正文里列三条,不得要求用户回复编号,不得先 write_strategy,也不得另问核心卖点。"
|
||||
)
|
||||
workflow_guidance = preset_workflow_guidance(conversation.preset) if context.is_video else ""
|
||||
if workflow_guidance:
|
||||
lines.append(f"【当前预设的工作重点】{workflow_guidance}")
|
||||
@@ -2516,6 +2863,64 @@ def _coerce_strategy_args(args: dict) -> dict:
|
||||
}
|
||||
|
||||
|
||||
_STRATEGY_TEXT_SECTION_ALIASES = {
|
||||
"目标受众": "target",
|
||||
"目标人群": "target",
|
||||
"这条视频给谁看": "target",
|
||||
"给谁看": "target",
|
||||
"用户为什么相信": "trust",
|
||||
"为什么相信": "trust",
|
||||
"内容逻辑": "trust",
|
||||
"可信依据": "trust",
|
||||
"信任依据": "trust",
|
||||
"希望用户相信什么": "belief",
|
||||
"希望相信": "belief",
|
||||
"核心卖点": "belief",
|
||||
"核心主张": "belief",
|
||||
"创作方向": "direction",
|
||||
"视觉调性": "direction",
|
||||
"视觉风格": "direction",
|
||||
"表达方向": "direction",
|
||||
}
|
||||
|
||||
|
||||
def strategy_args_from_text(text: str) -> dict:
|
||||
"""模型漏调 write_strategy 时,把带明确栏目名的策略正文救回结构化卡片。
|
||||
|
||||
只接受四个栏目都能识别的高置信文本,普通聊天不会被误转成策略卡。
|
||||
"""
|
||||
sections = {"target": [], "trust": [], "belief": [], "direction": []}
|
||||
current = ""
|
||||
for raw_line in str(text or "").splitlines():
|
||||
line = re.sub(r"^\s*(?:[-*#>]+\s*)?", "", raw_line).replace("**", "").strip()
|
||||
if not line:
|
||||
continue
|
||||
match = re.match(r"^([^::\n]{2,36})\s*[::]\s*(.*)$", line)
|
||||
if match:
|
||||
heading = re.sub(r"[((].*$", "", match.group(1)).strip().replace(" ", "")
|
||||
key = _STRATEGY_TEXT_SECTION_ALIASES.get(heading)
|
||||
if key:
|
||||
current = key
|
||||
content = match.group(2).strip()
|
||||
if content:
|
||||
sections[key].append(content)
|
||||
continue
|
||||
if heading in {"创作策略", "策略理解", "创作策略理解"}:
|
||||
current = ""
|
||||
continue
|
||||
if not current:
|
||||
continue
|
||||
if re.match(r"^(?:你看|请确认|如果你|是否需要|可以再)", line):
|
||||
continue
|
||||
sections[current].append(line)
|
||||
|
||||
payload = {
|
||||
key: "\n".join(parts).strip()
|
||||
for key, parts in sections.items()
|
||||
}
|
||||
return payload if all(payload.values()) else {}
|
||||
|
||||
|
||||
def _coerce_plan_card_args(args: dict) -> dict:
|
||||
usp = _pick_str(args, "usp", "主打卖点", "卖点", "core_usp", "main_point")
|
||||
points = _coerce_points(
|
||||
@@ -2543,6 +2948,69 @@ def _coerce_plan_card_args(args: dict) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def apply_click_swap_plan_card(
|
||||
conversation: CreationConversation,
|
||||
card: dict,
|
||||
duration: int,
|
||||
) -> dict:
|
||||
"""把模型可能写偏的方案卡收束成可核对的点击换款时间轴。"""
|
||||
if not is_click_swap_preset(conversation.preset):
|
||||
return card
|
||||
sequence = click_swap_sequence(conversation)
|
||||
if not sequence:
|
||||
return card
|
||||
total = max(4, min(int(duration or 0), 60))
|
||||
first_end = max(1, round(total * 0.2))
|
||||
second_end = max(first_end + 1, round(total * 0.45))
|
||||
third_end = max(second_end + 1, round(total * 0.75))
|
||||
third_end = min(third_end, total - 1)
|
||||
second_end = min(second_end, third_end - 1)
|
||||
return {
|
||||
**card,
|
||||
"usp": f"手指逐次点击,商品按「{sequence}」在原位连续换款",
|
||||
"points": [
|
||||
"固定机位、背景、光线与商品中心位置",
|
||||
"每次换款都由一次清楚的手指点击触发",
|
||||
f"严格按「{sequence}」逐款展示,结尾给出全款式总览",
|
||||
],
|
||||
"timeline": [
|
||||
{
|
||||
"start": 0,
|
||||
"end": first_end,
|
||||
"stage": "首款定帧",
|
||||
"desc": "固定机位建立首款,商品位置、尺寸、角度和背景作为后续唯一基准。",
|
||||
},
|
||||
{
|
||||
"start": first_end,
|
||||
"end": second_end,
|
||||
"stage": "首次点击换款",
|
||||
"desc": "手指清晰点击商品,接触瞬间在原位 match cut 为下一款。",
|
||||
},
|
||||
{
|
||||
"start": second_end,
|
||||
"end": third_end,
|
||||
"stage": "按序连续换款",
|
||||
"desc": f"按「{sequence}」继续一触一换;机位、构图、商品比例和光线完全不变。",
|
||||
},
|
||||
{
|
||||
"start": third_end,
|
||||
"end": total,
|
||||
"stage": "全款式收束",
|
||||
"desc": "保持同一构图完成全款式总览,不加入口播、剧情、换景或字幕。",
|
||||
},
|
||||
],
|
||||
"matrix": {
|
||||
"shots": 4,
|
||||
"rows": [
|
||||
{"point": "固定构图", "hits": [1, 2, 3, 4]},
|
||||
{"point": "点击触发", "hits": [2, 3]},
|
||||
{"point": "款式顺序", "hits": [1, 2, 3, 4]},
|
||||
],
|
||||
},
|
||||
"voice_chars": [0, 0],
|
||||
}
|
||||
|
||||
|
||||
def iter_creation_agent_events(
|
||||
*,
|
||||
conversation: CreationConversation,
|
||||
@@ -2633,6 +3101,27 @@ def iter_creation_agent_events(
|
||||
yield {"type": "done"}
|
||||
return
|
||||
|
||||
# 点击换款的款式清单和顺序是脚本事实,不能让模型自行猜色号或把预设改成普通展示片。
|
||||
if click_swap_needs_sequence(conversation):
|
||||
question = append_click_swap_sequence_gate(conversation)
|
||||
set_video_gate_stage(conversation, "clarify")
|
||||
conversation.agent_status = CreationConversation.AgentStatus.AWAITING_USER
|
||||
conversation.save(update_fields=["agent_status", "updated_at"])
|
||||
yield {"type": "message", "message": _message_payload(question)}
|
||||
yield {"type": "done"}
|
||||
return
|
||||
|
||||
# 需要真人/角色的视频必须先选定人物来源。这是平台闸门,
|
||||
# 不交给模型自由发挥,否则它会在脚本里随机造人,到 60s 分段时必然漂移。
|
||||
if video_needs_person_source(conversation, text):
|
||||
question = append_person_source_gate(conversation)
|
||||
set_video_gate_stage(conversation, "clarify")
|
||||
conversation.agent_status = CreationConversation.AgentStatus.AWAITING_USER
|
||||
conversation.save(update_fields=["agent_status", "updated_at"])
|
||||
yield {"type": "message", "message": _message_payload(question)}
|
||||
yield {"type": "done"}
|
||||
return
|
||||
|
||||
# 明确要商品/角色/场景列表时,直接生成真实选择卡,绝不先让模型念出素材名称。
|
||||
requested_card = requested_asset_card_from_context(conversation, text)
|
||||
if requested_card:
|
||||
@@ -2748,6 +3237,26 @@ def iter_creation_agent_events(
|
||||
"asset_types": ["product"],
|
||||
}]
|
||||
|
||||
# 痛点解决预设若模型只写了三条列表却漏调 ask_user,平台直接把这三条
|
||||
# 转成单选按钮;不允许用户再手抄一遍,也不进入重复的核心卖点闸门。
|
||||
memory = conversation.memory if isinstance(conversation.memory, dict) else {}
|
||||
if (
|
||||
not calls
|
||||
and fallback_fields is None
|
||||
and allow_plan
|
||||
and is_pain_point_conversation(conversation)
|
||||
and not memory.get("selling_point_ready")
|
||||
):
|
||||
pain_options = pain_point_direction_options_from_text(said)
|
||||
if pain_options:
|
||||
fallback_fields = [{
|
||||
"key": PAIN_POINT_DIRECTION_KEY,
|
||||
"label": "选择这条视频要重点解决的痛点",
|
||||
"type": "single",
|
||||
"required": True,
|
||||
"options": pain_options,
|
||||
}]
|
||||
|
||||
# 方向卡是剧情反转预设的固定入口。模型偶尔只会说「我准备了三个方向」而忘了调工具,
|
||||
# 此处直接补上可点击卡,不能让用户面对一段空话再自己追问。
|
||||
memory = conversation.memory if isinstance(conversation.memory, dict) else {}
|
||||
@@ -2770,6 +3279,32 @@ def iter_creation_agent_events(
|
||||
turn_has_gate = True
|
||||
break
|
||||
|
||||
# 有些模型会把完整策略按「核心卖点/目标受众/内容逻辑/视觉调性」写成散文,
|
||||
# 却漏掉 write_strategy 工具调用。高置信识别后直接走同一条结构化策略闸门,
|
||||
# 避免前端退化成一整块普通聊天气泡,也避免缺失「按这个继续」。
|
||||
current_stage = get_video_gate_stage(conversation)
|
||||
may_write_strategy = (
|
||||
current_stage == "clarify"
|
||||
or (current_stage == "strategy" and not bool(memory.get("strategy_confirmed")))
|
||||
)
|
||||
prose_strategy = (
|
||||
strategy_args_from_text(said)
|
||||
if not calls and context.is_video and may_write_strategy
|
||||
else {}
|
||||
)
|
||||
if prose_strategy:
|
||||
result, _stop = _dispatch_tool(context, "write_strategy", prose_strategy)
|
||||
for event in result.get("_events", []):
|
||||
yield event
|
||||
if event.get("type") == "message":
|
||||
msg = event.get("message") or {}
|
||||
if msg.get("kind") in (
|
||||
CreationMessage.Kind.ELICIT,
|
||||
CreationMessage.Kind.CONFIRM,
|
||||
):
|
||||
turn_has_gate = True
|
||||
break
|
||||
|
||||
# ask_user 自己会落一条可追踪的聊天问题。模型同时吐出的过渡文案不再
|
||||
# 另存一条,否则界面会连续出现两遍几乎相同的问题。
|
||||
asks_user = bool(fallback_fields) or any(call.get("name") == "ask_user" for call in calls)
|
||||
@@ -3024,6 +3559,8 @@ def _guided_elicit_text(field: dict) -> str:
|
||||
return label
|
||||
if str(field.get("key") or "") in SESSION_PARAM_KEYS:
|
||||
return label
|
||||
if field.get("type") == "single" and field.get("options"):
|
||||
return f"{label} 直接点击一个选项,也可以输入自己的想法。"
|
||||
return f"{label} 直接用一句话告诉我就行,不用整理成完整需求。"
|
||||
|
||||
|
||||
@@ -3137,7 +3674,34 @@ def _dispatch_tool(
|
||||
return {"payload": _run_search_library(context, args)}, False
|
||||
|
||||
if name == "write_strategy":
|
||||
if context.is_video and click_swap_needs_sequence(context.conversation):
|
||||
gate = append_click_swap_sequence_gate(context.conversation)
|
||||
set_video_gate_stage(context.conversation, "clarify")
|
||||
return {
|
||||
"payload": {"asked": True, "field": "sku_sequence"},
|
||||
"_events": [{"type": "message", "message": _message_payload(gate)}],
|
||||
}, True
|
||||
if context.is_video and video_needs_person_source(
|
||||
context.conversation,
|
||||
json.dumps(args if isinstance(args, dict) else {}, ensure_ascii=False),
|
||||
):
|
||||
gate = append_person_source_gate(context.conversation)
|
||||
set_video_gate_stage(context.conversation, "clarify")
|
||||
return {
|
||||
"payload": {"asked": True, "field": "person_source"},
|
||||
"_events": [{"type": "message", "message": _message_payload(gate)}],
|
||||
}, True
|
||||
memory = context.conversation.memory if isinstance(context.conversation.memory, dict) else {}
|
||||
if context.is_video and is_pain_point_conversation(context.conversation) and not memory.get("selling_point_ready"):
|
||||
return {
|
||||
"payload": {
|
||||
"error": (
|
||||
"痛点解决演示必须先调用 ask_user 展示痛点方向三选一:"
|
||||
"key=pain_point_direction、type=single、恰好 3 个 options。"
|
||||
"用户点击后该方向会直接成为核心卖点,不要另开卖点确认卡。"
|
||||
)
|
||||
}
|
||||
}, False
|
||||
if context.is_video and not memory.get("selling_point_ready"):
|
||||
gate = append_selling_point_gate(context.conversation)
|
||||
set_video_gate_stage(context.conversation, "clarify")
|
||||
@@ -3180,7 +3744,26 @@ def _dispatch_tool(
|
||||
video_prompt = str(args.get("video_prompt") or "").strip()
|
||||
if not video_prompt:
|
||||
return {"payload": {"error": "video_prompt 不能为空,请把完整出片指令写进去"}}, False
|
||||
if context.is_video and click_swap_needs_sequence(context.conversation):
|
||||
gate = append_click_swap_sequence_gate(context.conversation)
|
||||
set_video_gate_stage(context.conversation, "clarify")
|
||||
return {
|
||||
"payload": {"asked": True, "field": "sku_sequence"},
|
||||
"_events": [{"type": "message", "message": _message_payload(gate)}],
|
||||
}, True
|
||||
if context.is_video and video_needs_person_source(context.conversation, video_prompt):
|
||||
gate = append_person_source_gate(context.conversation)
|
||||
set_video_gate_stage(context.conversation, "clarify")
|
||||
return {
|
||||
"payload": {"asked": True, "field": "person_source"},
|
||||
"_events": [{"type": "message", "message": _message_payload(gate)}],
|
||||
}, True
|
||||
video_prompt = apply_video_preset_prompt(context.conversation.preset, video_prompt)
|
||||
if is_click_swap_preset(context.conversation.preset):
|
||||
video_prompt = (
|
||||
f"{video_prompt}\n\n【商家确认的换款顺序】{click_swap_sequence(context.conversation)}\n"
|
||||
"只允许按这个顺序逐款切换;不得跳序、漏款或自行增加颜色和款式。"
|
||||
)
|
||||
video_prompt = apply_product_voice_visual_guard(context.conversation, video_prompt)
|
||||
video_prompt = apply_product_reality_guard(video_prompt)
|
||||
video_prompt = apply_video_platform_safety_guard(video_prompt)
|
||||
@@ -3218,6 +3801,7 @@ def _dispatch_tool(
|
||||
prompt=video_prompt,
|
||||
timeline=card["timeline"],
|
||||
)
|
||||
card = apply_click_swap_plan_card(context.conversation, card, duration)
|
||||
lo = max(20, round(duration * 3.4))
|
||||
hi = max(lo + 1, round(duration * 4))
|
||||
plan_payload = {
|
||||
@@ -3225,7 +3809,11 @@ def _dispatch_tool(
|
||||
"points": card["points"],
|
||||
"timeline": card["timeline"],
|
||||
"matrix": card["matrix"],
|
||||
"voice_chars": _coerce_voice_chars(card["voice_chars"], [lo, hi]),
|
||||
"voice_chars": (
|
||||
[0, 0]
|
||||
if is_click_swap_preset(context.conversation.preset)
|
||||
else _coerce_voice_chars(card["voice_chars"], [lo, hi])
|
||||
),
|
||||
"ref_count": len(context.conversation.pinned_refs or []),
|
||||
}
|
||||
events = []
|
||||
@@ -3251,6 +3839,13 @@ def _dispatch_tool(
|
||||
video_prompt = get_pending_video_prompt(context.conversation)
|
||||
if not video_prompt:
|
||||
return {"payload": {"error": "video_prompt 不能为空,请把完整出片指令写进去"}}, False
|
||||
if context.is_video and click_swap_needs_sequence(context.conversation):
|
||||
gate = append_click_swap_sequence_gate(context.conversation)
|
||||
set_video_gate_stage(context.conversation, "clarify")
|
||||
return {
|
||||
"payload": {"asked": True, "field": "sku_sequence"},
|
||||
"_events": [{"type": "message", "message": _message_payload(gate)}],
|
||||
}, True
|
||||
video_prompt = apply_video_preset_prompt(context.conversation.preset, video_prompt)
|
||||
video_prompt = apply_product_voice_visual_guard(context.conversation, video_prompt)
|
||||
video_prompt = apply_product_reality_guard(video_prompt)
|
||||
|
||||
Reference in New Issue
Block a user