@@ -29,14 +29,17 @@ from django.db import transaction
|
||||
|
||||
from .creation import append_message, pin_refs
|
||||
from .creation_presets import (
|
||||
LEGACY_PLOT_TWIST_PRESET,
|
||||
PLOT_TWIST_PRESET,
|
||||
apply_image_preset_prompt,
|
||||
apply_plot_twist_story_contract,
|
||||
apply_plot_twist_direction_contract,
|
||||
format_plot_twist_direction_contract,
|
||||
apply_video_preset_prompt,
|
||||
canonical_preset_name,
|
||||
is_click_swap_preset,
|
||||
is_fish_eye_outfit_preset,
|
||||
is_plot_twist_preset,
|
||||
fish_eye_outfit_prompt_template,
|
||||
plot_twist_story_depth,
|
||||
plot_twist_story_contract,
|
||||
@@ -255,6 +258,7 @@ _STEP_CONFIRM_LABELS = {
|
||||
_PERSON_SOURCE_PRESETS = {
|
||||
"痛点解决演示",
|
||||
PLOT_TWIST_PRESET,
|
||||
LEGACY_PLOT_TWIST_PRESET,
|
||||
"短剧反转带货",
|
||||
"达人口播种草",
|
||||
"鱼眼换装",
|
||||
@@ -278,6 +282,7 @@ _CAST_RELATION_AUTO_RE = re.compile(r"(你来定|你安排|你决定|随便|都
|
||||
_PRODUCT_REQUIRED_PRESETS = {
|
||||
"痛点解决演示",
|
||||
PLOT_TWIST_PRESET,
|
||||
LEGACY_PLOT_TWIST_PRESET,
|
||||
"达人口播种草",
|
||||
"点击换款",
|
||||
"多色商品换款",
|
||||
@@ -290,7 +295,7 @@ _PRODUCT_REQUIRED_PRESETS = {
|
||||
|
||||
|
||||
def is_plot_twist_conversation(conversation: CreationConversation) -> bool:
|
||||
return conversation.mode == CreationConversation.Mode.VIDEO and conversation.preset == PLOT_TWIST_PRESET
|
||||
return conversation.mode == CreationConversation.Mode.VIDEO and is_plot_twist_preset(conversation.preset)
|
||||
|
||||
|
||||
|
||||
@@ -594,6 +599,45 @@ _SELLING_POINT_FOCUS_RE = re.compile(
|
||||
_SELLING_POINT_BRIEF_CREATIVE_RE = re.compile(
|
||||
r"(?:创作|做[一条]?|视频|帮我|生成|脚本|出片|参考|预设|方向)",
|
||||
)
|
||||
_BRIEF_CLAUSE_END = r"[^。!?!?;;,,\n]{2,160}"
|
||||
_BRIEF_AUDIENCE_RE = re.compile(
|
||||
rf"(?:适用人群|目标人群|目标用户|适合人群|面向人群|受众)\s*(?:是|为|包括|:|:)\s*({_BRIEF_CLAUSE_END})"
|
||||
)
|
||||
_BRIEF_SCENARIO_RE = re.compile(
|
||||
rf"(?:使用环境|使用场景|适用场景|拍摄场景|发生场景|场景)\s*(?:是|为|包括|设在|设定为|:|:)\s*({_BRIEF_CLAUSE_END})"
|
||||
)
|
||||
_BRIEF_ACTION_BENEFIT_RE = re.compile(
|
||||
r"(?:^|[。!?!?;;,,]\s*)(?P<action>(?:把|将)[^。!?!?;;,,\n]{2,100}?"
|
||||
r"(?:就|即可|便能|可以)[^。!?!?;;,,\n]{2,100})"
|
||||
)
|
||||
|
||||
|
||||
def extract_brief_context(text: str) -> dict[str, str]:
|
||||
"""只采集用户明确说出的受众、场景和使用动作,不推断价格、功效等商品事实。"""
|
||||
raw = (text or "").strip()
|
||||
found: dict[str, str] = {}
|
||||
for key, pattern in (("target_audience", _BRIEF_AUDIENCE_RE), ("usage_scenario", _BRIEF_SCENARIO_RE)):
|
||||
matches = list(pattern.finditer(raw))
|
||||
if matches:
|
||||
value = matches[-1].group(1).strip(" ::。;;,, ")
|
||||
if value and not re.match(r"^(?:和|与|及|或)?(?:核心卖点|使用场景|目标用户|价格)$", value):
|
||||
found[key] = value[:160]
|
||||
action = _BRIEF_ACTION_BENEFIT_RE.search(raw)
|
||||
if action and not re.search(r"(?:不要|不能|禁止|别)\s*$", raw[max(0, action.start() - 8):action.start()]):
|
||||
found["usage_benefit"] = action.group("action").strip()[:160]
|
||||
return found
|
||||
|
||||
|
||||
def collect_product_brief_context(conversation: CreationConversation, user_text: str = "") -> dict[str, str]:
|
||||
"""按消息顺序合并商品 brief,后续补充覆盖先前描述。"""
|
||||
found: dict[str, str] = {}
|
||||
for message_text in conversation.messages.filter(role=CreationMessage.Role.USER).order_by("seq").values_list("text", flat=True):
|
||||
found.update(extract_brief_context(str(message_text or "")))
|
||||
memory = conversation.memory if isinstance(conversation.memory, dict) else {}
|
||||
found.update(extract_brief_context(str(memory.get("product_brief_note") or "")))
|
||||
if user_text:
|
||||
found.update(extract_brief_context(user_text))
|
||||
return found
|
||||
|
||||
|
||||
def extract_declared_selling_point(text: str) -> str:
|
||||
@@ -614,6 +658,11 @@ def extract_declared_selling_point(text: str) -> str:
|
||||
value = value.strip("。;;!!??,,、 ")
|
||||
if len(value) >= 2:
|
||||
return value[:200]
|
||||
# 用户可能没有说「卖点」二字,却明确描述了商品使用动作及结果。
|
||||
# 这里只复述该动作,不衍生「更快/更健康」等未经证实的功效。
|
||||
action = extract_brief_context(raw).get("usage_benefit", "")
|
||||
if action:
|
||||
return action
|
||||
# 整段就是短卖点列表(如「补水保湿,便于携带,买三送一」),且不像创作指令
|
||||
if len(raw) <= 80 and not _SELLING_POINT_BRIEF_CREATIVE_RE.search(raw):
|
||||
parts = [part.strip() for part in re.split(r"[,,、;;]", raw) if part.strip()]
|
||||
@@ -655,7 +704,7 @@ def lock_selling_point(
|
||||
memory = dict(conversation.memory or {})
|
||||
memory["selling_point_ready"] = True
|
||||
memory["selling_point_mode"] = mode
|
||||
memory["selling_point"] = selling_point if mode == "manual" else ""
|
||||
memory["selling_point"] = selling_point if mode in {"manual", "inferred"} else ""
|
||||
conversation.memory = memory
|
||||
conversation.save(update_fields=["memory", "updated_at"])
|
||||
|
||||
@@ -674,17 +723,19 @@ def ensure_selling_point_ready_from_context(
|
||||
if memory.get("selling_point_ready"):
|
||||
return True
|
||||
|
||||
candidates: list[str] = []
|
||||
declared = extract_declared_selling_point(user_text)
|
||||
if declared:
|
||||
candidates.append(declared)
|
||||
candidates: list[tuple[str, str]] = []
|
||||
for text in conversation.messages.filter(role=CreationMessage.Role.USER).order_by("seq").values_list("text", flat=True):
|
||||
found = extract_declared_selling_point(text)
|
||||
if found:
|
||||
candidates.append(found)
|
||||
candidates.append((found, "inferred" if found == extract_brief_context(text).get("usage_benefit") else "manual"))
|
||||
for source in (str(memory.get("product_brief_note") or ""), user_text):
|
||||
found = extract_declared_selling_point(source)
|
||||
if found:
|
||||
candidates.append((found, "inferred" if found == extract_brief_context(source).get("usage_benefit") else "manual"))
|
||||
if candidates:
|
||||
# 以最近一次声明为准(主页首条或后续补充)
|
||||
lock_selling_point(conversation, candidates[-1], mode="manual")
|
||||
selling_point, mode = candidates[-1]
|
||||
lock_selling_point(conversation, selling_point, mode=mode)
|
||||
return True
|
||||
|
||||
summary = product_selling_points_summary(conversation)
|
||||
@@ -694,6 +745,17 @@ def ensure_selling_point_ready_from_context(
|
||||
return False
|
||||
|
||||
|
||||
def record_product_brief_review(conversation: CreationConversation, note: str = "") -> None:
|
||||
"""提交商品补充时同步卖点状态,避免选完剧情方向后再次追问已填写的内容。"""
|
||||
memory = dict(conversation.memory or {})
|
||||
memory["product_brief_reviewed"] = True
|
||||
if note:
|
||||
memory["product_brief_note"] = note
|
||||
conversation.memory = memory
|
||||
conversation.save(update_fields=["memory", "updated_at"])
|
||||
ensure_selling_point_ready_from_context(conversation, note)
|
||||
|
||||
|
||||
def append_selling_point_gate(conversation: CreationConversation) -> CreationMessage:
|
||||
"""在视频方案生成前确认卖点来源。
|
||||
|
||||
@@ -1117,6 +1179,20 @@ def product_brief_items(conversation: CreationConversation) -> list[dict]:
|
||||
or ""
|
||||
).strip()
|
||||
selling = str(memory.get("selling_point") or "").strip()
|
||||
context = collect_product_brief_context(conversation)
|
||||
audience = context.get("target_audience", "")
|
||||
for ref in products:
|
||||
if audience or ref.get("type") != "product":
|
||||
continue
|
||||
from apps.products.models import Product
|
||||
audience = str(
|
||||
Product.objects.filter(id=ref.get("id"), team_id=conversation.team_id)
|
||||
.values_list("target_audience", flat=True).first() or ""
|
||||
).strip()
|
||||
scenario = context.get("usage_scenario", "")
|
||||
audience_and_scenario = ";".join(
|
||||
part for part in (f"目标用户:{audience}" if audience else "", f"使用场景:{scenario}" if scenario else "") if part
|
||||
)
|
||||
|
||||
def item(label: str, status: str, value: str = "") -> dict:
|
||||
return {"label": label, "status": status, "value": value}
|
||||
@@ -1131,7 +1207,7 @@ def product_brief_items(conversation: CreationConversation) -> list[dict]:
|
||||
item("名称与品牌", "ready" if name else "missing", name),
|
||||
item("品类与外观", "ready" if products else "missing", "将以已锁定商品图为准" if products else ""),
|
||||
item("核心卖点", "ready" if selling else "missing", selling),
|
||||
item("使用场景与目标用户", "missing", ""),
|
||||
item("使用场景与目标用户", "ready" if audience and scenario else "missing", audience_and_scenario),
|
||||
item("价格与优惠", "missing" if factual_price_requested else "not_needed", ""),
|
||||
item("特别强调或禁止内容", "ready" if special else "not_needed", special),
|
||||
]
|
||||
@@ -1144,6 +1220,20 @@ def product_brief_needs_review(conversation: CreationConversation) -> bool:
|
||||
return has_locked_product_reference(conversation) and not bool(memory.get("product_brief_reviewed"))
|
||||
|
||||
|
||||
def accept_complete_product_brief(conversation: CreationConversation) -> bool:
|
||||
"""用户和商品库已给足必要信息时,跳过重复的核对问答。"""
|
||||
if not product_brief_needs_review(conversation):
|
||||
return False
|
||||
items = product_brief_items(conversation)
|
||||
if any(item["status"] == "missing" for item in items):
|
||||
return False
|
||||
memory = dict(conversation.memory or {})
|
||||
memory["product_brief_reviewed"] = True
|
||||
conversation.memory = memory
|
||||
conversation.save(update_fields=["memory", "updated_at"])
|
||||
return True
|
||||
|
||||
|
||||
def append_product_brief_review(conversation: CreationConversation) -> CreationMessage:
|
||||
"""商品信息只核对一次;已获取、待补充、暂不需要在一张卡里说清。"""
|
||||
return append_message(
|
||||
@@ -2024,7 +2114,7 @@ def emit_final_confirm_gate(
|
||||
},
|
||||
"submitted": False,
|
||||
"params": snapshot_session_params(conversation),
|
||||
"param_options": confirm_param_options(True),
|
||||
"param_options": confirm_param_options(True, fixed_duration=is_plot_twist_conversation(conversation)),
|
||||
},
|
||||
)
|
||||
set_video_gate_stage(conversation, "confirm", pending_video_prompt=prompt)
|
||||
@@ -2090,7 +2180,7 @@ video_prompt 是交给出片模型的完整制作文件,不是方案摘要、
|
||||
色彩与材质系统:写出主色、材质、皮肤/产品/环境的可见质感。
|
||||
打光规则:光源方向、软硬、色温、人物和产品分别如何受光。
|
||||
剪辑节奏:列出时间段与 Hook → 证据/体验 → 转化收束的推进逻辑。
|
||||
剧情反转带货例外:上述广告节奏让位于完整故事的起因→行动→受阻→升级→反转→结果;商品证据嵌入剧情因果,结局之后才自然收束,不要把 60 秒写成某一场戏的片段。
|
||||
短剧带货例外:上述广告节奏让位于完整故事的起因→行动→受阻→升级→反转→结果;商品证据嵌入剧情因果,结局之后才自然收束,不要把 60 秒写成某一场戏的片段。
|
||||
声音方向:人声身份、语气、语速、环境声/拟音、背景音乐的进入和收束;人声原文必须分配到对应分镜。
|
||||
场景:逐一写清可见地点、前中后景、环境道具与景深。
|
||||
主体与参考素材:逐一说明角色、商品、场景的可见身份和一致性要求。多人物时先分行写「角色1:身份、性别、成年年龄段、发型与服装」「角色2:…」,每位角色单独一行,便于逐位生成定妆图;角色外观不得与剧情身份矛盾。已 @ 的素材按 @图片1、@图片2 … 标注其用途;仅引用实际提供的素材。
|
||||
@@ -3028,6 +3118,8 @@ def apply_explicit_video_duration_from_text(
|
||||
"""
|
||||
if conversation.mode != CreationConversation.Mode.VIDEO:
|
||||
return False
|
||||
if is_plot_twist_conversation(conversation):
|
||||
return False
|
||||
match = _EXPLICIT_VIDEO_DURATION_RE.search(str(user_text or ""))
|
||||
if match is None:
|
||||
return False
|
||||
@@ -3057,6 +3149,8 @@ def apply_session_params(conversation, fields, answers: dict) -> bool:
|
||||
stored = _PARAM_TO_STORED.get(str(key), str(key))
|
||||
if stored not in {"model", "ratio", "resolution", "duration", "count"}:
|
||||
continue
|
||||
if stored == "duration" and is_plot_twist_conversation(conversation):
|
||||
continue
|
||||
value = "、".join(raw) if isinstance(raw, list) else str(raw or "").strip()
|
||||
if not value or current.get(stored) == value:
|
||||
continue
|
||||
@@ -3094,12 +3188,12 @@ def snapshot_session_params(conversation) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def confirm_param_options(is_video: bool) -> dict:
|
||||
def confirm_param_options(is_video: bool, *, fixed_duration: bool = False) -> dict:
|
||||
return {
|
||||
"model": VIDEO_MODELS if is_video else IMAGE_MODELS,
|
||||
"resolution": RESOLUTIONS if is_video else [],
|
||||
"ratio": RATIOS,
|
||||
"duration": VIDEO_DURATIONS if is_video else [],
|
||||
"duration": VIDEO_DURATIONS if is_video and not fixed_duration else [],
|
||||
"count": IMAGE_COUNTS if not is_video else [],
|
||||
}
|
||||
|
||||
@@ -3119,7 +3213,11 @@ def apply_confirm_params(conversation, incoming: dict | None) -> tuple[dict, boo
|
||||
old_duration = str(current.get("duration") or "")
|
||||
old_model = str(current.get("model") or "")
|
||||
changed = False
|
||||
for key, raw in (incoming or {}).items():
|
||||
confirmed = dict(incoming or {})
|
||||
if conversation.mode == CreationConversation.Mode.VIDEO and is_plot_twist_conversation(conversation):
|
||||
# 确认卡不开放时长;旧客户端和旧会话也必须回到固定的 60 秒。
|
||||
confirmed["duration"] = "60 秒"
|
||||
for key, raw in confirmed.items():
|
||||
if key not in {"model", "ratio", "resolution", "duration", "count"}:
|
||||
continue
|
||||
value = str(raw or "").strip()
|
||||
@@ -3248,7 +3346,7 @@ def tool_schemas(context: AgentContext, *, allow_plan: bool = True) -> list[dict
|
||||
"function": {
|
||||
"name": "present_story_directions",
|
||||
"description": (
|
||||
"剧情反转带货预设默认按 60 秒完整短剧展开,先调用此工具展示 3 个可点击的剧情方向。"
|
||||
"短剧带货预设默认按 60 秒完整短剧展开,先调用此工具展示 3 个可点击的剧情方向。"
|
||||
"每条都必须有不同的冲突、商品承担的实际作用、反转和情绪;不能只说‘我准备了三个方向’。"
|
||||
"用户点击其一后才可 write_strategy;本工具调用后必须停下来等待选择。"
|
||||
),
|
||||
@@ -3319,7 +3417,7 @@ def tool_schemas(context: AgentContext, *, allow_plan: bool = True) -> list[dict
|
||||
"声音/灯光/场景/参考素材锁定、可执行镜头和一致性收束,不要只写大纲。"
|
||||
)
|
||||
+ (
|
||||
f"剧情反转带货须先写【完整故事线】的{plot_beats},"
|
||||
f"短剧带货须先写【完整故事线】的{plot_beats},"
|
||||
f"再按至少{plot_stage_count}段连续时间轴写完整的一集;结尾解决开头的问题,不能只交中间一场戏。"
|
||||
if is_plot_twist_conversation(context.conversation) else ""
|
||||
)
|
||||
@@ -4055,7 +4153,10 @@ def submit_confirmed_video(*, conversation: CreationConversation, user, confirm_
|
||||
# 先落生成中消息,剩余片段会在槽位释放后由分批调度器继续提交。
|
||||
|
||||
if len(segments) == 1:
|
||||
message_payload = {"task_id": str(tasks[0].id), "kind": "video", "prompt": prompt}
|
||||
message_payload = {
|
||||
"task_id": str(tasks[0].id), "kind": "video", "prompt": prompt,
|
||||
"ratio": submit["aspect_ratio"],
|
||||
}
|
||||
else:
|
||||
submitted_by_index = {
|
||||
int(segment["index"]): str(task.id)
|
||||
@@ -4066,6 +4167,7 @@ def submit_confirmed_video(*, conversation: CreationConversation, user, confirm_
|
||||
"task_ids": [str(task.id) for task in tasks],
|
||||
"kind": "video_segments",
|
||||
"prompt": prompt,
|
||||
"ratio": submit["aspect_ratio"],
|
||||
"total_duration": total_duration,
|
||||
"segments": [
|
||||
{**segment, **({"task_id": submitted_by_index[int(segment["index"])]} if int(segment["index"]) in submitted_by_index else {})}
|
||||
@@ -4352,6 +4454,11 @@ def build_system_prompt(context: AgentContext, *, allow_plan: bool = True, has_c
|
||||
f"- 商家已确认核心卖点:【{selling_point}】。策略、方案、脚本和出片指令必须围绕它展开;"
|
||||
"只补充可从素材或正常使用中证明的支撑,不得替换或夸大。"
|
||||
)
|
||||
elif selling_mode == "inferred" and selling_point:
|
||||
lines.append(
|
||||
f"- 从用户原话归纳出的商品使用动作:【{selling_point}】。可作为创意切入点,"
|
||||
"但不是已核实的功效、规格或价格;不得放大成未经证实的商品承诺。"
|
||||
)
|
||||
elif selling_mode == "auto":
|
||||
lines.append(
|
||||
"- 商家已授权系统推荐卖点。你必须从商品资料、可见素材和正常使用动作中选择一个最易证明的核心卖点;"
|
||||
@@ -4360,6 +4467,11 @@ def build_system_prompt(context: AgentContext, *, allow_plan: bool = True, has_c
|
||||
product_note = str(memory.get("product_brief_note") or "").strip()
|
||||
if product_note:
|
||||
lines.append(f"- 用户在商品信息核对卡补充的真实事实:【{product_note}】。后续架构与 Prompt 必须沿用。")
|
||||
brief_context = collect_product_brief_context(conversation)
|
||||
if brief_context.get("target_audience"):
|
||||
lines.append(f"- 用户指定的目标人群:【{brief_context['target_audience']}】。视频架构和 Prompt 必须体现。")
|
||||
if brief_context.get("usage_scenario"):
|
||||
lines.append(f"- 用户指定的使用/拍摄场景:【{brief_context['usage_scenario']}】。视频架构和 Prompt 必须体现。")
|
||||
lines.append(
|
||||
"- 品牌、商品名、价格、优惠、规格、功效属于事实:缺失时只能询问或标待补充,"
|
||||
"禁止用三个创意选项让用户从虚构事实里选择。创意方向可以给 3 个基于已知商品的建议并允许自定义。"
|
||||
@@ -4411,7 +4523,7 @@ def build_system_prompt(context: AgentContext, *, allow_plan: bool = True, has_c
|
||||
if conversation.preset:
|
||||
# 只给名字模型只能靠猜;把这个预设的拍法约束一起给它
|
||||
guidance = preset_guidance(conversation.preset)
|
||||
lines.append(f"\n【创作预设】{conversation.preset}")
|
||||
lines.append(f"\n【创作预设】{canonical_preset_name(conversation.preset)}")
|
||||
if context.is_video:
|
||||
lines.append(
|
||||
"预设必须贯穿本次对话:据此判断该补问哪些必要事实和素材、给什么创意选择、"
|
||||
@@ -4423,7 +4535,7 @@ def build_system_prompt(context: AgentContext, *, allow_plan: bool = True, has_c
|
||||
if is_plot_twist_conversation(conversation):
|
||||
depth = active_plot_twist_story_depth(conversation)
|
||||
if not depth:
|
||||
lines.append("剧情反转带货默认 60 秒完整短剧;不要追问开场时长,先按 60 秒核对商品,再展示剧情方向。")
|
||||
lines.append("短剧带货默认 60 秒完整短剧;不要追问开场时长,先按 60 秒核对商品,再展示剧情方向。")
|
||||
else:
|
||||
lines.append(plot_twist_story_contract(depth))
|
||||
memory = conversation.memory if isinstance(conversation.memory, dict) else {}
|
||||
@@ -5112,6 +5224,7 @@ def iter_creation_agent_events(
|
||||
yield {"type": "done"}
|
||||
return
|
||||
|
||||
accept_complete_product_brief(conversation)
|
||||
if product_brief_needs_review(conversation):
|
||||
question = append_product_brief_review(conversation)
|
||||
set_video_gate_stage(conversation, "clarify")
|
||||
@@ -5917,7 +6030,7 @@ def _dispatch_tool(
|
||||
|
||||
if name == "present_story_directions":
|
||||
if not is_plot_twist_conversation(context.conversation):
|
||||
return {"payload": {"error": "当前会话不是剧情反转带货预设"}}, False
|
||||
return {"payload": {"error": "当前会话不是短剧带货预设"}}, False
|
||||
directions = _coerce_plot_twist_directions(args.get("directions"))
|
||||
if not directions:
|
||||
return {
|
||||
@@ -5955,7 +6068,7 @@ def _dispatch_tool(
|
||||
return {
|
||||
"payload": {
|
||||
"error": (
|
||||
"剧情反转带货必须先选定剧情方向:调用 present_story_directions 展示 3 张方向卡,"
|
||||
"短剧带货必须先选定剧情方向:调用 present_story_directions 展示 3 张方向卡,"
|
||||
"等用户选择后再写策略。不得自行编造未选中的故事桥段。"
|
||||
)
|
||||
}
|
||||
@@ -6061,7 +6174,7 @@ def _dispatch_tool(
|
||||
else:
|
||||
return {
|
||||
"payload": {
|
||||
"error": "剧情反转带货必须先选定剧情方向,再写方案。请先 present_story_directions 让用户选择。"
|
||||
"error": "短剧带货必须先选定剧情方向,再写方案。请先 present_story_directions 让用户选择。"
|
||||
}
|
||||
}, False
|
||||
if not card["usp"] or not card["points"]:
|
||||
|
||||
Reference in New Issue
Block a user