356 lines
15 KiB
Python
356 lines
15 KiB
Python
"""
|
||
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"})
|