第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:
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}
|
||||
Reference in New Issue
Block a user