Files
beige/backend/app/api/v1/review.py
yangqianqian ea423e8a32 fix(C2): 审核台显示文案质量分(总分+7维明细)
审核要能判断,原审核队列接口不返回分数=组长盲审。
- review.py _fmt_queue_item: 解析selected_text.score_json,复用现成
  _score_array_to_obj(tasks.py)算总分+透传维度,绝不读eval_score(留NULL)
- dto.tasks.ts: ReviewQueueItem.selected_text 加 score?:TextScore|null
- ReviewCard.tsx: 复用现成ScoreDimBars组件,passLine=80(合格线红线,
  不用组件默认85)

端到端验:task40审核队列返回total=83+5维明细,新旧维度兼容。tsc EXIT0。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 12:28:56 +08:00

151 lines
5.7 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/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_text = db.query(TextCandidate).filter(
TextCandidate.task_id == t.id, TextCandidate.is_selected == True
).first()
selected_image = db.query(ImageCandidate).filter(
ImageCandidate.task_id == t.id, ImageCandidate.is_selected == True
).first()
# content 列存 JSON dict解包取 title/content 分字段返回
text_parsed: dict = {}
if selected_text and selected_text.content:
try:
text_parsed = json.loads(selected_text.content)
except (json.JSONDecodeError, ValueError):
text_parsed = {"content": selected_text.content}
# 质量分:解析 score_json 明细数组算总分+透传维度(绝不读 eval_score,设计留NULL)
text_score = None
if selected_text and 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 {
"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": {
"candidate_id": selected_text.id,
"angle_label": selected_text.angle_label,
"title": text_parsed.get("title", ""),
"content": text_parsed.get("content", ""), # 纯正文
"tags": text_parsed.get("tags", []),
"score": text_score, # 审核显分:总分+7维明细(C2)
} if selected_text else None,
"selected_image": {
"candidate_id": selected_image.id,
"strategy": selected_image.strategy,
"url": selected_image.url,
} if selected_image else None,
}
@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"})