Files
beige/backend/app/api/v1/tasks.py
yangqianqian 7f419f4c8b 存量积累:生图素人感约束+评图分+幂等防重跑+审核回路
- 015-017迁移:image_candidate 文案复审/AI视觉分/重生标记
- constants C7素人感约束(反电商摆拍对齐真实笔记)+C3叠字口子
- celery visibility_timeout=2h 防长任务被误判重投重复烧钱(task75教训)
- image_scorer 评图分(只筛选+展示,真实信号才进飞轮权重)
- storyboard/image_gen/pipeline_io 生图存量
- task_actions/tasks/task_service/flywheel 审核回路+飞轮存量

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

200 lines
8.6 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/tasks.py — 任务路由(查询层)
发起任务 / 任务列表 / 任务详情
操作类端点(选文案/选图/导入/重生成/提审/偏好上下文)在 task_actions.py。
"""
import logging
from typing import Annotated
from fastapi import APIRouter, Depends, Query
from pydantic import BaseModel, field_validator
from sqlalchemy.orm import Session
from app.core.database import get_db
from app.core.response import ok, paginate, raise_not_found
from app.middleware.workspace_guard import CurrentUser, require_write_permission
from app.models.task import GenerationTask, ImageCandidate, TextCandidate
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/tasks", tags=["tasks"])
# ── DTO ────────────────────────────────────────────────────
class CreateTaskRequest(BaseModel):
product_id: int
benchmark_ids: list[int] = []
theme: str | None = None
text_count: int = 5 # 不写死基石A用户设定
image_count: int = 3 # 不写死基石A用户设定
track: str = "ai" # ai | import
need_product_image: bool = True # 本次产品是否入镜True=无图禁生成不降级
@field_validator("text_count", "image_count")
@classmethod
def positive(cls, v: int) -> int:
if v < 1 or v > 20:
raise ValueError("数量范围 1-20")
return v
@field_validator("track")
@classmethod
def valid_track(cls, v: str) -> str:
if v not in ("ai", "import"):
raise ValueError("track 必须是 ai 或 import")
return v
class SelectCandidateRequest(BaseModel):
candidate_id: int
class ImportTextRequest(BaseModel):
content: str
angle_label: str | None = None
# ── 格式化辅助 ──────────────────────────────────────────────
def _fmt_task(t: GenerationTask) -> dict:
return {
"id": t.id, "product_id": t.product_id, "theme": t.theme,
"status": t.status, "text_count": t.text_count, "image_count": t.image_count,
"track": t.track, "need_product_image": t.need_product_image,
# 审核回路:打回原因+审核状态,任务页/历史页展示"为何被打回"(R4-b死链修复,R8历史复用)
"review_status": t.review_status,
"reject_reason": t.reject_reason,
"reviewed_at": t.reviewed_at.isoformat() if t.reviewed_at else None,
"created_at": t.created_at.isoformat(),
}
def _score_array_to_obj(arr) -> dict:
"""score_json 是评分明细数组 [{item,score,max,note}]。
AI评委7维(2026-06-15新版痛点18/情绪18/买点18/钩子15/标题13/真实感13+合规5)
或旧机械5维均可处理。
total 直接对明细求和(不依赖固定key),透传 dims 给前端 ScoreDimBars 数据驱动渲染。
同时填旧5维key保持兼容(能映射的填映射不上的为0)。"""
# 旧5维 + 新7维维度名 → 旧key 的映射表(兼容新旧两套维度名)
legacy = {
# 旧机械维度
"标题吸引力": "title", "标题点击力": "title",
"情绪共鸣": "emotion", "情绪张力": "emotion",
"买点表达": "selling", "买点转化": "selling",
"关键词覆盖": "keyword", "合规性": "compliance",
# 新AI评委维度2026-06-15→ 最近似旧key
"痛点人群精准": "keyword", # 痛点人群精准语义最接近旧关键词(均为"内容精准度")
"开头钩子": "emotion", # 钩子即情绪抓点,归入 emotion
"真实感": "selling", # 真实感/买点转化同属"用户感知"维度
# "产品聚焦一件事" 已被"真实感"替换,保留映射避免旧存量数据报 KeyError
"产品聚焦一件事": "selling",
}
obj = {"title": 0, "emotion": 0, "selling": 0, "keyword": 0, "compliance": 0,
"total": 0, "dims": []}
if isinstance(arr, list):
total = 0
for d in arr:
if not isinstance(d, dict):
continue
item = d.get("item", "")
sc = d.get("score", 0) or 0
total += sc
obj["dims"].append({"item": item, "score": sc,
"max": d.get("max", 0), "note": d.get("note", "")})
key = legacy.get(item)
if key:
obj[key] = sc
obj["total"] = max(0, min(100, total))
return obj
def _fmt_text(tc: TextCandidate) -> dict:
import json
# content 列存整条 JSON dict含 title/content/tags/angle 等),解包后分字段返回
parsed: dict = {}
if tc.content:
try:
parsed = json.loads(tc.content)
except (json.JSONDecodeError, ValueError):
# 兼容旧数据:如果 content 已经是纯正文则原样返回
parsed = {"content": tc.content}
score_raw = json.loads(tc.score_json) if tc.score_json else None
return {
"candidate_id": tc.id,
"angle_label": tc.angle_label,
"title": parsed.get("title", ""),
"content": parsed.get("content", ""), # 纯正文,前端直接展示
"tags": parsed.get("tags", []),
"cover_title": parsed.get("coverTitle", ""),
"image_brief": parsed.get("imageBrief", ""),
"source": tc.source,
"score": _score_array_to_obj(score_raw), # 转成 {title,emotion,...,total,dims} 对象
"banned_word_status": tc.banned_word_status,
"is_selected": tc.is_selected,
# AI 评委总评verdict/summary 存在 content 列,新数据有,旧数据为空字符串降级)
"verdict": parsed.get("verdict", ""), # "优秀"|"合格"|"不合格"
"summary": parsed.get("summary", ""), # 一句话总评含改进点
"edited": tc.edited, # D1 改稿标记(飞轮最强信号)
}
def _fmt_image(ic: ImageCandidate) -> dict:
return {
"candidate_id": ic.id, "role": ic.role, "url": ic.url,
"strategy": ic.strategy, "seq": ic.seq, "is_selected": ic.is_selected,
"is_regen": getattr(ic, "is_regen", False), # R2 重生标记(前端同strategy+role去重展示最新)
# E12 AI评图分纯展示+排序,绝不进飞轮权重(eval_score 另留 NULL)
"ai_visual_score": ic.ai_visual_score, "ai_visual_note": ic.ai_visual_note,
}
def _check_task_ownership(task: GenerationTask | None, workspace_id: int) -> GenerationTask:
if not task or task.workspace_id != workspace_id:
raise_not_found("任务不存在")
return task
# ── 路由 ───────────────────────────────────────────────────
@router.post("")
def create_task(
body: CreateTaskRequest,
current_user: Annotated[CurrentUser, Depends(require_write_permission)],
db: Session = Depends(get_db),
):
"""发起任务:校验有无 key → 只推 task_id 入队,绝不传 key。"""
from app.services.task_service import create_generation_task
task = create_generation_task(db, current_user, body)
return ok(_fmt_task(task))
@router.get("")
def list_tasks(
page: int = 1, page_size: int = 20,
status: list[str] | None = Query(default=None),
current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None,
db: Session = Depends(get_db),
):
q = db.query(GenerationTask).filter(GenerationTask.workspace_id == current_user.workspace_id)
if status:
# 支持多状态(?status=approved&status=rejected),单值也兼容
q = q.filter(GenerationTask.status.in_(status))
total = q.count()
items = q.order_by(GenerationTask.created_at.desc()).offset((page - 1) * page_size).limit(page_size).all()
return ok(paginate([_fmt_task(t) for t in items], total, page, page_size))
@router.get("/{task_id}")
def get_task(
task_id: int,
current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None,
db: Session = Depends(get_db),
):
task = db.query(GenerationTask).filter(GenerationTask.id == task_id).first()
task = _check_task_ownership(task, current_user.workspace_id)
texts = db.query(TextCandidate).filter(TextCandidate.task_id == task_id).all()
images = db.query(ImageCandidate).filter(ImageCandidate.task_id == task_id).all()
return ok({
**_fmt_task(task),
"text_candidates": [_fmt_text(tc) for tc in texts],
"image_candidates": [_fmt_image(ic) for ic in images],
})