优化住流程和添加复刻视频页面
This commit is contained in:
@@ -68,6 +68,51 @@ STRUCTURE_MIN_DURATION: dict[str, int] = {
|
||||
"pain": 15, "contrast": 15, "review": 30, "scene": 30, "promo": 15, "knowledge": 30,
|
||||
}
|
||||
|
||||
# 用户在设定卡里选的是「怎么说服」,不是一个仅供展示的标签。这里把每种结构收成
|
||||
# 可直接塞进任务上下文的验收合同;模型不得拿痛点结构去代替知识分享、拿种草去代替测评。
|
||||
STRUCTURE_CONTRACTS: dict[str, str] = {
|
||||
"pain": (
|
||||
"【痛点解决·结构合同】必须按顺序完成:具体问题出现 → 这个问题造成的不便 → 商品作为解法介入"
|
||||
"→ 对应卖点与真实使用过程 → 可见的改善 → 一个行动引导。开头先给可拍到的具体问题,"
|
||||
"商品不能在第一句话直接硬推;一个痛点只对应一个卖点。禁止把普通不便夸成严重后果或制造焦虑。\n"
|
||||
),
|
||||
"contrast": (
|
||||
"【前后对比·结构合同】必须建立同一人物/场景/角度/光线下可比较的“使用前”和“使用后”,"
|
||||
"中间必须有商品使用过程作为转折,再总结差异并 CTA。旁白只交代条件、过程和差异,"
|
||||
"不能写成长篇原理讲解;不得跳过过程、换条件伪造差异,或同时对比多个维度。\n"
|
||||
),
|
||||
"review": (
|
||||
"【测评验证·结构合同】必须按顺序完成:提出要验证什么 → 说清测评标准/条件 → 完整展示测试动作"
|
||||
"→ 呈现画面可观察的结果 → 给出带适用边界的结论和 CTA。开场必须是疑问或待验证目标,"
|
||||
"不是直接夸商品;每个卖点都要改写成可演示的验证项目。禁止虚构检测数据、权威结论、用户评价或编造缺点。\n"
|
||||
),
|
||||
"scene": (
|
||||
"【场景种草·结构合同】必须按顺序完成:先交代人物、具体时间和地点 → 场景中自然出现需求"
|
||||
"→ 商品以正在被使用的方式入场 → 写出该场景里的真实体验 → 轻度推荐/软 CTA。第一镜先建立场景,"
|
||||
"不要一上来抛痛点提问或商品参数;商品不能像硬广道具摆拍,卖点要融入动作与感受。\n"
|
||||
),
|
||||
"promo": (
|
||||
"【促销抢购·结构合同】必须按顺序完成:真实福利/价格钩子(仅在资料明确时)→ 商品基础价值"
|
||||
"→ 优惠适用的规格、条件与领取方式 → 真实的时间/库存信息(仅在资料明确时)→ 明确购买入口和操作 CTA。"
|
||||
"若资料没有价格、优惠、库存或时间,就不得捏造或制造抢购氛围,改为商品价值与查看详情的引导。\n"
|
||||
),
|
||||
"knowledge": (
|
||||
"【知识分享·结构合同】必须按顺序完成:提出一个选购/使用问题或常见误区 → 给出清楚的判断方法"
|
||||
"→ 用实物细节、步骤或正确/错误示范讲清方法 → 商品在方法建立后作为符合该标准的实例出现"
|
||||
"→ 总结选择建议和自然 CTA。只讲 1–3 个知识点,商品不能在开头就变成卖点罗列。"
|
||||
"禁止虚构数据、标准、专业身份或功效承诺,也不能只靠人物口头科普而没有画面演示。\n"
|
||||
),
|
||||
}
|
||||
|
||||
STRUCTURE_STAGES: dict[str, tuple[str, ...]] = {
|
||||
"pain": ("具体问题与不便", "商品介入和真实用法", "可见改善与行动引导"),
|
||||
"contrast": ("使用前的基准状态", "商品使用过程", "使用后的同条件状态", "差异总结与行动引导"),
|
||||
"review": ("测评目标与标准", "完整测试过程", "可观察的结果", "适用结论与行动引导"),
|
||||
"scene": ("具体时间、地点与人物状态", "场景中的自然需求", "商品被使用着入场", "真实体验与软 CTA"),
|
||||
"promo": ("真实福利或商品价值钩子", "商品与适用场景", "明确的优惠条件和购买方法", "入口与操作 CTA"),
|
||||
"knowledge": ("问题或常见误区", "判断方法或核心结论", "实物步骤或正反示范", "商品承接、选择建议与 CTA"),
|
||||
}
|
||||
|
||||
# 设定卡人物 key → 中文(与前端 WIZ_PERSONA_LABEL / 模板 coerce_persona 对齐)。
|
||||
PERSONA_LABELS: dict[str, str] = {
|
||||
"urban": "都市白领女性",
|
||||
@@ -177,6 +222,68 @@ def coerce_combo(fmt: str | None, structure: str | None) -> tuple[str, str]:
|
||||
return fmt, structure
|
||||
|
||||
|
||||
# 与前端 script-setup.ts CATEGORY_COMBOS 对齐。改这里时两边一起改。
|
||||
_CATEGORY_COMBOS: dict[str, dict[str, str]] = {
|
||||
"美妆个护": {"format": "oral", "structure": "contrast", "persona": "bestie"},
|
||||
"食品饮料": {"format": "oral", "structure": "scene", "persona": "bestie"},
|
||||
"服饰鞋包": {"format": "oral", "structure": "scene", "persona": "urban"},
|
||||
"家居日用": {"format": "oral", "structure": "pain", "persona": "mom"},
|
||||
"数码家电": {"format": "oral", "structure": "review", "persona": "reviewer"},
|
||||
"母婴玩具": {"format": "oral", "structure": "pain", "persona": "mom"},
|
||||
"运动健康": {"format": "oral", "structure": "scene", "persona": "urban"},
|
||||
"珠宝配饰": {"format": "oral", "structure": "scene", "persona": "ceo"},
|
||||
"汽车用品": {"format": "oral", "structure": "review", "persona": "reviewer"},
|
||||
"宠物用品": {"format": "oral", "structure": "pain", "persona": "mom"},
|
||||
"图书文教": {"format": "oral", "structure": "knowledge", "persona": "urban"},
|
||||
"五金农资": {"format": "oral", "structure": "review", "persona": "reviewer"},
|
||||
"其他商品": {"format": "oral", "structure": "pain", "persona": "urban"},
|
||||
}
|
||||
_CATEGORY_KEYWORD_RULES: tuple[tuple[tuple[str, ...], dict[str, str]], ...] = (
|
||||
(("美妆", "护肤", "彩妆", "面膜", "精华", "洗护", "个护"), _CATEGORY_COMBOS["美妆个护"]),
|
||||
(("保健", "营养", "膳食", "益生菌", "维生素"), {"format": "oral", "structure": "review", "persona": "reviewer"}),
|
||||
(("数码", "3c", "电子", "电器", "手机", "耳机", "相机", "工具", "家电"), _CATEGORY_COMBOS["数码家电"]),
|
||||
(("食品", "零食", "饮料", "咖啡", "茶", "生鲜", "酒"), _CATEGORY_COMBOS["食品饮料"]),
|
||||
(("服饰", "女装", "男装", "服装", "鞋", "包", "配饰", "内衣"), _CATEGORY_COMBOS["服饰鞋包"]),
|
||||
(("家居", "家纺", "收纳", "厨具", "清洁", "日用"), _CATEGORY_COMBOS["家居日用"]),
|
||||
(("母婴", "宝宝", "儿童", "玩具"), _CATEGORY_COMBOS["母婴玩具"]),
|
||||
(("宠物",), _CATEGORY_COMBOS["宠物用品"]),
|
||||
(("户外", "露营", "运动", "健身"), _CATEGORY_COMBOS["运动健康"]),
|
||||
)
|
||||
_FALLBACK_COMBO: dict[str, str] = {"format": "oral", "structure": "pain", "persona": "urban"}
|
||||
|
||||
|
||||
def recommend_script_setup(category: str = "", title: str = "") -> dict:
|
||||
"""按商品品类推荐表现形式 × 视频结构 × 人物 × 时长。用户随时可改。"""
|
||||
category = (category or "").strip()
|
||||
title = (title or "").strip()
|
||||
combo = _FALLBACK_COMBO
|
||||
reason = "商品信息不足,先给一组最通用的"
|
||||
if category in _CATEGORY_COMBOS:
|
||||
combo = _CATEGORY_COMBOS[category]
|
||||
reason = f"按「{category}」品类推荐"
|
||||
else:
|
||||
haystack = f"{category} {title}".lower()
|
||||
for keywords, item in _CATEGORY_KEYWORD_RULES:
|
||||
hit = next((word for word in keywords if word.lower() in haystack), None)
|
||||
if hit:
|
||||
combo = item
|
||||
reason = f"按「{hit}」品类推荐"
|
||||
break
|
||||
# 自动推荐只给口播;Vlog / 短剧留给用户自己选。
|
||||
fmt, structure = coerce_combo("oral", combo["structure"])
|
||||
persona = coerce_persona(combo.get("persona") or "urban")
|
||||
if persona not in PERSONA_LABELS:
|
||||
persona = "urban"
|
||||
duration = max(FORMAT_DEFAULT_DURATION[fmt], STRUCTURE_MIN_DURATION[structure])
|
||||
return {
|
||||
"format": fmt,
|
||||
"structure": structure,
|
||||
"persona": persona,
|
||||
"duration": duration,
|
||||
"reason": reason,
|
||||
}
|
||||
|
||||
|
||||
def narration_limit(duration: int) -> int:
|
||||
"""这一镜旁白的字数上限:秒数 × 5.2,且不超过硬上限 78。"""
|
||||
return max(1, min(NARRATION_CHARS_HARD_CAP, int(duration * NARRATION_CHARS_PER_SECOND)))
|
||||
@@ -367,8 +474,12 @@ _CREATIVE_DIRECTION = """
|
||||
|
||||
## 出片感与转化感(硬性创作方向)
|
||||
|
||||
**先服从用户选定的视频结构。** 用户选的是痛点解决、前后对比、测评验证、场景种草、促销抢购或知识分享中的哪一种,
|
||||
就只走该结构合同的顺序与说服方式;不能因为“更好写”而擅自换成痛点开场、测评口吻或场景氛围。
|
||||
下列通用写法仅用于增强已选结构,和结构合同冲突时一律以结构合同为准。
|
||||
|
||||
你的脚本不能像商品详情页、说明书或主播念稿;它必须让用户在第一秒看到一个
|
||||
**正在发生的具体瞬间**。先选一个最能代表目标人群的「时间 + 地点 + 小麻烦/小欲望」,
|
||||
**正在发生的具体瞬间**。先选一个最能代表典型使用者的「时间 + 地点 + 小麻烦/小欲望」,
|
||||
全片只围绕这一个情境推进。不要在一条短视频里罗列所有卖点。
|
||||
|
||||
### 每一镜都必须有推进,禁止平铺直叙
|
||||
@@ -487,8 +598,7 @@ def _product_context(project, selling_point_ids: list[str] | None, persona: str
|
||||
f"品牌:{product.brand or '未填写'}\n"
|
||||
f"{type_line}"
|
||||
f"品类:{product.category or '未填写'}\n"
|
||||
f"目标人群:{product.target_audience or '未填写'}\n"
|
||||
f"人物设定:{persona_text or '未指定,按目标人群自行定一个具体身份'}\n"
|
||||
f"人物设定:{persona_text or '未指定,按商品信息自行定一个具体身份'}\n"
|
||||
f"人物口吻:{persona_brief or '按人物设定自己定一个具体身份,不要用万能主播腔'}\n"
|
||||
f"商品描述:{desc}\n"
|
||||
f"规格:{specs_block}\n"
|
||||
@@ -572,6 +682,7 @@ def build_agent_messages(
|
||||
|
||||
suggested = plan_segment_durations(total, fmt)
|
||||
shot_n = len(suggested)
|
||||
stage_plan = structure_stage_plan(structure, shot_n)
|
||||
extra = (user_prompt or "").strip()
|
||||
if target_index is not None and base_draft:
|
||||
existing_n = len(base_draft.get("segments") or [])
|
||||
@@ -610,24 +721,10 @@ def build_agent_messages(
|
||||
f"【写法硬约束】有人声的镜口播/对白 {speech_floor}–{speech_cap} 字,允许个别镜纯画面;"
|
||||
f"visual 至少 {VISUAL_CHARS_MIN} 字。\n"
|
||||
)
|
||||
structure_line = ""
|
||||
if structure == "scene":
|
||||
structure_line = (
|
||||
"【场景种草骨架】按镜走完:场景建立(商品先不出现)→商品被使用着入场→场景因它更好/软CTA。"
|
||||
"每镜写出具体时刻+地点+光/声音/物件。口播时每镜仍要说满字数,留白靠停顿不是靠少写字。\n"
|
||||
)
|
||||
elif structure == "promo":
|
||||
structure_line = (
|
||||
"【促销抢购合规】只可使用商品资料中明确给出的价格、优惠、库存、赠品和活动时间;"
|
||||
"没有明确数据时,绝不写原价、最低价、限时、限量、领券或赠品。"
|
||||
"结尾只给一个清楚的购买入口和操作动作,不制造虚假的紧迫感。\n"
|
||||
)
|
||||
elif structure == "knowledge":
|
||||
structure_line = (
|
||||
"【知识分享骨架】先提出一个与商品相关的选购/使用误区或判断方法,再用实物与步骤证明,"
|
||||
"最后把商品作为符合该判断的具体实例。只能讲商品资料可支持的事实,"
|
||||
"禁止编造专业身份、数据、标准或功效承诺。\n"
|
||||
)
|
||||
structure_line = STRUCTURE_CONTRACTS[structure]
|
||||
stage_line = "【镜序验收】" + ";".join(
|
||||
f"第 {index + 1} 镜:{stage}" for index, stage in enumerate(stage_plan)
|
||||
) + "。镜数不够时只可合并相邻步骤,顺序不得颠倒,也不得插入其他结构的主线。\n"
|
||||
combo_line = (
|
||||
f"严格按已加载的「{PRESENTATION_FORMATS[fmt]} × {VIDEO_STRUCTURES[structure]}」套路写,"
|
||||
f"不要串成别的结构的套话。\n"
|
||||
@@ -646,6 +743,7 @@ def build_agent_messages(
|
||||
f"{writing_line}"
|
||||
f"{beats_line}"
|
||||
f"{structure_line}"
|
||||
f"{stage_line}"
|
||||
f"{combo_line}"
|
||||
f"{authenticity_line}"
|
||||
f"【商品信息】\n{_product_context(project, selling_point_ids, persona)}"
|
||||
@@ -685,7 +783,7 @@ def build_agent_messages(
|
||||
"写 visual 时把拆解稿里的景别/机位/运镜/人物动作/表情/音效/背景音乐/字幕/备注"
|
||||
"折进【声音】和【画面内容】的秒级分镜,不要压成一句画面摘要。"
|
||||
"原稿写「无 / 不可见 / 听不清」的栏不要编造。"
|
||||
"钩子、痛点、卖点必须能对上这份商品。"
|
||||
"钩子、已选结构的核心步骤和商品事实必须能对上。"
|
||||
"每镜旁白要能撑满指定时长;画面必须按秒拆分镜,够导演在 15 秒里切 3–5 刀。\n"
|
||||
f"{head}"
|
||||
f"{extra_block}\n"
|
||||
@@ -695,7 +793,7 @@ def build_agent_messages(
|
||||
user = (
|
||||
"【任务】全自动(模式①):仅凭上面的商品事实与前置条件,按指定的表现形式与视频结构套路"
|
||||
"自动定镜/选 tone/造 entity/填结构骨架。"
|
||||
"不要另起一个空主题;钩子、痛点、卖点必须能对上这份商品,而不是品类套话。"
|
||||
"不要另起一个空主题;钩子、已选结构的核心步骤和商品事实必须能对上这份商品,而不是品类套话。"
|
||||
"每镜旁白要能撑满 15 秒;画面必须按秒拆分镜,够导演在 15 秒里切 3–5 刀。\n"
|
||||
f"{head}"
|
||||
f"{extra_block}\n"
|
||||
@@ -793,6 +891,20 @@ def plan_segment_durations(total_duration: int, presentation_format: str | None
|
||||
return [SEGMENT_DURATION_MAX] * count
|
||||
|
||||
|
||||
def structure_stage_plan(video_structure: str, segment_count: int) -> list[str]:
|
||||
"""把选中结构的完整说服路径按当前镜数连续压缩,绝不换成通用痛点模板。"""
|
||||
stages = list(STRUCTURE_STAGES[video_structure])
|
||||
count = max(1, int(segment_count or 1))
|
||||
if count >= len(stages):
|
||||
return stages + [stages[-1]] * (count - len(stages))
|
||||
# 前面镜先承接前置步骤,最后一镜必须保留收束/CTA;每个阶段只会向相邻镜合并。
|
||||
groups: list[list[str]] = [[] for _ in range(count)]
|
||||
for index, stage in enumerate(stages):
|
||||
target = min(count - 1, index * count // len(stages))
|
||||
groups[target].append(stage)
|
||||
return [" → ".join(group) for group in groups]
|
||||
|
||||
|
||||
def plan_roles(count: int) -> list[str]:
|
||||
"""镜数 → role 序列。通用规则:首钩子、次痛点、末 CTA,中间全是卖点。"""
|
||||
if count <= 1:
|
||||
|
||||
@@ -894,6 +894,20 @@ class VideoSubmissionStateUnknown(ProviderOutcomeUnknownError):
|
||||
"""视频提交可能已到达供应商但未拿到可靠任务 ID;禁止自动重提。"""
|
||||
|
||||
|
||||
NO_EMBEDDED_CAPTIONS_REQUIREMENT = (
|
||||
"【成片硬性规则】画面不得出现字幕、台词字幕、花字、标题、价格贴片、UI 文字或其他后期文字叠加;"
|
||||
"只保留商品包装上参考图中原有的真实物理文字与标识。旁白和环境音可以保留,但不要把声音转成画面文字。"
|
||||
)
|
||||
|
||||
|
||||
def enforce_no_embedded_captions(prompt: str) -> str:
|
||||
"""所有视频入口最终汇入这里,避免某个入口漏传“无字幕”导致模型自行加花字。"""
|
||||
base = (prompt or "").strip()
|
||||
if NO_EMBEDDED_CAPTIONS_REQUIREMENT in base:
|
||||
return base
|
||||
return f"{base}\n{NO_EMBEDDED_CAPTIONS_REQUIREMENT}".strip()
|
||||
|
||||
|
||||
def execute_routed_video_submit(
|
||||
*,
|
||||
task: AITask,
|
||||
@@ -913,6 +927,7 @@ def execute_routed_video_submit(
|
||||
"""异步视频只路由“提交”阶段;拿到 Provider 任务 ID 后固定该实际模型轮询。"""
|
||||
from apps.billing.pricing import quote_video_estimate
|
||||
|
||||
prompt = enforce_no_embedded_captions(prompt)
|
||||
references = list(reference_images or [])
|
||||
routed_content_items = list(content_items) if content_items is not None else None
|
||||
pricing_refs = list(pricing_references or [])
|
||||
@@ -2693,7 +2708,7 @@ def build_storyboard_frame_prompt(project, segment, extra_prompt: str = "") -> s
|
||||
补充=(("\n" + extra_prompt.strip()) if extra_prompt else ""),
|
||||
)
|
||||
cleaned = "\n".join(line for line in rendered.split("\n") if line.strip())
|
||||
return _apply_storyboard_output_ratio(cleaned, project)
|
||||
return enforce_no_embedded_captions(_apply_storyboard_output_ratio(cleaned, project))
|
||||
|
||||
|
||||
def build_video_segment_prompt(project, video_segment, scene, refs, user_prompt: str = "") -> str:
|
||||
@@ -2729,7 +2744,7 @@ def build_video_segment_prompt(project, video_segment, scene, refs, user_prompt:
|
||||
脚本=(script_text or f"第 {video_segment.sort_order + 1} 段"),
|
||||
时长=video_segment.target_duration_seconds,
|
||||
)
|
||||
return "\n".join(line for line in rendered.split("\n") if line.strip())
|
||||
return enforce_no_embedded_captions("\n".join(line for line in rendered.split("\n") if line.strip()))
|
||||
|
||||
|
||||
def ensure_storyboard_shots(project) -> list:
|
||||
@@ -2904,7 +2919,7 @@ def build_storyboard_frame_prompt_refs(project, segment, refs: list[dict], extra
|
||||
补充=(("\n" + extra_prompt.strip()) if extra_prompt else ""),
|
||||
)
|
||||
cleaned = "\n".join(line for line in rendered.split("\n") if line.strip())
|
||||
return _apply_storyboard_output_ratio(cleaned, project)
|
||||
return enforce_no_embedded_captions(_apply_storyboard_output_ratio(cleaned, project))
|
||||
|
||||
|
||||
def _is_transient_error(exc: Exception) -> bool:
|
||||
|
||||
@@ -13,6 +13,7 @@ from apps.ai.script_agent import (
|
||||
DEFAULT_TOTAL_DURATION,
|
||||
PRESENTATION_FORMATS,
|
||||
SEGMENT_DURATION_MAX,
|
||||
STRUCTURE_CONTRACTS,
|
||||
TOTAL_DURATION_MAX,
|
||||
TOTAL_DURATION_MIN,
|
||||
VIDEO_STRUCTURES,
|
||||
@@ -35,6 +36,8 @@ from apps.ai.script_agent import (
|
||||
parse_visual_beats,
|
||||
plan_roles,
|
||||
plan_segment_durations,
|
||||
recommend_script_setup,
|
||||
structure_stage_plan,
|
||||
)
|
||||
|
||||
ALL_TOTALS = [15, 30, 45, 60]
|
||||
@@ -81,6 +84,16 @@ class SegmentPlanningTests(SimpleTestCase):
|
||||
self.assertEqual(plan[0], "钩子")
|
||||
self.assertEqual(plan[-1], "CTA")
|
||||
|
||||
def test_structure_stage_plan_preserves_selected_structure_order_when_compressed(self):
|
||||
self.assertEqual(
|
||||
structure_stage_plan("knowledge", 2),
|
||||
["问题或常见误区 → 判断方法或核心结论", "实物步骤或正反示范 → 商品承接、选择建议与 CTA"],
|
||||
)
|
||||
self.assertEqual(
|
||||
structure_stage_plan("contrast", 4),
|
||||
["使用前的基准状态", "商品使用过程", "使用后的同条件状态", "差异总结与行动引导"],
|
||||
)
|
||||
|
||||
|
||||
class SegmentDurationFittingTests(SimpleTestCase):
|
||||
def test_uneven_durations_are_forced_to_15(self):
|
||||
@@ -265,7 +278,6 @@ class PromptAssemblyTests(SimpleTestCase):
|
||||
product.brand = "AirBrand"
|
||||
product.business_type = "ecommerce"
|
||||
product.category = "美妆"
|
||||
product.target_audience = "油皮"
|
||||
product.description = "夜间修护"
|
||||
product.specs = {"price": "99", "source": "product-create-upload"}
|
||||
product.selling_points.all.return_value = _QS([sp])
|
||||
@@ -290,6 +302,7 @@ class PromptAssemblyTests(SimpleTestCase):
|
||||
self.assertNotIn("一句话主题扩写", user)
|
||||
self.assertIn("【补充要求】节奏紧凑,适合短视频投放", user)
|
||||
self.assertIn("控油12小时", user)
|
||||
self.assertNotIn("目标人群:", user)
|
||||
self.assertIn("都市白领女性", user)
|
||||
self.assertIn("价格:99", user)
|
||||
self.assertIn("每镜必须 15 秒", user)
|
||||
@@ -301,6 +314,38 @@ class PromptAssemblyTests(SimpleTestCase):
|
||||
self.assertIn("禁止使用「唯一缺点", user)
|
||||
self.assertIn("测试过程里的意外发现", user)
|
||||
|
||||
def test_selected_video_structure_injects_its_own_contract(self):
|
||||
expected_titles = {
|
||||
"pain": "【痛点解决·结构合同】",
|
||||
"contrast": "【前后对比·结构合同】",
|
||||
"review": "【测评验证·结构合同】",
|
||||
"scene": "【场景种草·结构合同】",
|
||||
"promo": "【促销抢购·结构合同】",
|
||||
"knowledge": "【知识分享·结构合同】",
|
||||
}
|
||||
for structure, expected_title in expected_titles.items():
|
||||
with self.subTest(structure=structure):
|
||||
messages = build_agent_messages(
|
||||
project=self._project(),
|
||||
mode="auto",
|
||||
user_prompt="",
|
||||
selling_point_ids=["1"],
|
||||
base_draft=None,
|
||||
aspect_ratio="9:16",
|
||||
total_duration=30,
|
||||
presentation_format="oral",
|
||||
video_structure=structure,
|
||||
persona="urban",
|
||||
)
|
||||
user = messages[1]["content"]
|
||||
self.assertIn(STRUCTURE_CONTRACTS[structure], user)
|
||||
self.assertIn(expected_title, user)
|
||||
self.assertIn("【镜序验收】", user)
|
||||
self.assertIn(structure_stage_plan(structure, 2)[0], user)
|
||||
for other, other_title in expected_titles.items():
|
||||
if other != structure:
|
||||
self.assertNotIn(other_title, user)
|
||||
|
||||
def test_video_digest_prompt_maps_camera_and_sound(self):
|
||||
digest = (
|
||||
"片名:《雨夜归家》\n视频类型:剧情短片\n"
|
||||
@@ -338,7 +383,7 @@ class PromptAssemblyTests(SimpleTestCase):
|
||||
persona="urban",
|
||||
)
|
||||
user = messages[1]["content"]
|
||||
self.assertIn("场景种草骨架", user)
|
||||
self.assertIn("【场景种草·结构合同】", user)
|
||||
self.assertIn("口播每镜必须说满", user)
|
||||
self.assertIn("一共 3 镜", user)
|
||||
self.assertIn("秒级分镜", user)
|
||||
@@ -550,3 +595,45 @@ class SkillLoadingTests(SimpleTestCase):
|
||||
self.assertIn("每镜固定 15 秒", text)
|
||||
self.assertIn("商品原词", text)
|
||||
self.assertNotIn("单镜 4–15 秒,允许不等长", text)
|
||||
|
||||
|
||||
class RecommendScriptSetupTests(SimpleTestCase):
|
||||
def test_exact_category_beauty(self):
|
||||
rec = recommend_script_setup("美妆个护", "精华")
|
||||
self.assertEqual(rec["format"], "oral")
|
||||
self.assertEqual(rec["structure"], "contrast")
|
||||
self.assertEqual(rec["persona"], "bestie")
|
||||
self.assertIn("美妆个护", rec["reason"])
|
||||
|
||||
def test_digital_uses_reviewer(self):
|
||||
rec = recommend_script_setup("数码家电")
|
||||
self.assertEqual(rec["format"], "oral")
|
||||
self.assertEqual(rec["structure"], "review")
|
||||
self.assertEqual(rec["persona"], "reviewer")
|
||||
|
||||
def test_food_uses_oral_scene_bestie(self):
|
||||
rec = recommend_script_setup("食品饮料", "轻醒咖啡")
|
||||
self.assertEqual(rec["format"], "oral")
|
||||
self.assertEqual(rec["structure"], "scene")
|
||||
self.assertEqual(rec["persona"], "bestie")
|
||||
|
||||
def test_legacy_keyword_fallback(self):
|
||||
rec = recommend_script_setup("服饰内衣", "连衣裙")
|
||||
self.assertEqual(rec["format"], "oral")
|
||||
self.assertEqual(rec["structure"], "scene")
|
||||
self.assertEqual(rec["persona"], "urban")
|
||||
|
||||
def test_never_recommends_vlog_or_drama(self):
|
||||
for category in (
|
||||
"美妆个护", "食品饮料", "服饰鞋包", "家居日用", "数码家电",
|
||||
"母婴玩具", "运动健康", "珠宝配饰", "汽车用品", "宠物用品",
|
||||
"图书文教", "五金农资", "其他商品",
|
||||
):
|
||||
rec = recommend_script_setup(category)
|
||||
self.assertEqual(rec["format"], "oral", category)
|
||||
|
||||
def test_empty_falls_back_to_generic(self):
|
||||
rec = recommend_script_setup("", "")
|
||||
self.assertEqual(rec["format"], "oral")
|
||||
self.assertEqual(rec["structure"], "pain")
|
||||
self.assertEqual(rec["persona"], "urban")
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
from django.test import SimpleTestCase
|
||||
|
||||
from apps.ai.services import NO_EMBEDDED_CAPTIONS_REQUIREMENT, enforce_no_embedded_captions
|
||||
|
||||
|
||||
class VideoCaptionPolicyTests(SimpleTestCase):
|
||||
def test_always_appends_no_embedded_captions_requirement(self):
|
||||
prompt = enforce_no_embedded_captions("一位用户在厨房展示商品")
|
||||
|
||||
self.assertIn(NO_EMBEDDED_CAPTIONS_REQUIREMENT, prompt)
|
||||
self.assertIn("一位用户在厨房展示商品", prompt)
|
||||
|
||||
def test_requirement_is_not_duplicated(self):
|
||||
prompt = enforce_no_embedded_captions(NO_EMBEDDED_CAPTIONS_REQUIREMENT)
|
||||
|
||||
self.assertEqual(prompt.count(NO_EMBEDDED_CAPTIONS_REQUIREMENT), 1)
|
||||
@@ -10,7 +10,7 @@ from django.utils import timezone
|
||||
|
||||
from apps.accounts.models import Team, TeamMember, User
|
||||
from apps.ai.models import AITask, ModelConfig, ModelProvider
|
||||
from apps.ai.services import poll_video_segment, submit_video_segment
|
||||
from apps.ai.services import NO_EMBEDDED_CAPTIONS_REQUIREMENT, poll_video_segment, submit_video_segment
|
||||
from apps.assets.models import Asset
|
||||
from apps.billing.models import CreditAccount, CreditLedger
|
||||
from apps.products.models import Product
|
||||
@@ -140,6 +140,8 @@ class VideoSegmentRoutingTests(TestCase):
|
||||
self.assertEqual(self.ledger_count(task, CreditLedger.Type.RESERVE), 1)
|
||||
self.assertEqual(self.ledger_count(task, CreditLedger.Type.CHARGE), 0)
|
||||
self.assertEqual(self.ledger_count(task, CreditLedger.Type.RELEASE), 0)
|
||||
submitted_prompt = provider.create_video_task.call_args.kwargs["prompt"]
|
||||
self.assertIn(NO_EMBEDDED_CAPTIONS_REQUIREMENT, submitted_prompt)
|
||||
|
||||
def test_second_submit_does_not_create_another_task(self):
|
||||
primary = self.model(self.provider("video-primary-once", 20), "video-primary-once", outbound=True, default=True)
|
||||
|
||||
@@ -488,7 +488,7 @@ _KEYWORDS: tuple[tuple[ProductKind, tuple[str, ...]], ...] = (
|
||||
_GENERIC_WEARABLE_HINTS = (
|
||||
"围巾", "披肩", "领带", "领结", "袜", "腰带", "皮带", "手套", "护膝", "护腕", "服饰", "服装", "穿戴", "衣帽", "鞋服",
|
||||
)
|
||||
_BROAD_WEARABLE_CATEGORIES = ("服饰内衣", "服饰", "服装", "女装", "男装", "配饰")
|
||||
_BROAD_WEARABLE_CATEGORIES = ("服饰内衣", "服饰鞋包", "服饰", "服装", "女装", "男装", "配饰")
|
||||
_USER_RELATION_RULES: tuple[tuple[ProductKind, tuple[str, ...]], ...] = (
|
||||
(ProductKind.HANDHELD, ("手持", "拿着", "拿在手", "握住")),
|
||||
(ProductKind.WEARABLE_GENERIC, ("穿上", "穿着", "换上", "佩戴", "戴上", "戴着")),
|
||||
|
||||
@@ -12,6 +12,11 @@ class Product(TeamOwnedModel):
|
||||
ECOMMERCE = "ecommerce", "电商"
|
||||
LOCAL_LIFE = "local_life", "本地生活"
|
||||
|
||||
ECOMMERCE_CATEGORIES = (
|
||||
"美妆个护", "食品饮料", "服饰鞋包", "家居日用", "数码家电", "母婴玩具", "运动健康",
|
||||
"珠宝配饰", "汽车用品", "宠物用品", "图书文教", "五金农资", "其他商品",
|
||||
)
|
||||
|
||||
title = models.CharField(max_length=255)
|
||||
brand = models.CharField(max_length=128, blank=True)
|
||||
# 类目(电商/本地生活)≠品类(美妆/食品)。类目驱动节点屏蔽与后续 skill 挂载。
|
||||
|
||||
@@ -423,11 +423,12 @@ class QuickCreateJobSerializer(serializers.ModelSerializer):
|
||||
if version is not None and is_playable_video(version.asset):
|
||||
video_url = _asset_preview_url(version.asset)
|
||||
break
|
||||
first_shot = next(
|
||||
(shot for shot in project.storyboard_shots.all() if shot.adopted_version_id),
|
||||
None,
|
||||
)
|
||||
poster_url = _asset_preview_url(first_shot.adopted_version.asset) if first_shot else ""
|
||||
product = getattr(project, "product", None)
|
||||
cover = getattr(product, "cover_asset", None) if product is not None else None
|
||||
poster_url = _asset_preview_url(cover) if cover else ""
|
||||
if not poster_url and product is not None:
|
||||
first_image = next(iter(product.images.all()), None)
|
||||
poster_url = _asset_preview_url(first_image.asset) if first_image else ""
|
||||
script = next((version for version in project.script_versions.all() if version.is_adopted), None)
|
||||
script_meta = (script.metadata or {}) if script is not None else {}
|
||||
format_label = {"oral": "口播展示", "drama": "短剧演绎", "vlog": "Vlog种草"}.get(
|
||||
|
||||
@@ -15,7 +15,13 @@ from django.db import connections, transaction
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.ai.models import AITask, ModelConfig
|
||||
from apps.ai.script_agent import stream_script_agent
|
||||
from apps.ai.script_agent import (
|
||||
PRESENTATION_FORMATS,
|
||||
VIDEO_STRUCTURES,
|
||||
persona_label,
|
||||
recommend_script_setup,
|
||||
stream_script_agent,
|
||||
)
|
||||
from apps.ai.services import (
|
||||
collect_video_review_blockers,
|
||||
create_export_job,
|
||||
@@ -364,6 +370,17 @@ def _consume_script_agent(job: QuickCreateJob) -> None:
|
||||
if model_config is None:
|
||||
raise ValueError(f"{QUICK_SCRIPT_MODEL_NAME} is not configured")
|
||||
|
||||
wizard = dict((project.metadata or {}).get("wizard") or {})
|
||||
product = project.product
|
||||
rec = recommend_script_setup(
|
||||
getattr(product, "category", "") or "",
|
||||
getattr(product, "title", "") or "",
|
||||
)
|
||||
fmt = wizard.get("presentation_format") or rec["format"]
|
||||
structure = wizard.get("video_structure") or rec["structure"]
|
||||
persona = wizard.get("persona") or rec["persona"]
|
||||
format_label = PRESENTATION_FORMATS.get(fmt, fmt)
|
||||
structure_label = VIDEO_STRUCTURES.get(structure, structure)
|
||||
error_detail = ""
|
||||
stream = stream_script_agent(
|
||||
project=project,
|
||||
@@ -371,15 +388,16 @@ def _consume_script_agent(job: QuickCreateJob) -> None:
|
||||
model_config=model_config,
|
||||
mode="auto",
|
||||
user_prompt=(
|
||||
"请根据商品名称与商品参考图信息,自动推荐最适合的"
|
||||
f"{settings['total_duration']}秒、{settings['aspect_ratio']}画幅带货方案。"
|
||||
"请根据商品名称、品类与商品参考图信息,按推荐的"
|
||||
f"{format_label} × {structure_label} × {persona_label(persona)},"
|
||||
f"生成{settings['total_duration']}秒、{settings['aspect_ratio']}画幅带货方案。"
|
||||
),
|
||||
aspect_ratio=settings["aspect_ratio"],
|
||||
total_duration=settings["total_duration"],
|
||||
presentation_format="oral",
|
||||
video_structure="pain",
|
||||
presentation_format=fmt,
|
||||
video_structure=structure,
|
||||
entry_source="ai",
|
||||
persona="reviewer",
|
||||
persona=persona,
|
||||
)
|
||||
# 豆包思考模型会连续吐几百帧 reasoning。原先每帧 refresh_from_db,远程 MySQL
|
||||
# 一抖动就会把生成器关掉,模型调用被误记成 stream aborted (client disconnected)。
|
||||
|
||||
@@ -76,6 +76,7 @@ class QuickCreateApiTests(TestCase):
|
||||
"aspect_ratio": "9:16",
|
||||
"resolution": "720p",
|
||||
"total_duration": "30",
|
||||
"category": "食品饮料",
|
||||
},
|
||||
format="multipart",
|
||||
)
|
||||
@@ -83,10 +84,14 @@ class QuickCreateApiTests(TestCase):
|
||||
self.assertEqual(response.status_code, 202)
|
||||
product = Product.objects.get(title="轻醒咖啡")
|
||||
self.assertEqual(product.images.count(), 2)
|
||||
self.assertEqual(product.category, "食品饮料")
|
||||
self.assertEqual(product.cover_asset_id, product.images.order_by("sort_order").first().asset_id)
|
||||
project = Project.objects.get(product=product)
|
||||
self.assertTrue(project.metadata["quick_create"])
|
||||
self.assertEqual(project.metadata["wizard"]["total_duration"], 30)
|
||||
self.assertEqual(project.metadata["wizard"]["presentation_format"], "oral")
|
||||
self.assertEqual(project.metadata["wizard"]["video_structure"], "scene")
|
||||
self.assertEqual(project.metadata["wizard"]["persona"], "bestie")
|
||||
self.assertEqual(project.metadata["wizard"]["resolution"], "720p")
|
||||
self.assertEqual(project.metadata["wizard"]["video_model_config_id"], str(video_model_id))
|
||||
self.assertEqual(project.stages.count(), 5)
|
||||
@@ -106,6 +111,30 @@ class QuickCreateApiTests(TestCase):
|
||||
{str(image.asset_id) for image in product.images.order_by("sort_order")},
|
||||
)
|
||||
|
||||
@patch("apps.projects.services.quick_create.get_quick_script_model", return_value=object())
|
||||
@patch("apps.projects.views.get_default_model")
|
||||
@patch("apps.projects.tasks.advance_quick_create_task.apply_async")
|
||||
@patch("apps.projects.views.require_worker_task")
|
||||
@patch("apps.projects.views._store_uploaded_asset")
|
||||
def test_submit_requires_product_category(self, store_asset, require_worker_task, enqueue, get_model, get_quick_model):
|
||||
store_asset.side_effect = self._uploaded_asset
|
||||
get_model.side_effect = [
|
||||
object(),
|
||||
SimpleNamespace(id=uuid.uuid4(), name="seedance", display_name="Seedance", metadata={}),
|
||||
]
|
||||
response = self.client.post(
|
||||
"/api/projects/quick-create/",
|
||||
{
|
||||
"name": "无品类咖啡",
|
||||
"images": [SimpleUploadedFile("front.png", b"png", content_type="image/png")],
|
||||
},
|
||||
format="multipart",
|
||||
)
|
||||
self.assertEqual(response.status_code, 400)
|
||||
self.assertIn("品类", str(response.data["detail"]))
|
||||
self.assertFalse(Product.objects.filter(title="无品类咖啡").exists())
|
||||
enqueue.assert_not_called()
|
||||
|
||||
@patch("apps.projects.services.quick_create.get_quick_script_model", return_value=object())
|
||||
@patch("apps.projects.views.get_default_model", return_value=object())
|
||||
@patch("apps.projects.tasks.advance_quick_create_task.apply_async")
|
||||
@@ -129,6 +158,7 @@ class QuickCreateApiTests(TestCase):
|
||||
{
|
||||
"name": "复用商品图",
|
||||
"source_product_id": str(source.id),
|
||||
"category": "美妆个护",
|
||||
"aspect_ratio": "9:16",
|
||||
"resolution": "720p",
|
||||
"total_duration": "15",
|
||||
@@ -140,6 +170,7 @@ class QuickCreateApiTests(TestCase):
|
||||
store_asset.assert_not_called()
|
||||
product = Product.objects.get(title="复用商品图")
|
||||
self.assertEqual(product.images.count(), 1)
|
||||
self.assertEqual(product.category, "美妆个护")
|
||||
self.assertEqual(product.images.first().asset_id, source_asset.id)
|
||||
self.assertEqual(response.data["product_images"][0]["asset_id"], str(source_asset.id))
|
||||
|
||||
@@ -651,6 +682,52 @@ class QuickCreateCoordinatorTests(TestCase):
|
||||
self.assertEqual(self.job.status, QuickCreateJob.Status.RUNNING)
|
||||
enqueue.assert_called_once()
|
||||
|
||||
@patch("apps.projects.services.quick_create.get_quick_script_model")
|
||||
@patch("apps.projects.services.quick_create.stream_script_agent")
|
||||
def test_consume_script_uses_category_recommendation(self, stream_fn, get_model):
|
||||
get_model.return_value = self.model
|
||||
self.product.category = "食品饮料"
|
||||
self.product.title = "轻醒咖啡"
|
||||
self.product.save(update_fields=["category", "title", "updated_at"])
|
||||
self.project.metadata = {"wizard": {"total_duration": 30, "aspect_ratio": "9:16"}}
|
||||
self.project.save(update_fields=["metadata", "updated_at"])
|
||||
stream_fn.return_value = iter([])
|
||||
with self.assertRaises(ValueError):
|
||||
_consume_script_agent(self.job)
|
||||
kwargs = stream_fn.call_args.kwargs
|
||||
self.assertEqual(kwargs["presentation_format"], "oral")
|
||||
self.assertEqual(kwargs["video_structure"], "scene")
|
||||
self.assertEqual(kwargs["persona"], "bestie")
|
||||
self.assertEqual(kwargs["total_duration"], 30)
|
||||
self.assertIn("口播", kwargs["user_prompt"])
|
||||
self.assertIn("场景种草", kwargs["user_prompt"])
|
||||
self.assertIn("闺蜜种草", kwargs["user_prompt"])
|
||||
|
||||
@patch("apps.projects.services.quick_create.get_quick_script_model")
|
||||
@patch("apps.projects.services.quick_create.stream_script_agent")
|
||||
def test_consume_script_keeps_wizard_combo(self, stream_fn, get_model):
|
||||
get_model.return_value = self.model
|
||||
self.product.category = "食品饮料"
|
||||
self.product.save(update_fields=["category", "updated_at"])
|
||||
self.project.metadata = {
|
||||
"wizard": {
|
||||
"presentation_format": "oral",
|
||||
"video_structure": "pain",
|
||||
"persona": "reviewer",
|
||||
"total_duration": 15,
|
||||
"aspect_ratio": "9:16",
|
||||
}
|
||||
}
|
||||
self.project.save(update_fields=["metadata", "updated_at"])
|
||||
stream_fn.return_value = iter([])
|
||||
with self.assertRaises(ValueError):
|
||||
_consume_script_agent(self.job)
|
||||
kwargs = stream_fn.call_args.kwargs
|
||||
self.assertEqual(kwargs["presentation_format"], "oral")
|
||||
self.assertEqual(kwargs["video_structure"], "pain")
|
||||
self.assertEqual(kwargs["persona"], "reviewer")
|
||||
self.assertEqual(kwargs["total_duration"], 15)
|
||||
|
||||
@patch("apps.projects.services.quick_create.get_quick_script_model")
|
||||
@patch("apps.projects.services.quick_create.stream_script_agent")
|
||||
def test_consume_script_does_not_hit_db_on_every_sse_frame(self, stream_fn, get_model):
|
||||
|
||||
@@ -21,6 +21,7 @@ from apps.ai.script_agent import (
|
||||
SEGMENT_DURATION_MIN,
|
||||
combo_keys,
|
||||
coerce_total_duration,
|
||||
recommend_script_setup,
|
||||
stream_script_agent,
|
||||
)
|
||||
from apps.ai.services import (
|
||||
@@ -607,6 +608,11 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
return Response({"detail": "请至少上传一张商品图片"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
if len(reused_assets) + len(uploads) > 9:
|
||||
return Response({"detail": "商品图片最多上传9张"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
category = str(request.data.get("category") or "").strip()
|
||||
if not category:
|
||||
return Response({"detail": "请选择商品品类"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
if category not in Product.ECOMMERCE_CATEGORIES:
|
||||
return Response({"detail": "请选择有效的商品品类"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
with transaction.atomic():
|
||||
assets = list(reused_assets)
|
||||
@@ -628,6 +634,7 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
created_by=request.user,
|
||||
title=name,
|
||||
business_type=Product.BusinessType.ECOMMERCE,
|
||||
category=category,
|
||||
cover_asset=assets[0],
|
||||
)
|
||||
ProductImage.objects.bulk_create(
|
||||
@@ -641,6 +648,7 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
for index, asset in enumerate(assets)
|
||||
]
|
||||
)
|
||||
rec = recommend_script_setup(category, name)
|
||||
project = Project.objects.create(
|
||||
team=team,
|
||||
created_by=request.user,
|
||||
@@ -657,9 +665,9 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
"video_model_config_id": str(video_model.id),
|
||||
"video_model_name": video_model.name,
|
||||
"video_model_label": video_model.display_name,
|
||||
"presentation_format": "oral",
|
||||
"video_structure": "pain",
|
||||
"persona": "reviewer",
|
||||
"presentation_format": rec["format"],
|
||||
"video_structure": rec["structure"],
|
||||
"persona": rec["persona"],
|
||||
},
|
||||
},
|
||||
)
|
||||
@@ -697,9 +705,10 @@ class ProjectViewSet(TeamScopedViewSetMixin, ModelViewSet):
|
||||
|
||||
def _quick_job_queryset(self):
|
||||
return (
|
||||
QuickCreateJob.objects.select_related("project__product", "project__timeline")
|
||||
QuickCreateJob.objects.select_related("project__product", "project__product__cover_asset", "project__timeline")
|
||||
.prefetch_related(
|
||||
"project__product__images__asset__files",
|
||||
"project__product__cover_asset__files",
|
||||
"project__script_versions",
|
||||
"project__base_asset_groups",
|
||||
"project__storyboard_shots__adopted_version__asset__files",
|
||||
|
||||
Reference in New Issue
Block a user