A8多套打包+M4归档+R5多图:存量功能备份
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>
This commit is contained in:
47
backend/alembic/versions/020_product_images_table.py
Normal file
47
backend/alembic/versions/020_product_images_table.py
Normal file
@@ -0,0 +1,47 @@
|
||||
"""create product_images table
|
||||
|
||||
Revision ID: 020
|
||||
Revises: 019
|
||||
Create Date: 2026-06-18
|
||||
|
||||
R5 产品多图:一产品多张参考图,每张标 scene 场景类型(primary/scene/
|
||||
texture/ingredient/model),生图按分镜 role 选用对应图,取不到回落主图。
|
||||
product.image_path 仍保留当主图(向后兼容,零破坏)。
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
revision = "020"
|
||||
down_revision = "019"
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
_SCENES = ("primary", "scene", "texture", "ingredient", "model")
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.create_table(
|
||||
"product_images",
|
||||
sa.Column("id", sa.BigInteger, primary_key=True, autoincrement=True),
|
||||
sa.Column(
|
||||
"product_id", sa.BigInteger,
|
||||
sa.ForeignKey("products.id", ondelete="CASCADE"),
|
||||
nullable=False,
|
||||
),
|
||||
sa.Column("path", sa.String(512), nullable=False, comment="绝对路径,worker直接open"),
|
||||
sa.Column(
|
||||
"scene", sa.Enum(*_SCENES, name="productimagescene"),
|
||||
nullable=False, server_default="primary",
|
||||
comment="场景类型:白底主图/使用场景/质地/成分/上脸",
|
||||
),
|
||||
sa.Column("is_primary", sa.Boolean, nullable=False, server_default=sa.false(),
|
||||
comment="主图(同步 product.image_path)"),
|
||||
sa.Column("sort_order", sa.Integer, nullable=False, server_default="0"),
|
||||
sa.Column("created_at", sa.DateTime, server_default=sa.func.now(), nullable=False),
|
||||
)
|
||||
op.create_index("idx_product_images_product_id", "product_images", ["product_id"])
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_index("idx_product_images_product_id", table_name="product_images")
|
||||
op.drop_table("product_images")
|
||||
100
backend/app/api/v1/exports.py
Normal file
100
backend/app/api/v1/exports.py
Normal file
@@ -0,0 +1,100 @@
|
||||
"""
|
||||
app/api/v1/exports.py — 客户原始输入物导出(数据归属红线)
|
||||
产品/标杆属客户 client_data,可导出(JSON/CSV);飞轮偏好属平台不在此列。
|
||||
require_write_permission:仅 workspace 成员可导出本工作区数据。
|
||||
"""
|
||||
|
||||
import csv
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from fastapi.responses import StreamingResponse
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.database import get_db
|
||||
from app.middleware.workspace_guard import CurrentUser, require_write_permission
|
||||
from app.models.product import BenchmarkNote, Product
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["exports"])
|
||||
|
||||
|
||||
def _csv_response(rows: list[dict], fieldnames: list[str], filename: str) -> StreamingResponse:
|
||||
"""通用 CSV 流式响应。加 UTF-8 BOM 防 Windows Excel 中文乱码。"""
|
||||
buf = io.StringIO()
|
||||
buf.write("") # BOM
|
||||
writer = csv.DictWriter(buf, fieldnames=fieldnames, extrasaction="ignore")
|
||||
writer.writeheader()
|
||||
for r in rows:
|
||||
writer.writerow(r)
|
||||
buf.seek(0)
|
||||
return StreamingResponse(
|
||||
iter([buf.getvalue()]),
|
||||
media_type="text/csv; charset=utf-8",
|
||||
headers={"Content-Disposition": f'attachment; filename="{filename}"'},
|
||||
)
|
||||
|
||||
|
||||
@router.get("/products/export")
|
||||
def export_products(
|
||||
current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""产品档案导出(JSON)。selling_points 还原为数组,非 JSON 字符串。"""
|
||||
rows = (
|
||||
db.query(Product)
|
||||
.filter(Product.workspace_id == current_user.workspace_id, Product.is_active == True)
|
||||
.order_by(Product.id)
|
||||
.all()
|
||||
)
|
||||
data = [
|
||||
{
|
||||
"id": p.id,
|
||||
"name": p.name,
|
||||
"category": p.category,
|
||||
"selling_points": json.loads(p.selling_points) if p.selling_points else [],
|
||||
"brand_keyword": p.brand_keyword,
|
||||
"target_audience": p.target_audience,
|
||||
"style_tone": p.style_tone,
|
||||
"created_at": p.created_at.isoformat(),
|
||||
}
|
||||
for p in rows
|
||||
]
|
||||
# 直接返数组(非包 ok 信封):导出供下载/二次处理,前端按 blob 存盘。
|
||||
return data
|
||||
|
||||
|
||||
@router.get("/benchmarks/export")
|
||||
def export_benchmarks(
|
||||
format: str = Query(default="json", pattern="^(json|csv)$"),
|
||||
current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""标杆笔记导出(JSON/CSV)。JOIN Product 填 product_name 便于客户阅读。"""
|
||||
rows = (
|
||||
db.query(BenchmarkNote, Product.name)
|
||||
.outerjoin(Product, BenchmarkNote.product_id == Product.id)
|
||||
.filter(BenchmarkNote.workspace_id == current_user.workspace_id)
|
||||
.order_by(BenchmarkNote.id)
|
||||
.all()
|
||||
)
|
||||
data = [
|
||||
{
|
||||
"id": b.id,
|
||||
"product_id": b.product_id,
|
||||
"product_name": pname,
|
||||
"highlights": b.highlights or "",
|
||||
"link_url": b.link_url or "",
|
||||
"analyze_status": b.analyze_status,
|
||||
"created_at": b.created_at.isoformat(),
|
||||
}
|
||||
for b, pname in rows
|
||||
]
|
||||
if format == "csv":
|
||||
fields = ["id", "product_id", "product_name", "highlights",
|
||||
"link_url", "analyze_status", "created_at"]
|
||||
return _csv_response(data, fields, "benchmarks.csv")
|
||||
return data
|
||||
121
backend/app/api/v1/product_images.py
Normal file
121
backend/app/api/v1/product_images.py
Normal file
@@ -0,0 +1,121 @@
|
||||
"""
|
||||
app/api/v1/product_images.py — R5 产品多图管理(列/删/改场景/设主图)
|
||||
|
||||
上传走 products.py 的 /products/{id}/upload-image(带 scene)。
|
||||
本文件管已上传图的增删改。product.image_path 始终同步当前主图(向后兼容)。
|
||||
"""
|
||||
import logging
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.constants.enums import ProductImageScene
|
||||
from app.core.database import get_db
|
||||
from app.core.response import ok, raise_not_found, raise_business
|
||||
from app.middleware.workspace_guard import CurrentUser, require_write_permission
|
||||
from app.models.product import Product, ProductImage
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["product-images"])
|
||||
|
||||
|
||||
class SceneUpdate(BaseModel):
|
||||
scene: str
|
||||
|
||||
|
||||
def _get_product(db: Session, product_id: int, workspace_id: int) -> Product:
|
||||
p = db.query(Product).filter(
|
||||
Product.id == product_id, Product.workspace_id == workspace_id
|
||||
).first()
|
||||
if not p:
|
||||
raise_not_found("产品不存在")
|
||||
return p
|
||||
|
||||
|
||||
def _get_image(db: Session, product_id: int, image_id: int) -> ProductImage:
|
||||
img = db.query(ProductImage).filter(
|
||||
ProductImage.id == image_id, ProductImage.product_id == product_id
|
||||
).first()
|
||||
if not img:
|
||||
raise_not_found("产品图不存在")
|
||||
return img
|
||||
|
||||
|
||||
@router.get("/products/{product_id}/images")
|
||||
def list_images(
|
||||
product_id: int,
|
||||
current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
_get_product(db, product_id, current_user.workspace_id)
|
||||
imgs = db.query(ProductImage).filter(
|
||||
ProductImage.product_id == product_id
|
||||
).order_by(ProductImage.sort_order).all()
|
||||
return ok([
|
||||
{"id": im.id, "path": im.path, "scene": im.scene,
|
||||
"is_primary": im.is_primary, "sort_order": im.sort_order}
|
||||
for im in imgs
|
||||
])
|
||||
|
||||
|
||||
@router.put("/products/{product_id}/images/{image_id}/scene")
|
||||
def update_scene(
|
||||
product_id: int, image_id: int, body: SceneUpdate,
|
||||
current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
_get_product(db, product_id, current_user.workspace_id)
|
||||
if body.scene not in {s.value for s in ProductImageScene}:
|
||||
raise_business(f"非法场景类型 {body.scene}")
|
||||
img = _get_image(db, product_id, image_id)
|
||||
img.scene = body.scene
|
||||
db.commit()
|
||||
logger.info("product image scene updated: id=%s scene=%s", image_id, body.scene)
|
||||
return ok({"id": image_id, "scene": body.scene})
|
||||
|
||||
|
||||
@router.put("/products/{product_id}/images/{image_id}/primary")
|
||||
def set_primary(
|
||||
product_id: int, image_id: int,
|
||||
current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
p = _get_product(db, product_id, current_user.workspace_id)
|
||||
img = _get_image(db, product_id, image_id)
|
||||
db.query(ProductImage).filter(
|
||||
ProductImage.product_id == product_id, ProductImage.is_primary == True
|
||||
).update({"is_primary": False})
|
||||
img.is_primary = True
|
||||
p.image_path = img.path # 同步主图字段(管道/校验读此字段)
|
||||
db.commit()
|
||||
logger.info("product primary image set: product_id=%s image_id=%s", product_id, image_id)
|
||||
return ok({"product_id": product_id, "primary_image_id": image_id})
|
||||
|
||||
|
||||
@router.delete("/products/{product_id}/images/{image_id}")
|
||||
def delete_image(
|
||||
product_id: int, image_id: int,
|
||||
current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
p = _get_product(db, product_id, current_user.workspace_id)
|
||||
img = _get_image(db, product_id, image_id)
|
||||
was_primary = img.is_primary
|
||||
db.delete(img)
|
||||
db.flush()
|
||||
# 删的是主图:把剩余 sort_order 最小的一张顶上来当主图,保证 image_path 不悬空
|
||||
if was_primary:
|
||||
nxt = db.query(ProductImage).filter(
|
||||
ProductImage.product_id == product_id
|
||||
).order_by(ProductImage.sort_order).first()
|
||||
if nxt:
|
||||
nxt.is_primary = True
|
||||
p.image_path = nxt.path
|
||||
else:
|
||||
p.image_path = None
|
||||
db.commit()
|
||||
logger.info("product image deleted: product_id=%s image_id=%s was_primary=%s",
|
||||
product_id, image_id, was_primary)
|
||||
return ok({"deleted": image_id})
|
||||
@@ -7,14 +7,14 @@ category 是纯数据字段,不在代码里做枚举(基石A)。
|
||||
import logging
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, UploadFile, File
|
||||
from fastapi import APIRouter, Depends, UploadFile, File, Form
|
||||
from pydantic import BaseModel
|
||||
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.product import BannedWord, BenchmarkNote, Product
|
||||
from app.models.product import BannedWord, BenchmarkNote, Product, ProductImage
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
router = APIRouter(tags=["products"])
|
||||
@@ -50,6 +50,7 @@ class BannedWordCreate(BaseModel):
|
||||
|
||||
def _fmt_product(p: Product) -> dict:
|
||||
import json
|
||||
imgs = sorted(getattr(p, "images", None) or [], key=lambda im: im.sort_order)
|
||||
return {
|
||||
"id": p.id, "name": p.name, "category": p.category,
|
||||
"source": p.source, "is_active": p.is_active,
|
||||
@@ -58,6 +59,11 @@ def _fmt_product(p: Product) -> dict:
|
||||
"text_angles": json.loads(p.text_angles) if p.text_angles else [],
|
||||
"custom_prompt": p.custom_prompt,
|
||||
"image_path": p.image_path,
|
||||
"images": [
|
||||
{"id": im.id, "path": im.path, "scene": im.scene,
|
||||
"is_primary": im.is_primary, "sort_order": im.sort_order}
|
||||
for im in imgs
|
||||
],
|
||||
"brand_keyword": p.brand_keyword, # 012: 套2字段暴露
|
||||
"target_audience": p.target_audience, # 012: 套2字段暴露
|
||||
"created_at": p.created_at.isoformat(),
|
||||
@@ -82,7 +88,7 @@ def list_products(
|
||||
@router.post("/products")
|
||||
def create_product(
|
||||
body: ProductCreate,
|
||||
current_user: Annotated[CurrentUser, Depends(require_admin)] = None,
|
||||
current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
import json
|
||||
@@ -118,7 +124,7 @@ def get_product(
|
||||
@router.put("/products/{product_id}")
|
||||
def update_product(
|
||||
product_id: int, body: ProductCreate,
|
||||
current_user: Annotated[CurrentUser, Depends(require_admin)] = None,
|
||||
current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
import json
|
||||
@@ -155,10 +161,28 @@ _ALLOWED_CONTENT_TYPES = {"image/jpeg", "image/png", "image/webp"}
|
||||
_MAX_SIZE_BYTES = 10 * 1024 * 1024 # 10 MB
|
||||
|
||||
|
||||
def _check_image_magic(data: bytes) -> bool:
|
||||
"""
|
||||
校验文件头 magic number,防 .exe 改名 .jpg 伪造 content_type 绕过。
|
||||
content_type 来自客户端可伪造,magic number 是真实字节,二者都要过。
|
||||
JPEG: FF D8 FF / PNG: 89 50 4E 47 0D 0A 1A 0A / WebP: RIFF....WEBP
|
||||
"""
|
||||
if len(data) < 12:
|
||||
return False
|
||||
if data[:3] == b"\xff\xd8\xff": # JPEG
|
||||
return True
|
||||
if data[:8] == b"\x89PNG\r\n\x1a\n": # PNG
|
||||
return True
|
||||
if data[:4] == b"RIFF" and data[8:12] == b"WEBP": # WebP
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
@router.post("/products/{product_id}/upload-image")
|
||||
async def upload_product_image(
|
||||
product_id: int,
|
||||
file: UploadFile = File(...),
|
||||
scene: str = Form("primary"),
|
||||
current_user: Annotated[CurrentUser, Depends(require_write_permission)] = None,
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
@@ -166,10 +190,12 @@ async def upload_product_image(
|
||||
上传产品参考图(铁律:生图必须带产品图)。
|
||||
文件类型:JPEG/PNG/WebP;大小上限 10 MB。
|
||||
存储路径:uploads/products/{workspace_id}/{product_id}/{filename}
|
||||
写回 product.image_path,生图管道读此字段构建 reference_images。
|
||||
R5多图:每次上传落一条 product_images(带 scene 场景类型)。
|
||||
首张或 scene=primary 时同步写 product.image_path 当主图(向后兼容)。
|
||||
"""
|
||||
from app.core.response import raise_business
|
||||
from app.core.config import get_settings
|
||||
from app.constants.enums import ProductImageScene
|
||||
import os, uuid
|
||||
|
||||
p = db.query(Product).filter(
|
||||
@@ -179,12 +205,18 @@ async def upload_product_image(
|
||||
if not p:
|
||||
raise_not_found("产品不存在")
|
||||
|
||||
valid_scenes = {s.value for s in ProductImageScene}
|
||||
if scene not in valid_scenes:
|
||||
raise_business(f"非法场景类型 {scene},仅支持 {sorted(valid_scenes)}")
|
||||
|
||||
if file.content_type not in _ALLOWED_CONTENT_TYPES:
|
||||
raise_business(f"不支持的文件类型 {file.content_type},仅支持 JPEG/PNG/WebP")
|
||||
|
||||
data = await file.read()
|
||||
if len(data) > _MAX_SIZE_BYTES:
|
||||
raise_business("文件超过 10 MB 限制")
|
||||
if not _check_image_magic(data):
|
||||
raise_business("文件内容与扩展名不符(非真实 JPEG/PNG/WebP 图片)")
|
||||
|
||||
settings = get_settings()
|
||||
ext = os.path.splitext(file.filename or "img.jpg")[1] or ".jpg"
|
||||
@@ -197,10 +229,29 @@ async def upload_product_image(
|
||||
with open(save_path, "wb") as f:
|
||||
f.write(data)
|
||||
|
||||
p.image_path = save_path # 绝对路径,worker 直接 open 可读
|
||||
# 是否首张:决定要不要兜底设主图
|
||||
existing = db.query(ProductImage).filter(
|
||||
ProductImage.product_id == product_id
|
||||
).count()
|
||||
is_primary = (existing == 0) or (scene == ProductImageScene.PRIMARY.value)
|
||||
if is_primary:
|
||||
# 同产品其它主图标记降级,保证唯一主图
|
||||
db.query(ProductImage).filter(
|
||||
ProductImage.product_id == product_id,
|
||||
ProductImage.is_primary == True,
|
||||
).update({"is_primary": False})
|
||||
|
||||
img = ProductImage(
|
||||
product_id=product_id, path=save_path, scene=scene,
|
||||
is_primary=is_primary, sort_order=existing,
|
||||
)
|
||||
db.add(img)
|
||||
if is_primary:
|
||||
p.image_path = save_path # 向后兼容:管道/校验仍读此字段当主图
|
||||
db.commit()
|
||||
db.refresh(p)
|
||||
logger.info("product image uploaded: product_id=%s path=%s", product_id, save_path)
|
||||
logger.info("product image uploaded: product_id=%s scene=%s primary=%s path=%s",
|
||||
product_id, scene, is_primary, save_path)
|
||||
return ok(_fmt_product(p))
|
||||
|
||||
|
||||
@@ -273,6 +324,8 @@ async def analyze_product_image(
|
||||
data = await file.read()
|
||||
if len(data) > _MAX_SIZE_BYTES:
|
||||
raise_business("文件超过 10 MB 限制")
|
||||
if not _check_image_magic(data):
|
||||
raise_business("文件内容与扩展名不符(非真实 JPEG/PNG/WebP 图片)")
|
||||
|
||||
# key 解密(照抄 pipeline_steps.decrypt_user_key,基石B)
|
||||
api_key_row = db.query(UserApiKey).filter(
|
||||
|
||||
@@ -58,7 +58,9 @@ def select_image(
|
||||
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,
|
||||
@@ -70,7 +72,14 @@ def select_image(
|
||||
raise_not_found("图片候选不存在")
|
||||
ic.is_selected = True
|
||||
db.commit()
|
||||
record_signal(db, current_user, task, "image_select", candidate_id=ic.id)
|
||||
# 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})
|
||||
|
||||
|
||||
@@ -159,7 +168,14 @@ def get_preference_context(
|
||||
current_user.workspace_id,
|
||||
)
|
||||
from app.services.flywheel_service import get_preference_context
|
||||
ctx = get_preference_context(db, current_user.workspace_id, task.product_id)
|
||||
# 取产品档案供冷启动基线(与生产链同口径)
|
||||
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)
|
||||
|
||||
|
||||
|
||||
@@ -170,16 +170,45 @@ def create_task(
|
||||
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
|
||||
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))
|
||||
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 格式")
|
||||
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))
|
||||
# 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["product_name"] = name_map.get(t.product_id)
|
||||
rows.append(d)
|
||||
return ok(paginate(rows, total, page, page_size))
|
||||
|
||||
|
||||
@router.get("/{task_id}")
|
||||
|
||||
@@ -75,6 +75,16 @@ SIGNAL_WEIGHTS: dict[str, int] = {
|
||||
}
|
||||
|
||||
|
||||
# ── 生图3套正交叙事策略 → 飞轮角度标签 ───────────────────
|
||||
# 选图信号 angle_label 用此映射,与文案 angle_label 体系并轨进同一偏好聚合。
|
||||
# A痛点先行/B场景先行/C成分背书先行(倩倩姐2026-06-15起草,北哥过目版)。
|
||||
IMAGE_STRATEGY_ANGLE: dict[str, str] = {
|
||||
"A": "痛点先行",
|
||||
"B": "场景先行",
|
||||
"C": "成分背书先行",
|
||||
}
|
||||
|
||||
|
||||
# ── 数据归属 ──────────────────────────────────────────
|
||||
class DataOwnership(str, Enum):
|
||||
CLIENT_DATA = "client_data" # 原始输入产出,客户可导出
|
||||
@@ -110,6 +120,15 @@ class ImageRole(str, Enum):
|
||||
MAIN = "main"
|
||||
|
||||
|
||||
# ── 产品参考图场景类型(R5多图:标注每张产品图拍的是什么,供生图按分镜role选用)──
|
||||
class ProductImageScene(str, Enum):
|
||||
PRIMARY = "primary" # 白底/正面主图(默认主图,必有)
|
||||
SCENE = "scene" # 使用场景图(梳妆台/生活环境)
|
||||
TEXTURE = "texture" # 质地特写(膏体/涂抹)
|
||||
INGREDIENT = "ingredient" # 成分/包装细节
|
||||
MODEL = "model" # 上脸/使用中
|
||||
|
||||
|
||||
# ── AI 图片提供商 ─────────────────────────────────────
|
||||
class ImageProvider(str, Enum):
|
||||
GPT = "gpt"
|
||||
|
||||
@@ -42,7 +42,8 @@ class Settings(BaseSettings):
|
||||
CELERY_RESULT_BACKEND: str = ""
|
||||
|
||||
# ── 并发限制(可配置,不写死)─────────────────────
|
||||
MAX_CONCURRENT_TASKS_PER_USER: int = 2
|
||||
# 倩倩姐红线:每用户并发上限 5 个任务(客户侧实际跑几个再观察调整)。
|
||||
MAX_CONCURRENT_TASKS_PER_USER: int = 5
|
||||
|
||||
# ── 文件存储路径 ──────────────────────────────────
|
||||
UPLOAD_BASE_PATH: str = "uploads/packages"
|
||||
|
||||
@@ -59,5 +59,9 @@ def raise_business(message: str) -> None:
|
||||
raise CloverHTTPException(422, ErrorCode.BUSINESS_ERROR, message)
|
||||
|
||||
|
||||
def raise_param_error(message: str = "参数非法") -> None:
|
||||
raise CloverHTTPException(400, ErrorCode.PARAM_INVALID, message)
|
||||
|
||||
|
||||
def raise_state_invalid(message: str = "状态机非法流转") -> None:
|
||||
raise CloverHTTPException(409, ErrorCode.STATE_INVALID, message)
|
||||
|
||||
@@ -13,7 +13,7 @@ from sqlalchemy import (
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.constants.enums import BannedWordLevel, ProductSource
|
||||
from app.constants.enums import BannedWordLevel, ProductImageScene, ProductSource
|
||||
from app.core.database import Base
|
||||
|
||||
|
||||
@@ -52,12 +52,45 @@ class Product(Base):
|
||||
benchmark_notes: Mapped[list["BenchmarkNote"]] = relationship(
|
||||
back_populates="product", lazy="noload"
|
||||
)
|
||||
images: Mapped[list["ProductImage"]] = relationship(
|
||||
back_populates="product", lazy="selectin",
|
||||
cascade="all, delete-orphan", order_by="ProductImage.sort_order",
|
||||
)
|
||||
|
||||
__table_args__ = (
|
||||
Index("idx_products_workspace_id", "workspace_id"),
|
||||
)
|
||||
|
||||
|
||||
class ProductImage(Base):
|
||||
"""产品参考图(R5多图):一产品多张,每张标 scene 场景类型,生图按分镜role选用。
|
||||
|
||||
product.image_path 仍保留当主图(向后兼容,零破坏);本表是其超集。
|
||||
"""
|
||||
__tablename__ = "product_images"
|
||||
|
||||
id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
|
||||
product_id: Mapped[int] = mapped_column(
|
||||
BigInteger, ForeignKey("products.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
path: Mapped[str] = mapped_column(String(512), nullable=False) # 绝对路径,worker 直接 open
|
||||
scene: Mapped[str] = mapped_column(
|
||||
Enum(ProductImageScene, values_callable=lambda x: [e.value for e in x]),
|
||||
default=ProductImageScene.PRIMARY, nullable=False,
|
||||
)
|
||||
is_primary: Mapped[bool] = mapped_column(default=False, nullable=False) # 主图(同步 product.image_path)
|
||||
sort_order: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(
|
||||
DateTime, server_default=func.now(), nullable=False
|
||||
)
|
||||
|
||||
product: Mapped["Product"] = relationship(back_populates="images")
|
||||
|
||||
__table_args__ = (
|
||||
Index("idx_product_images_product_id", "product_id"),
|
||||
)
|
||||
|
||||
|
||||
class BenchmarkNote(Base):
|
||||
"""标杆笔记(截图+手填亮点为主通道)"""
|
||||
__tablename__ = "benchmark_notes"
|
||||
|
||||
@@ -131,6 +131,38 @@ angle(本条角度标签)/ coverTitle(封面大字≤10字)/ imageBrief
|
||||
硬性格式:只输出JSON,不要markdown代码块,字符串内用中文引号「」。"""
|
||||
|
||||
|
||||
# ── 第2环标杆爆款配方 → 文案 prompt(借方法层结构,禁抄竞品原话)──────────────
|
||||
_BM_DIM_LABELS: list[tuple[str, str]] = [
|
||||
("title_formula", "标题公式"),
|
||||
("opening_hook", "开篇钩子"),
|
||||
("content_structure", "内容结构"),
|
||||
("selling_point_style", "卖点表达风格"),
|
||||
("emotion_tone", "情绪基调"),
|
||||
("topic_tags", "话题标签思路"),
|
||||
]
|
||||
|
||||
|
||||
def _build_benchmark_block(refs: list[dict]) -> str:
|
||||
"""把标杆 8维配方渲染成 prompt 块。只借'方法结构',硬约束禁抄竞品品牌/功效原话。"""
|
||||
if not refs:
|
||||
return ""
|
||||
lines: list[str] = []
|
||||
for i, f in enumerate(refs[:3], 1): # 最多取3条标杆,避免prompt膨胀
|
||||
dims = [f"{label}=「{str(f.get(key, '')).strip()}」"
|
||||
for key, label in _BM_DIM_LABELS if str(f.get(key, "")).strip()]
|
||||
if dims:
|
||||
lines.append(f" 标杆{i}:" + ";".join(dims))
|
||||
if not lines:
|
||||
return ""
|
||||
body = "\n".join(lines)
|
||||
return (
|
||||
"\n【对标爆款配方参考(只借方法层结构,绝不照抄)】\n"
|
||||
f"{body}\n"
|
||||
"硬性约束:仅参考上面的标题套路/开篇方式/结构节奏/情绪基调来组织本产品文案;"
|
||||
"禁止照搬竞品品牌名、产品名、功效原话或具体数字;产品信息一律以本产品为准。"
|
||||
)
|
||||
|
||||
|
||||
def build_prompt(product: dict, count: int, extra_rules: str = "") -> str:
|
||||
"""
|
||||
组装文案生成 user_prompt。
|
||||
@@ -153,6 +185,7 @@ def build_prompt(product: dict, count: int, extra_rules: str = "") -> str:
|
||||
|
||||
angle_hint = f"文案角度要覆盖:{'、'.join(angles)}(每条用不同角度)。" if angles else ""
|
||||
brand_rule = f"每条正文和标题中植入品牌词「{brand_kw}」一次(自然融入,不生硬)。" if brand_kw else ""
|
||||
benchmark_block = _build_benchmark_block(product.get("benchmark_refs") or [])
|
||||
|
||||
lines = [
|
||||
f"产品:{name}",
|
||||
@@ -161,6 +194,7 @@ def build_prompt(product: dict, count: int, extra_rules: str = "") -> str:
|
||||
angle_hint,
|
||||
brand_rule,
|
||||
custom,
|
||||
benchmark_block,
|
||||
f"\n【Q1随机变量池·每条身份/起因/小缺点各不相同,严格按下方分配使用】",
|
||||
combos_text,
|
||||
extra_rules,
|
||||
|
||||
@@ -70,6 +70,23 @@ PAGE_ROLES = [
|
||||
]
|
||||
PAGE_ROLE_MAP = {r["role"]: r for r in PAGE_ROLES}
|
||||
|
||||
# ── R5多图:分镜role → 优先产品图场景(scene) 偏好表 ──────────
|
||||
# 生图时按分镜 role 选该场景的产品图当参考;取不到回落主图(primary)。
|
||||
# scene 取值见 enums.ProductImageScene:primary/scene/texture/ingredient/model
|
||||
ROLE_SCENE_PREFERENCE = {
|
||||
"hook": ["scene", "primary"], # 封面:真实生活场景优先
|
||||
"pain_scene": ["scene", "primary"], # 痛点共鸣:使用前情境
|
||||
"product_closeup": ["primary"], # 单品特写:白底主图
|
||||
"ingredient": ["ingredient", "primary"], # 成分拆解:成分/包装细节
|
||||
"texture": ["texture", "primary"], # 质地展示:质地特写
|
||||
"applied_proof": ["model", "scene", "primary"], # 上脸:上脸图/场景
|
||||
"social_proof": ["scene", "primary"], # 社交背书:场景
|
||||
"closer": ["primary"], # 促单收尾:主图
|
||||
"scenario": ["scene", "primary"],
|
||||
"tutorial": ["model", "scene", "primary"],
|
||||
}
|
||||
|
||||
|
||||
# ── 生图风格预设(扒 image.js STYLE_PROMPTS:26-29)──────────
|
||||
# 按 style 参数选小红书风格调性,注入 base_prompt 的"视觉风格"行
|
||||
STYLE_PROMPTS = {
|
||||
|
||||
@@ -14,13 +14,36 @@ import logging
|
||||
import os
|
||||
from typing import Any, Protocol
|
||||
|
||||
from .constants import IMAGE_RETRY_ATTEMPTS, IMAGE_RETRY_BACKOFF_BASE, IMAGE_SIZE_DEFAULT
|
||||
from .constants import IMAGE_RETRY_ATTEMPTS, IMAGE_RETRY_BACKOFF_BASE, IMAGE_SIZE_DEFAULT, ROLE_SCENE_PREFERENCE
|
||||
from .image_scorer import score_image
|
||||
from .storyboard import plan_image_set, sanitize_text
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _pick_reference_for_role(
|
||||
role: str,
|
||||
images_by_scene: dict[str, list[bytes]] | None,
|
||||
fallback: list[bytes] | None,
|
||||
) -> tuple[list[bytes] | None, str]:
|
||||
"""R5多图:按分镜 role 选该场景的产品图。取不到回落主图。
|
||||
|
||||
返回 (参考图bytes列表, 命中scene标签用于日志)。
|
||||
"""
|
||||
if images_by_scene:
|
||||
for scene in ROLE_SCENE_PREFERENCE.get(role, ["primary"]):
|
||||
imgs = images_by_scene.get(scene)
|
||||
if imgs:
|
||||
return imgs, scene
|
||||
# 偏好全落空:用任意可用图兜底(仍优先 primary)
|
||||
if images_by_scene.get("primary"):
|
||||
return images_by_scene["primary"], "primary"
|
||||
for scene, imgs in images_by_scene.items():
|
||||
if imgs:
|
||||
return imgs, f"{scene}(兜底)"
|
||||
return fallback, "fallback"
|
||||
|
||||
|
||||
class ImageClient(Protocol):
|
||||
"""worker 注入的图片生成客户端协议(隔离 key 细节)"""
|
||||
async def gpt_edits(
|
||||
@@ -129,12 +152,18 @@ async def generate_storyboard_images(
|
||||
strategy: str | None = None,
|
||||
target_role: str | None = None,
|
||||
custom_prompt: str | None = None,
|
||||
images_by_scene: dict[str, list[bytes]] | None = None,
|
||||
flywheel_fragment: str | None = None,
|
||||
) -> list[dict]:
|
||||
"""
|
||||
按 storyboard 逐张生图(asyncio.gather 并发),返回每张结果列表。
|
||||
strategy: None=默认叙事,'A'/'B'/'C'=三套正交叙事策略
|
||||
target_role: 非空时只生成该 role 那一张(R2 单张重生)
|
||||
custom_prompt: 非空时追加到每张 per_prompt 末尾(R2 人工提示词)
|
||||
images_by_scene: R5多图,{scene: [bytes]},按分镜 role 选对应场景图;
|
||||
为空则全分镜共用 reference_images(向后兼容)。
|
||||
flywheel_fragment: R7 飞轮偏好片段(最近选图/打回真实信号聚合),注入图片
|
||||
排版偏好;仅影响文字角度/版式取向,绝不改瓶身(合规红线)。
|
||||
每项:{role, name, image_bytes, error}
|
||||
"""
|
||||
plan = plan_image_set(note, product, image_count, analysis, strategy=strategy)
|
||||
@@ -169,8 +198,20 @@ async def generate_storyboard_images(
|
||||
# R2 人工提示词:追加到末尾权重最高,但不覆盖前面合规/真实约束
|
||||
if custom_prompt:
|
||||
per_prompt += f"\n运营补充要求(在不违反上述合规与真实约束前提下尽量满足):{sanitize_text(custom_prompt, 200)}。"
|
||||
# R7 飞轮偏好:仅作用于文字角度/版式取向参考,绝不改瓶身(合规+真实红线)
|
||||
if flywheel_fragment:
|
||||
per_prompt += (
|
||||
f"\n历史偏好参考(仅影响标题文字角度与排版取向,不得据此改动产品瓶身):"
|
||||
f"{sanitize_text(flywheel_fragment, 300)}。"
|
||||
)
|
||||
try:
|
||||
img_bytes = await generate_one_image(client, per_prompt, reference_images)
|
||||
# R5多图:按本张分镜 role 选对应场景产品图;无多图则共用 reference_images
|
||||
ref_for_item, _scene_hit = _pick_reference_for_role(
|
||||
item["role"], images_by_scene, reference_images
|
||||
)
|
||||
if images_by_scene:
|
||||
logger.info("分镜 %s 选用产品图场景=%s", item["role"], _scene_hit)
|
||||
img_bytes = await generate_one_image(client, per_prompt, ref_for_item)
|
||||
# 注:gpt-image-2 渲染中文偶发错别字(约1/6)。vision/OCR 文字校验闸门实测
|
||||
# 不可靠(漏报形近字+幻觉误伤品牌词),倩倩姐2026-06-16拍板先撤,纯生图,
|
||||
# 错别字作已知问题记录,后续迭代再处理。详见记忆 clover-image-text-check-shelved。
|
||||
|
||||
@@ -52,7 +52,8 @@ def aggregate_preference_context(
|
||||
weight = int(e.get("signal_weight", 1))
|
||||
|
||||
# text_edit(改稿)是最强真实信号,角度按权重计入(倩倩姐2026-06-16拍板)
|
||||
if sig_type in ("text_select", "approve", "text_edit") and angle:
|
||||
# image_select(选图)按套别叙事角度计入,让选图偏好真正闭环回生图(R7断点2)
|
||||
if sig_type in ("text_select", "approve", "text_edit", "image_select") and angle:
|
||||
angle_counts[angle] += weight
|
||||
elif sig_type == "reject_with_reason":
|
||||
reason = str(e.get("reason") or "").strip()
|
||||
|
||||
@@ -60,6 +60,19 @@ def retry_fission_note_images(
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("裂变补图参考图读取失败,无参考图模式: %s", e)
|
||||
|
||||
# R5多图:补图也按场景分组,重生失败分镜时选对应场景图
|
||||
images_by_scene: dict[str, list[bytes]] = {}
|
||||
for _im in (product.get("images") or []):
|
||||
_p = _resolve_image_path(_im.get("path", ""))
|
||||
if _p and os.path.isfile(_p):
|
||||
try:
|
||||
with open(_p, "rb") as _f:
|
||||
images_by_scene.setdefault(_im.get("scene") or "primary", []).append(_f.read())
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
if reference_images and not images_by_scene.get("primary"):
|
||||
images_by_scene.setdefault("primary", []).extend(reference_images)
|
||||
|
||||
upload_base = get_settings().UPLOAD_ABS_ROOT
|
||||
img_dir = os.path.join(upload_base, str(ft.workspace_id), f"fission_{ft.id}", str(fn.seq))
|
||||
os.makedirs(img_dir, exist_ok=True)
|
||||
@@ -73,6 +86,7 @@ def retry_fission_note_images(
|
||||
client=clients, note=note, product=product,
|
||||
image_count=image_count, reference_images=reference_images,
|
||||
target_role=role,
|
||||
images_by_scene=images_by_scene or None,
|
||||
))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.error("裂变补图 seq=%s role=%s 仍失败: %s", fn.seq, role, exc)
|
||||
|
||||
@@ -39,6 +39,19 @@ def generate_fission_images(
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.warning("裂变参考图读取失败,无参考图模式: %s", e)
|
||||
|
||||
# R5多图:裂变同样按场景分组,生图按分镜 role 选对应图
|
||||
images_by_scene: dict[str, list[bytes]] = {}
|
||||
for _im in (product.get("images") or []):
|
||||
_p = _resolve_image_path(_im.get("path", ""))
|
||||
if _p and os.path.isfile(_p):
|
||||
try:
|
||||
with open(_p, "rb") as _f:
|
||||
images_by_scene.setdefault(_im.get("scene") or "primary", []).append(_f.read())
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
if reference_images and not images_by_scene.get("primary"):
|
||||
images_by_scene.setdefault("primary", []).extend(reference_images)
|
||||
|
||||
upload_base = get_settings().UPLOAD_ABS_ROOT
|
||||
for nid in note_ids:
|
||||
fn = db.query(FissionNote).filter(FissionNote.id == nid).first()
|
||||
@@ -52,6 +65,7 @@ def generate_fission_images(
|
||||
results = asyncio.run(generate_storyboard_images(
|
||||
client=clients, note=note, product=product,
|
||||
image_count=image_count, reference_images=reference_images,
|
||||
images_by_scene=images_by_scene or None,
|
||||
))
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.error("裂变套 seq=%s 生图失败: %s", fn.seq, exc)
|
||||
|
||||
@@ -8,10 +8,10 @@ preference_aggregator:查最近50条 → 最常选角度 + 打回原因近3条
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import desc, func
|
||||
from sqlalchemy import desc
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.constants.enums import SIGNAL_WEIGHTS, DataOwnership, SignalType
|
||||
from app.constants.enums import SIGNAL_WEIGHTS, DataOwnership
|
||||
from app.middleware.workspace_guard import CurrentUser
|
||||
from app.models.flywheel import PreferenceEvent
|
||||
from app.models.task import GenerationTask
|
||||
@@ -20,8 +20,6 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
# 实时聚合窗口:最近50条事件
|
||||
_AGGREGATION_WINDOW = 50
|
||||
# 冷启动阈值:不足5条信号用产品档案冷启动
|
||||
_COLD_START_THRESHOLD = 5
|
||||
|
||||
|
||||
def record_signal(
|
||||
@@ -32,12 +30,14 @@ def record_signal(
|
||||
candidate_id: int | None = None,
|
||||
angle_label: str | None = None,
|
||||
reason: str | None = None,
|
||||
signal_meta: str | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
写入飞轮信号。
|
||||
workspace_id + product_id 都必须有(基石C + 按产品分开学)。
|
||||
signal_weight 用枚举默认值,北哥可校准。
|
||||
data_ownership 默认 client_data(选择行为归客户)。
|
||||
signal_meta:JSON 扩展(如选图存 strategy),不参与角度聚合主逻辑。
|
||||
"""
|
||||
weight = SIGNAL_WEIGHTS.get(signal_type, 0)
|
||||
event = PreferenceEvent(
|
||||
@@ -50,6 +50,7 @@ def record_signal(
|
||||
candidate_id=candidate_id,
|
||||
angle_label=angle_label,
|
||||
reason=reason,
|
||||
signal_meta=signal_meta,
|
||||
data_ownership=DataOwnership.CLIENT_DATA,
|
||||
)
|
||||
try:
|
||||
@@ -69,14 +70,17 @@ def record_signal(
|
||||
|
||||
|
||||
def get_preference_context(
|
||||
db: Session, workspace_id: int, product_id: int
|
||||
db: Session, workspace_id: int, product_id: int,
|
||||
product_dict: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
实时聚合偏好上下文(最近50条 events)。
|
||||
返回:recent_preference摘要 + reject_reasons近3条 + injected_count。
|
||||
不足5条 → 冷启动提示(产品档案兜底,由 AIE prompt 层读 products.custom_prompt)。
|
||||
实时聚合偏好上下文(最近50条 events)供前端展示。
|
||||
R7断点3:统一口径——委托给生产链同一个 aggregate_preference_context(权重口径),
|
||||
消除"前端展示按次数/生成按权重"的口径分裂(说一套做一套)。
|
||||
按 workspace_id + product_id 严格过滤(不串数据,基石C)。
|
||||
"""
|
||||
from app.services.ai_engine.preference_aggregator import aggregate_preference_context
|
||||
|
||||
recent = (
|
||||
db.query(PreferenceEvent)
|
||||
.filter(
|
||||
@@ -87,35 +91,18 @@ def get_preference_context(
|
||||
.limit(_AGGREGATION_WINDOW)
|
||||
.all()
|
||||
)
|
||||
|
||||
if len(recent) < _COLD_START_THRESHOLD:
|
||||
return {
|
||||
"recent_preference": "信号不足,使用产品档案基线(冷启动)",
|
||||
"reject_reasons": [],
|
||||
"injected_count": len(recent),
|
||||
}
|
||||
|
||||
# 统计最常被选中的角度(text_edit 改稿=最强真实信号,按权重计入,倩倩姐2026-06-16)
|
||||
angle_counts: dict[str, int] = {}
|
||||
for ev in recent:
|
||||
if ev.signal_type in (SignalType.TEXT_SELECT, SignalType.APPROVE, SignalType.TEXT_EDIT) and ev.angle_label:
|
||||
angle_counts[ev.angle_label] = angle_counts.get(ev.angle_label, 0) + 1
|
||||
|
||||
top_angles = sorted(angle_counts.items(), key=lambda x: x[1], reverse=True)[:3]
|
||||
if top_angles:
|
||||
pref_desc = ";".join(f"{a}(已选{c}次)" for a, c in top_angles)
|
||||
preference_summary = f"最近偏好:{pref_desc}"
|
||||
else:
|
||||
preference_summary = "暂无明显角度偏好"
|
||||
|
||||
# 取最近3条打回原因原文(不做 AI 归纳,契约§3)
|
||||
reject_reasons = [
|
||||
ev.reason for ev in recent
|
||||
if ev.signal_type == SignalType.REJECT_WITH_REASON and ev.reason
|
||||
][:3]
|
||||
|
||||
events_dicts = [
|
||||
{"signal_type": e.signal_type, "workspace_id": e.workspace_id,
|
||||
"product_id": e.product_id, "angle_label": e.angle_label or "",
|
||||
"signal_weight": e.signal_weight, "reason": e.reason or ""}
|
||||
for e in recent
|
||||
]
|
||||
ctx = aggregate_preference_context(
|
||||
events_dicts, product_dict or {}, workspace_id, product_id
|
||||
)
|
||||
# 前端展示不需要 prompt_fragment(注入用),剥掉只回摘要+原因+计数
|
||||
return {
|
||||
"recent_preference": preference_summary,
|
||||
"reject_reasons": reject_reasons,
|
||||
"injected_count": len(recent),
|
||||
"recent_preference": ctx.get("recent_preference", ""),
|
||||
"reject_reasons": ctx.get("reject_reasons", []),
|
||||
"injected_count": ctx.get("injected_count", 0),
|
||||
}
|
||||
|
||||
@@ -30,6 +30,28 @@ def _check_user_has_key(db: Session, user_id: int, workspace_id: int) -> None:
|
||||
raise_business("尚未配置 API Key,请先在设置中录入")
|
||||
|
||||
|
||||
def _check_concurrency_limit(db: Session, user_id: int, workspace_id: int) -> None:
|
||||
"""
|
||||
校验该用户未完成任务数未超并发上限(红线=5,可配置)。
|
||||
只算 pending/generating(真占 worker),挑选/审核态不计。超限引导稍后再试。
|
||||
"""
|
||||
from app.core.config import get_settings
|
||||
from app.constants.enums import TaskStatus
|
||||
|
||||
limit = get_settings().MAX_CONCURRENT_TASKS_PER_USER
|
||||
running = (
|
||||
db.query(GenerationTask)
|
||||
.filter(
|
||||
GenerationTask.operator_id == user_id,
|
||||
GenerationTask.workspace_id == workspace_id,
|
||||
GenerationTask.status.in_([TaskStatus.PENDING.value, TaskStatus.GENERATING.value]),
|
||||
)
|
||||
.count()
|
||||
)
|
||||
if running >= limit:
|
||||
raise_business(f"您有 {running} 个任务正在生成,已达并发上限 {limit} 个,请等待完成后再发起")
|
||||
|
||||
|
||||
def create_generation_task(
|
||||
db: Session,
|
||||
current_user: CurrentUser,
|
||||
@@ -42,6 +64,9 @@ def create_generation_task(
|
||||
if body.track == "ai":
|
||||
# 轨A:先检查有没有 key
|
||||
_check_user_has_key(db, current_user.user_id, current_user.workspace_id)
|
||||
# 并发上限:只算正在消耗生成资源的任务(pending/generating),
|
||||
# 已生成完等挑选/审核的不占 worker。红线=每用户5个(可配置)。
|
||||
_check_concurrency_limit(db, current_user.user_id, current_user.workspace_id)
|
||||
|
||||
# 禁降级铁律:本次产品入镜(need_product_image=True)时,产品必须已上传参考图,
|
||||
# 否则拒绝建任务(不允许降级纯文生图,防产品包装跑偏/过抽检失败)。
|
||||
@@ -57,6 +82,11 @@ def create_generation_task(
|
||||
if not (product.image_path or "").strip():
|
||||
raise_business("该产品未上传参考图,无法生成产品入镜内容;请先到产品库上传产品图,或关闭「产品入镜」开关")
|
||||
|
||||
# 第2环:关联标杆笔记ID存库(JSON list)。pipeline 据此读 features_json 注入文案 prompt。
|
||||
import json
|
||||
_bids = getattr(body, "benchmark_ids", None) or []
|
||||
benchmark_ids_json = json.dumps([int(i) for i in _bids]) if _bids else None
|
||||
|
||||
task = GenerationTask(
|
||||
workspace_id=current_user.workspace_id,
|
||||
product_id=body.product_id,
|
||||
@@ -66,6 +96,7 @@ def create_generation_task(
|
||||
image_count=body.image_count,
|
||||
track=body.track,
|
||||
need_product_image=need_img,
|
||||
benchmark_ids=benchmark_ids_json,
|
||||
status="pending",
|
||||
)
|
||||
db.add(task)
|
||||
|
||||
@@ -42,47 +42,62 @@ def build_delivery_package(self, package_id: int) -> dict:
|
||||
settings = get_settings()
|
||||
upload_base = settings.UPLOAD_BASE_PATH.rstrip("/")
|
||||
|
||||
selected_text = db.query(TextCandidate).filter(
|
||||
# A8 多套打包:按 strategy(A/B/C 三套正交叙事)分组,每套成一篇独立 note。
|
||||
# 北哥拿到的交付包含完整 3 套(note_01/02/03),不再只打第 1 条文案。
|
||||
selected_texts = db.query(TextCandidate).filter(
|
||||
TextCandidate.task_id == task_id, TextCandidate.is_selected == True,
|
||||
).first()
|
||||
# 整套全打(倩倩姐2026-06-08拍板):一条笔记的全部图按 seq 排序进包,
|
||||
# 不再只打 is_selected 的封面。北哥6张标准套 seq=1 是 hook 封面,天然排第一。
|
||||
selected_images = db.query(ImageCandidate).filter(
|
||||
ImageCandidate.task_id == task_id,
|
||||
).order_by(ImageCandidate.seq).all()
|
||||
|
||||
if not selected_text:
|
||||
).order_by(TextCandidate.id).all()
|
||||
if not selected_texts:
|
||||
raise ValueError("无已选文案,请先选择文案")
|
||||
|
||||
text_data = json.loads(selected_text.content or "{}")
|
||||
images_data = []
|
||||
for ic in selected_images:
|
||||
# 整套全打:一套内全部图按 seq 排序进包,不只打封面。重生场景同 (strategy,seq)
|
||||
# 可能多条(新增不删旧),去重取最新(id最大),避免包内重复图。
|
||||
all_images = db.query(ImageCandidate).filter(
|
||||
ImageCandidate.task_id == task_id,
|
||||
).order_by(ImageCandidate.strategy, ImageCandidate.seq).all()
|
||||
|
||||
def _read_image(ic) -> dict:
|
||||
img_bytes = b""
|
||||
if ic.url:
|
||||
# url 形如 /uploads/ws/task/file.jpg,本身已含 uploads 前缀。
|
||||
# 工作目录是 /app,直接 lstrip("/") 当相对路径读,不能再拼 upload_base(会重复 uploads/uploads)。
|
||||
rel = ic.url.lstrip("/")
|
||||
abs_path = rel
|
||||
# url 已含 uploads 前缀;工作目录 /app,lstrip 当相对路径读,勿再拼 base(防 uploads/uploads)
|
||||
try:
|
||||
with open(abs_path, "rb") as f:
|
||||
with open(ic.url.lstrip("/"), "rb") as f:
|
||||
img_bytes = f.read()
|
||||
except OSError as e:
|
||||
logger.warning("图片读取失败,跳过:%s %s", abs_path, e)
|
||||
images_data.append({
|
||||
logger.warning("图片读取失败,跳过:%s %s", ic.url, e)
|
||||
return {
|
||||
"seq": ic.seq,
|
||||
"role": ic.role.value if hasattr(ic.role, "value") else str(ic.role),
|
||||
"data": img_bytes,
|
||||
})
|
||||
}
|
||||
|
||||
notes = [{
|
||||
"title": text_data.get("title", ""),
|
||||
"content": text_data.get("content", ""),
|
||||
"tags": text_data.get("tags", []),
|
||||
"images": images_data,
|
||||
"banned_word_status": (selected_text.banned_word_status.value
|
||||
if hasattr(selected_text.banned_word_status, "value")
|
||||
else str(selected_text.banned_word_status)),
|
||||
}]
|
||||
# 按 strategy 分组(A/B/C;老数据 strategy=None 归一套,向后兼容)
|
||||
from collections import OrderedDict
|
||||
groups: "OrderedDict[str, dict]" = OrderedDict()
|
||||
for ic in all_images:
|
||||
slot = groups.setdefault(ic.strategy or "_", {})
|
||||
prev = slot.get(ic.seq)
|
||||
if prev is None or ic.id > prev.id:
|
||||
slot[ic.seq] = ic # 同 seq 留最新
|
||||
|
||||
notes = []
|
||||
for idx, (_strategy, slot) in enumerate(groups.items()):
|
||||
images_data = [_read_image(slot[k]) for k in sorted(slot)]
|
||||
# 文案配对:选中文案数≥套数则一套一条;否则各套共用第 1 条
|
||||
# (图均以第 1 条文案为语境生成,共用合理;多选则尊重运营按套选的文案)
|
||||
tc = selected_texts[idx] if idx < len(selected_texts) else selected_texts[0]
|
||||
text_data = json.loads(tc.content or "{}")
|
||||
notes.append({
|
||||
"title": text_data.get("title", ""),
|
||||
"content": text_data.get("content", ""),
|
||||
"tags": text_data.get("tags", []),
|
||||
"images": images_data,
|
||||
"banned_word_status": (tc.banned_word_status.value
|
||||
if hasattr(tc.banned_word_status, "value")
|
||||
else str(tc.banned_word_status)),
|
||||
})
|
||||
if not notes:
|
||||
raise ValueError("无图片候选,无法打包")
|
||||
|
||||
from app.services.ai_engine.package_exporter import build_delivery_package as do_build
|
||||
# 打包产物放专用目录 uploads/packages/,与图片目录 uploads/{ws}/{task}/ 分开
|
||||
|
||||
@@ -148,7 +148,8 @@ def run_image_generation(db, clients, task, product_dict: dict,
|
||||
first_copy: dict, upload_base_path: str,
|
||||
regen_strategy: str | None = None,
|
||||
regen_role: str | None = None,
|
||||
custom_prompt: str | None = None) -> int:
|
||||
custom_prompt: str | None = None,
|
||||
flywheel_fragment: str | None = None) -> int:
|
||||
"""
|
||||
Step6+7+8(image): 调 generate_storyboard_images → 后处理 → 存 ImageCandidate → 推 SSE。
|
||||
返回 next_seq。
|
||||
@@ -202,6 +203,23 @@ def run_image_generation(db, clients, task, product_dict: dict,
|
||||
"拒绝降级纯文生图。请确认产品已上传参考图。"
|
||||
)
|
||||
|
||||
# R5多图:按场景分组加载产品图,生图按分镜 role 选对应场景图
|
||||
images_by_scene: dict[str, list[bytes]] = {}
|
||||
for _im in (product_dict.get("images") or []):
|
||||
_p = _resolve_image_path(_im.get("path", ""))
|
||||
_scene = _im.get("scene") or "primary"
|
||||
if _p and os.path.isfile(_p):
|
||||
try:
|
||||
with open(_p, "rb") as _f:
|
||||
images_by_scene.setdefault(_scene, []).append(_f.read())
|
||||
except Exception as _e:
|
||||
logger.warning("产品图(scene=%s)读取失败,跳过:%s %s", _scene, _p, _e)
|
||||
# 主图始终保底进 primary(多图表为空或主图未入表时仍可用)
|
||||
if reference_images and not images_by_scene.get("primary"):
|
||||
images_by_scene.setdefault("primary", []).extend(reference_images)
|
||||
if images_by_scene:
|
||||
logger.info("R5多图已加载:%s", {k: len(v) for k, v in images_by_scene.items()})
|
||||
|
||||
seq = seq_start
|
||||
# R2: 限定重生套别(regen_strategy)则只跑该套,否则全量 A/B/C 三套正交叙事
|
||||
_strategies = (regen_strategy,) if regen_strategy else ("A", "B", "C")
|
||||
@@ -227,6 +245,8 @@ def run_image_generation(db, clients, task, product_dict: dict,
|
||||
strategy=strategy,
|
||||
target_role=regen_role,
|
||||
custom_prompt=custom_prompt,
|
||||
images_by_scene=images_by_scene or None,
|
||||
flywheel_fragment=flywheel_fragment,
|
||||
))
|
||||
except Exception as exc:
|
||||
img_success = False
|
||||
|
||||
@@ -58,6 +58,7 @@ def build_clients_and_clear_key(plain_key: str):
|
||||
def build_product_dict(product) -> dict:
|
||||
"""把 ORM product 转成 AI 引擎所需的 dict(不含任何 key)。"""
|
||||
return {
|
||||
"id": product.id,
|
||||
"name": product.name,
|
||||
"category": product.category or "通用好物",
|
||||
"selling_points": json.loads(product.selling_points or "[]"),
|
||||
@@ -66,10 +67,52 @@ def build_product_dict(product) -> dict:
|
||||
"custom_prompt": product.custom_prompt or "",
|
||||
"brand_keyword": product.brand_keyword or "", # S3: 品牌词透传进生成prompt(每条植入)
|
||||
"target_audience": product.target_audience or "", # 012: 人群透传进storyboard/文案prompt
|
||||
"image_path": product.image_path or "", # 产品参考图路径(前端上传后填入)
|
||||
"image_path": product.image_path or "", # 产品参考图路径(主图,向后兼容)
|
||||
# R5多图:每张产品图 {path, scene},生图按分镜role选对应场景图
|
||||
"images": [
|
||||
{"path": im.path, "scene": im.scene}
|
||||
for im in (getattr(product, "images", None) or [])
|
||||
],
|
||||
# 第2环标杆配方,默认空;走 AI 主链时由 load_benchmark_features 覆盖填充
|
||||
"benchmark_refs": [],
|
||||
}
|
||||
|
||||
|
||||
def load_benchmark_features(db, task, workspace_id: int) -> list[dict]:
|
||||
"""
|
||||
第2环→第5环接线:读 task.benchmark_ids → 查 analyze_status=done 的标杆 features_json。
|
||||
返回 8维配方 dict 列表(供 build_prompt 借方法层结构,禁抄竞品品牌/功效原话)。
|
||||
未选/未分析完/解析失败都安全返空,绝不阻断生成。
|
||||
"""
|
||||
from app.models.product import BenchmarkNote
|
||||
|
||||
raw_ids = getattr(task, "benchmark_ids", None)
|
||||
if not raw_ids:
|
||||
return []
|
||||
try:
|
||||
ids = [int(i) for i in json.loads(raw_ids)]
|
||||
except Exception:
|
||||
return []
|
||||
if not ids:
|
||||
return []
|
||||
|
||||
rows = db.query(BenchmarkNote).filter(
|
||||
BenchmarkNote.id.in_(ids),
|
||||
BenchmarkNote.workspace_id == workspace_id,
|
||||
BenchmarkNote.analyze_status == "done",
|
||||
).all()
|
||||
|
||||
feats: list[dict] = []
|
||||
for b in rows:
|
||||
if not b.features_json:
|
||||
continue
|
||||
try:
|
||||
feats.append(json.loads(b.features_json))
|
||||
except Exception:
|
||||
logger.warning("标杆 features_json 解析失败 id=%s", b.id)
|
||||
return feats
|
||||
|
||||
|
||||
def load_flywheel_context(db, workspace_id: int, product_id: int, product_dict: dict) -> tuple[str, dict]:
|
||||
"""
|
||||
查最近50条飞轮事件,聚合偏好上下文。
|
||||
|
||||
@@ -106,6 +106,7 @@ def run_generation_pipeline(self, task_id: int, regen_strategy: str | None = Non
|
||||
build_clients_and_clear_key,
|
||||
build_product_dict,
|
||||
load_flywheel_context,
|
||||
load_benchmark_features,
|
||||
)
|
||||
from app.workers.pipeline_io import run_text_generation, run_image_generation
|
||||
from app.core.config import get_settings
|
||||
@@ -135,6 +136,8 @@ def run_generation_pipeline(self, task_id: int, regen_strategy: str | None = Non
|
||||
}, seq)
|
||||
|
||||
product_dict = build_product_dict(product)
|
||||
# 第2环爆款配方接进文案链:选中并分析完的标杆 8维配方注入 build_prompt(借方法层结构)
|
||||
product_dict["benchmark_refs"] = load_benchmark_features(db, task, workspace_id)
|
||||
flywheel_fragment, flywheel_ctx = load_flywheel_context(db, workspace_id, task.product_id, product_dict)
|
||||
|
||||
if flywheel_fragment:
|
||||
@@ -192,6 +195,7 @@ def run_generation_pipeline(self, task_id: int, regen_strategy: str | None = Non
|
||||
first_copy, settings.UPLOAD_BASE_PATH,
|
||||
regen_strategy=regen_strategy, regen_role=regen_role,
|
||||
custom_prompt=custom_prompt,
|
||||
flywheel_fragment=flywheel_fragment,
|
||||
)
|
||||
|
||||
# 最终状态 + task_done
|
||||
|
||||
@@ -71,6 +71,7 @@ def _register_routers(app: FastAPI) -> None:
|
||||
from app.api.v1.auth import router as auth_router
|
||||
from app.api.v1.api_keys import router as api_keys_router
|
||||
from app.api.v1.products import router as products_router
|
||||
from app.api.v1.product_images import router as product_images_router
|
||||
from app.api.v1.benchmarks import router as benchmarks_router
|
||||
from app.api.v1.tasks import router as tasks_router
|
||||
from app.api.v1.task_actions import router as task_actions_router
|
||||
@@ -79,11 +80,15 @@ def _register_routers(app: FastAPI) -> None:
|
||||
from app.api.v1.stream import router as stream_router
|
||||
from app.api.v1.workspaces import router as workspaces_router
|
||||
from app.api.v1.fission import router as fission_router
|
||||
from app.api.v1.exports import router as exports_router
|
||||
|
||||
prefix = "/api/v1"
|
||||
app.include_router(auth_router, prefix=prefix)
|
||||
app.include_router(api_keys_router, prefix=prefix)
|
||||
# 导出 router 必须在 products_router 之前:否则 /products/export 被 /products/{product_id} 吞掉
|
||||
app.include_router(exports_router, prefix=prefix)
|
||||
app.include_router(products_router, prefix=prefix)
|
||||
app.include_router(product_images_router, prefix=prefix)
|
||||
app.include_router(benchmarks_router, prefix=prefix)
|
||||
app.include_router(tasks_router, prefix=prefix)
|
||||
app.include_router(task_actions_router, prefix=prefix)
|
||||
|
||||
@@ -156,7 +156,10 @@ class TestPoint4SseFlywheelEvent:
|
||||
mq.limit.return_value = mq
|
||||
mq.all.return_value = orm_events
|
||||
|
||||
result = get_preference_context(mock_db, workspace_id=1, product_id=10)
|
||||
result = get_preference_context(
|
||||
mock_db, workspace_id=1, product_id=10,
|
||||
product_dict={"custom_prompt": "", "style_tone": ""},
|
||||
)
|
||||
|
||||
fe_dto_keys = {"recent_preference", "reject_reasons", "injected_count"}
|
||||
missing = fe_dto_keys - result.keys()
|
||||
|
||||
Reference in New Issue
Block a user