架构从"扇出N个GenerationTask各跑完整管道"改为"一次LLM调用直接出N套
完整笔记包(N=1~5)",落 FissionNote 表 + 独立展示页。
后端:
- 018迁移:fission_notes 表(文案JSON+score+passed+imagePlan+images+status)
- fission_prompt:FISSION_SYSTEM+三档参考度(low/mid/high)+normalize_tags+品类兜底
- fission_pipeline:一次LLM出N套→各评分(@80合格线)→排序→落库,不达标标
needs_optimization 非丢弃;apiports 503 回落 codeproxy gpt-5.5 强档兜底
- fission_images:每套串行调现有生图接口(零改动image_gen/storyboard)
- tasks.py:run_fission_pipeline Celery task,删旧扇出注入
- api/v1/fission:进度聚合FissionNote + GET /fission/{id}/notes(剥内部字段)
前端:FissionProgress对齐状态机 + N套独立展示页 + FissionNoteCard
测试:test_fission_engine(19)+test_fission_pipeline(5) 全过;104 全量回归绿
实测task5(fanout=2,mid)端到端跑通:一次出2套→seq0=85过/seq1=79标优化→
生图codeproxy/edits→1024×1536去AI化→task completed→notes端点返完整数据
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
146 lines
8.0 KiB
Python
146 lines
8.0 KiB
Python
"""
|
||
app/services/ai_engine/fission_fallback.py — 裂变品类兜底(从 fission_prompt 拆出)
|
||
|
||
LLM 挂/返回不可解析时,按品类生成完整草稿,保证不卡用户(对齐上线版 split.js
|
||
的 inferCategory / fallbackAnglesByCategory / buildFallbackNotes)。
|
||
拆分原因:fission_prompt.py 超 200 行红线上限,品类兜底是内聚可独立的一块。
|
||
"""
|
||
from __future__ import annotations
|
||
|
||
import re
|
||
|
||
from app.services.ai_engine.fission_prompt import (
|
||
normalize_tags,
|
||
sanitize_image_plan_text,
|
||
)
|
||
|
||
_CATEGORY_PATTERNS = [
|
||
("个护护理", re.compile(r"护手|手霜|身体乳|润肤|唇膏|洗护|护理")),
|
||
("美妆护肤", re.compile(r"霜|乳|精华|面膜|粉底|彩妆|口红|护肤|美妆")),
|
||
("食品饮品", re.compile(r"饮|茶|咖啡|果汁|奶|冲泡|零食|食品|吃")),
|
||
("营养健康", re.compile(r"维生素|益生菌|蛋白|营养|保健|膳食")),
|
||
("家居生活", re.compile(r"收纳|清洁|家居|厨房|小物|工具|电器")),
|
||
("服饰穿搭", re.compile(r"衣|裤|鞋|包|穿搭|面料|服饰")),
|
||
]
|
||
|
||
|
||
def infer_category(product: dict) -> str:
|
||
"""按产品名/卖点/关键词推断品类(对齐上线版 inferCategory)。"""
|
||
p = product or {}
|
||
text = (
|
||
str(p.get("name", "")) + "、".join(p.get("selling_points", []) or [])
|
||
+ "、".join(p.get("keywords", []) or [])
|
||
)
|
||
for cat, pattern in _CATEGORY_PATTERNS:
|
||
if pattern.search(text):
|
||
return cat
|
||
return "通用好物"
|
||
|
||
|
||
def _fallback_angles(category: str, product_name: str, points: list[str]) -> list[dict]:
|
||
"""按品类返回兜底角度(对齐上线版 fallbackAnglesByCategory,节选主品类+通用兜底)。"""
|
||
name = product_name or "这个好物"
|
||
p0 = points[0] if points else "到底好不好用"
|
||
maps = {
|
||
"个护护理": [
|
||
{"dimension": "换人群", "title": f"{name}手干星人真的会回购!", "scene": "办公室/通勤", "painPoint": "手干、倒刺、涂完黏手"},
|
||
{"dimension": "换场景", "title": "包里常备这支太省心了", "scene": "随身护理", "painPoint": "出门临时干到难受"},
|
||
{"dimension": "换测评", "title": "不黏手这点太加分了!", "scene": "手部质地测评", "painPoint": "摸键盘手机都怕黏"},
|
||
{"dimension": "换痛点", "title": "换季手粗糙别硬扛", "scene": "换季护理", "painPoint": "洗完手紧绷粗糙"},
|
||
{"dimension": "换选择理由", "title": "这支属于会推荐给同事", "scene": "办公室分享", "painPoint": "想要清爽又好用"},
|
||
],
|
||
"食品饮品": [
|
||
{"dimension": "换场景", "title": "工位囤这个真的方便", "scene": "办公室饮用", "painPoint": "下午嘴馋又怕踩雷"},
|
||
{"dimension": "换口感", "title": "第一口就知道没买错", "scene": "口感测评", "painPoint": "怕味道寡淡或太腻"},
|
||
{"dimension": "换步骤", "title": "懒人冲泡也能很稳定", "scene": "快速准备", "painPoint": "想方便但不想牺牲口感"},
|
||
{"dimension": "换囤货", "title": "这波囤在家里不心疼", "scene": "拆箱囤货", "painPoint": "高频喝/吃更看重性价比"},
|
||
{"dimension": "换人群", "title": "打工人下午这口太需要了", "scene": "下午补给", "painPoint": "没精神但不想太复杂"},
|
||
],
|
||
}
|
||
return maps.get(category) or [
|
||
{"dimension": "换人群", "title": f"{name}比想象中实用!", "scene": "真实使用", "painPoint": f"用户关心{p0}"},
|
||
{"dimension": "换场景", "title": "这个场景下真的会用到", "scene": "日常场景", "painPoint": "买前不知道适不适合自己"},
|
||
{"dimension": "换痛点", "title": "这个小问题终于被解决了", "scene": "痛点解决", "painPoint": "日常高频但容易被忽略的问题"},
|
||
{"dimension": "换测评", "title": "细节近看才知道值不值", "scene": "细节测评", "painPoint": "怕宣传好看但实际一般"},
|
||
{"dimension": "换转化", "title": "这波属于会推荐给朋友", "scene": "软性转化", "painPoint": "想要真实选择理由"},
|
||
]
|
||
|
||
|
||
_FALLBACK_OVERLAY = {
|
||
"hook": "这也太自然了", "pain_scene": "这个痛点太真实",
|
||
"applied_proof": "核心卖点看得见", "texture": "质地水润好推开",
|
||
"social_proof": "身边人都在问", "scenario": "出门随手带",
|
||
"tutorial": "三步快速出门", "closer": "这波真的会囤",
|
||
"product_closeup": "单品细节近看", "ingredient": "成分看得见",
|
||
}
|
||
_FALLBACK_TEXT = {
|
||
"applied_proof": "按当前品类生成核心证明页:用真实使用过程、细节近景、成分/口感/材质/质地等可感知证据证明卖点",
|
||
"texture": "展示产品质地、材质、口感、成分或使用细节,让用户看到卖点证据",
|
||
"closer": "拆箱、囤货角、通勤包或桌面场景,用省钱情报/暗号口吻做软性转化",
|
||
}
|
||
|
||
|
||
def build_fallback_image_plan(note: dict, image_count: int) -> list[dict]:
|
||
"""LLM挂时按叙事角色兜底 imagePlan(对齐上线版 buildFallbackImagePlan)。"""
|
||
from app.services.ai_engine.storyboard import get_narrative_roles
|
||
existing = note.get("imagePlan") if isinstance(note.get("imagePlan"), list) else []
|
||
plan = []
|
||
for i, role in enumerate(get_narrative_roles(image_count)):
|
||
r = role.get("role", "")
|
||
ex = existing[i] if i < len(existing) else {}
|
||
plan.append({
|
||
"role": r,
|
||
"title": sanitize_image_plan_text(ex.get("title") or role.get("name", ""), 12),
|
||
"overlayText": sanitize_image_plan_text(
|
||
ex.get("overlayText") or note.get("coverTitle") or note.get("title")
|
||
if r == "hook" else (ex.get("overlayText") or _FALLBACK_OVERLAY.get(r) or role.get("name", "")), 18),
|
||
"text": sanitize_image_plan_text(
|
||
ex.get("text") or _FALLBACK_TEXT.get(r) or role.get("focus", ""), 72),
|
||
})
|
||
return plan
|
||
|
||
|
||
def build_fallback_notes(
|
||
source_note: dict, product: dict, note_count: int, image_count: int,
|
||
) -> list[dict]:
|
||
"""LLM返回不可解析时的品类兜底完整草稿(对齐上线版 buildFallbackNotes)。"""
|
||
prod = product or {}
|
||
src = source_note or {}
|
||
name = prod.get("name", "") or "这款产品"
|
||
points = prod.get("selling_points", []) or ["使用方便", "真实可感知", "适合日常场景", "性价比高"]
|
||
audience = prod.get("target_audience", "") or "目标用户"
|
||
keywords = prod.get("keywords", []) or []
|
||
category = infer_category(prod)
|
||
tags = normalize_tags(
|
||
src.get("tags", []),
|
||
keywords or [name, category, "真实测评", "好物分享"],
|
||
)
|
||
angles = _fallback_angles(category, name, points)
|
||
kw = keywords or [x for x in [name, category, "真实测评", "好物分享"] if x]
|
||
out = []
|
||
for i in range(note_count):
|
||
a = angles[i % len(angles)]
|
||
main = points[i % len(points)]
|
||
second = points[(i + 1) % len(points)]
|
||
title = a["title"]
|
||
note = {
|
||
"title": title,
|
||
"content": (
|
||
f"姐妹们,这条先当真实测评草稿看。{name}主打{main},对{audience}来说,"
|
||
f"最有用的不是堆参数,而是解决「{a['painPoint']}」这个真实场景。✅\n\n"
|
||
f"我会先看它在{a['scene']}里是不是真的方便,再看{second}有没有日常可感知的体验。✨ "
|
||
f"如果不是那种一眼硬广的表达,反而更像朋友顺手分享。\n\n"
|
||
f"如果你也在意{a['painPoint']},这类选择理由会更容易判断适不适合自己。🌿"
|
||
),
|
||
"tags": tags,
|
||
"coverTitle": re.sub(r"[!!]", "", title),
|
||
"dimension": a["dimension"],
|
||
"audience": audience,
|
||
"scene": a["scene"],
|
||
"painPoint": a["painPoint"],
|
||
"keywords": kw,
|
||
}
|
||
note["imagePlan"] = build_fallback_image_plan(note, image_count)
|
||
out.append(note)
|
||
return out
|