Files
beige/backend/app/services/ai_engine/fission_fallback.py
yangqianqian d85dcd401b 第11环裂变重写:对齐上线版 split.js 一次LLM出N套完整笔记包
架构从"扇出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>
2026-06-18 11:17:37 +08:00

146 lines
8.0 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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