""" 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], })