优化全能创作
Deploy dev / deploy (push) Successful in 44s

This commit is contained in:
Azmat@qq.com
2026-09-29 18:11:24 +08:00
parent c687ec4bbd
commit fa94051381
14 changed files with 669 additions and 183 deletions
+135 -22
View File
@@ -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"]: