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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user