Files
beige/backend/app/api/v1/tasks.py
2026-07-01 14:48:18 +08:00

356 lines
15 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/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_admin,
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,
"strategy": tc.strategy, # A/B/C 正交叙事套(前端按套分组展示)
"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),
date_from: str | None = Query(default=None, description="起始日(YYYY-MM-DD)"),
date_to: str | None = Query(default=None, description="结束日(YYYY-MM-DD,含当日)"),
product_id: int | None = Query(default=None),
current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None,
db: Session = Depends(get_db),
):
from datetime import datetime, timedelta
from app.models.product import Product
include_fission = bool(status and "fission" in status)
task_statuses = [s for s in (status or []) if s != "fission"]
q = db.query(GenerationTask).filter(GenerationTask.workspace_id == current_user.workspace_id)
if task_statuses:
# 支持多状态(?status=approved&status=rejected),单值也兼容
q = q.filter(GenerationTask.status.in_(task_statuses))
if product_id is not None:
q = q.filter(GenerationTask.product_id == product_id)
# 日期筛选date 形参非法 → 40001(不静默吞成全量,防运营误判)
try:
if date_from:
q = q.filter(GenerationTask.created_at >= datetime.fromisoformat(date_from))
if date_to:
# date_to 含当日:取次日 0 点为开区间上界,覆盖当日全部时刻
_end = datetime.fromisoformat(date_to) + timedelta(days=1)
q = q.filter(GenerationTask.created_at < _end)
except ValueError:
from app.core.response import raise_param_error
raise_param_error("date_from/date_to 需为 YYYY-MM-DD 格式")
if not include_fission:
total = q.count()
items = q.order_by(GenerationTask.created_at.desc()).offset((page - 1) * page_size).limit(page_size).all()
# product_name 一次性批量查(防 N+1):收集本页 product_id → id→name dict
pids = {t.product_id for t in items if t.product_id}
name_map: dict[int, str] = {}
if pids:
for p in db.query(Product.id, Product.name).filter(Product.id.in_(pids)).all():
name_map[p.id] = p.name
rows = []
for t in items:
d = _fmt_task(t)
d["item_type"] = "task"
d["product_name"] = name_map.get(t.product_id)
rows.append(d)
return ok(paginate(rows, total, page, page_size))
from app.models.fission import FissionTask, FissionNote
task_items = q.all() if task_statuses else []
fission_q = db.query(FissionTask).filter(FissionTask.workspace_id == current_user.workspace_id)
try:
if date_from:
fission_q = fission_q.filter(FissionTask.created_at >= datetime.fromisoformat(date_from))
if date_to:
_end = datetime.fromisoformat(date_to) + timedelta(days=1)
fission_q = fission_q.filter(FissionTask.created_at < _end)
except ValueError:
from app.core.response import raise_param_error
raise_param_error("date_from/date_to 需为 YYYY-MM-DD 格式")
fission_items = fission_q.all()
source_ids = {f.source_task_id for f in fission_items if f.source_task_id}
source_tasks: dict[int, GenerationTask] = {}
if source_ids:
for t in db.query(GenerationTask).filter(GenerationTask.id.in_(source_ids)).all():
if t.workspace_id == current_user.workspace_id:
source_tasks[t.id] = t
if product_id is not None:
fission_items = [
f for f in fission_items
if f.source_task_id and source_tasks.get(f.source_task_id)
and source_tasks[f.source_task_id].product_id == product_id
]
# product_name 一次性批量查(防 N+1):收集本页 product_id → id→name dict
pids = {t.product_id for t in task_items if t.product_id}
pids.update(
t.product_id for t in source_tasks.values()
if t.product_id and (product_id is None or t.product_id == product_id)
)
name_map: dict[int, str] = {}
if pids:
for p in db.query(Product.id, Product.name).filter(Product.id.in_(pids)).all():
name_map[p.id] = p.name
rows = []
for t in task_items:
d = _fmt_task(t)
d["item_type"] = "task"
d["product_name"] = name_map.get(t.product_id)
rows.append(d)
for f in fission_items:
source = source_tasks.get(f.source_task_id) if f.source_task_id else None
notes = db.query(FissionNote).filter(
FissionNote.fission_id == f.id,
FissionNote.workspace_id == current_user.workspace_id,
).all()
image_count = 0
for n in notes:
import json
try:
images = json.loads(n.images_json) if n.images_json else []
except (TypeError, ValueError):
images = []
image_count += sum(1 for im in images if isinstance(im, dict) and im.get("url") and not im.get("error"))
rows.append({
"id": f.id,
"item_type": "fission",
"product_id": source.product_id if source else 0,
"product_name": name_map.get(source.product_id) if source else None,
"theme": (source.theme if source and source.theme else f"裂变笔记 #{f.id}"),
"status": "fission",
"created_at": f.created_at.isoformat(),
"text_count": len(notes),
"image_count": image_count,
"review_status": None,
"reject_reason": None,
"reviewed_at": None,
})
rows.sort(key=lambda x: x["created_at"], reverse=True)
total = len(rows)
start = (page - 1) * page_size
rows = rows[start:start + page_size]
return ok(paginate(rows, 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],
})
@router.delete("/{task_id}")
def delete_task(
task_id: int,
current_user: Annotated[CurrentUser, Depends(require_admin)] = None,
db: Session = Depends(get_db),
):
"""删除整个图文任务仅管理员倩倩姐2026-06-26拍板
智能删:有成品(delivery_packages)→转归档保留(status=archived不进看板)
无成品(残次/废稿)→物理删(先清 ai_call_logs/preference_events 外键,候选 CASCADE
"""
from datetime import datetime
from app.models.flywheel import AiCallLog, PreferenceEvent
from app.models.task import DeliveryPackage
from app.constants.enums import TaskStatus
task = db.query(GenerationTask).filter(GenerationTask.id == task_id).first()
task = _check_task_ownership(task, current_user.workspace_id)
has_package = (
db.query(DeliveryPackage.id)
.filter(DeliveryPackage.task_id == task_id)
.first()
is not None
)
if has_package:
task.status = TaskStatus.ARCHIVED.value
task.archived_at = datetime.utcnow()
db.commit()
return ok({"task_id": task_id, "action": "archived"})
# 物理删:先清无 CASCADE 的外键引用(候选表 CASCADE 自动删)
db.query(AiCallLog).filter(AiCallLog.task_id == task_id).delete(synchronize_session=False)
db.query(PreferenceEvent).filter(PreferenceEvent.task_id == task_id).delete(synchronize_session=False)
db.delete(task)
db.commit()
return ok({"task_id": task_id, "action": "deleted"})