A8 多套交付包(packaging_task.py): - 修复交付包只打第1条混乱note的bug,按ImageCandidate.strategy分A/B/C组 - 每组生独立note_0N夹(6图+文案.txt),同seq留最新去重,老数据兼容 - task74端到端验:3套各6图,独立agent7项交叉验证全过 M4 归档(tasks.py/exports.py/前端): - list_tasks加date_from/date_to/product_id筛选+product_name批量填(防N+1) - 新增exports.py:产品JSON导出+标杆CSV导出(UTF-8 BOM) - 前端HistoryFilters日期/产品筛选+产品列+打回原因红banner - response.py加raise_param_error;独立agent验A1/A2/A9通过 R5 产品多图(product_images.py/020迁移/前端): - product_images表+5端点(上传/列/改场景/设主图/删图) - 生图按ROLE_SCENE_PREFERENCE选对应场景图,回落primary - 前端ProductImageManager多图画廊 R6 账号config拆页(settings/): - 配置页按角色拆/settings(运营+组长+admin)+/config(仅admin) - Key只显末4位不显余额(守红线) 核销表对齐真实代码状态:D1改稿框/M7裂变/E12评图分纠偏为已完成(曾漏回写) 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
214 lines
8.3 KiB
Python
214 lines
8.3 KiB
Python
"""
|
||
app/api/v1/task_actions.py — 任务操作端点
|
||
选文案 / 选图 / 导入文案 / 重新生成 / 提交审核 / 偏好上下文
|
||
DTOs 和辅助函数从 tasks.py 导入。
|
||
"""
|
||
|
||
import logging
|
||
from typing import Annotated
|
||
|
||
from fastapi import APIRouter, Depends
|
||
from pydantic import BaseModel, Field
|
||
from sqlalchemy.orm import Session
|
||
|
||
from app.core.database import get_db
|
||
from app.core.response import ok, raise_not_found, raise_state_invalid
|
||
from app.middleware.workspace_guard import CurrentUser, require_write_permission
|
||
from app.models.task import GenerationTask, ImageCandidate, TextCandidate
|
||
|
||
from app.api.v1.tasks import (
|
||
SelectCandidateRequest,
|
||
ImportTextRequest,
|
||
_fmt_text,
|
||
_fmt_image,
|
||
_check_task_ownership,
|
||
)
|
||
|
||
logger = logging.getLogger(__name__)
|
||
router = APIRouter(prefix="/tasks", tags=["tasks"])
|
||
|
||
|
||
@router.post("/{task_id}/text-candidates/select")
|
||
def select_text(
|
||
task_id: int, body: SelectCandidateRequest,
|
||
current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None,
|
||
db: Session = Depends(get_db),
|
||
):
|
||
"""选文案(飞轮信号 text_select +3)。"""
|
||
from app.services.flywheel_service import record_signal
|
||
task = _check_task_ownership(
|
||
db.query(GenerationTask).filter(GenerationTask.id == task_id).first(),
|
||
current_user.workspace_id,
|
||
)
|
||
tc = db.query(TextCandidate).filter(
|
||
TextCandidate.id == body.candidate_id, TextCandidate.task_id == task_id
|
||
).first()
|
||
if not tc:
|
||
raise_not_found("文案候选不存在")
|
||
tc.is_selected = True
|
||
db.commit()
|
||
record_signal(db, current_user, task, "text_select", candidate_id=tc.id, angle_label=tc.angle_label)
|
||
return ok({"selected": body.candidate_id})
|
||
|
||
|
||
@router.post("/{task_id}/image-candidates/select")
|
||
def select_image(
|
||
task_id: int, body: SelectCandidateRequest,
|
||
current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None,
|
||
db: Session = Depends(get_db),
|
||
):
|
||
"""选图(飞轮信号 image_select +3)。"""
|
||
import json
|
||
from app.services.flywheel_service import record_signal
|
||
from app.constants.enums import IMAGE_STRATEGY_ANGLE
|
||
task = _check_task_ownership(
|
||
db.query(GenerationTask).filter(GenerationTask.id == task_id).first(),
|
||
current_user.workspace_id,
|
||
)
|
||
ic = db.query(ImageCandidate).filter(
|
||
ImageCandidate.id == body.candidate_id, ImageCandidate.task_id == task_id
|
||
).first()
|
||
if not ic:
|
||
raise_not_found("图片候选不存在")
|
||
ic.is_selected = True
|
||
db.commit()
|
||
# R7 断点2:选图带 strategy → 映射叙事角度标签进飞轮,与文案 angle_label 并轨;
|
||
# strategy 原值另存 signal_meta,便于后续按套别复盘。angle_label=None 不再空转。
|
||
angle = IMAGE_STRATEGY_ANGLE.get(ic.strategy or "")
|
||
meta = json.dumps({"strategy": ic.strategy, "role": ic.role}, ensure_ascii=False) if ic.strategy else None
|
||
record_signal(
|
||
db, current_user, task, "image_select",
|
||
candidate_id=ic.id, angle_label=angle, signal_meta=meta,
|
||
)
|
||
return ok({"selected": body.candidate_id})
|
||
|
||
|
||
@router.post("/{task_id}/import-text")
|
||
def import_text(
|
||
task_id: int, body: ImportTextRequest,
|
||
current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None,
|
||
db: Session = Depends(get_db),
|
||
):
|
||
"""轨B:导入外部文案直接进候选池,跳过 AI 生成(洞2)。"""
|
||
_check_task_ownership(
|
||
db.query(GenerationTask).filter(GenerationTask.id == task_id).first(),
|
||
current_user.workspace_id,
|
||
)
|
||
tc = TextCandidate(
|
||
workspace_id=current_user.workspace_id, task_id=task_id,
|
||
source="import", content=body.content, angle_label=body.angle_label,
|
||
)
|
||
db.add(tc)
|
||
db.commit()
|
||
db.refresh(tc)
|
||
return ok(_fmt_text(tc))
|
||
|
||
|
||
class RegenerateRequest(BaseModel):
|
||
"""R2 重生参数(全 None=整体重生,兼容旧调用)。
|
||
strategy='A'/'B'/'C' 指定单套;role 指定单张(需配 strategy);custom_prompt 人工追加提示词。"""
|
||
strategy: str | None = Field(default=None, pattern="^[ABC]$")
|
||
role: str | None = None
|
||
custom_prompt: str | None = Field(default=None, max_length=500)
|
||
|
||
|
||
@router.post("/{task_id}/regenerate")
|
||
def regenerate(
|
||
task_id: int,
|
||
body: RegenerateRequest | None = None,
|
||
current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None,
|
||
db: Session = Depends(get_db),
|
||
):
|
||
"""重新生成(飞轮信号 regenerate -1)。
|
||
body 全空=整体重生;带 strategy=单套重生;带 strategy+role=单张重生;custom_prompt=人工提示词。"""
|
||
from app.services.flywheel_service import record_signal
|
||
from app.services.task_service import enqueue_generation
|
||
task = _check_task_ownership(
|
||
db.query(GenerationTask).filter(GenerationTask.id == task_id).first(),
|
||
current_user.workspace_id,
|
||
)
|
||
body = body or RegenerateRequest()
|
||
# role 必须配 strategy(单张重生需知道是哪套的哪张)
|
||
if body.role and not body.strategy:
|
||
raise_state_invalid("指定单张重生(role)时必须同时指定 strategy(套别)")
|
||
record_signal(db, current_user, task, "regenerate")
|
||
enqueue_generation(task.id, regen_strategy=body.strategy,
|
||
regen_role=body.role, custom_prompt=body.custom_prompt)
|
||
scope = "单张" if body.role else ("单套" if body.strategy else "整体")
|
||
return ok({"task_id": task_id, "status": "regenerating", "scope": scope})
|
||
|
||
|
||
@router.post("/{task_id}/submit-review")
|
||
def submit_review(
|
||
task_id: int,
|
||
current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None,
|
||
db: Session = Depends(get_db),
|
||
):
|
||
"""提交审核(状态 pending_selection → pending_review)。"""
|
||
task = _check_task_ownership(
|
||
db.query(GenerationTask).filter(GenerationTask.id == task_id).first(),
|
||
current_user.workspace_id,
|
||
)
|
||
if task.status != "pending_selection":
|
||
raise_state_invalid("只有 pending_selection 状态可提审")
|
||
task.status = "pending_review"
|
||
db.commit()
|
||
return ok({"task_id": task_id, "status": "pending_review"})
|
||
|
||
|
||
@router.get("/{task_id}/preference/context")
|
||
def get_preference_context(
|
||
task_id: int,
|
||
current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None,
|
||
db: Session = Depends(get_db),
|
||
):
|
||
"""取本任务飞轮偏好上下文(最近选中样本+打回原因)。"""
|
||
task = _check_task_ownership(
|
||
db.query(GenerationTask).filter(GenerationTask.id == task_id).first(),
|
||
current_user.workspace_id,
|
||
)
|
||
from app.services.flywheel_service import get_preference_context
|
||
# 取产品档案供冷启动基线(与生产链同口径)
|
||
from app.models.product import Product
|
||
_p = db.query(Product).filter(Product.id == task.product_id).first()
|
||
product_dict = {
|
||
"custom_prompt": getattr(_p, "custom_prompt", "") or "",
|
||
"style_tone": getattr(_p, "style_tone", "") or "",
|
||
} if _p else {}
|
||
ctx = get_preference_context(db, current_user.workspace_id, task.product_id, product_dict)
|
||
return ok(ctx)
|
||
|
||
|
||
class EditTextRequest(BaseModel):
|
||
"""改稿请求:candidate_id + 用户改后的新文案。"""
|
||
candidate_id: int
|
||
content: str = Field(min_length=1, max_length=5000)
|
||
|
||
|
||
@router.post("/{task_id}/text-candidates/{candidate_id}/edit")
|
||
def edit_text(
|
||
task_id: int, candidate_id: int, body: EditTextRequest,
|
||
current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None,
|
||
db: Session = Depends(get_db),
|
||
):
|
||
"""改稿(飞轮信号 text_edit +5,最强真实信号)。
|
||
|
||
用户真改了文案的字=最在乎这条方向。回写 content + edited=True,
|
||
并写 text_edit 飞轮偏好事件。倩倩姐2026-06-16拍板,先激进入飞轮跑一周再校准。
|
||
"""
|
||
from app.services.flywheel_service import record_signal
|
||
task = _check_task_ownership(
|
||
db.query(GenerationTask).filter(GenerationTask.id == task_id).first(),
|
||
current_user.workspace_id,
|
||
)
|
||
tc = db.query(TextCandidate).filter(
|
||
TextCandidate.id == candidate_id, TextCandidate.task_id == task_id
|
||
).first()
|
||
if not tc:
|
||
raise_not_found("文案候选不存在")
|
||
tc.content = body.content
|
||
tc.edited = True
|
||
db.commit()
|
||
record_signal(db, current_user, task, "text_edit", candidate_id=tc.id, angle_label=tc.angle_label)
|
||
return ok({"candidate_id": candidate_id, "edited": True})
|