Files
beige/backend/app/api/v1/tasks.py
yangqianqian 6a2632da70 baseline: Clover 独立仓库首次基线提交
将 Clover 从上层产品包旧仓库中独立出来,建立专属版本控制。
当前状态=纵切片端到端已打通(登录→选品→出文出图→审核→下载包),
M1文案质量去套路化已验收。此提交作为后续按核销清单逐条修复的基线。

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

192 lines
7.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/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,
"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", ""), # 一句话总评含改进点
}
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,
}
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],
})