Files
beige/backend/app/api/v1/review.py
yangqianqian df1856d793 上线版: 产品表单统一+form嵌套修复+用户管理+部署+三套叙事
- 产品编辑入口统一走 ProductFormFull(卖点/风格/人群/品牌词全字段);
  修复开任务页 <form> 套 <form> 致"编辑产品"报错、改不了、跳回首个产品
- dashboard 入口卡片对齐实际路由: 系统管理(/config) 与 工作配置(/settings) 分开;
  settings ?tab=products 直达改用挂载后读 URL, 消除 hydration mismatch
- 新增用户管理(users API/admin service/改密页) + alembic 022/023/024
- 上线部署: Dockerfile / docker-compose.prod+https / nginx https / .env.example
- A8 三套正交叙事(痛点/场景/成分背书) + beige 调色去AI化 + 飞轮 text_import 高权重信号

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

191 lines
7.3 KiB
Python
Raw Permalink 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_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"})