Files
beige/backend/app/api/v1/task_actions.py
yangqianqian df1856d793 上线版: 产品表单统一+form嵌套修复+用户管理+部署+三套叙事
- 产品编辑入口统一走 ProductFormFull(卖点/风格/人群/品牌词全字段);
  修复开任务页 <form> 套 <form> 致"编辑产品"报错、改不了、跳回首个产品
- dashboard 入口卡片对齐实际路由: 系统管理(/config) 与 工作配置(/settings) 分开;
  settings ?tab=products 直达改用挂载后读 URL, 消除 hydration mismatch
- 新增用户管理(users API/admin service/改密页) + alembic 022/023/024
- 上线部署: Dockerfile / docker-compose.prod+https / nginx https / .env.example
- A8 三套正交叙事(痛点/场景/成分背书) + beige 调色去AI化 + 飞轮 text_import 高权重信号

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

365 lines
15 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/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, require_admin
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("文案候选不存在")
# A/B/C 同套单选:避免旧选择累积导致审核/打包拿错套别。
db.query(TextCandidate).filter(
TextCandidate.task_id == task_id,
TextCandidate.strategy == tc.strategy,
TextCandidate.id != tc.id,
).update({"is_selected": False})
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("图片候选不存在")
# 同套同角色只保留一张已选图;重生后选择新图会自动取消旧图。
db.query(ImageCandidate).filter(
ImageCandidate.task_id == task_id,
ImageCandidate.strategy == ic.strategy,
ImageCandidate.role == ic.role,
ImageCandidate.id != ic.id,
).update({"is_selected": False})
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
按已导入条数 %3 轮转分配 strategy(A/B/C),让导入轨「去生图」时能出三套正交配图
(生图按 strategy 匹配文案NULL 套永远匹配不上)。
导入=客户实跑验证过的范本,记 text_import 飞轮信号(权重高于改稿/AI选稿,倩倩姐2026-06-26)。"""
from app.constants.enums import IMAGE_STRATEGY_ANGLE
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,
)
# 已导入条数 %3 → A/B/C 轮转第1条A 第2条B 第3条C 第4条A…三套均匀分布
existing = db.query(TextCandidate).filter(TextCandidate.task_id == task_id).count()
strategy = ("A", "B", "C")[existing % 3]
tc = TextCandidate(
workspace_id=current_user.workspace_id, task_id=task_id,
source="import", content=body.content, angle_label=body.angle_label,
strategy=strategy,
)
db.add(tc)
db.commit()
db.refresh(tc)
# 飞轮信号:导入文案=最值得学习的范本。角度优先用户填的,缺则按套别兜底,让偏好聚合学到角度。
# 飞轮辅助不阻塞主路径:记信号失败只 log,导入文案(tc 已 commit)照常返回。
try:
record_signal(
db, current_user, task, "text_import",
candidate_id=tc.id,
angle_label=body.angle_label or IMAGE_STRATEGY_ANGLE.get(strategy),
)
except Exception:
logger.warning(
"导入文案飞轮信号记录失败(不阻塞导入): task_id=%s candidate_id=%s",
task_id, tc.id,
)
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()
if str(task.status) == "generating" or getattr(task.status, "value", None) == "generating":
raise_state_invalid("任务正在生成中,请等待本轮完成后再重生")
import redis as redis_lib
from app.core.config import get_settings
try:
r = redis_lib.from_url(get_settings().REDIS_URL, decode_responses=True)
except Exception as exc:
logger.warning("检查重生锁失败,继续提交: task_id=%s err=%s", task_id, exc)
else:
if r.exists(f"pipeline:lock:{task_id}"):
raise_state_invalid("任务正在重生中,请等待本轮完成后再操作")
# 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}/generate-images")
def generate_images(
task_id: int,
current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None,
db: Session = Depends(get_db),
):
"""导入轨「去生图」:复用库内导入文案,跳过文案生成,只生图(首次出图)。
复用 regenerate 的 generating 守卫 + redis 锁防重复烧钱。"""
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,
)
if str(task.status) == "generating" or getattr(task.status, "value", None) == "generating":
raise_state_invalid("任务正在生成中,请等待本轮完成")
# 必须库内有导入文案才放行生图,避免空语境生图(质量崩)
has_text = db.query(TextCandidate).filter(TextCandidate.task_id == task_id).count()
if not has_text:
raise_state_invalid("无可用文案,请先导入文案再去生图")
import redis as redis_lib
from app.core.config import get_settings
try:
r = redis_lib.from_url(get_settings().REDIS_URL, decode_responses=True)
except Exception as exc:
logger.warning("检查生图锁失败,继续提交: task_id=%s err=%s", task_id, exc)
else:
if r.exists(f"pipeline:lock:{task_id}"):
raise_state_invalid("任务正在生成中,请等待本轮完成")
enqueue_generation(task.id, reuse_text=True)
return ok({"task_id": task_id, "status": "generating"})
@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 状态可提审")
_validate_strategy_selection(db, task)
task.status = "pending_review"
db.commit()
return ok({"task_id": task_id, "status": "pending_review"})
def _validate_strategy_selection(db: Session, task: GenerationTask) -> None:
"""提审前最低门槛:至少 1 条已选文案 + 至少 1 张已选图。
三套 A/B/C 是「挑着选」——用户可只要其中一套、丢弃某套、或某套重新生成,
不强制三套齐全、不强制每套选满(倩倩姐 2026-06-26 拍板)。
"""
from app.core.response import raise_business
selected_text_count = (
db.query(TextCandidate.id)
.filter(TextCandidate.task_id == task.id, TextCandidate.is_selected == True)
.count()
)
selected_image_count = (
db.query(ImageCandidate.id)
.filter(ImageCandidate.task_id == task.id, ImageCandidate.is_selected == True)
.count()
)
parts = []
if selected_text_count < 1:
parts.append("请至少选择 1 条文案")
if selected_image_count < 1:
parts.append("请至少选择 1 张图片")
if parts:
raise_business("".join(parts))
@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})
@router.delete("/{task_id}/text-candidates/{candidate_id}")
def delete_text_candidate(
task_id: int, candidate_id: int,
current_user: Annotated[CurrentUser, Depends(require_admin)] = None,
db: Session = Depends(get_db),
):
"""删除单条文案候选仅管理员物理删倩倩姐2026-06-30拍板删除权限只给管理员"""
_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("文案候选不存在")
db.delete(tc)
db.commit()
return ok({"deleted": candidate_id})
@router.delete("/{task_id}/image-candidates/{candidate_id}")
def delete_image_candidate(
task_id: int, candidate_id: int,
current_user: Annotated[CurrentUser, Depends(require_admin)] = None,
db: Session = Depends(get_db),
):
"""删除单张图片候选仅管理员物理删倩倩姐2026-06-30拍板删除权限只给管理员"""
_check_task_ownership(
db.query(GenerationTask).filter(GenerationTask.id == task_id).first(),
current_user.workspace_id,
)
ic = db.query(ImageCandidate).filter(
ImageCandidate.id == candidate_id, ImageCandidate.task_id == task_id
).first()
if not ic:
raise_not_found("图片候选不存在")
db.delete(ic)
db.commit()
return ok({"deleted": candidate_id})