优化住流程和添加复刻视频页面

This commit is contained in:
Azmat@qq.com
2026-08-26 18:44:03 +08:00
parent 0ee498d807
commit c25060c6c6
30 changed files with 2574 additions and 155 deletions
+135 -23
View File
@@ -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:
+18 -3
View File
@@ -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("单镜 415 秒,允许不等长", 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)
+1 -1
View File
@@ -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, ("穿上", "穿着", "换上", "佩戴", "戴上", "戴着")),
+5
View File
@@ -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 挂载。
+6 -5
View File
@@ -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):
+13 -4
View File
@@ -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",