@@ -83,7 +83,7 @@ def _meta_from_task(task, message: CreationMessage) -> dict:
|
||||
model = task.model_config.display_name or task.model_config.name
|
||||
return {
|
||||
"model": model or payload.get("model") or req.get("model") or "",
|
||||
"ratio": req.get("ratio") or payload.get("ratio") or "",
|
||||
"ratio": req.get("aspect_ratio") or req.get("ratio") or payload.get("ratio") or "",
|
||||
"prompt": payload.get("prompt") or req.get("prompt") or "",
|
||||
}
|
||||
|
||||
@@ -103,11 +103,15 @@ def _message_task(message: CreationMessage):
|
||||
@transaction.atomic
|
||||
def fail_generating_message(message: CreationMessage, error: str) -> CreationMessage:
|
||||
"""生成失败:GENERATING 原地改成 ERROR,不另开一条,避免中间态刷屏。"""
|
||||
# worker 与会话轮询可能同时回填同一条消息;先锁会话再锁消息,和 append_message 的锁顺序一致。
|
||||
conversation = CreationConversation.objects.select_for_update().get(pk=message.conversation_id)
|
||||
message = CreationMessage.objects.select_for_update().get(pk=message.pk)
|
||||
if message.kind != CreationMessage.Kind.GENERATING:
|
||||
return message
|
||||
is_person_reference = (message.payload or {}).get("kind") == "person_reference"
|
||||
message.kind = CreationMessage.Kind.ERROR
|
||||
message.text = (error or "生成失败")[:500]
|
||||
message.save(update_fields=["kind", "text", "updated_at"])
|
||||
conversation = message.conversation
|
||||
if is_person_reference:
|
||||
memory = dict(conversation.memory or {})
|
||||
memory["person_source_pending"] = False
|
||||
@@ -443,6 +447,7 @@ def start_segmented_video_merge(*, conversation: CreationConversation, message:
|
||||
"source_message_id": str(message.id),
|
||||
"source_task_ids": task_ids,
|
||||
"prompt": payload.get("prompt") or "",
|
||||
"aspect_ratio": payload.get("ratio") or (source_tasks[0].request_payload or {}).get("aspect_ratio") or "",
|
||||
},
|
||||
)
|
||||
payload["merge_state"] = "queued"
|
||||
@@ -453,7 +458,11 @@ def start_segmented_video_merge(*, conversation: CreationConversation, message:
|
||||
conversation,
|
||||
role="assistant",
|
||||
kind=CreationMessage.Kind.GENERATING,
|
||||
payload={"task_id": str(merge_task.id), "kind": "video_merge", "prompt": payload.get("prompt") or ""},
|
||||
payload={
|
||||
"task_id": str(merge_task.id), "kind": "video_merge",
|
||||
"prompt": payload.get("prompt") or "",
|
||||
"ratio": payload.get("ratio") or (source_tasks[0].request_payload or {}).get("aspect_ratio") or "",
|
||||
},
|
||||
task=merge_task,
|
||||
)
|
||||
return merge_task, generating
|
||||
@@ -573,12 +582,13 @@ def finish_generating_message(message: CreationMessage, *, assets: list[dict], m
|
||||
重生成是**新开一条** GENERATING → RESULT,所以对话流仍然是往下叠加;
|
||||
这里改的只是同一次生成自己的中间态。
|
||||
"""
|
||||
# select_for_update 后重读状态:第二个回填者不能凭旧 GENERATING 对象再发一次确认。
|
||||
conversation = CreationConversation.objects.select_for_update().get(pk=message.conversation_id)
|
||||
message = CreationMessage.objects.select_for_update().get(pk=message.pk)
|
||||
if message.kind != CreationMessage.Kind.GENERATING:
|
||||
return message
|
||||
original_payload = dict(message.payload or {})
|
||||
is_person_reference = original_payload.get("kind") == "person_reference"
|
||||
message.kind = CreationMessage.Kind.RESULT
|
||||
message.payload = {**original_payload, **meta, "assets": assets}
|
||||
message.save(update_fields=["kind", "payload", "updated_at"])
|
||||
conversation = message.conversation
|
||||
if is_person_reference:
|
||||
from apps.assets.models import Asset, Model
|
||||
|
||||
@@ -591,6 +601,10 @@ def finish_generating_message(message: CreationMessage, *, assets: list[dict], m
|
||||
).first()
|
||||
if asset is None:
|
||||
return fail_generating_message(message, "人物参考已生成,但未找到可锁定的图片资产")
|
||||
message.kind = CreationMessage.Kind.RESULT
|
||||
message.payload = {**original_payload, **meta, "assets": assets}
|
||||
message.save(update_fields=["kind", "payload", "updated_at"])
|
||||
if is_person_reference:
|
||||
cast_index = int(original_payload.get("cast_index") or 1)
|
||||
cast_total = int(original_payload.get("cast_total") or 1)
|
||||
cast_model_name = str(original_payload.get("cast_model_name") or "").strip()
|
||||
|
||||
@@ -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"]:
|
||||
|
||||
@@ -11,6 +11,20 @@ from __future__ import annotations
|
||||
import re
|
||||
|
||||
CLICK_SWAP_PRESETS = frozenset({"点击换款", "点触换款", "多色商品换款"})
|
||||
PLOT_TWIST_PRESET = "短剧带货"
|
||||
LEGACY_PLOT_TWIST_PRESET = "剧情反转带货"
|
||||
|
||||
|
||||
def is_plot_twist_preset(name: str | None) -> bool:
|
||||
"""新旧预设名称执行同一套短剧流程,兼容已保存的历史会话。"""
|
||||
return str(name or "").strip() in {PLOT_TWIST_PRESET, LEGACY_PLOT_TWIST_PRESET}
|
||||
|
||||
|
||||
def canonical_preset_name(name: str | None) -> str:
|
||||
raw = str(name or "").strip()
|
||||
return PLOT_TWIST_PRESET if raw == LEGACY_PLOT_TWIST_PRESET else raw
|
||||
|
||||
|
||||
_CLICK_SWAP_FINGER_GUIDANCE = (
|
||||
"形态:只出手指出镜。固定机位的商品点击换款短片。必须先确认要展示的颜色/款式/SKU 及顺序,"
|
||||
"画面只围绕同一件商品的逐款切换:机位、背景与灯光稳定,商品构图稳定。"
|
||||
@@ -39,8 +53,8 @@ VIDEO_PRESETS: dict[str, str] = {
|
||||
"没有明确的防漏/承重/容量事实时,不用装水、尖锐物或超载来证明性能。"
|
||||
"用户没给关键功能或使用边界时,只问一次缺失事实;其余镜头用可观察的正常动作补齐。"
|
||||
),
|
||||
"剧情反转带货": (
|
||||
"剧情反转带货不是给商品套一个故事壳。商品必须作为解决困境、解除误会、证明事实、完成翻盘或回收伏笔的关键物,"
|
||||
PLOT_TWIST_PRESET: (
|
||||
"短剧带货不是给商品套一个故事壳。商品必须作为解决困境、解除误会、证明事实、完成翻盘或回收伏笔的关键物,"
|
||||
"并在最终 Prompt、镜头、动作、对白和反转结果中反复可见地承担这个作用。"
|
||||
"不得在故事结束后突然停下来介绍商品,也不能只在最后两秒放商品图。"
|
||||
"开场默认按 60 秒完整短剧结构写人物关系、持续冲突、商品介入时机、反转和情绪落点,不追问 15/30 秒故事深度;"
|
||||
@@ -100,9 +114,6 @@ VIDEO_PRESETS: dict[str, str] = {
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
PLOT_TWIST_PRESET = "剧情反转带货"
|
||||
|
||||
# 保留旧会话 15/30 秒的稳定标识以便回放;新会话不再展示故事深度选择卡。
|
||||
PLOT_TWIST_STORY_DEPTH_OPTIONS = (
|
||||
{
|
||||
@@ -164,7 +175,7 @@ def plot_twist_story_contract(value: str) -> str:
|
||||
depth = _PLOT_TWIST_DEPTH_BY_VALUE["60s"]
|
||||
if depth["value"] == "15s":
|
||||
return (
|
||||
"【剧情反转带货·15秒快节奏反转·强制执行】虽短也要有开端、冲突、反转和明确结局,不能只截取一场戏;"
|
||||
"【短剧带货·15秒快节奏反转·强制执行】虽短也要有开端、冲突、反转和明确结局,不能只截取一场戏;"
|
||||
"只保留一条主冲突,主角 1 人、最多 1 名辅助人物。"
|
||||
"0-3 秒强冲突或意外,3-6 秒问题升级,6-11 秒商品以正常使用动作介入并证明一个核心卖点,"
|
||||
"11-15 秒完成结果反转与自然行动引导。商品必须在中段前出现;对白短、直接、有记忆点;"
|
||||
@@ -172,7 +183,7 @@ def plot_twist_story_contract(value: str) -> str:
|
||||
)
|
||||
if depth["value"] == "30s":
|
||||
return (
|
||||
"【剧情反转带货·30秒轻剧情带货·强制执行】写完人物目标从开端、冲突、反转到结局的因果线,"
|
||||
"【短剧带货·30秒轻剧情带货·强制执行】写完人物目标从开端、冲突、反转到结局的因果线,"
|
||||
"不可只写故事中段;必须交代人物处境,可设置两人互动。"
|
||||
"0-4 秒抛出结果预告或冲突钩子,4-10 秒建立处境,10-17 秒让矛盾升级或一次错误尝试,"
|
||||
"17-24 秒商品在转折点以完整正常使用过程介入,24-28 秒回收反转与人物反应,28-30 秒自然收束。"
|
||||
@@ -180,7 +191,7 @@ def plot_twist_story_contract(value: str) -> str:
|
||||
)
|
||||
if depth["value"] == "60s":
|
||||
return (
|
||||
"【剧情反转带货·60秒完整短剧带货·强制执行】这是一集有开端和结局的独立短剧,不是一部长剧中截取的一段。"
|
||||
"【短剧带货·60秒完整短剧带货·强制执行】这是一集有开端和结局的独立短剧,不是一部长剧中截取的一段。"
|
||||
"先写【完整故事线】,依次写清开端、冲突、升级、反转、结局;每一段都有前因后果,人物目标和关系最终有结果。"
|
||||
"0-5 秒可以用结果闪前作钩子,但随后必须回到故事起因;5-15 秒交代关系、目标与处境;"
|
||||
"15-28 秒发生具体阻碍并升级;28-38 秒主角尝试、失败或作出选择;"
|
||||
@@ -191,7 +202,7 @@ def plot_twist_story_contract(value: str) -> str:
|
||||
)
|
||||
if depth["value"] == "180s":
|
||||
return (
|
||||
"【剧情反转带货·180秒三幕完整故事·强制执行】全片按三个连续 60 秒章节推进,不能把三条短片简单拼接。"
|
||||
"【短剧带货·180秒三幕完整故事·强制执行】全片按三个连续 60 秒章节推进,不能把三条短片简单拼接。"
|
||||
"第一章 0-60 秒:0-8 秒以结果预告或关系冲突抓人,8-28 秒建立人物目标与现实阻力,"
|
||||
"28-48 秒埋入商品/SKU 与一个可见卖点证据,48-60 秒用第一次选择或失败把行动推入下一章。"
|
||||
"第二章 60-120 秒:承接上一章未完成动作,扩大矛盾并安排一次错误尝试;商品通过正常使用过程提供新的证据,"
|
||||
@@ -207,7 +218,7 @@ def plot_twist_story_contract(value: str) -> str:
|
||||
def apply_plot_twist_story_contract(name: str, story_depth: str, prompt: str) -> str:
|
||||
"""在最终出片指令里确定性追加所选故事深度,避免模型漏写关键结构。"""
|
||||
base = str(prompt or "").strip()
|
||||
if (name or "").strip() != PLOT_TWIST_PRESET or not base:
|
||||
if not is_plot_twist_preset(name) or not base:
|
||||
return base
|
||||
contract = plot_twist_story_contract(story_depth)
|
||||
if not contract or contract in base:
|
||||
@@ -277,7 +288,7 @@ def apply_plot_twist_direction_contract(prompt: str, *, title: str = "", conflic
|
||||
VIDEO_PRESET_WORKFLOWS: dict[str, str] = {
|
||||
"痛点解决演示": "先确认商品真实解决的具体问题与正常用法;生成前核对痛点、过程和结果都有可见证据。",
|
||||
"真实使用演示": "优先核对商品真实用途、关键步骤和可证实卖点;不为氛围而增加不合理测试。",
|
||||
"剧情反转带货": "默认按 60 秒完整短剧带货创作,不在开头追问或展示 15/30 秒故事深度选择;核对商品信息后给三个完整剧情方向。商品必须成为解决问题、解除误会、证明事实、回收伏笔或完成翻盘的关键。",
|
||||
PLOT_TWIST_PRESET: "默认按 60 秒完整短剧带货创作,不在开头追问或展示 15/30 秒故事深度选择;核对商品信息后给三个完整剧情方向。商品必须成为解决问题、解除误会、证明事实、回收伏笔或完成翻盘的关键。",
|
||||
"商品拟人广告": "先确认商品外观和性格表达方式;默认无脸拟人,商品保持真实完整,台词用画外声。",
|
||||
"达人口播种草": "优先确认人物、多人出镜关系、真实体验和主卖点;生成前核对口播字数能在时长内说完,每个卖点都有画面证明。",
|
||||
"商品图一键成片": "优先从商品参考图锁定外观;自动补场景和动作,但不替换或改变用户商品图里的结构、颜色和包装。",
|
||||
@@ -304,8 +315,8 @@ VIDEO_PRESET_DELIVERY_CONTRACTS: dict[str, str] = {
|
||||
"【预设执行层·真实使用演示】像一镜到底可复核的日常实拍:先交代真实场景,再按正确用途完成步骤,"
|
||||
"用近景展示材质、动作或结果。商品全程保持正常完整;不以破损、漏液、渗水、超载或超出用途的测试制造戏剧性。"
|
||||
),
|
||||
"剧情反转带货": (
|
||||
"【预设执行层·剧情反转带货】按同一条完整故事线连续推进开端、冲突、升级、反转和结局;"
|
||||
PLOT_TWIST_PRESET: (
|
||||
"【预设执行层·短剧带货】按同一条完整故事线连续推进开端、冲突、升级、反转和结局;"
|
||||
"人物关系、目标与情绪在每个关键镜头都有因果变化,结尾回收开头问题而非停在故事中段。"
|
||||
"商品不是摆拍道具:必须充当解决问题的工具、解除误会的证据、关系变化的礼物或回收伏笔的关键物;"
|
||||
"商品出现、使用和结果必须直接改变剧情走向,禁止剧情结束后再硬插卖点。"
|
||||
@@ -411,12 +422,12 @@ ALL_PRESETS: dict[str, str] = {**VIDEO_PRESETS, **IMAGE_PRESETS}
|
||||
def preset_guidance(name: str) -> str:
|
||||
"""预设名 → 拍法约束。认不出的名字返回 "" —— 前端加了新卡但这里还没写时,
|
||||
退回「只有名字」的行为,不要报错。"""
|
||||
return ALL_PRESETS.get((name or "").strip(), "")
|
||||
return ALL_PRESETS.get(canonical_preset_name(name), "")
|
||||
|
||||
|
||||
def preset_workflow_guidance(name: str) -> str:
|
||||
"""预设名 → Agent 的素材、追问和检查重点。"""
|
||||
return VIDEO_PRESET_WORKFLOWS.get((name or "").strip(), "")
|
||||
return VIDEO_PRESET_WORKFLOWS.get(canonical_preset_name(name), "")
|
||||
|
||||
|
||||
_CLICK_SWAP_FINGER_CONTRACT = (
|
||||
@@ -439,7 +450,7 @@ _CLICK_SWAP_CHARACTER_CONTRACT = (
|
||||
|
||||
def video_preset_delivery_contract(name: str, *, click_swap_mode: str = "") -> str:
|
||||
"""预设名 → 最终视频生成阶段必须执行的结构与镜头约束。"""
|
||||
preset = (name or "").strip()
|
||||
preset = canonical_preset_name(name)
|
||||
if preset in CLICK_SWAP_PRESETS:
|
||||
if (click_swap_mode or "").strip() == "character":
|
||||
return _CLICK_SWAP_CHARACTER_CONTRACT
|
||||
@@ -546,13 +557,13 @@ def scrub_click_swap_same_position_wording(text: str) -> str:
|
||||
def apply_video_preset_prompt(name: str, prompt: str, *, click_swap_mode: str = "") -> str:
|
||||
"""把视频预设确定性并入最终出片指令,避免只依赖对话模型主动复述。"""
|
||||
base = str(prompt or "").strip()
|
||||
preset = (name or "").strip()
|
||||
preset = canonical_preset_name(name)
|
||||
mode = (click_swap_mode or "").strip()
|
||||
contract = video_preset_delivery_contract(preset, click_swap_mode=mode)
|
||||
if not base or not contract:
|
||||
return base
|
||||
marker = f"【视频预设】{preset}"
|
||||
if marker in base:
|
||||
if marker in base or (is_plot_twist_preset(name) and f"【视频预设】{LEGACY_PLOT_TWIST_PRESET}" in base):
|
||||
return base
|
||||
if is_click_swap_preset(preset):
|
||||
if mode == "character":
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from rest_framework import serializers
|
||||
|
||||
from .creation_presets import PLOT_TWIST_PRESET
|
||||
from .creation_presets import is_plot_twist_preset
|
||||
from .models import (
|
||||
AITask,
|
||||
CreationConversation,
|
||||
@@ -248,18 +248,20 @@ class CreationConversationSerializer(serializers.ModelSerializer):
|
||||
return progress
|
||||
|
||||
def create(self, validated_data):
|
||||
# 剧情反转带货的新会话直接以完整短剧的 60 秒开场,不再弹故事深度选择。
|
||||
# 旧客户端仍传「智能时长」时也要在服务端落成确定参数。
|
||||
if validated_data.get("mode") == CreationConversation.Mode.VIDEO and validated_data.get("preset") == PLOT_TWIST_PRESET:
|
||||
# 短剧带货现阶段固定为完整的 60 秒短剧,旧客户端传任何时长也不能覆盖。
|
||||
if validated_data.get("mode") == CreationConversation.Mode.VIDEO and is_plot_twist_preset(validated_data.get("preset")):
|
||||
params = dict(validated_data.get("params") or {})
|
||||
if not str(params.get("duration") or "").strip() or str(params.get("duration")).strip() == "智能时长":
|
||||
params["duration"] = "60 秒"
|
||||
params["duration"] = "60 秒"
|
||||
validated_data["params"] = params
|
||||
return super().create(validated_data)
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
# mode 定死:允许传但忽略,避免前端误改后顶栏参数与已生成内容对不上
|
||||
validated_data.pop("mode", None)
|
||||
if instance.mode == CreationConversation.Mode.VIDEO and is_plot_twist_preset(validated_data.get("preset", instance.preset)):
|
||||
params = dict(validated_data.get("params", instance.params) or {})
|
||||
params["duration"] = "60 秒"
|
||||
validated_data["params"] = params
|
||||
return super().update(instance, validated_data)
|
||||
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
工具调用拼装、追问中断、计费闸门、参考图带入、错误不打崩流。
|
||||
"""
|
||||
import json
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
import requests
|
||||
@@ -30,6 +31,9 @@ from .creation_agent import (
|
||||
apply_pain_point_direction,
|
||||
ensure_selling_point_ready_from_context,
|
||||
extract_declared_selling_point,
|
||||
extract_brief_context,
|
||||
product_brief_items,
|
||||
accept_complete_product_brief,
|
||||
apply_restart_intent,
|
||||
apply_person_identity_guard,
|
||||
apply_product_reference_guard,
|
||||
@@ -60,6 +64,7 @@ from .creation_agent import (
|
||||
resolve_smart_video_duration,
|
||||
is_continue_intent,
|
||||
is_greeting,
|
||||
is_plot_twist_conversation,
|
||||
is_pure_chitchat,
|
||||
is_restart_intent,
|
||||
session_has_creative_context,
|
||||
@@ -623,6 +628,17 @@ class PersonReferenceCompletionTests(CreationAgentBaseTests):
|
||||
is_primary=True,
|
||||
)
|
||||
|
||||
finish_generating_message(
|
||||
generating,
|
||||
assets=[{
|
||||
"id": str(asset.id),
|
||||
"url": "https://cdn.example/person-generated.jpg",
|
||||
"cover": "https://cdn.example/person-generated.jpg",
|
||||
"type": "image",
|
||||
}],
|
||||
meta={"prompt": "person"},
|
||||
)
|
||||
# worker 与页面轮询都拿着旧的 GENERATING 对象回填时,只能出现一次确认。
|
||||
finish_generating_message(
|
||||
generating,
|
||||
assets=[{
|
||||
@@ -646,6 +662,9 @@ class PersonReferenceCompletionTests(CreationAgentBaseTests):
|
||||
self.assertTrue(
|
||||
self.conversation.messages.filter(text__contains="是否使用这个角色").exists()
|
||||
)
|
||||
self.assertEqual(
|
||||
self.conversation.messages.filter(text__contains="是否使用这个角色").count(), 1
|
||||
)
|
||||
|
||||
def test_platform_person_submission_uses_model_mode(self):
|
||||
task = AITask.objects.create(
|
||||
@@ -1831,6 +1850,67 @@ class VideoPlanAndConfirmTests(CreationAgentBaseTests):
|
||||
self.assertEqual(conversation.params["duration"], "60 秒")
|
||||
self.assertEqual(conversation.params["ratio"], "9:16")
|
||||
|
||||
def test_short_drama_new_name_and_legacy_name_share_the_same_workflow(self):
|
||||
from apps.ai.creation_presets import (
|
||||
PLOT_TWIST_PRESET,
|
||||
apply_plot_twist_story_contract,
|
||||
apply_video_preset_prompt,
|
||||
preset_guidance,
|
||||
preset_workflow_guidance,
|
||||
video_preset_delivery_contract,
|
||||
)
|
||||
from apps.ai.serializers import CreationConversationSerializer
|
||||
|
||||
self.assertEqual(PLOT_TWIST_PRESET, "短剧带货")
|
||||
for name in ("短剧带货", "剧情反转带货"):
|
||||
self.conversation.preset = name
|
||||
self.assertTrue(is_plot_twist_conversation(self.conversation))
|
||||
self.assertEqual(preset_guidance(name), preset_guidance("短剧带货"))
|
||||
self.assertEqual(preset_workflow_guidance(name), preset_workflow_guidance("短剧带货"))
|
||||
self.assertEqual(video_preset_delivery_contract(name), video_preset_delivery_contract("短剧带货"))
|
||||
self.assertIn("【短剧带货·60秒", apply_plot_twist_story_contract(name, "60s", "完整短剧提示词"))
|
||||
self.assertIn("【视频预设】短剧带货", apply_video_preset_prompt(name, "完整短剧提示词"))
|
||||
|
||||
serializer = CreationConversationSerializer(data={
|
||||
"title": "短剧带货",
|
||||
"mode": "video",
|
||||
"preset": "短剧带货",
|
||||
"params": {"ratio": "9:16"},
|
||||
})
|
||||
self.assertTrue(serializer.is_valid(), serializer.errors)
|
||||
created = serializer.save(team=self.team, created_by=self.user)
|
||||
self.assertEqual(created.params["duration"], "60 秒")
|
||||
|
||||
# 即使旧客户端在创建/修改会话时仍传其他时长,短剧也保持 60 秒。
|
||||
serializer = CreationConversationSerializer(data={
|
||||
"title": "短剧带货", "mode": "video", "preset": "短剧带货",
|
||||
"params": {"duration": "15 秒", "ratio": "9:16"},
|
||||
})
|
||||
self.assertTrue(serializer.is_valid(), serializer.errors)
|
||||
fixed = serializer.save(team=self.team, created_by=self.user)
|
||||
self.assertEqual(fixed.params["duration"], "60 秒")
|
||||
serializer = CreationConversationSerializer(fixed, data={"params": {"duration": "30 秒"}}, partial=True)
|
||||
self.assertTrue(serializer.is_valid(), serializer.errors)
|
||||
self.assertEqual(serializer.save().params["duration"], "60 秒")
|
||||
|
||||
def test_short_drama_confirm_duration_is_fixed_and_legacy_session_rebuilds(self):
|
||||
from .creation_agent import apply_confirm_params, confirm_param_options
|
||||
|
||||
self.conversation.preset = "短剧带货"
|
||||
self.conversation.params = {**self.conversation.params, "duration": "60 秒"}
|
||||
self.conversation.save(update_fields=["preset", "params", "updated_at"])
|
||||
params, rebuild = apply_confirm_params(self.conversation, {"duration": "15 秒", "model": "Seedance 2.5"})
|
||||
self.assertEqual(params["duration"], "60 秒")
|
||||
self.assertFalse(rebuild)
|
||||
self.assertEqual(confirm_param_options(True, fixed_duration=True)["duration"], [])
|
||||
self.assertEqual([segment["duration"] for segment in plan_video_segments(60)], [30, 30])
|
||||
|
||||
self.conversation.params = {**self.conversation.params, "duration": "30 秒"}
|
||||
self.conversation.save(update_fields=["params", "updated_at"])
|
||||
params, rebuild = apply_confirm_params(self.conversation, {"duration": "30 秒"})
|
||||
self.assertEqual(params["duration"], "60 秒")
|
||||
self.assertTrue(rebuild)
|
||||
|
||||
def test_plot_twist_legacy_smart_choice_uses_60_second_story_contract(self):
|
||||
from apps.ai.creation_presets import plot_twist_story_contract
|
||||
|
||||
@@ -1933,6 +2013,117 @@ class VideoPlanAndConfirmTests(CreationAgentBaseTests):
|
||||
)
|
||||
self.assertEqual(fake.calls, [])
|
||||
|
||||
def test_natural_product_brief_uses_stated_audience_scene_and_action(self):
|
||||
self._pin_product("空气炸锅")
|
||||
self.conversation.preset = "短剧带货"
|
||||
self.conversation.memory = {}
|
||||
self.conversation.save(update_fields=["preset", "memory", "updated_at"])
|
||||
brief = (
|
||||
"使用此商品制作一条短剧带货视频,商品的适用人群是懒得下厨做饭的人以及来不及做饭的人,"
|
||||
"将食材放进空气炸锅一炸就可以吃了。使用环境是家庭环境或公司午休环境。"
|
||||
)
|
||||
append_message(self.conversation, role="user", text=brief)
|
||||
self.assertTrue(ensure_selling_point_ready_from_context(self.conversation, brief))
|
||||
items = {item["label"]: item for item in product_brief_items(self.conversation)}
|
||||
self.assertEqual(items["核心卖点"]["status"], "ready")
|
||||
self.assertIn("将食材放进空气炸锅", items["核心卖点"]["value"])
|
||||
self.assertEqual(items["使用场景与目标用户"]["status"], "ready")
|
||||
self.assertIn("懒得下厨做饭的人", items["使用场景与目标用户"]["value"])
|
||||
self.assertIn("公司午休环境", items["使用场景与目标用户"]["value"])
|
||||
self.assertEqual(items["价格与优惠"]["status"], "not_needed")
|
||||
self.assertTrue(accept_complete_product_brief(self.conversation))
|
||||
self.conversation.refresh_from_db()
|
||||
self.assertTrue(self.conversation.memory["product_brief_reviewed"])
|
||||
system = build_system_prompt(AgentContext(conversation=self.conversation, user=self.user, model_config=self.model))
|
||||
self.assertIn("懒得下厨做饭的人", system)
|
||||
self.assertIn("公司午休环境", system)
|
||||
|
||||
def test_product_brief_does_not_invent_missing_claims(self):
|
||||
self._pin_product("空气炸锅")
|
||||
self.conversation.preset = "短剧带货"
|
||||
self.conversation.memory = {}
|
||||
self.conversation.save(update_fields=["preset", "memory", "updated_at"])
|
||||
append_message(self.conversation, role="user", text="面向人群是上班族。价格是多少?做个短剧。")
|
||||
self.assertFalse(ensure_selling_point_ready_from_context(self.conversation))
|
||||
items = {item["label"]: item for item in product_brief_items(self.conversation)}
|
||||
self.assertEqual(items["核心卖点"]["status"], "missing")
|
||||
self.assertEqual(items["使用场景与目标用户"]["status"], "missing")
|
||||
self.assertIn("上班族", items["使用场景与目标用户"]["value"])
|
||||
self.assertEqual(items["价格与优惠"]["status"], "missing")
|
||||
self.assertFalse(accept_complete_product_brief(self.conversation))
|
||||
self.assertEqual(extract_brief_context("重点讲清使用场景和目标用户"), {})
|
||||
|
||||
def test_complete_natural_brief_skips_redundant_review_in_stream(self):
|
||||
self._pin_product("空气炸锅")
|
||||
self.conversation.preset = "短剧带货"
|
||||
self.conversation.memory = {}
|
||||
self.conversation.save(update_fields=["preset", "memory", "updated_at"])
|
||||
fake = FakeProvider([_text_chunks("继续创作")])
|
||||
with patch("apps.ai.creation_agent.build_provider", return_value=fake):
|
||||
events = _events(stream_creation_agent(
|
||||
conversation=self.conversation,
|
||||
user=self.user,
|
||||
text=("适用人群是懒得做饭的上班族,将食材放进空气炸锅一炸就可以吃了。"
|
||||
"使用环境是家庭厨房或公司午休环境。"),
|
||||
model_config=self.model,
|
||||
))
|
||||
interactions = [
|
||||
event["message"]["payload"].get("interaction")
|
||||
for event in events
|
||||
if event.get("type") == "message" and event["message"]["kind"] == "elicit"
|
||||
]
|
||||
self.assertNotIn("product_brief_review", interactions)
|
||||
self.conversation.refresh_from_db()
|
||||
self.assertTrue(self.conversation.memory.get("product_brief_reviewed"))
|
||||
|
||||
@override_settings(CREATION_AGENT_INLINE=False, CREATION_AGENT_TASK_QUEUE="airshelf.local")
|
||||
def test_review_card_note_is_locked_as_selling_point_before_story_choice(self):
|
||||
TeamMember.objects.create(team=self.team, user=self.user, role="owner")
|
||||
self._pin_product("空气炸锅")
|
||||
self.conversation.preset = "短剧带货"
|
||||
self.conversation.memory = {}
|
||||
self.conversation.save(update_fields=["preset", "memory", "updated_at"])
|
||||
review = append_message(
|
||||
self.conversation, role="assistant", kind=CreationMessage.Kind.ELICIT,
|
||||
payload={"interaction": "product_brief_review", "submitted": False, "answers": {}},
|
||||
)
|
||||
note = (
|
||||
"商品的适用人群是懒得下厨做饭的人以及来不及做饭的人,"
|
||||
"将食材放进空气炸锅一炸就可以吃了。使用环境是家庭环境或公司午休环境。"
|
||||
)
|
||||
client = APIClient()
|
||||
client.force_authenticate(self.user)
|
||||
with patch("apps.ai.views.begin_agent_planning", return_value=True), \
|
||||
patch("apps.ai.views.team_agent_busy", return_value=False), \
|
||||
patch("apps.ai.views.run_creation_agent_turn_task") as task:
|
||||
response = client.post(
|
||||
f"/api/ai/creations/{self.conversation.id}/send/",
|
||||
{"kind": "elicit_answer", "reply_to": str(review.id), "answers": {
|
||||
"review_action": "supplement", "product_brief_note": note,
|
||||
}},
|
||||
format="json",
|
||||
)
|
||||
self.assertEqual(response.status_code, 202)
|
||||
self.assertTrue(task.apply_async.called)
|
||||
self.conversation.refresh_from_db()
|
||||
self.assertTrue(self.conversation.memory.get("selling_point_ready"))
|
||||
self.assertIn("将食材放进空气炸锅", self.conversation.memory.get("selling_point", ""))
|
||||
from .views import _plot_twist_direction_continuation
|
||||
_plot_twist_direction_continuation(
|
||||
self.conversation,
|
||||
{"directions": [{"id": "office", "title": "午休误会", "conflict": "同事误会", "product_role": "空气炸锅做饭", "reversal": "误会解除"}]},
|
||||
"office",
|
||||
)
|
||||
result, _ = _dispatch_tool(
|
||||
AgentContext(conversation=self.conversation, user=self.user, model_config=self.model),
|
||||
"write_strategy",
|
||||
{"target": "上班族", "trust": "用户描述的使用动作", "belief": "操作方便", "direction": "办公室轻喜剧"},
|
||||
)
|
||||
self.assertNotEqual(result.get("payload", {}).get("field"), "selling_point")
|
||||
self.assertFalse(self.conversation.messages.filter(
|
||||
kind=CreationMessage.Kind.ELICIT, payload__interaction="selling_point_gate"
|
||||
).exists())
|
||||
|
||||
def test_plot_twist_depth_syncs_duration_and_is_written_into_final_prompt(self):
|
||||
self.conversation.preset = "剧情反转带货"
|
||||
self.conversation.save(update_fields=["preset", "updated_at"])
|
||||
@@ -2889,7 +3080,7 @@ class VideoPlanAndConfirmTests(CreationAgentBaseTests):
|
||||
portrait_asset=portrait,
|
||||
)
|
||||
self.conversation.preset = "达人口播种草"
|
||||
self.conversation.params = {**self.conversation.params, "duration": "60 秒"}
|
||||
self.conversation.params = {**self.conversation.params, "duration": "60 秒", "ratio": "16:9"}
|
||||
self.conversation.pinned_refs = [{"type": "model", "id": str(model.id), "name": model.name}]
|
||||
self.conversation.save(update_fields=["preset", "params", "pinned_refs", "updated_at"])
|
||||
card = append_message(
|
||||
@@ -2922,6 +3113,7 @@ class VideoPlanAndConfirmTests(CreationAgentBaseTests):
|
||||
|
||||
self.assertEqual(error, "")
|
||||
self.assertEqual(message.payload["kind"], "video_segments")
|
||||
self.assertEqual(message.payload["ratio"], "16:9")
|
||||
self.assertEqual(submit.call_count, 2)
|
||||
first = submit.call_args_list[0].kwargs["params"]
|
||||
second = submit.call_args_list[1].kwargs["params"]
|
||||
@@ -2929,6 +3121,7 @@ class VideoPlanAndConfirmTests(CreationAgentBaseTests):
|
||||
self.assertEqual(first["seed"], second["seed"])
|
||||
self.assertEqual(first["references"][0]["asset_id"], str(portrait.id))
|
||||
for params in (first, second):
|
||||
self.assertEqual(params["aspect_ratio"], "16:9")
|
||||
self.assertTrue(params["prompt"].startswith("【画面洁净 · 最高优先级】"))
|
||||
self.assertIn("【开场】这是全片的第一段", params["prompt"])
|
||||
self.assertNotIn("限时8折", params["prompt"])
|
||||
@@ -3141,6 +3334,16 @@ class VideoPlanAndConfirmTests(CreationAgentBaseTests):
|
||||
|
||||
|
||||
class VideoParamParsingTests(TestCase):
|
||||
def test_video_result_ratio_reads_aspect_ratio_from_task(self):
|
||||
from .creation import _meta_from_task
|
||||
|
||||
task = SimpleNamespace(
|
||||
model_config_id=None,
|
||||
request_payload={"aspect_ratio": "16:9", "prompt": "横屏视频"},
|
||||
)
|
||||
message = SimpleNamespace(payload={"ratio": "9:16"})
|
||||
self.assertEqual(_meta_from_task(task, message)["ratio"], "16:9")
|
||||
|
||||
def test_duration_label_and_smart_fallback(self):
|
||||
self.assertEqual(video_duration({"duration": "15 秒"}), 15)
|
||||
self.assertEqual(video_duration({"duration": "智能时长"}), SMART_DURATION)
|
||||
|
||||
@@ -57,6 +57,7 @@ from .creation_agent import (
|
||||
is_restart_intent,
|
||||
multi_character_relation_needs_clarification,
|
||||
restore_gated_step_after_cancel,
|
||||
record_product_brief_review,
|
||||
get_video_gate_stage,
|
||||
set_video_gate_stage,
|
||||
sync_prompt_after_cast,
|
||||
@@ -2087,12 +2088,7 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
force_creative_turn = True
|
||||
continuation_instruction = _plot_twist_depth_continuation(depth)
|
||||
elif payload.get("interaction") == "product_brief_review":
|
||||
memory = dict(conversation.memory or {})
|
||||
memory["product_brief_reviewed"] = True
|
||||
if text:
|
||||
memory["product_brief_note"] = text
|
||||
conversation.memory = memory
|
||||
conversation.save(update_fields=["memory", "updated_at"])
|
||||
record_product_brief_review(conversation, text)
|
||||
payload["answers"] = {
|
||||
"review_action": "supplement" if text else "continue",
|
||||
"product_brief_note": text,
|
||||
@@ -2715,12 +2711,7 @@ class CreationConversationViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
note = str(answers.get("product_brief_note") or "").strip()
|
||||
if action == "supplement" and not note:
|
||||
return JsonResponse({"detail": "请填写要补充的真实商品信息"}, status=400)
|
||||
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"])
|
||||
record_product_brief_review(conversation, note)
|
||||
text = ""
|
||||
record_user_message = False
|
||||
force_creative_turn = True
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import os
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from django.test import SimpleTestCase, override_settings
|
||||
@@ -7,6 +8,24 @@ from apps.common import celery_health
|
||||
from apps.common.celery_health import require_worker_task, worker_consumes_queue
|
||||
|
||||
|
||||
class RedisLocalUrlTests(SimpleTestCase):
|
||||
def test_macos_maps_docker_only_host_to_local_redis_for_celery_and_django(self):
|
||||
from airshelf.settings import base
|
||||
|
||||
with patch.dict(os.environ, {"CELERY_BROKER_URL": "redis://:example@yingqing-redis:6379/1"}):
|
||||
with patch.object(base.sys, "platform", "darwin"):
|
||||
self.assertEqual(base.redis_url("CELERY_BROKER_URL", 1), "redis://127.0.0.1:6379/1")
|
||||
self.assertEqual(os.environ["CELERY_BROKER_URL"], "redis://127.0.0.1:6379/1")
|
||||
|
||||
def test_container_keeps_configured_redis_host(self):
|
||||
from airshelf.settings import base
|
||||
|
||||
configured = "redis://:example@yingqing-redis:6379/1"
|
||||
with patch.dict(os.environ, {"CELERY_BROKER_URL": configured}):
|
||||
with patch.object(base.sys, "platform", "linux"):
|
||||
self.assertEqual(base.redis_url("CELERY_BROKER_URL", 1), configured)
|
||||
|
||||
|
||||
class RequireWorkerTaskTests(SimpleTestCase):
|
||||
def setUp(self):
|
||||
celery_health._registered_task_cache.clear()
|
||||
|
||||
Reference in New Issue
Block a user