""" app/api/v1/fission.py — 第11环裂变路由(对齐上线版 split.js:一次出N套) POST /fission 触发裂变(1源任务→一次LLM出N套完整笔记包) GET /fission/{id} 查裂变进度(聚合 FissionNote 落库/生图状态) GET /fission/{id}/notes 取 N 套完整笔记包(独立展示页用) 工作台内呈现,N 套笔记落 FissionNote 表,非扇出 GenerationTask。 """ import json import logging from typing import Annotated from fastapi import APIRouter, Depends from pydantic import BaseModel, Field from sqlalchemy.orm import Session from app.core.database import get_db from app.core.response import ok, raise_not_found from app.middleware.workspace_guard import CurrentUser, require_write_permission from app.models.fission import FissionTask, FissionNote logger = logging.getLogger(__name__) router = APIRouter(tags=["fission"]) class FissionCreate(BaseModel): source_task_id: int reference_level: str = Field(default="mid") # low/mid/high fanout_count: int = Field(default=3, ge=1, le=5) @router.post("/fission") def create_fission_endpoint( body: FissionCreate, current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None, db: Session = Depends(get_db), ): """触发裂变:从源任务一次出 N 套完整笔记包。返回 fission_id。""" from app.services.fission_service import create_fission fission_id, _ = create_fission( db, current_user, body.source_task_id, body.reference_level, body.fanout_count, ) return ok({"fission_id": fission_id, "reference_level": body.reference_level, "fanout_count": body.fanout_count}) @router.get("/fission/{fission_id}") def get_fission_progress( fission_id: int, current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None, db: Session = Depends(get_db), ): """查裂变进度:聚合 FissionNote 落库/生图状态(x/N 完成)。""" ft = db.query(FissionTask).filter( FissionTask.id == fission_id, FissionTask.workspace_id == current_user.workspace_id, ).first() if not ft: raise_not_found("裂变任务不存在") notes = db.query(FissionNote).filter( FissionNote.fission_id == fission_id, FissionNote.workspace_id == current_user.workspace_id, ).all() done = sum(1 for n in notes if n.status == "image_done") failed = sum(1 for n in notes if n.status == "failed") total = len(notes) # FissionTask.status 由 pipeline 维护(generating→completed/failed);此处只读不改, # 避免 API 轮询与 worker 写状态打架。进度数 = 已落库套数的生图完成度。 return ok({ "fission_id": ft.id, "reference_level": ft.reference_level, "fanout_count": ft.fanout_count, "status": ft.status, "progress": {"done": done, "failed": failed, "total": total}, }) @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}) @router.post("/fission/{fission_id}/notes/{seq}/retry-images") def retry_fission_note_images_endpoint( fission_id: int, seq: int, current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None, db: Session = Depends(get_db), ): """补图:对某一套只重生标了 error 的失败分镜(中转站波动兜底)。 异步丢 Celery;前端轮询 GET /fission/{id}/notes 看 images 更新。 """ ft = db.query(FissionTask).filter( FissionTask.id == fission_id, FissionTask.workspace_id == current_user.workspace_id, ).first() if not ft: raise_not_found("裂变任务不存在") fn = db.query(FissionNote).filter( FissionNote.fission_id == fission_id, FissionNote.workspace_id == current_user.workspace_id, FissionNote.seq == seq, ).first() if not fn: raise_not_found("该套笔记不存在") try: images = json.loads(fn.images_json) if fn.images_json else [] except (ValueError, TypeError): images = [] failed = [img.get("role") for img in images if img.get("error")] if not failed: return ok({"fission_id": fission_id, "seq": seq, "queued": False, "message": "该套无失败分镜,无需补图"}) from app.workers.tasks import retry_fission_images retry_fission_images.delay(fission_id, fn.id) return ok({"fission_id": fission_id, "seq": seq, "note_id": fn.id, "queued": True, "failed_roles": failed})