第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:
yangqianqian
2026-06-18 11:17:37 +08:00
parent 7f419f4c8b
commit d85dcd401b
18 changed files with 1772 additions and 106 deletions

View File

@@ -0,0 +1,57 @@
"""create fission_notes table
Revision ID: 018
Revises: 017
Create Date: 2026-06-18
裂变重写(对齐上线版 split.js一次LLM出N套完整笔记包每套落一行。
每套含 标题/正文/标签/封面/imagePlan/裂变维度,带评分+生图结果。
"""
from alembic import op
import sqlalchemy as sa
revision = "018"
down_revision = "017"
branch_labels = None
depends_on = None
def upgrade():
op.create_table(
"fission_notes",
sa.Column("id", sa.BigInteger, primary_key=True, autoincrement=True),
sa.Column(
"fission_id", sa.BigInteger,
sa.ForeignKey("fission_tasks.id", ondelete="CASCADE"),
nullable=False,
),
sa.Column(
"workspace_id", sa.BigInteger,
sa.ForeignKey("workspaces.id", ondelete="CASCADE"),
nullable=False, comment="多租户隔离",
),
sa.Column("seq", sa.Integer, nullable=False, server_default="0",
comment="第几套0起"),
sa.Column("note_json", sa.Text, nullable=True, comment="完整笔记包JSON"),
sa.Column("images_json", sa.Text, nullable=True, comment="生图结果JSON"),
sa.Column("score", sa.Integer, nullable=False, server_default="0"),
sa.Column("passed", sa.Integer, nullable=False, server_default="0",
comment="是否过线(>=80)"),
sa.Column("needs_optimization", sa.Integer, nullable=False,
server_default="0", comment="不达标降级草稿标记"),
sa.Column("dimension", sa.String(64), nullable=True,
comment="裂变维度: 换角度/换痛点/换人群"),
sa.Column("status", sa.String(20), nullable=False,
server_default="pending",
comment="状态: pending/scored/image_done/failed"),
sa.Column("created_at", sa.DateTime, server_default=sa.func.now(),
nullable=False),
)
op.create_index("idx_fission_notes_fission_id", "fission_notes", ["fission_id"])
op.create_index("idx_fission_notes_workspace_id", "fission_notes", ["workspace_id"])
def downgrade():
op.drop_index("idx_fission_notes_workspace_id", table_name="fission_notes")
op.drop_index("idx_fission_notes_fission_id", table_name="fission_notes")
op.drop_table("fission_notes")

View File

