第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,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}