Files
beige/backend/app/api/v1/fission.py
yangqianqian 285791c12f 裂变补图:中转站波动导致单张失败时只重生失败分镜
实测发现 codeproxy 偶发不稳,单套中某张图重试3次仍失败会记 error。
本次加按需补图:只重生标 error 的分镜,成功的图与 token 不浪费。

- 019迁移+模型:fission_tasks 加 source_task_id(新架构不扇出GenerationTask,
  补图需复刻首轮上下文:产品/operator key/张数,故落库回查)
- fission_image_retry:只补失败 role(引擎 target_role 单张重生,R2零改动),
  merge 回 images_json,全恢复则 status=image_done
- tasks.py:retry_fission_images Celery task,key归属源任务operator(基石B),
  幂等锁防重复补图
- api:POST /fission/{id}/notes/{seq}/retry-images,无失败分镜守卫 queued=false
- 前端:FissionNoteCard 失败提示改"重试补图"按钮+loading,父页轮询刷新

实测task5 seq1(hook图首轮codeproxy重试3次失败)补图跑通:只补hook(1024×1536
去AI化)、applied_proof原图不动、recovered=1 still_failed=0、status=image_done;
apiports503回落codeproxy gpt-5.5强档兜底;seq0无失败图守卫queued=false

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 11:30:41 +08:00

159 lines
5.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
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})