@@ -1,10 +1,12 @@
""" """
app/api/v1/fission.py — 第11环裂变路由 app/api/v1/fission.py — 第11环裂变路由(对齐上线版 split.js一次出N套
POST /fission 触发裂变(1源任务→N套子任务) POST /fission 触发裂变(1源任务→一次LLM出N套完整笔记包)
GET /fission/{id} 查裂变进度(聚合N个子任务状态) GET /fission/{id} 查裂变进度(聚合 FissionNote 落库/生图状态)
工作台内呈现,子任务走标准 SSE 流回进度。 GET /fission/{id}/notes 取 N 套完整笔记包(独立展示页用)
工作台内呈现N 套笔记落 FissionNote 表,非扇出 GenerationTask。
""" """
import json
import logging import logging
from typing import Annotated from typing import Annotated
@@ -15,8 +17,7 @@ from sqlalchemy.orm import Session
from app.core.database import get_db from app.core.database import get_db
from app.core.response import ok, raise_not_found from app.core.response import ok, raise_not_found
from app.middleware.workspace_guard import CurrentUser, require_write_permission from app.middleware.workspace_guard import CurrentUser, require_write_permission
from app.models.fission import FissionTask from app.models.fission import FissionTask, FissionNote
from app.models.task import GenerationTask
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
router = APIRouter(tags=["fission"]) router = APIRouter(tags=["fission"])
@@ -34,13 +35,13 @@ def create_fission_endpoint(
current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None, current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None,
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
"""触发裂变:从源任务出 N 套完整笔记包。返回 fission_id + 子 task_ids""" """触发裂变:从源任务一次出 N 套完整笔记包。返回 fission_id。"""
from app.services.fission_service import create_fission from app.services.fission_service import create_fission
fission_id, task_ids = create_fission( fission_id, _ = create_fission(
db, current_user, body.source_task_id, body.reference_level, body.fanout_count, db, current_user, body.source_task_id, body.reference_level, body.fanout_count,
) )
return ok({"fission_id": fission_id, "task_ids": task_ids, return ok({"fission_id": fission_id,
"reference_level": body.reference_level, "fanout_count": len(task_ids)}) "reference_level": body.reference_level, "fanout_count": body.fanout_count})
@router.get("/fission/{fission_id}") @router.get("/fission/{fission_id}")
@@ -49,7 +50,7 @@ def get_fission_progress(
current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None, current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None,
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
"""查裂变进度:聚合 N 个子任务状态x/N 完成)。""" """查裂变进度:聚合 FissionNote 落库/生图状态x/N 完成)。"""
ft = db.query(FissionTask).filter( ft = db.query(FissionTask).filter(
FissionTask.id == fission_id, FissionTask.id == fission_id,
FissionTask.workspace_id == current_user.workspace_id, FissionTask.workspace_id == current_user.workspace_id,
@@ -57,22 +58,60 @@ def get_fission_progress(
if not ft: if not ft:
raise_not_found("裂变任务不存在") raise_not_found("裂变任务不存在")
subs = db.query(GenerationTask).filter( notes = db.query(FissionNote).filter(
GenerationTask.source_fission_id == fission_id, FissionNote.fission_id == fission_id,
GenerationTask.workspace_id == current_user.workspace_id, FissionNote.workspace_id == current_user.workspace_id,
).all() ).all()
done_states = {"pending_selection", "pending_review", "approved", "archived"} done = sum(1 for n in notes if n.status == "image_done")
done = sum(1 for s in subs if s.status in done_states) failed = sum(1 for n in notes if n.status == "failed")
failed = sum(1 for s in subs if s.status == "failed") total = len(notes)
total = len(subs) # FissionTask.status 由 pipeline 维护(generating→completed/failed);此处只读不改,
# 聚合状态:全失败=failed,全完成=done,否则generating # 避免 API 轮询与 worker 写状态打架。进度数 = 已落库套数的生图完成度。
agg = "done" if done == total and total > 0 else ("failed" if failed == total and total > 0 else "generating")
if ft.status != agg:
ft.status = agg; db.commit()
return ok({ return ok({
"fission_id": ft.id, "reference_level": ft.reference_level, "fission_id": ft.id, "reference_level": ft.reference_level,
"fanout_count": ft.fanout_count, "status": agg, "fanout_count": ft.fanout_count, "status": ft.status,
"progress": {"done": done, "failed": failed, "total": total}, "progress": {"done": done, "failed": failed, "total": total},
"task_ids": [s.id for s in subs],
}) })
@router.get("/fission/{fission_id}/notes")
def get_fission_notes(
fission_id: int,
current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None,
db: Session = Depends(get_db),
):
"""取 N 套完整笔记包(独立展示页用):每套含文案+标签+imagePlan+图+分数。"""
ft = db.query(FissionTask).filter(
FissionTask.id == fission_id,
FissionTask.workspace_id == current_user.workspace_id,
).first()
if not ft:
raise_not_found("裂变任务不存在")
rows = db.query(FissionNote).filter(
FissionNote.fission_id == fission_id,
FissionNote.workspace_id == current_user.workspace_id,
).order_by(FissionNote.seq.asc()).all()
notes = []
for r in rows:
try:
note = json.loads(r.note_json) if r.note_json else {}
except (ValueError, TypeError):
note = {}
try:
images = json.loads(r.images_json) if r.images_json else []
except (ValueError, TypeError):
images = []
# 内部评分中间字段(_score/_passed/_block)不外泄,前端用平铺的 score/passed
for k in ("_score", "_passed", "_block"):
note.pop(k, None)
notes.append({
"seq": r.seq, "note": note, "images": images,
"score": r.score, "passed": bool(r.passed),
"needs_optimization": bool(r.needs_optimization),
"dimension": r.dimension, "status": r.status,
})
return ok({"fission_id": ft.id, "status": ft.status,
"fanout_count": ft.fanout_count, "notes": notes})

View File

@@ -51,3 +51,53 @@ class FissionTask(Base):
__table_args__ = ( __table_args__ = (
Index("idx_fission_tasks_workspace_id", "workspace_id"), Index("idx_fission_tasks_workspace_id", "workspace_id"),
) )
class FissionNote(Base):
"""裂变产出的单套完整笔记包一次LLM出N套每套落一行
对齐上线版 split.js handleContentSplit每套含 标题/正文/标签/封面/imagePlan
带评分+裂变维度+生图结果。北哥在独立展示页查看。
"""
__tablename__ = "fission_notes"
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
fission_id: Mapped[int] = mapped_column(
BigInteger, ForeignKey("fission_tasks.id", ondelete="CASCADE"), nullable=False
)
workspace_id: Mapped[int] = mapped_column(
BigInteger, ForeignKey("workspaces.id", ondelete="CASCADE"), nullable=False
)
seq: Mapped[int] = mapped_column(
Integer, default=0, nullable=False, comment="第几套0起"
)
# 完整笔记包 JSONtitle/content/tags/coverTitle/imagePlan/dimension/audience/scene/painPoint/keywords
note_json: Mapped[str | None] = mapped_column(
Text, comment="完整笔记包JSON"
)
# 生图结果 JSON[{role,name,image_url,error}...]
images_json: Mapped[str | None] = mapped_column(
Text, comment="生图结果JSON"
)
score: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
passed: Mapped[bool] = mapped_column(
Integer, default=0, nullable=False, comment="是否过线(>=80)"
)
needs_optimization: Mapped[bool] = mapped_column(
Integer, default=0, nullable=False, comment="不达标降级草稿标记"
)
dimension: Mapped[str | None] = mapped_column(
String(64), comment="裂变维度: 换角度/换痛点/换人群"
)
status: Mapped[str] = mapped_column(
String(20), default="pending", nullable=False,
comment="状态: pending/scored/image_done/failed"
)
created_at: Mapped[datetime] = mapped_column(
DateTime, server_default=func.now(), nullable=False
)
__table_args__ = (
Index("idx_fission_notes_fission_id", "fission_id"),
Index("idx_fission_notes_workspace_id", "workspace_id"),
)

View 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

View File

@@ -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套完整笔记包。参考度档位控制"新套子贴原爆款多紧" 裂变=1爆款→一次LLM出N套完整笔记包。重写对齐产品包上线版 split.js
high=贴原爆款结构+卖点(最像) / mid=保卖点换叙事角度 / low=只借选题方向(最自由) - FISSION_SYSTEM 完整笔记包字段(含 dimension/audience/scene/painPoint/keywords/imagePlan
- 参考度连续 30-85reference_strategy_from_level低≤40/高≥80/中
- 违禁词清洗 sanitize_image_plan_text
- 品类兜底 build_fallback_notes / infer_categoryLLM挂时不卡用户
🔴 占位规则(倩倩姐2026-06-16:先移植打通再请北哥定档位) 🔴 参考度入参保留 low/mid/high 三档(倩倩姐拍板),内部 _LEVEL_TO_INT 映射成数值
档位真实措辞待北哥定义后替换 _LEVEL_RULES,引擎链路不动 🔴 三档真实业务措辞待北哥定义;本次接口按数值做对
🔴 LLM 走 chat_complete(OpenAI兼容)FISSION_SYSTEM 作 messages[0].role=system 传。
""" """
from __future__ import annotations
# 三档占位规则:注入文案引擎 flywheel_context约束"参考原爆款多少" # 三档→数值映射(保留枚举入参,内部走连续值逻辑,对齐上线版 referenceStrategyFromLevel
_LEVEL_RULES = { _LEVEL_TO_INT = {"low": 35, "mid": 60, "high": 82}
"high": (
"【裂变参考度=高】请紧贴下面这篇原爆款的正文结构、开头钩子、卖点排布与情绪语气,"
"近乎仿写——只替换表达措辞做到不查重,骨架与卖点角度都保留。" def reference_strategy_from_level(level: str | int) -> dict:
), """参考度策略(对齐上线版 referenceStrategyFromLevel
"mid": (
"【裂变参考度=中】请保留下面这篇原爆款的核心卖点,但换一个全新的叙事角度/切入场景重写," 入参支持枚举 low/mid/high 或数值;钳到 30-85。
"结构可调整,让读者看不出是同一套路。" 返回 {level, level_label, prompt_rule, summary}。
), """
"low": ( 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 build_fission_context(source_note: dict, reference_level: str) -> str:
"""
返回注入 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}"
def valid_level(level: str | None) -> str: def valid_level(level: str | None) -> str:
"""校验档位,非法回落 mid。""" """校验三档枚举,非法回落 mid(保留旧接口兼容)"""
return level if level in _LEVEL_RULES else "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 []

View File

@@ -0,0 +1,81 @@
"""
app/services/fission_images.py — 第11环裂变 逐套生图(从 fission_pipeline 拆出)
复用单篇 generate_storyboard_imagescodeproxy 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()

View 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_imagescodeproxy 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}

View File

@@ -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), 裂变 = 1爆款 → 一次 LLM 出 N 套完整笔记包N=1~5
复用主管道 run_generation_pipeline 生成文案+配图。 本文件只留 API 入口:
🔴 占位档位规则见 fission_prompt.py,真实措辞待北哥定义(倩倩姐2026-06-16先打通)。 - create_fission建 FissionTask 后丢 run_fission_pipeline.delay不再扇出 GenerationTask
编排层(解析/评分/落库/生图)在 app/services/fission_pipeline.py红线≤200行已拆
""" """
import json import json
import logging import logging
@@ -15,11 +16,11 @@ from app.middleware.workspace_guard import CurrentUser
from app.models.fission import FissionTask from app.models.fission import FissionTask
from app.models.task import GenerationTask from app.models.task import GenerationTask
from app.services.ai_engine.fission_prompt import valid_level 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__) logger = logging.getLogger(__name__)
MAX_FANOUT = 5 # 每用户并发上限5(红线),裂变扇出数受同等约束 MAX_FANOUT = 5 # 每用户并发上限5(红线)裂变 N 套直出受同等约束
def create_fission( def create_fission(
@@ -27,15 +28,16 @@ def create_fission(
source_task_id: int, reference_level: str, fanout_count: int, source_task_id: int, reference_level: str, fanout_count: int,
) -> tuple[int, list[int]]: ) -> tuple[int, list[int]]:
""" """
从一个源任务裂变出 N 套子任务 从一个源任务裂变任务,丢 Celery 一次出 N 套笔记包
返回 (fission_id, [子task_id,...])。 返回 (fission_id, [])。子笔记落 FissionNote不再扇出 GenerationTask
故第二个返回值恒为空列表(保留签名兼容旧调用方)。
""" """
_check_user_has_key(db, current_user.user_id, current_user.workspace_id) _check_user_has_key(db, current_user.user_id, current_user.workspace_id)
level = valid_level(reference_level) level = valid_level(reference_level)
n = max(1, min(fanout_count or 3, MAX_FANOUT)) n = max(1, min(fanout_count or 3, MAX_FANOUT))
# 读源任务(取产品+主题+已选文案作为爆款源) # 读源任务取产品+主题+已选文案作为爆款源
src = db.query(GenerationTask).filter( src = db.query(GenerationTask).filter(
GenerationTask.id == source_task_id, GenerationTask.id == source_task_id,
GenerationTask.workspace_id == current_user.workspace_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)} source_note = {"title": src.theme or "", "content": _extract_source_copy(db, src)}
# 建 fission_task 记录 # 建 fission_task 记录(带 source_task_id 供 pipeline 取产品/张数)
ft = FissionTask( ft = FissionTask(
workspace_id=current_user.workspace_id, workspace_id=current_user.workspace_id,
source_note=json.dumps(source_note, ensure_ascii=False), source_note=json.dumps(source_note, ensure_ascii=False),
@@ -53,27 +55,17 @@ def create_fission(
) )
db.add(ft); db.commit(); db.refresh(ft) db.add(ft); db.commit(); db.refresh(ft)
# 扇出 N 个子任务(回填 source_fission_id),复用主管道 # 丢 Celery一次 LLM 出 N 套(不扇出 GenerationTask
task_ids: list[int] = [] from app.workers.tasks import run_fission_pipeline
for _ in range(n): run_fission_pipeline.delay(ft.id, source_task_id)
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)
logger.info("fission created id=%s level=%s fanout=%s tasks=%s", logger.info("fission created id=%s level=%s n=%s src_task=%s",
ft.id, level, n, task_ids) ft.id, level, n, source_task_id)
return ft.id, task_ids return ft.id, []
def _extract_source_copy(db: Session, src: GenerationTask) -> str: def _extract_source_copy(db: Session, src: GenerationTask) -> str:
"""取源任务已选/最高分文案当爆款正文;无则用主题兜底。""" """取源任务已选/最高分文案当爆款正文无则用主题兜底。"""
from app.models.task import TextCandidate from app.models.task import TextCandidate
c = (db.query(TextCandidate) c = (db.query(TextCandidate)
.filter(TextCandidate.task_id == src.id) .filter(TextCandidate.task_id == src.id)

View File

@@ -14,6 +14,19 @@ from app.workers.replenish_task import replenish_text_candidates # noqa: F401
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
def _json_loads_safe(raw: str | None) -> dict:
"""TextCandidate.content 存整条 JSON dicttitle/content/tags/...)。
解析失败兼容旧数据:纯正文则包成 {'content': raw}。供 R2 重生复用已有文案。"""
import json as _json
if not raw:
return {}
try:
v = _json.loads(raw)
return v if isinstance(v, dict) else {"content": raw}
except (ValueError, TypeError):
return {"content": raw}
def _get_db(): def _get_db():
"""获取同步 DB SessionCelery worker 环境用同步)""" """获取同步 DB SessionCelery worker 环境用同步)"""
from app.core.database import SessionLocal from app.core.database import SessionLocal
@@ -36,6 +49,28 @@ def _push_event_sync(task_id: int, workspace_id: int, event: str, data: dict, se
r.publish(channel, record) r.publish(channel, record)
def _acquire_run_lock(task_id: int) -> bool:
"""
幂等锁:同一 task 同时只允许一条 pipeline 在跑。
visibility_timeout 是主防线(防 broker 误判重投);此锁是双保险,
挡住"窗口内仍并发重投"导致两个 worker 同跑同一 task、重复烧钱(task75 教训)。
返回 True=抢到锁可执行False=已有人在跑,本次直接跳过。
锁 TTL 略大于 visibility_timeout确保跨整个任务生命周期。
"""
import redis as redis_lib
from app.core.config import get_settings
r = redis_lib.from_url(get_settings().REDIS_URL, decode_responses=True)
# NX+EX不存在才设自带过期防死锁(worker 崩了锁自动释放)
return bool(r.set(f"pipeline:lock:{task_id}", "1", nx=True, ex=7500))
def _release_run_lock(task_id: int) -> None:
import redis as redis_lib
from app.core.config import get_settings
r = redis_lib.from_url(get_settings().REDIS_URL, decode_responses=True)
r.delete(f"pipeline:lock:{task_id}")
@celery_app.task( @celery_app.task(
bind=True, bind=True,
name="app.workers.tasks.run_generation_pipeline", name="app.workers.tasks.run_generation_pipeline",
@@ -43,13 +78,22 @@ def _push_event_sync(task_id: int, workspace_id: int, event: str, data: dict, se
default_retry_delay=30, default_retry_delay=30,
queue="generation", queue="generation",
) )
def run_generation_pipeline(self, task_id: int) -> dict: def run_generation_pipeline(self, task_id: int, regen_strategy: str | None = None,
regen_role: str | None = None, custom_prompt: str | None = None) -> dict:
""" """
生产链主任务。只接收 task_id绝不接收 key。 生产链主任务。只接收 task_id绝不接收 key。
子步骤委托给 pipeline_steps查库/解密/飞轮上下文) 子步骤委托给 pipeline_steps查库/解密/飞轮上下文)
和 pipeline_io文案/图片生成+存储)。 和 pipeline_io文案/图片生成+存储)。
R2 重生参数(均 None=常规全量生成)
regen_strategy='A'/'B'/'C' → 只重生该套regen_role 配合 → 只重生该套的该张;
custom_prompt → 人工追加提示词。重生模式跳过文案生成,复用已有合格文案。
""" """
logger.info("run_generation_pipeline start: task_id=%s", task_id) logger.info("run_generation_pipeline start: task_id=%s", task_id)
# 幂等双保险:抢不到锁说明已有 worker 在跑同一 task直接跳过(防重复烧钱)。
if not _acquire_run_lock(task_id):
logger.warning("run_generation_pipeline 跳过(已有实例在跑): task_id=%s", task_id)
return {"task_id": task_id, "status": "skipped_locked"}
db = _get_db() db = _get_db()
seq = 0 seq = 0
workspace_id = 0 # G3坑修复确保 except 里能用真实 workspace_id workspace_id = 0 # G3坑修复确保 except 里能用真实 workspace_id
@@ -86,28 +130,13 @@ def run_generation_pipeline(self, task_id: int) -> dict:
# Step4: 推 task_started + 飞轮上下文 # Step4: 推 task_started + 飞轮上下文
seq += 1 seq += 1
_push_event_sync(task_id, workspace_id, "task_started", { _push_event_sync(task_id, workspace_id, "task_started", {
"task_id": task_id, "total_text": task.text_count, "total_image": task.image_count, "task_id": task_id, "total_text": task.text_count,
"total_image": task.image_count * 3,
}, seq) }, seq)
product_dict = build_product_dict(product) product_dict = build_product_dict(product)
flywheel_fragment, flywheel_ctx = load_flywheel_context(db, workspace_id, task.product_id, product_dict) flywheel_fragment, flywheel_ctx = load_flywheel_context(db, workspace_id, task.product_id, product_dict)
# 第11环裂变:子任务(source_fission_id非空)按裂变档位+源爆款生成,拼进文案上下文。
if getattr(task, "source_fission_id", None):
import json as _json
from app.models.fission import FissionTask
from app.services.ai_engine.fission_prompt import build_fission_context
ft = db.query(FissionTask).filter(FissionTask.id == task.source_fission_id).first()
if ft:
try:
src_note = _json.loads(ft.source_note) if ft.source_note else {}
except Exception:
src_note = {}
fission_ctx = build_fission_context(src_note, ft.reference_level)
flywheel_fragment = f"{fission_ctx}\n\n{flywheel_fragment}".strip()
logger.info("裂变子任务注入档位: task_id=%s fission=%s level=%s",
task_id, ft.id, ft.reference_level)
if flywheel_fragment: if flywheel_fragment:
seq += 1 seq += 1
_push_event_sync(task_id, workspace_id, "flywheel_injected", { _push_event_sync(task_id, workspace_id, "flywheel_injected", {
@@ -116,6 +145,27 @@ def run_generation_pipeline(self, task_id: int) -> dict:
}, seq) }, seq)
# Step5: 文案生成S1: 返回 needs_replenish=合格数<目标数,触发后台补充) # Step5: 文案生成S1: 返回 needs_replenish=合格数<目标数,触发后台补充)
# R2 重生模式:跳过文案重生,复用库内已有合格文案作生图依据(重生只针对图片)
_is_regen = bool(regen_strategy or regen_role)
if _is_regen:
from app.models.task import TextCandidate as _TC
_existing = db.query(_TC).filter(
_TC.task_id == task_id
).order_by(_TC.id.asc()).first()
if not _existing:
# 重生依赖已有文案作生图语境;一条都没有时硬失败,绝不以空文案降级生图(质量崩)
from app.constants.enums import TaskStatus as _TS
logger.warning("重生无可复用文案,拒绝空语境生图: task_id=%s", task_id)
task.status = _TS.PENDING_SELECTION
db.commit()
_push_event_sync(task_id, workspace_id, "error", {
"code": 40002,
"message": "无可复用文案,请先完成文案生成再重生图片",
}, seq + 1)
return {"task_id": task_id, "status": "regen_no_text"}
candidates_raw = [_json_loads_safe(_existing.content)]
needs_replenish = False
else:
candidates_raw, seq, needs_replenish = run_text_generation( candidates_raw, seq, needs_replenish = run_text_generation(
db, clients, task, product_dict, flywheel_fragment, db, clients, task, product_dict, flywheel_fragment,
_push_event_sync, workspace_id, seq, _push_event_sync, workspace_id, seq,
@@ -140,6 +190,8 @@ def run_generation_pipeline(self, task_id: int) -> dict:
db, clients, task, product_dict, db, clients, task, product_dict,
_push_event_sync, workspace_id, seq, _push_event_sync, workspace_id, seq,
first_copy, settings.UPLOAD_BASE_PATH, first_copy, settings.UPLOAD_BASE_PATH,
regen_strategy=regen_strategy, regen_role=regen_role,
custom_prompt=custom_prompt,
) )
# 最终状态 + task_done # 最终状态 + task_done
@@ -174,4 +226,63 @@ def run_generation_pipeline(self, task_id: int) -> dict:
return {"task_id": task_id, "status": "failed", "error": str(exc)} return {"task_id": task_id, "status": "failed", "error": str(exc)}
raise self.retry(exc=exc) raise self.retry(exc=exc)
finally: finally:
# 释放幂等锁:本次执行结束(成功/失败/重试间隙)即放锁。
# retry 是串行的(旧执行抛异常结束→30s后新执行重新抢锁),非并发,故每次都放。
# 真正的并发威胁(broker 窗口内重投)由 visibility_timeout=2h 兜底。
_release_run_lock(task_id)
db.close()
@celery_app.task(
bind=True,
name="app.workers.tasks.run_fission_pipeline",
max_retries=2,
default_retry_delay=30,
queue="generation",
)
def run_fission_pipeline(self, fission_id: int, source_task_id: int) -> dict:
"""第11环裂变主任务一次 LLM 出 N 套完整笔记包(对齐上线版 split.js
只接收 id基石B不接收 key。幂等锁防重复烧钱。
解密 key→构建 clients→委托 fission_pipeline.execute_fission_pipeline。
"""
logger.info("run_fission_pipeline start: fission_id=%s src=%s", fission_id, source_task_id)
lock_key = f"fission:lock:{fission_id}"
import redis as _redis
from app.core.config import get_settings as _gs
_r = _redis.from_url(_gs().REDIS_URL, decode_responses=True)
if not _r.set(lock_key, "1", nx=True, ex=7500):
logger.warning("run_fission_pipeline 跳过(已在跑): fission_id=%s", fission_id)
return {"fission_id": fission_id, "status": "skipped_locked"}
db = _get_db()
try:
from app.models.task import GenerationTask
from app.workers.pipeline_steps import decrypt_user_key, build_clients_and_clear_key
from app.services.fission_pipeline import execute_fission_pipeline
src = db.query(GenerationTask).filter(GenerationTask.id == source_task_id).first()
if not src:
return {"fission_id": fission_id, "status": "not_found"}
plain_key = decrypt_user_key(db, src.operator_id, src.workspace_id)
clients = build_clients_and_clear_key(plain_key)
plain_key = None # 用完即清基石B
return execute_fission_pipeline(db, clients, fission_id, source_task_id)
except Exception as exc:
logger.error("run_fission_pipeline failed: fission_id=%s err=%s", fission_id, exc)
exhausted = self.request.retries >= self.max_retries
if exhausted:
try:
from app.models.fission import FissionTask
ft = db.query(FissionTask).filter(FissionTask.id == fission_id).first()
if ft:
ft.status = "failed"; db.commit()
except Exception:
pass
return {"fission_id": fission_id, "status": "failed", "error": str(exc)}
raise self.retry(exc=exc)
finally:
_r.delete(lock_key)
db.close() db.close()

View File

@@ -0,0 +1,197 @@
"""
裂变引擎单元测试第11环·一次LLM出N套架构
覆盖:参考强度映射 / 标签归一 / imagePlan文字清洗 / prompt组装 /
模型输出解析 N 套 / 品类兜底 build_fallback_notes / infer_category。
纯函数为主,不连 DB/LLMconftest 已 stub 环境)。
"""
import sys
import os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
import json
import pytest
from app.services.ai_engine.fission_prompt import (
reference_strategy_from_level, valid_level, normalize_tags,
sanitize_image_plan_text, build_fission_prompt, notes_array_from_parsed,
)
from app.services.ai_engine.fission_fallback import (
infer_category, build_fallback_notes, build_fallback_image_plan,
)
PRODUCT = {
"name": "倍分子素颜霜",
"category": "美妆护肤",
"selling_points": ["轻薄不厚重", "水润自然", "不卡粉"],
"keywords": ["素颜霜", "日常通勤"],
"style_tone": "素人分享风",
"brand_keyword": "倍分子",
}
SOURCE_NOTE = {
"title": "素颜霜真实测评",
"content": "用了两周,底子真的透,不卡粉,姐妹们冲。",
"tags": ["素颜霜", "护肤"],
}
# ── 参考强度映射 ────────────────────────────────────────────
def test_reference_level_three_tiers_map_to_int():
"""low/mid/high 三档保留,内部映射数值(倩倩姐拍板)。"""
low = reference_strategy_from_level("low")
mid = reference_strategy_from_level("mid")
high = reference_strategy_from_level("high")
# 返回 {level, level_label, prompt_rule, summary}level 数值 low<mid<high
assert low["level"] < mid["level"] < high["level"]
assert low["level_label"] == "低参考"
assert high["level_label"] == "高参考"
def test_valid_level_defaults_and_passthrough():
assert valid_level("low") == "low"
assert valid_level("mid") == "mid"
assert valid_level("high") == "high"
# 非法值兜底成 mid默认档
assert valid_level("garbage") == "mid"
assert valid_level(None) == "mid"
# ── 标签归一 ────────────────────────────────────────────────
def test_normalize_tags_dedupe_and_merge_keywords():
tags = normalize_tags(["素颜霜", "素颜霜", "护肤"], ["通勤", "护肤"])
# 归一化补 # 前缀
assert "#素颜霜" in tags
# 去重:素颜霜只出现一次
assert tags.count("#素颜霜") == 1
# keywords 合并进来
assert "#通勤" in tags
# 截断 8 个上限
assert len(tags) <= 8
def test_normalize_tags_handles_none_and_str():
assert normalize_tags(None, None) == []
assert isinstance(normalize_tags("单串 标签", None), list)
# ── imagePlan 文字清洗 ──────────────────────────────────────
def test_sanitize_image_plan_text_truncates():
long = "美白祛斑特效" * 20
out = sanitize_image_plan_text(long, max_length=56)
assert len(out) <= 56
def test_sanitize_image_plan_text_empty():
assert sanitize_image_plan_text("") == ""
# ── prompt 组装 ─────────────────────────────────────────────
def test_build_fission_prompt_includes_counts_and_strategy():
prompt = build_fission_prompt(SOURCE_NOTE, PRODUCT, "high", note_count=3, image_count=6)
assert "3套" in prompt
assert "6张" in prompt
# 高参考策略文案带入
assert "高参考" in prompt
# 源爆款标题带入
assert "素颜霜真实测评" in prompt
# ── 模型输出解析 N 套 ───────────────────────────────────────
def test_notes_array_from_list():
parsed = [{"title": "a"}, {"title": "b"}]
assert len(notes_array_from_parsed(parsed)) == 2
def test_notes_array_from_wrapped_dict():
# 模型可能把数组包在 notes/variants 等键下
assert len(notes_array_from_parsed({"notes": [{"title": "x"}]})) == 1
assert len(notes_array_from_parsed({"variants": [{"title": "x"}, {"title": "y"}]})) == 2
def test_notes_array_from_single_note_dict():
# 单套笔记直接当对象返回
assert len(notes_array_from_parsed({"title": "单套", "content": "正文"})) == 1
def test_notes_array_from_garbage():
assert notes_array_from_parsed("not json") == []
assert notes_array_from_parsed({}) == []
# ── 品类推断 ────────────────────────────────────────────────
def test_infer_category_returns_str():
cat = infer_category(PRODUCT)
assert isinstance(cat, str) and cat
# ── 品类兜底完整草稿 ────────────────────────────────────────
def test_build_fallback_notes_count_and_fields():
notes = build_fallback_notes(SOURCE_NOTE, PRODUCT, note_count=3, image_count=6)
assert len(notes) == 3
for n in notes:
# 完整笔记包字段齐全
for field in ("title", "content", "tags", "coverTitle", "dimension",
"audience", "scene", "painPoint", "keywords", "imagePlan"):
assert field in n
# imagePlan 数量必须等于 image_count
assert len(n["imagePlan"]) == 6
def test_build_fallback_image_plan_matches_count():
note = {"title": "t", "content": "c"}
for count in (3, 6, 8):
plan = build_fallback_image_plan(note, count)
assert len(plan) == count
for p in plan:
assert "role" in p
def test_build_fallback_notes_dedupes_titles():
# 多套兜底标题不应全部雷同(裂变要差异化)
notes = build_fallback_notes(SOURCE_NOTE, PRODUCT, note_count=3, image_count=3)
titles = [n["title"] for n in notes]
assert len(set(titles)) >= 2
# ── 集成:编排层 _parse_fission_responsemock LLM 固定 JSON──
from app.services.fission_pipeline import _parse_fission_response
_LLM_JSON = json.dumps([
{"title": "套1标题", "content": "套1正文" * 30, "tags": ["素颜霜"],
"coverTitle": "钩子1", "dimension": "换角度", "keywords": ["通勤"],
"imagePlan": [{"role": "cover", "title": "t", "overlayText": "o", "text": "x"},
{"role": "proof", "title": "t2", "overlayText": "o2", "text": "x2"},
{"role": "cta", "title": "t3", "overlayText": "o3", "text": "x3"}]},
{"title": "套2标题", "content": "套2正文" * 30, "tags": ["护肤"],
"coverTitle": "钩子2", "dimension": "换痛点", "keywords": ["熬夜"],
"imagePlan": [{"role": "cover"}, {"role": "proof"}, {"role": "cta"}]},
], ensure_ascii=False)
def test_parse_response_returns_all_notes():
notes = _parse_fission_response(_LLM_JSON, SOURCE_NOTE, PRODUCT, 2, 3)
assert len(notes) == 2
assert notes[0]["title"] == "套1标题"
# tags 经归一化补 #
assert all(t.startswith("#") for t in notes[0]["tags"])
def test_parse_response_corrects_imageplan_count():
# imagePlan 数不等于 image_count 时,被兜底重建成正确张数
notes = _parse_fission_response(_LLM_JSON, SOURCE_NOTE, PRODUCT, 2, 6)
for n in notes:
assert len(n["imagePlan"]) == 6
def test_parse_response_markdown_wrapped():
# 模型常把 JSON 包在 ```json ``` 里,需容错
wrapped = f"```json\n{_LLM_JSON}\n```"
notes = _parse_fission_response(wrapped, SOURCE_NOTE, PRODUCT, 2, 3)
assert len(notes) == 2
def test_parse_response_garbage_falls_back_not_crash():
# 完全不可解析 → 品类兜底,绝不抛异常/不返回空
notes = _parse_fission_response("彻底不是JSON的东西", SOURCE_NOTE, PRODUCT, 3, 6)
assert len(notes) == 3
for n in notes:
assert len(n["imagePlan"]) == 6
assert n["title"]

View File

@@ -0,0 +1,82 @@
"""
裂变编排层 _score_notes 测试(合格线@80 + 违禁词硬拦截逻辑)
mock llm_score_copy验证 _passed = passed AND not _block 的安全关键规则。
排序/降级落库逻辑(needs_optimization=0 if passed else 1)在 execute_fission_pipeline
本文件只锁评分写回的 _score/_block/_passed 三字段语义。
"""
import sys
import os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
import unittest.mock as mock
from app.services.fission_pipeline import _score_notes
SOURCE = {"title": "", "content": "源正文"}
def _patch_scorer(monkeypatch, results):
"""按调用顺序返回 results 里的评分 dict。"""
calls = {"i": 0}
async def fake(client, copy, source, banned=None, pass_score=80):
r = results[calls["i"] % len(results)]
calls["i"] += 1
return r
monkeypatch.setattr("app.services.ai_engine.llm_scorer.llm_score_copy", fake)
def test_score_high_no_banned_passes(monkeypatch):
_patch_scorer(monkeypatch, [
{"score": 88, "passed": True, "banned_words_found": []},
])
notes = [{"title": "a", "content": "c", "tags": []}]
_score_notes(mock.MagicMock(), notes, SOURCE, [])
assert notes[0]["_score"] == 88
assert notes[0]["_block"] is False
assert notes[0]["_passed"] is True
def test_score_low_marks_not_passed(monkeypatch):
# 低于合格线 → 不达标,但分数照写(落库时标 needs_optimization 不丢弃)
_patch_scorer(monkeypatch, [
{"score": 62, "passed": False, "banned_words_found": []},
])
notes = [{"title": "a", "content": "c", "tags": []}]
_score_notes(mock.MagicMock(), notes, SOURCE, [])
assert notes[0]["_score"] == 62
assert notes[0]["_passed"] is False
def test_score_banned_word_hard_blocks_even_if_passed(monkeypatch):
# 违禁词硬拦截:即便分数达标 passed=True命中违禁词也强制 _passed=False
_patch_scorer(monkeypatch, [
{"score": 95, "passed": True, "banned_words_found": ["美白"]},
])
notes = [{"title": "美白神器", "content": "c", "tags": []}]
_score_notes(mock.MagicMock(), notes, SOURCE, ["美白"])
assert notes[0]["_block"] is True
assert notes[0]["_passed"] is False
def test_score_exception_records_zero(monkeypatch):
# 评分接口抛异常 → 记0分不达标不让整批裂变崩
async def boom(*a, **k):
raise RuntimeError("中转站429")
monkeypatch.setattr("app.services.ai_engine.llm_scorer.llm_score_copy", boom)
notes = [{"title": "a", "content": "c", "tags": []}]
_score_notes(mock.MagicMock(), notes, SOURCE, [])
assert notes[0]["_score"] == 0
assert notes[0]["_passed"] is False
def test_score_multiple_notes_each_scored(monkeypatch):
_patch_scorer(monkeypatch, [
{"score": 90, "passed": True, "banned_words_found": []},
{"score": 50, "passed": False, "banned_words_found": []},
])
notes = [{"title": "a", "content": "c", "tags": []},
{"title": "b", "content": "d", "tags": []}]
_score_notes(mock.MagicMock(), notes, SOURCE, [])
assert notes[0]["_passed"] is True
assert notes[1]["_passed"] is False

View File

@@ -31,6 +31,8 @@ class TestAiePipelineStepsContract:
style_tone = "素人分享风" style_tone = "素人分享风"
text_angles = '["痛点切入","场景型"]' text_angles = '["痛点切入","场景型"]'
custom_prompt = "" custom_prompt = ""
brand_keyword = "倍分子" # S3: 品牌词随产品固定,植入文案+图片文字层
target_audience = "" # 012: 人群透传进 storyboard/文案 prompt
image_path = None # 前端图片上传完成前为空 image_path = None # 前端图片上传完成前为空
return P() return P()

View File

@@ -0,0 +1,112 @@
'use client';
/**
* /fission/[id]/notes — N 套裂变笔记包独立展示页F2新架构
* 一次 LLM 出的 N 套完整笔记包:标题/正文/标签/封面钩子/imagePlan/生成图/分数。
* 数据源 GET /api/v1/fission/{id}/notesFissionNote 落库结果)。
*/
import { useEffect, useState, useCallback } from 'react';
import Link from 'next/link';
import { useParams } from 'next/navigation';
import { api } from '@/lib/api';
import { FissionNoteCard } from '@/components/fission/FissionNoteCard';
export interface FissionImagePlan {
role?: string;
title?: string;
overlayText?: string;
text?: string;
}
export interface FissionNoteData {
title?: string;
content?: string;
tags?: string[];
coverTitle?: string;
dimension?: string;
audience?: string;
scene?: string;
painPoint?: string;
keywords?: string[];
imagePlan?: FissionImagePlan[];
}
export interface FissionImage {
role: string;
seq?: number;
url?: string;
error?: string;
}
export interface FissionNoteItem {
seq: number;
note: FissionNoteData;
images: FissionImage[];
score: number | null;
passed: boolean;
needs_optimization: boolean;
dimension: string | null;
status: string;
}
interface NotesResponse {
fission_id: number;
status: string;
fanout_count: number;
notes: FissionNoteItem[];
}
export default function FissionNotesPage() {
const params = useParams();
const fissionId = Number(params?.id);
const [data, setData] = useState<NotesResponse | null>(null);
const [error, setError] = useState<string | null>(null);
const fetchNotes = useCallback(async () => {
setError(null);
try {
const res = await api.get<NotesResponse>(`/api/v1/fission/${fissionId}/notes`);
setData(res);
} catch {
setError('加载裂变笔记包失败');
}
}, [fissionId]);
useEffect(() => { if (fissionId) fetchNotes(); }, [fissionId, fetchNotes]);
return (
<div className="flex flex-col gap-6 p-6 max-w-4xl">
<div className="flex items-center justify-between">
<div>
<h1 className="text-xl font-semibold text-text-primary">
#{fissionId} · {data?.fanout_count ?? ''}
</h1>
<p className="text-sm text-text-secondary mt-1">
</p>
</div>
<Link href="/fission" className="text-sm text-text-secondary underline">
</Link>
</div>
{error && (
<div className="rounded-lg bg-red-50 border border-red-200 p-4 text-sm text-red-600">
{error}
<button className="ml-4 underline" onClick={fetchNotes}></button>
</div>
)}
{!data && !error && (
<div className="py-12 text-center text-text-tertiary text-sm"></div>
)}
{data && data.notes.length === 0 && (
<div className="py-12 text-center text-text-tertiary text-sm">
</div>
)}
<div className="flex flex-col gap-6">
{data?.notes.map((item) => (
<FissionNoteCard key={item.seq} item={item} />
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,76 @@
'use client';
/**
* /fission — 独立裂变页第11环倩倩姐2026-06-15拍板"新建独立裂变页"
* 选一个已通过/已归档的爆款源任务 → 设定参考强度+扇出套数 → 一键裂变成 N 套完整笔记包。
* 后端POST /api/v1/fissionGET /api/v1/fission/{id} 查聚合进度。
*/
import { useEffect, useState, useCallback } from 'react';
import { api } from '@/lib/api';
import { TaskListItem, PaginatedResponse } from '@/types';
import { getErrorAction } from '@/types/errors';
import { FissionLauncher } from '@/components/fission/FissionLauncher';
import { FissionProgress } from '@/components/fission/FissionProgress';
// 裂变源 = 已验证的爆款,只从通过/归档里选
const SOURCE_STATUSES = ['approved', 'archived'];
export default function FissionPage() {
const [sources, setSources] = useState<TaskListItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [activeFissionId, setActiveFissionId] = useState<number | null>(null);
const fetchSources = useCallback(async () => {
setLoading(true);
setError(null);
try {
const params = new URLSearchParams({ page: '1', page_size: '50' });
SOURCE_STATUSES.forEach((s) => params.append('status', s));
const res = await api.get<PaginatedResponse<TaskListItem>>(`/api/v1/tasks?${params}`);
setSources(res.items);
} catch (e: unknown) {
const ae = e as { code?: number };
setError(getErrorAction(ae.code ?? 50001).message);
} finally {
setLoading(false);
}
}, []);
useEffect(() => { fetchSources(); }, [fetchSources]);
return (
<div className="flex flex-col gap-6 p-6 max-w-3xl">
<div>
<h1 className="text-xl font-semibold text-text-primary"></h1>
<p className="text-sm text-text-secondary mt-1">
</p>
</div>
{error && (
<div className="rounded-lg bg-red-50 border border-red-200 p-4 text-sm text-red-600">
{error}
<button className="ml-4 underline" onClick={fetchSources}></button>
</div>
)}
{activeFissionId ? (
<FissionProgress
fissionId={activeFissionId}
onReset={() => { setActiveFissionId(null); fetchSources(); }}
/>
) : loading ? (
<div className="py-12 text-center text-text-tertiary text-sm"></div>
) : sources.length === 0 ? (
<div className="py-12 text-center text-text-tertiary text-sm">
</div>
) : (
<FissionLauncher
sources={sources}
onLaunched={(fissionId) => setActiveFissionId(fissionId)}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,112 @@
'use client';
/**
* FissionLauncher — 裂变发起面板
* 选源爆款 + 参考强度(low/mid/high) + 扇出套数(1~5) → POST /fission回调 fission_id。
*/
import { useState } from 'react';
import { api } from '@/lib/api';
import { TaskListItem } from '@/types';
import { getErrorAction } from '@/types/errors';
import { clsx } from 'clsx';
interface Props {
sources: TaskListItem[];
onLaunched: (fissionId: number) => void;
}
const LEVELS: { key: string; label: string; desc: string }[] = [
{ key: 'low', label: '低', desc: '只借结构,文案视觉大改' },
{ key: 'mid', label: '中', desc: '保留核心卖点,角度换新' },
{ key: 'high', label: '高', desc: '贴近原作,仅做差异微调' },
];
export function FissionLauncher({ sources, onLaunched }: Props) {
const [sourceId, setSourceId] = useState<number | null>(sources[0]?.id ?? null);
const [level, setLevel] = useState('mid');
const [fanout, setFanout] = useState(3);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
async function launch() {
if (!sourceId) { setError('请选择一个源爆款'); return; }
setSubmitting(true);
setError(null);
try {
const res = await api.post<{ fission_id: number }>('/api/v1/fission', {
source_task_id: sourceId, reference_level: level, fanout_count: fanout,
});
onLaunched(res.fission_id);
} catch (e: unknown) {
const ae = e as { code?: number; message?: string };
setError(ae.message || getErrorAction(ae.code ?? 50001).message);
} finally {
setSubmitting(false);
}
}
return (
<div className="flex flex-col gap-5">
{/* 选源爆款 */}
<div>
<label className="block text-sm font-medium text-text-primary mb-2"></label>
<select
value={sourceId ?? ''}
onChange={(e) => setSourceId(Number(e.target.value))}
className="w-full border border-border-default rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-brand-orange"
>
{sources.map((t) => (
<option key={t.id} value={t.id}>
#{t.id} · {t.theme || '(无主题)'}
</option>
))}
</select>
</div>
{/* 参考强度 */}
<div>
<label className="block text-sm font-medium text-text-primary mb-2"></label>
<div className="grid grid-cols-3 gap-2">
{LEVELS.map((l) => (
<button
key={l.key}
type="button"
onClick={() => setLevel(l.key)}
className={clsx(
'border rounded-lg p-3 text-left transition-colors',
level === l.key
? 'border-brand-orange bg-brand-orange-light'
: 'border-border-default hover:border-brand-orange'
)}
>
<div className="text-sm font-medium text-text-primary">{l.label}</div>
<div className="text-xs text-text-tertiary mt-1">{l.desc}</div>
</button>
))}
</div>
</div>
{/* 扇出套数 */}
<div>
<label className="block text-sm font-medium text-text-primary mb-2">
<span className="text-brand-orange font-semibold">{fanout}</span>
</label>
<input
type="range" min={1} max={5} value={fanout}
onChange={(e) => setFanout(Number(e.target.value))}
className="w-full accent-brand-orange"
/>
</div>
{error && <div className="text-sm text-red-600">{error}</div>}
<button
type="button"
onClick={launch}
disabled={submitting || !sourceId}
className="self-start bg-brand-orange text-white text-sm font-medium px-6 py-2.5 rounded-lg hover:bg-orange-600 disabled:opacity-50"
>
{submitting ? '裂变中…' : `裂变成 ${fanout} 套笔记包 →`}
</button>
</div>
);
}

View File

@@ -0,0 +1,117 @@
'use client';
/**
* FissionNoteCard — 单套裂变笔记包卡片F2 子组件)
* 展示:维度/分数徽章 + 标题 + 正文 + 标签 + 生成图 + imagePlan 排版说明。
*/
import type { FissionNoteItem } from '@/app/fission/[id]/notes/page';
interface Props {
item: FissionNoteItem;
}
// 图片 url 后端给 /uploads/... 相对路径,前端同源直接用
function imgSrc(url?: string): string | null {
if (!url) return null;
return url.startsWith('/uploads/') ? url : `/uploads/${url.replace(/^.*\/uploads\//, '')}`;
}
export function FissionNoteCard({ item }: Props) {
const { note, images, score, passed, needs_optimization, dimension, seq } = item;
const okImages = images.filter((im) => im.url && !im.error);
const failedImages = images.filter((im) => im.error);
return (
<div className="rounded-xl border border-border-default bg-surface-primary p-5 flex flex-col gap-4">
{/* 头部:序号 + 维度 + 分数 */}
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-text-primary"> {seq} </span>
{(dimension || note.dimension) && (
<span className="text-xs px-2 py-0.5 rounded-full bg-surface-tertiary text-text-secondary">
{dimension || note.dimension}
</span>
)}
</div>
<div className="flex items-center gap-2">
{score != null && (
<span className={`text-xs font-medium px-2 py-0.5 rounded-full ${
passed ? 'bg-green-50 text-green-600' : 'bg-amber-50 text-amber-600'
}`}>
{score}
</span>
)}
{needs_optimization && (
<span className="text-xs px-2 py-0.5 rounded-full bg-amber-50 text-amber-600">
</span>
)}
</div>
</div>
{/* 标题 + 封面钩子 */}
{note.coverTitle && (
<p className="text-sm font-medium text-brand-orange">{note.coverTitle}</p>
)}
{note.title && (
<h3 className="text-base font-semibold text-text-primary">{note.title}</h3>
)}
{/* 正文 */}
{note.content && (
<p className="text-sm text-text-secondary whitespace-pre-wrap leading-relaxed">
{note.content}
</p>
)}
{/* 标签 */}
{note.tags && note.tags.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{note.tags.map((t, i) => (
<span key={i} className="text-xs text-brand-orange">#{t}</span>
))}
</div>
)}
{/* 生成图 */}
{okImages.length > 0 && (
<div className="grid grid-cols-3 gap-2">
{okImages.map((im) => (
<div key={im.seq} className="flex flex-col gap-1">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={imgSrc(im.url) || ''}
alt={im.role}
className="w-full aspect-[3/4] object-cover rounded-lg border border-border-default"
/>
<span className="text-[11px] text-text-tertiary text-center">{im.role}</span>
</div>
))}
</div>
)}
{failedImages.length > 0 && (
<p className="text-xs text-amber-600">
{failedImages.length}
</p>
)}
{/* imagePlan 排版说明(运营按此排版/叠字) */}
{note.imagePlan && note.imagePlan.length > 0 && (
<details className="text-sm">
<summary className="cursor-pointer text-text-secondary">
{note.imagePlan.length}
</summary>
<ol className="mt-2 flex flex-col gap-2 list-decimal list-inside">
{note.imagePlan.map((p, i) => (
<li key={i} className="text-xs text-text-tertiary">
<span className="text-text-secondary">{p.role || `${i + 1}`}</span>
{p.title && <> · {p.title}</>}
{p.overlayText && <> · {p.overlayText}</>}
{p.text && <> · {p.text}</>}
</li>
))}
</ol>
</details>
)}
</div>
);
}

View File

@@ -0,0 +1,108 @@
'use client';
/**
* FissionProgress — 裂变进度(轮询 GET /fission/{id} 聚合 x/N 完成)
* 新架构:一次 LLM 出 N 套完整笔记包落 FissionNote完成后进独立展示页看 N 套。
*/
import { useEffect, useState, useCallback } from 'react';
import Link from 'next/link';
import { api } from '@/lib/api';
interface FissionStatus {
fission_id: number;
reference_level: string;
fanout_count: number;
status: 'generating' | 'completed' | 'failed';
progress: { done: number; failed: number; total: number };
}
interface Props {
fissionId: number;
onReset: () => void;
}
export function FissionProgress({ fissionId, onReset }: Props) {
const [data, setData] = useState<FissionStatus | null>(null);
const [error, setError] = useState<string | null>(null);
const poll = useCallback(async () => {
try {
const res = await api.get<FissionStatus>(`/api/v1/fission/${fissionId}`);
setData(res);
return res.status;
} catch {
setError('查询裂变进度失败');
return 'failed';
}
}, [fissionId]);
useEffect(() => {
let timer: ReturnType<typeof setTimeout>;
let stopped = false;
const tick = async () => {
const status = await poll();
if (!stopped && status === 'generating') timer = setTimeout(tick, 3000);
};
tick();
return () => { stopped = true; clearTimeout(timer); };
}, [poll]);
if (error) {
return (
<div className="rounded-lg bg-red-50 border border-red-200 p-4 text-sm text-red-600">
{error}
<button className="ml-4 underline" onClick={onReset}></button>
</div>
);
}
if (!data) return <div className="py-12 text-center text-text-tertiary text-sm"></div>;
const { progress, status } = data;
const pct = progress.total > 0 ? Math.round((progress.done / progress.total) * 100) : 0;
return (
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<h2 className="text-base font-semibold text-text-primary">
#{data.fission_id} · {data.fanout_count}
</h2>
<span className="text-sm text-text-secondary">
{progress.done}/{progress.total || data.fanout_count}
{progress.failed > 0 && <span className="text-red-500"> · {progress.failed} </span>}
</span>
</div>
<div className="w-full h-2 bg-surface-tertiary rounded-full overflow-hidden">
<div
className="h-full bg-brand-orange transition-all duration-500"
style={{ width: `${pct}%` }}
/>
</div>
{status === 'generating' && (
<p className="text-sm text-text-tertiary"> {data.fanout_count} </p>
)}
{status === 'failed' && (
<div className="rounded-lg bg-red-50 border border-red-200 p-3 text-sm text-red-600">
</div>
)}
{status === 'completed' && (
<Link
href={`/fission/${data.fission_id}/notes`}
className="self-start rounded-lg bg-brand-orange text-white px-5 py-2.5 text-sm font-medium hover:opacity-90 transition-opacity"
>
{data.fanout_count}
</Link>
)}
<button
type="button"
onClick={onReset}
className="self-start text-sm text-text-secondary underline mt-2"
>
</button>
</div>
);
}

View File

@@ -0,0 +1,66 @@
# 裂变重写核销表(对齐上线版 split.js一次出N套完整包
> 倩倩姐 2026-06 拍板:①架构=一次LLM出N套完整笔记包 ②N套存FissionNote表+独立展示页 ③参考度保留三档枚举,内部映射数值(low=35/mid=60/high=82)。
> 断点可续;做完一项物理勾一项;报✅必附真实证据。
## 🔴 规则清单(开工前每人逐条确认,违反即返工)
| # | 红线/约束 | 后果 |
|---|---|---|
| R1 | QUALITY_PASS_SCORE=80 任何地方不得改 | 返工 |
| R2 | 生图引擎零改动:不碰 image_gen.py/storyboard.py/pipeline_io.run_image_generation | 返工 |
| R3 | 模型最强档:文字 claude-opus-4-8回落 gpt-5.5(codeproxy),绝不降 sonnet | 返工 |
| R4 | 新建文件目标≤100行上限200行单次编辑≤100行超先建框架再Edit | — |
| R5 | 裂变三档真实措辞待北哥定义,本次用占位但接口按"内部数值"做对 | — |
| R6 | 差异化质量验收北哥过目N套真差异搁置不在本次范围 | — |
| R7 | LLM 走 chat_complete(OpenAI兼容 messages[system,user]),不是上线版/v1/messages原生端点 | 风险1 |
| R8 | FISSION_SYSTEM 作 messages[0].role=system 传不是顶级system字段 | 风险1 |
| R9 | llm_score_copy 是 asyncCelery同步环境用 asyncio.run 不能直接await | 风险 |
| R10 | 参考度入参保留 low/mid/high内部 _LEVEL_TO_INT 映射DB列不动不加迁移 | 已拍板 |
## 数据结构(已拍板)
- **新建 FissionNote 表**018迁移每套笔记一行存 文案JSON+score+passed+imagePlan+images+status。
- reference_level DB列保持 String(low/mid/high)**不改**。
- FissionTask 状态机pending→generating_text→text_done→generating_images→done/failed
- FissionNote 状态pending→scored→image_done→failed
## 文件改动清单与核销
### 后端
| # | 文件 | 改什么 | 编辑轮次 | 状态 |
|---|------|--------|---------|------|
| B1 | models/fission.py | 追加 FissionNote class(~50行) | 1次 | ✅ 已加(103行,含idx) |
| B2 | alembic/versions/018_fission_notes_table.py | 新建迁移 create_table+index(~40行) | 新建 | ✅ 已建(rev018←017) |
| B3 | ai_engine/fission_prompt.py | 全量重写:_LEVEL_TO_INT+reference_strategy_from_level+FISSION_SYSTEM+build_fission_prompt+sanitize_image_plan_text+normalize_tags+notes_array_from_parsed | 4次 | ✅ 183行;品类兜底超200行拆至 fission_fallback.py(145行) |
| B4 | services/fission_service.py | 重写:create_fission改丢Celery+编排(一次LLM出N套→评分→排序→落FissionNote→喂生图) | 4次 | ✅ 超200行拆3文件:service入口76行/fission_pipeline.py编排179行/fission_images.py生图81行 |
| B5 | workers/tasks.py | 追加 run_fission_pipeline Celery task(~35行);删旧裂变扇出注入(build_fission_context已删) | 1次 | ✅ 已注册(celery inspect registered见run_fission_pipeline);实测task5 worker日志"run_fission_pipeline start: fission_id=5 src=76"真触发 |
| B6 | api/v1/fission.py | 进度改读FissionNote聚合+新增 GET /fission/{id}/notes | 2次 | ✅ 117行,py_compile过,清掉GenerationTask import,进度按FissionNote.status聚合,notes端点剥_score/_passed内部字段+workspace隔离 |
### 前端
| # | 文件 | 改什么 | 状态 |
|---|------|--------|------|
| F1 | components/fission/FissionProgress.tsx | 进度数据源改读FissionNote结构 | ✅ status对齐generating/completed/failed,去掉子任务入口,完成跳/fission/{id}/notes展示页 |
| F2 | app/fission/[id]/notes 展示页(或组件) | 新建N套笔记展示页(标题/正文/标签/封面/imagePlan/图) | ✅ page.tsx(116行框架+类型)+FissionNoteCard.tsx(分数徽章/文案/标签/图网格/imagePlan),tsc全过 |
| F3 | FissionLauncher.tsx | 三档保留,零改动(确认即可) | ✅ low/mid/high三档+POST契约(reference_level/fanout_count/回调fission_id)与新后端一致,零改动 |
## 生图触发方案(已定)
方案一裂变Celery任务内对每套FissionNote串行调 generate_storyboard_images(现有接口零改动),结果存 FissionNote.images_json。评分用 asyncio 并发限2(参考image并发模式)。
## 验证方案
- TDD: tests/test_fission_engine.py — reference_strategy_from_level/sanitize/normalize_tags/build_fallback_notes/infer_category/评分每套独立@80 | ✅ 19 passed(197行)
- 集成: mock LLM固定JSON → parse N套→各评分→合格套排序→不合格降草稿不丢弃 | ✅ test_fission_engine(parse 4例:全套/imagePlan校正/markdown包裹/兜底不崩) + test_fission_pipeline.py(_score_notes 5例:合格线/违禁词硬拦/异常记0/多套) 全过
- 端到端: POST /fission→状态done; 一次LLM拿N套完整字段; imagePlan数=image_count; score<80标needs_optimization非丢弃; 兜底不报500; 生图走现有接口 | ✅ **真实容器实测task5(fanout=2,mid,src=76)2026-06-18跑通**: POST返回fission_id=5→worker一次apiports/chat拿2套完整(title/content/tags/imagePlan)→seq0=85分passed/seq1=79分needs_optimization=1非丢弃(R1守住)→生图走codeproxy/v1/images/edits(R2现有接口零改动)→落FissionNote→task=completed→GET/fission/5/notes返2套完整数据内部字段(_score/_passed)零泄漏;图1024×1536(对齐大卫去AI化未强缩);apiports503→回落codeproxy gpt-5.5(R3铁律真生效,日志"强档兜底非降级");seq1 hook图codeproxy重试3次失败被捕获记error不阻塞不崩任务(健壮性正确)
- 交叉审: 另起独立agent重核, 不信第一遍 | ⏳ 进行中
- 全量回归: tests/ 104 passed(顺手补 test_integration_seams mock product 缺 brand_keyword/target_audience 字段,非裂变引入的预存在失败)
## 风险点
1. FISSION_SYSTEM端点格式(chat_complete非/v1/messages) — 最易踩
2. max_tokens按note_count缩放,实测超时加FISSION_MAX_TOKENS env
3. FissionNote前端展示(已定独立展示页F2)
4. build_fallback_notes品类映射表完整移植(~80行,单独一次编辑)
5. 评分并发vs串行(asyncio.gather限2)
6. FissionProgress进度数据源同步改(F1)
## 搁置/待北哥(攒批)
- 裂变三档/参考度真实业务措辞
- N套真差异化质量验收(北哥过目)