""" app/api/v1/review.py — 审核路由(组长) 通过(+5,最重) / 打回(写原因,-3,回 pending_selection) 打回原因存 preference_events.reason,不做 AI 归纳。 """ import logging from datetime import datetime, timezone from typing import Annotated from fastapi import APIRouter, Depends from pydantic import BaseModel from sqlalchemy.orm import Session from app.core.database import get_db from app.core.response import ok, paginate, raise_not_found, raise_state_invalid from app.middleware.workspace_guard import CurrentUser, require_supervisor_or_above from app.models.task import GenerationTask, ImageCandidate, TextCandidate from app.models.user import User logger = logging.getLogger(__name__) router = APIRouter(prefix="/review", tags=["review"]) class RejectRequest(BaseModel): reason: str # 原文存入 preference_events,不做 AI 归纳(契约§3) @router.get("/queue") def review_queue( page: int = 1, page_size: int = 20, current_user: Annotated[CurrentUser, Depends(require_supervisor_or_above)] = None, db: Session = Depends(get_db), ): """待审队列(pending_review 状态)。""" q = ( db.query(GenerationTask) .filter( GenerationTask.workspace_id == current_user.workspace_id, GenerationTask.status == "pending_review", ) .order_by(GenerationTask.updated_at.asc()) ) total = q.count() tasks = q.offset((page - 1) * page_size).limit(page_size).all() items = [_fmt_queue_item(t, db) for t in tasks] return ok(paginate(items, total, page, page_size)) def _fmt_queue_item(t: GenerationTask, db: Session) -> dict: import json from app.models.product import Product from app.api.v1.tasks import _score_array_to_obj product = db.query(Product).filter(Product.id == t.product_id).first() operator = db.query(User).filter(User.id == t.operator_id).first() selected_texts = db.query(TextCandidate).filter( TextCandidate.task_id == t.id, TextCandidate.is_selected == True ).order_by(TextCandidate.strategy.asc(), TextCandidate.id.asc()).all() selected_images = db.query(ImageCandidate).filter( ImageCandidate.task_id == t.id, ImageCandidate.is_selected == True ).order_by(ImageCandidate.strategy.asc(), ImageCandidate.seq.asc()).all() selected_text = selected_texts[0] if selected_texts else None selected_image = selected_images[0] if selected_images else None def _fmt_selected_text(selected_text: TextCandidate | None) -> dict | None: if not selected_text: return None text_parsed: dict = {} try: text_parsed = json.loads(selected_text.content or "{}") except (json.JSONDecodeError, ValueError): text_parsed = {"content": selected_text.content} text_score = None if selected_text.score_json: try: text_score = _score_array_to_obj(json.loads(selected_text.score_json)) except (json.JSONDecodeError, ValueError): text_score = None return { "candidate_id": selected_text.id, "strategy": selected_text.strategy, "angle_label": selected_text.angle_label, "title": text_parsed.get("title", ""), "content": text_parsed.get("content", ""), "tags": text_parsed.get("tags", []), "score": text_score, } def _fmt_selected_image(selected_image: ImageCandidate | None) -> dict | None: if not selected_image: return None return { "candidate_id": selected_image.id, "strategy": selected_image.strategy, "url": selected_image.url, "role": selected_image.role, "seq": selected_image.seq, } images_by_strategy: dict[str, list[ImageCandidate]] = {} for img in selected_images: images_by_strategy.setdefault(img.strategy or "_", []).append(img) selected_sets = [] for strategy in ("A", "B", "C"): text = next((tc for tc in selected_texts if tc.strategy == strategy), None) imgs = images_by_strategy.get(strategy, []) if text or imgs: selected_sets.append({ "strategy": strategy, "selected_text": _fmt_selected_text(text), "selected_images": [_fmt_selected_image(img) for img in imgs], }) # 质量分:解析 score_json 明细数组算总分+透传维度(绝不读 eval_score,设计留NULL) legacy_text = _fmt_selected_text(selected_text) legacy_image = _fmt_selected_image(selected_image) # 兼容旧前端字段:selected_text/selected_image 仍返回第一套第一张。 if legacy_text: legacy_text.pop("strategy", None) if legacy_image: legacy_image = { "candidate_id": legacy_image["candidate_id"], "strategy": legacy_image["strategy"], "url": legacy_image["url"], } return { "task_id": t.id, "product_name": product.name if product else None, "theme": t.theme, "submitted_at": t.updated_at.isoformat(), "operator_name": operator.username if operator else None, "selected_text": legacy_text, "selected_image": legacy_image, "selected_sets": selected_sets, } @router.post("/{task_id}/approve") def approve_task( task_id: int, current_user: Annotated[CurrentUser, Depends(require_supervisor_or_above)] = None, db: Session = Depends(get_db), ): """通过(飞轮 +5,最重,状态 → approved)。""" from app.services.flywheel_service import record_signal task = db.query(GenerationTask).filter( GenerationTask.id == task_id, GenerationTask.workspace_id == current_user.workspace_id, ).first() if not task: raise_not_found("任务不存在") if task.status != "pending_review": raise_state_invalid("只有 pending_review 状态可审核") task.status = "approved" task.review_status = "approved" task.reviewer_id = current_user.user_id task.reviewed_at = datetime.now(timezone.utc) task.approved_at = datetime.now(timezone.utc) db.commit() record_signal(db, current_user, task, "approve") return ok({"task_id": task_id, "status": "approved"}) @router.post("/{task_id}/reject") def reject_task( task_id: int, body: RejectRequest, current_user: Annotated[CurrentUser, Depends(require_supervisor_or_above)] = None, db: Session = Depends(get_db), ): """打回(飞轮 -3,回 pending_selection,原因下次注入 prompt)。""" from app.services.flywheel_service import record_signal task = db.query(GenerationTask).filter( GenerationTask.id == task_id, GenerationTask.workspace_id == current_user.workspace_id, ).first() if not task: raise_not_found("任务不存在") if task.status != "pending_review": raise_state_invalid("只有 pending_review 状态可打回") task.status = "pending_selection" # 打回回 pending_selection task.review_status = "rejected" task.reviewer_id = current_user.user_id task.reviewed_at = datetime.now(timezone.utc) task.reject_reason = body.reason db.commit() record_signal(db, current_user, task, "reject_with_reason", reason=body.reason) return ok({"task_id": task_id, "status": "pending_selection"})