第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>
This commit is contained in:
145
backend/app/services/ai_engine/fission_fallback.py
Normal file
145
backend/app/services/ai_engine/fission_fallback.py
Normal file
@@ -0,0 +1,145 @@
|
||||
"""
|
||||
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
|
||||
@@ -1,43 +1,183 @@
|
||||
"""
|
||||
app/services/ai_engine/fission_prompt.py — 第11环裂变 三档参考度 prompt
|
||||
app/services/ai_engine/fission_prompt.py — 第11环裂变 prompt(对齐上线版 split.js)
|
||||
|
||||
裂变=1爆款→N套完整笔记包。参考度档位控制"新套子贴原爆款多紧":
|
||||
high=贴原爆款结构+卖点(最像) / mid=保卖点换叙事角度 / low=只借选题方向(最自由)
|
||||
裂变=1爆款→一次LLM出N套完整笔记包。重写对齐产品包上线版 split.js:
|
||||
- FISSION_SYSTEM 完整笔记包字段(含 dimension/audience/scene/painPoint/keywords/imagePlan)
|
||||
- 参考度连续 30-85(reference_strategy_from_level),低≤40/高≥80/中
|
||||
- 违禁词清洗 sanitize_image_plan_text
|
||||
- 品类兜底 build_fallback_notes / infer_category(LLM挂时不卡用户)
|
||||
|
||||
🔴 占位规则(倩倩姐2026-06-16:先移植打通再请北哥定档位)。
|
||||
档位真实措辞待北哥定义后替换 _LEVEL_RULES,引擎链路不动。
|
||||
🔴 参考度入参保留 low/mid/high 三档(倩倩姐拍板),内部 _LEVEL_TO_INT 映射成数值。
|
||||
🔴 三档真实业务措辞待北哥定义;本次接口按数值做对。
|
||||
🔴 LLM 走 chat_complete(OpenAI兼容),FISSION_SYSTEM 作 messages[0].role=system 传。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
# 三档占位规则:注入文案引擎 flywheel_context,约束"参考原爆款多少"
|
||||
_LEVEL_RULES = {
|
||||
"high": (
|
||||
"【裂变参考度=高】请紧贴下面这篇原爆款的正文结构、开头钩子、卖点排布与情绪语气,"
|
||||
"近乎仿写——只替换表达措辞做到不查重,骨架与卖点角度都保留。"
|
||||
),
|
||||
"mid": (
|
||||
"【裂变参考度=中】请保留下面这篇原爆款的核心卖点,但换一个全新的叙事角度/切入场景重写,"
|
||||
"结构可调整,让读者看不出是同一套路。"
|
||||
),
|
||||
"low": (
|
||||
"【裂变参考度=低】请只借鉴下面这篇原爆款的选题方向与目标人群,"
|
||||
"文案结构、卖点呈现、叙事全部自由发挥,做出明显差异化的新笔记。"
|
||||
),
|
||||
}
|
||||
# 三档→数值映射(保留枚举入参,内部走连续值逻辑,对齐上线版 referenceStrategyFromLevel)
|
||||
_LEVEL_TO_INT = {"low": 35, "mid": 60, "high": 82}
|
||||
|
||||
|
||||
def build_fission_context(source_note: dict, reference_level: str) -> str:
|
||||
def reference_strategy_from_level(level: str | int) -> dict:
|
||||
"""参考度策略(对齐上线版 referenceStrategyFromLevel)。
|
||||
|
||||
入参支持枚举 low/mid/high 或数值;钳到 30-85。
|
||||
返回 {level, level_label, prompt_rule, summary}。
|
||||
"""
|
||||
返回注入 text_variants(flywheel_context=) 的档位规则字符串。
|
||||
source_note: {title, content, ...} 原爆款笔记内容。
|
||||
reference_level: high/mid/low,非法值回落 mid。
|
||||
"""
|
||||
rule = _LEVEL_RULES.get(reference_level, _LEVEL_RULES["mid"])
|
||||
title = (source_note or {}).get("title", "")
|
||||
content = (source_note or {}).get("content", "")
|
||||
src = f"原爆款标题:{title}\n原爆款正文:{content}".strip()
|
||||
return f"{rule}\n\n--- 原爆款参考 ---\n{src}"
|
||||
if isinstance(level, str):
|
||||
value = _LEVEL_TO_INT.get(level, 60)
|
||||
else:
|
||||
try:
|
||||
value = int(level)
|
||||
except (TypeError, ValueError):
|
||||
value = 60
|
||||
value = max(30, min(85, value))
|
||||
if value <= 40:
|
||||
return {
|
||||
"level": value, "level_label": "低参考",
|
||||
"prompt_rule": "只参考爆款的内容结构和图文角色,不沿用原文表达、标题句式和具体画面。",
|
||||
"summary": "参考爆款结构,不贴近原文表达。",
|
||||
}
|
||||
if value >= 80:
|
||||
return {
|
||||
"level": value, "level_label": "高参考",
|
||||
"prompt_rule": "强参考爆款的标题节奏、痛点切入、图文递进和情绪强度,但必须替换人群、场景、表达和图片,不得抄袭。",
|
||||
"summary": "强参考爆点、标题节奏和图文递进,但替换表达避免相似。",
|
||||
}
|
||||
return {
|
||||
"level": value, "level_label": "中参考",
|
||||
"prompt_rule": "参考爆款的结构、痛点表达方式和标题节奏,同时重写正文、标签和每张图片画面。",
|
||||
"summary": "参考结构、痛点和标题节奏,输出新的完整笔记包。",
|
||||
}
|
||||
|
||||
|
||||
def valid_level(level: str | None) -> str:
|
||||
"""校验档位,非法回落 mid。"""
|
||||
return level if level in _LEVEL_RULES else "mid"
|
||||
"""校验三档枚举,非法回落 mid(保留旧接口兼容)。"""
|
||||
return level if level in _LEVEL_TO_INT else "mid"
|
||||
|
||||
|
||||
# 默认裂变维度(对齐上线版 dimensions)
|
||||
DEFAULT_DIMENSIONS = ["换角度", "换痛点", "换人群"]
|
||||
|
||||
FISSION_SYSTEM = """你是小红书完整图文笔记裂变专家。
|
||||
|
||||
你必须基于爆款参考生成"完整小红书笔记包",不是只生成文案,也不是只生成图片提示词。
|
||||
|
||||
完整笔记包必须包含:标题(可直接发布)、正文(180-260字种草口吻真实场景卖点转买点)、标签(5-8个)、点击钩子标题(第1张图大字)、imagePlan(每张图的图上文字+画面内容+排版)、dimension(裂变维度)、keywords、audience(适用人群)、scene(使用场景)、painPoint(切入痛点)。
|
||||
|
||||
裂变规则:
|
||||
- 每套必须不重复,标题/正文/标签/图文结构都要变,图片重新配套不可一套图反复发
|
||||
- 保持种草安利+情绪共鸣风格
|
||||
- 正文自然出现2-5个小红书符号/emoji(✅✨🌿💧📦🔍🧡🥹‼️),放在痛点/实测/选择理由/软性转化处,不堆砌不每句塞
|
||||
- 标题可适度带符号,但不要所有标题同一种符号
|
||||
- 图片=可上传的独立3:4图文海报,不是App截图/笔记详情页截图
|
||||
- 图片禁止出现Like/评论/分享/底栏/头像/状态栏等社交App界面元素
|
||||
- 对比页只做质地/场景/肤感说明对比,禁用前后、before/after、变白、瑕疵消失、治疗前后
|
||||
- imagePlan只写短标题/短卖点/短画面要求,不塞长正文
|
||||
- 禁用词:美白、祛斑、速效、医用、药妆、变白、before、after、使用前后
|
||||
- 图文张数叙事:3张=点击→核心证明→软性转化;6张=点击→痛点→证明→质感→背书→软性转化;8张=点击→痛点→证明→质感→多场景→教程→背书→软性转化
|
||||
- 最后一张是软性转化,不做淘宝式硬广;用囤货/省钱情报/搜索暗示/评论暗号等原生分享口吻
|
||||
|
||||
返回纯JSON数组,每个元素含:title/content/tags(数组)/coverTitle/dimension/audience/scene/painPoint/keywords(数组)/imagePlan(数组,每项{role,title,overlayText,text})。
|
||||
|
||||
硬性格式要求:
|
||||
- 只输出JSON,不要markdown代码块
|
||||
- 字符串内部不用英文双引号,引用词用中文书名号或中文引号
|
||||
- content是客户可直接发布的正文,不能写"配图建议/图片方向/imagePlan/内页规划"等内部提示
|
||||
- imagePlan数量必须等于用户要求的图片数量"""
|
||||
|
||||
|
||||
import re
|
||||
|
||||
# 违禁词清洗替换表(对齐上线版 sanitizeImagePlanText,有序应用)
|
||||
_SANITIZE_RULES = [
|
||||
(re.compile(r"before\s*&\s*after", re.I), "质地与肤感说明"),
|
||||
(re.compile(r"before\s*/?\s*after", re.I), "质地与肤感说明"),
|
||||
(re.compile(r"\bbefore\b", re.I), "质地状态"),
|
||||
(re.compile(r"\bafter\b", re.I), "上脸肤感"),
|
||||
(re.compile(r"使用前后|用前用后|用前后|前后对比|使用前|使用后"), "质地/场景/肤感说明"),
|
||||
(re.compile(r"功效对比|效果对比|改善对比"), "质地/场景说明对比"),
|
||||
(re.compile(r"肤色变白|皮肤变白|变白|美白"), "自然光泽感"),
|
||||
(re.compile(r"瑕疵消失|斑点消失|痘印消失|消除瑕疵|祛斑"), "妆感更服帖"),
|
||||
(re.compile(r"治疗前后|治疗后|医美前后|治愈|修复受损"), "日常使用场景说明"),
|
||||
]
|
||||
|
||||
|
||||
def sanitize_image_plan_text(value: str = "", max_length: int = 56) -> str:
|
||||
"""清洗 imagePlan 文字里的违禁词(对齐上线版)。"""
|
||||
text = str(value or "")
|
||||
for pattern, repl in _SANITIZE_RULES:
|
||||
text = pattern.sub(repl, text)
|
||||
text = re.sub(r"\s+", " ", text).strip()
|
||||
return text[:max_length]
|
||||
|
||||
|
||||
def normalize_tags(tags=None, keywords=None) -> list[str]:
|
||||
"""标签归一化:补#前缀、去重、截8个(对齐上线版 normalizeTags)。"""
|
||||
tags = tags or []
|
||||
keywords = keywords or []
|
||||
if not isinstance(tags, list):
|
||||
tags = str(tags).split()
|
||||
from_tags = [
|
||||
t if str(t).strip().startswith("#") else f"#{str(t).strip()}"
|
||||
for t in tags if str(t).strip()
|
||||
]
|
||||
from_kw = [
|
||||
k if str(k).startswith("#") else f"#{k}"
|
||||
for k in list(keywords)[:5] if str(k).strip()
|
||||
]
|
||||
seen, out = set(), []
|
||||
for t in from_tags + from_kw:
|
||||
if t not in seen:
|
||||
seen.add(t)
|
||||
out.append(t)
|
||||
return out[:8]
|
||||
|
||||
|
||||
def build_fission_prompt(
|
||||
source_note: dict, product: dict, reference_level: str,
|
||||
note_count: int, image_count: int, dimensions: list[str] | None = None,
|
||||
) -> str:
|
||||
"""组装裂变 user prompt(对齐上线版 handleContentSplit 的 prompt 变量拼装)。"""
|
||||
src = source_note or {}
|
||||
prod = product or {}
|
||||
dims = dimensions or DEFAULT_DIMENSIONS
|
||||
strategy = reference_strategy_from_level(reference_level)
|
||||
title = src.get("title", "")
|
||||
content = src.get("content") or src.get("text", "")
|
||||
tags = src.get("tags", []) or []
|
||||
name = prod.get("name", "") or "未提供"
|
||||
points = prod.get("selling_points", []) or []
|
||||
audience = prod.get("target_audience", "") or "未提供"
|
||||
keywords = prod.get("keywords", []) or []
|
||||
kw_line = "、".join(keywords) if keywords else "、".join(
|
||||
[x for x in [name, audience, "真实测评", "好物分享"] if x and x != "未提供"]
|
||||
)
|
||||
return f"""爆款参考:
|
||||
标题:{title}
|
||||
正文:{content}
|
||||
标签:{' '.join(tags)}
|
||||
|
||||
产品:{name}
|
||||
产品卖点:{'、'.join(points) or '未提供'}
|
||||
目标人群:{audience}
|
||||
关键词:{kw_line}
|
||||
裂变维度:{'、'.join(dims)}
|
||||
爆款参考度:{strategy['level_label']}。{strategy['prompt_rule']}
|
||||
生成数量:{note_count}套
|
||||
每套图片数量:{image_count}张
|
||||
|
||||
请生成{note_count}套完整小红书图文笔记包。每套都必须含标题、正文、标签、点击钩子标题、{image_count}张图的imagePlan。
|
||||
imagePlan必须按叙事链路逐张递进。3张版第2张必须是按当前品类变化的核心证明页,不能和第1张重复构图,不得让第2张重复第1张标题。
|
||||
正文必须像真实小红书种草笔记一样自然带2-5个emoji;不要把图片规划/配图建议/内部审核建议写进正文。"""
|
||||
|
||||
|
||||
def notes_array_from_parsed(parsed) -> list[dict]:
|
||||
"""从LLM解析结果里拎出笔记数组(对齐上线版 notesArrayFromParsed)。"""
|
||||
if isinstance(parsed, list):
|
||||
return parsed
|
||||
if not isinstance(parsed, dict):
|
||||
return []
|
||||
for key in ("notes", "variants", "data", "items", "result", "results"):
|
||||
if isinstance(parsed.get(key), list):
|
||||
return parsed[key]
|
||||
return [parsed] if (parsed.get("title") or parsed.get("content")) else []
|
||||
|
||||
81
backend/app/services/fission_images.py
Normal file
81
backend/app/services/fission_images.py
Normal file
@@ -0,0 +1,81 @@
|
||||
"""
|
||||
app/services/fission_images.py — 第11环裂变 逐套生图(从 fission_pipeline 拆出)
|
||||
|
||||
复用单篇 generate_storyboard_images(codeproxy edits 带产品参考图),结果存
|
||||
FissionNote.images_json。拆分原因:fission_pipeline.py 超 200 行红线上限。
|
||||
🔴 生图引擎零改动;串行跑各套(套内 storyboard 已 asyncio.gather 并发)避免打爆中转站。
|
||||
"""
|
||||
import logging
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def generate_fission_images(
|
||||
db: Session, clients, ft, product: dict, image_count: int, note_ids: list[int],
|
||||
) -> None:
|
||||
"""逐套生图,结果存 FissionNote.images_json。
|
||||
|
||||
每套用该套自己的 note_json(含定制 imagePlan)当 note 喂生图引擎。
|
||||
产品参考图沿用源产品同一张,与单篇一致。
|
||||
"""
|
||||
import asyncio
|
||||
import json as _json
|
||||
import os
|
||||
|
||||
from app.models.fission import FissionNote
|
||||
from app.core.config import get_settings
|
||||
from app.services.ai_engine.image_gen import generate_storyboard_images
|
||||
from app.services.ai_engine.image_postprocessor import process_image
|
||||
from app.workers.pipeline_io import _resolve_image_path
|
||||
|
||||
reference_images = None
|
||||
_img_path = _resolve_image_path(product.get("image_path", ""))
|
||||
if _img_path and os.path.isfile(_img_path):
|
||||
try:
|
||||
with open(_img_path, "rb") as f:
|
||||
reference_images = [f.read()]
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("裂变参考图读取失败,无参考图模式: %s", e)
|
||||
|
||||
upload_base = get_settings().UPLOAD_ABS_ROOT
|
||||
for nid in note_ids:
|
||||
fn = db.query(FissionNote).filter(FissionNote.id == nid).first()
|
||||
if not fn:
|
||||
continue
|
||||
try:
|
||||
note = _json.loads(fn.note_json) if fn.note_json else {}
|
||||
except (ValueError, TypeError):
|
||||
note = {}
|
||||
try:
|
||||
results = asyncio.run(generate_storyboard_images(
|
||||
client=clients, note=note, product=product,
|
||||
image_count=image_count, reference_images=reference_images,
|
||||
))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.error("裂变套 seq=%s 生图失败: %s", fn.seq, exc)
|
||||
fn.status = "failed"; db.commit()
|
||||
continue
|
||||
|
||||
img_dir = os.path.join(upload_base, str(ft.workspace_id), f"fission_{ft.id}", str(fn.seq))
|
||||
os.makedirs(img_dir, exist_ok=True)
|
||||
images: list[dict] = []
|
||||
for i, r in enumerate(results):
|
||||
if r.get("error") or not r.get("image_bytes"):
|
||||
images.append({"role": r.get("role", ""), "error": str(r.get("error", "生图失败"))[:64]})
|
||||
continue
|
||||
try:
|
||||
processed = process_image(r["image_bytes"], aspect_ratio="3:4", resample_strength=1)
|
||||
except Exception: # noqa: BLE001
|
||||
processed = r["image_bytes"]
|
||||
fname = f"{i + 1:02d}_{r['role']}.jpg"
|
||||
with open(os.path.join(img_dir, fname), "wb") as f:
|
||||
f.write(processed)
|
||||
images.append({
|
||||
"role": r["role"], "seq": i + 1,
|
||||
"url": f"/uploads/{ft.workspace_id}/fission_{ft.id}/{fn.seq}/{fname}",
|
||||
})
|
||||
fn.images_json = _json.dumps(images, ensure_ascii=False)
|
||||
fn.status = "image_done"
|
||||
db.commit()
|
||||
179
backend/app/services/fission_pipeline.py
Normal file
179
backend/app/services/fission_pipeline.py
Normal file
@@ -0,0 +1,179 @@
|
||||
"""
|
||||
app/services/fission_pipeline.py — 第11环裂变 编排层(从 fission_service 拆出)
|
||||
|
||||
Celery 内调用:一次 LLM 出 N 套完整笔记包 → 解析/兜底 → 评分@80 → 落 FissionNote
|
||||
→ 逐套生图存 images_json。拆分原因:fission_service.py 超 200 行红线上限。
|
||||
🔴 生图引擎零改动:复用单篇 generate_storyboard_images(codeproxy edits 带产品参考图)。
|
||||
🔴 评分沿用 llm_score_copy,合格线 QUALITY_PASS_SCORE=80(禁改)。
|
||||
🔴 FISSION_SYSTEM 作 messages[0].role=system 传(chat_complete 是 OpenAI 兼容)。
|
||||
"""
|
||||
import logging
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MAX_FANOUT = 5 # 每用户并发上限5(红线);裂变 N 套直出受同等约束
|
||||
|
||||
|
||||
def _parse_fission_response(
|
||||
raw: str, source_note: dict, product: dict, note_count: int, image_count: int,
|
||||
) -> list[dict]:
|
||||
"""解析 LLM 返回的 N 套笔记;不可解析则品类兜底(不卡用户)。
|
||||
|
||||
解析链:parse_json_array(容错markdown) → notes_array_from_parsed(拎数组) →
|
||||
空则 build_fallback_notes 品类草稿。每套补 imagePlan/tags 归一化。
|
||||
"""
|
||||
import json as _json
|
||||
from app.services.ai_engine._text_prompt import parse_json_array
|
||||
from app.services.ai_engine.fission_prompt import (
|
||||
normalize_tags, notes_array_from_parsed,
|
||||
)
|
||||
from app.services.ai_engine.fission_fallback import (
|
||||
build_fallback_image_plan, build_fallback_notes,
|
||||
)
|
||||
|
||||
notes = notes_array_from_parsed(parse_json_array(raw))
|
||||
if not notes:
|
||||
# parse_json_array 只认数组;再试整段当对象解析(容错单对象返回)
|
||||
try:
|
||||
notes = notes_array_from_parsed(_json.loads(raw))
|
||||
except (ValueError, TypeError):
|
||||
notes = []
|
||||
if not notes:
|
||||
logger.warning("裂变 LLM 返回不可解析,启用品类兜底。raw[:120]=%s", str(raw)[:120])
|
||||
return build_fallback_notes(source_note, product, note_count, image_count)
|
||||
|
||||
out: list[dict] = []
|
||||
for n in notes:
|
||||
if not isinstance(n, dict):
|
||||
continue
|
||||
n["tags"] = normalize_tags(n.get("tags", []), n.get("keywords", []))
|
||||
plan = n.get("imagePlan")
|
||||
if not isinstance(plan, list) or len(plan) != image_count:
|
||||
n["imagePlan"] = build_fallback_image_plan(n, image_count)
|
||||
out.append(n)
|
||||
return out or build_fallback_notes(source_note, product, note_count, image_count)
|
||||
|
||||
|
||||
def _score_notes(clients, notes: list[dict], source_note: dict, banned: list[str]) -> None:
|
||||
"""对每套笔记 LLM 评分(限2并发),结果就地写回 note['_score']/_passed/_block。
|
||||
|
||||
Celery 同步环境:用 asyncio.run 跑一个内部 gather(信号量限2并发,
|
||||
对齐生图限流,避免中转站 429)。
|
||||
"""
|
||||
import asyncio
|
||||
from app.services.ai_engine.llm_scorer import llm_score_copy
|
||||
from app.services.ai_engine.constants import QUALITY_PASS_SCORE
|
||||
|
||||
async def _run() -> None:
|
||||
sem = asyncio.Semaphore(2)
|
||||
|
||||
async def _one(note: dict) -> None:
|
||||
copy = {
|
||||
"title": note.get("title", ""),
|
||||
"content": note.get("content", ""),
|
||||
"tags": note.get("tags", []),
|
||||
}
|
||||
async with sem:
|
||||
try:
|
||||
r = await llm_score_copy(
|
||||
clients, copy, source_note, banned, pass_score=QUALITY_PASS_SCORE,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning("裂变评分异常,记0分不达标: %s", exc)
|
||||
r = {"score": 0, "passed": False, "banned_words_found": []}
|
||||
note["_score"] = int(r.get("score", 0))
|
||||
note["_block"] = bool(r.get("banned_words_found"))
|
||||
# 过线 = score≥80 且无硬拦违禁词
|
||||
note["_passed"] = bool(r.get("passed")) and not note["_block"]
|
||||
|
||||
await asyncio.gather(*(_one(n) for n in notes))
|
||||
|
||||
asyncio.run(_run())
|
||||
|
||||
|
||||
def execute_fission_pipeline(db: Session, clients, fission_id: int, source_task_id: int) -> dict:
|
||||
"""裂变主编排(Celery 内调用,clients 已构建好)。
|
||||
|
||||
流程:查 FissionTask+源产品 → 一次 chat_complete 出 N 套 → 解析/兜底
|
||||
→ 每套评分@80 → 按分排序取 N 套(不够用兜底补,不达标标 needs_optimization)
|
||||
→ 落 FissionNote → 逐套生图存 images_json → 回写 FissionTask.status。
|
||||
"""
|
||||
import json as _json
|
||||
|
||||
from app.models.fission import FissionTask, FissionNote
|
||||
from app.models.task import GenerationTask
|
||||
from app.models.product import Product
|
||||
from app.workers.pipeline_steps import build_product_dict
|
||||
from app.services.ai_engine.fission_prompt import build_fission_prompt
|
||||
|
||||
ft = db.query(FissionTask).filter(FissionTask.id == fission_id).first()
|
||||
if not ft:
|
||||
return {"fission_id": fission_id, "status": "not_found"}
|
||||
|
||||
n = max(1, min(ft.fanout_count or 3, MAX_FANOUT))
|
||||
try:
|
||||
source_note = _json.loads(ft.source_note) if ft.source_note else {}
|
||||
except (ValueError, TypeError):
|
||||
source_note = {}
|
||||
|
||||
src = db.query(GenerationTask).filter(GenerationTask.id == source_task_id).first()
|
||||
product_row = db.query(Product).filter(Product.id == src.product_id).first() if src else None
|
||||
if not src or not product_row:
|
||||
ft.status = "failed"; db.commit()
|
||||
return {"fission_id": fission_id, "status": "failed", "reason": "源任务或产品缺失"}
|
||||
|
||||
product = build_product_dict(product_row)
|
||||
image_count = max(1, src.image_count or 3)
|
||||
banned = [] # 违禁词分级表后续接入;评分器内置 BANNED_WORDS_DEFAULT 已兜底
|
||||
|
||||
# 一次 LLM 出 N 套(FISSION_SYSTEM 作 messages[0].role=system)
|
||||
from app.services.ai_engine.fission_prompt import FISSION_SYSTEM
|
||||
user_prompt = build_fission_prompt(
|
||||
source_note, product, ft.reference_level, n, image_count,
|
||||
)
|
||||
# max_tokens 按套数缩放:每套完整笔记包约 1200 token,留足余量
|
||||
max_tokens = min(8192, 1800 + n * 1400)
|
||||
raw = ""
|
||||
try:
|
||||
import asyncio
|
||||
raw = asyncio.run(clients.chat_complete(
|
||||
messages=[{"role": "system", "content": FISSION_SYSTEM},
|
||||
{"role": "user", "content": user_prompt}],
|
||||
model=clients._model, max_tokens=max_tokens, temperature=0.8,
|
||||
))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.warning("裂变 LLM 调用失败,启用品类兜底: %s", exc)
|
||||
|
||||
notes = _parse_fission_response(raw, source_note, product, n, image_count)
|
||||
_score_notes(clients, notes, source_note, banned)
|
||||
|
||||
# 排序:过线优先,再按分降序;取前 N 套(不足用兜底草稿补齐)
|
||||
notes.sort(key=lambda x: (x.get("_passed", False), x.get("_score", 0)), reverse=True)
|
||||
chosen = notes[:n]
|
||||
|
||||
saved_ids: list[int] = []
|
||||
for seq, note in enumerate(chosen):
|
||||
passed = bool(note.get("_passed"))
|
||||
fn = FissionNote(
|
||||
fission_id=fission_id, workspace_id=ft.workspace_id, seq=seq,
|
||||
note_json=_json.dumps(note, ensure_ascii=False),
|
||||
score=int(note.get("_score", 0)),
|
||||
passed=1 if passed else 0,
|
||||
needs_optimization=0 if passed else 1, # 不达标不丢弃,标降级草稿
|
||||
dimension=str(note.get("dimension", ""))[:64],
|
||||
status="scored",
|
||||
)
|
||||
db.add(fn); db.flush()
|
||||
saved_ids.append(fn.id)
|
||||
db.commit()
|
||||
|
||||
logger.info("裂变出 %s 套已落库 fission=%s ids=%s", len(saved_ids), fission_id, saved_ids)
|
||||
|
||||
# 逐套生图(复用单篇引擎),存 images_json
|
||||
from app.services.fission_images import generate_fission_images
|
||||
generate_fission_images(db, clients, ft, product, image_count, saved_ids)
|
||||
|
||||
ft.status = "completed"; db.commit()
|
||||
return {"fission_id": fission_id, "status": "completed", "note_ids": saved_ids}
|
||||
@@ -1,9 +1,10 @@
|
||||
"""
|
||||
app/services/fission_service.py — 第11环裂变 扇出 service
|
||||
app/services/fission_service.py — 第11环裂变 service 入口(对齐上线版 split.js)
|
||||
|
||||
1爆款 → N套完整笔记包。每套=一个独立 GenerationTask(回填 source_fission_id),
|
||||
复用主管道 run_generation_pipeline 生成文案+配图。
|
||||
🔴 占位档位规则见 fission_prompt.py,真实措辞待北哥定义(倩倩姐2026-06-16先打通)。
|
||||
裂变 = 1爆款 → 一次 LLM 出 N 套完整笔记包(N=1~5)。
|
||||
本文件只留 API 入口:
|
||||
- create_fission:建 FissionTask 后丢 run_fission_pipeline.delay,不再扇出 GenerationTask
|
||||
编排层(解析/评分/落库/生图)在 app/services/fission_pipeline.py(红线≤200行已拆)。
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
@@ -15,11 +16,11 @@ from app.middleware.workspace_guard import CurrentUser
|
||||
from app.models.fission import FissionTask
|
||||
from app.models.task import GenerationTask
|
||||
from app.services.ai_engine.fission_prompt import valid_level
|
||||
from app.services.task_service import _check_user_has_key, enqueue_generation
|
||||
from app.services.task_service import _check_user_has_key
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
MAX_FANOUT = 5 # 每用户并发上限5(红线),裂变扇出数受同等约束
|
||||
MAX_FANOUT = 5 # 每用户并发上限5(红线);裂变 N 套直出受同等约束
|
||||
|
||||
|
||||
def create_fission(
|
||||
@@ -27,15 +28,16 @@ def create_fission(
|
||||
source_task_id: int, reference_level: str, fanout_count: int,
|
||||
) -> tuple[int, list[int]]:
|
||||
"""
|
||||
从一个源任务裂变出 N 套子任务。
|
||||
返回 (fission_id, [子task_id,...])。
|
||||
从一个源任务建裂变任务,丢 Celery 一次出 N 套笔记包。
|
||||
返回 (fission_id, [])。子笔记落 FissionNote,不再扇出 GenerationTask,
|
||||
故第二个返回值恒为空列表(保留签名兼容旧调用方)。
|
||||
"""
|
||||
_check_user_has_key(db, current_user.user_id, current_user.workspace_id)
|
||||
|
||||
level = valid_level(reference_level)
|
||||
n = max(1, min(fanout_count or 3, MAX_FANOUT))
|
||||
|
||||
# 读源任务(取产品+主题+已选文案作为爆款源)
|
||||
# 读源任务(取产品+主题+已选文案作为爆款源)
|
||||
src = db.query(GenerationTask).filter(
|
||||
GenerationTask.id == source_task_id,
|
||||
GenerationTask.workspace_id == current_user.workspace_id,
|
||||
@@ -45,7 +47,7 @@ def create_fission(
|
||||
|
||||
source_note = {"title": src.theme or "", "content": _extract_source_copy(db, src)}
|
||||
|
||||
# 建 fission_task 记录
|
||||
# 建 fission_task 记录(带 source_task_id 供 pipeline 取产品/张数)
|
||||
ft = FissionTask(
|
||||
workspace_id=current_user.workspace_id,
|
||||
source_note=json.dumps(source_note, ensure_ascii=False),
|
||||
@@ -53,27 +55,17 @@ def create_fission(
|
||||
)
|
||||
db.add(ft); db.commit(); db.refresh(ft)
|
||||
|
||||
# 扇出 N 个子任务(回填 source_fission_id),复用主管道
|
||||
task_ids: list[int] = []
|
||||
for _ in range(n):
|
||||
sub = GenerationTask(
|
||||
workspace_id=current_user.workspace_id,
|
||||
product_id=src.product_id, operator_id=current_user.user_id,
|
||||
theme=src.theme, text_count=src.text_count, image_count=src.image_count,
|
||||
track="ai", need_product_image=src.need_product_image,
|
||||
status="pending", source_fission_id=ft.id,
|
||||
)
|
||||
db.add(sub); db.commit(); db.refresh(sub)
|
||||
enqueue_generation(sub.id)
|
||||
task_ids.append(sub.id)
|
||||
# 丢 Celery:一次 LLM 出 N 套(不扇出 GenerationTask)
|
||||
from app.workers.tasks import run_fission_pipeline
|
||||
run_fission_pipeline.delay(ft.id, source_task_id)
|
||||
|
||||
logger.info("fission created id=%s level=%s fanout=%s tasks=%s",
|
||||
ft.id, level, n, task_ids)
|
||||
return ft.id, task_ids
|
||||
logger.info("fission created id=%s level=%s n=%s src_task=%s",
|
||||
ft.id, level, n, source_task_id)
|
||||
return ft.id, []
|
||||
|
||||
|
||||
def _extract_source_copy(db: Session, src: GenerationTask) -> str:
|
||||
"""取源任务已选/最高分文案当爆款正文;无则用主题兜底。"""
|
||||
"""取源任务已选/最高分文案当爆款正文;无则用主题兜底。"""
|
||||
from app.models.task import TextCandidate
|
||||
c = (db.query(TextCandidate)
|
||||
.filter(TextCandidate.task_id == src.id)
|
||||
|
||||
Reference in New Issue
Block a user