- constants: 新增 TEXT_NARRATIVE_BY_STRATEGY(A痛点/B场景/C成分),与图片侧同轴 - build_prompt: 加 strategy_narrative 参数并注入 prompt - text_variants: 全链路透传(含优化轮) - run_text_generation: 改循环三套,text_count均摊(divmod余前补),跨套去重,打_strategy标记 - TextCandidate: 加 strategy String(4) 字段 + 迁移021(已upgrade head) - packaging: 打包按strategy精准配对文图(texts_by_strategy映射+三层兜底) - SSE text_candidate 事件携带 strategy 独立agent交叉验证7改造点全过,边界(text_count<3/无别名/不截断)无must-fix Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
135 lines
5.7 KiB
Python
135 lines
5.7 KiB
Python
"""
|
||
app/workers/packaging_task.py — 交付打包 Celery 任务
|
||
build_delivery_package:查已选文案+图片 → package_exporter → 存路径
|
||
"""
|
||
|
||
import json
|
||
import logging
|
||
|
||
from app.workers.celery_app import celery_app
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
def _get_db():
|
||
from app.core.database import SessionLocal
|
||
return SessionLocal()
|
||
|
||
|
||
@celery_app.task(
|
||
bind=True,
|
||
name="app.workers.tasks.build_delivery_package",
|
||
max_retries=2,
|
||
default_retry_delay=10,
|
||
queue="packaging",
|
||
)
|
||
def build_delivery_package(self, package_id: int) -> dict:
|
||
"""打包交付任务。查 delivery_packages → 收集笔记 → package_exporter"""
|
||
logger.info("build_delivery_package start: package_id=%s", package_id)
|
||
db = _get_db()
|
||
try:
|
||
from app.models.task import DeliveryPackage, TextCandidate, ImageCandidate
|
||
from app.constants.enums import PackageStatus
|
||
|
||
pkg = db.query(DeliveryPackage).filter(DeliveryPackage.id == package_id).first()
|
||
if not pkg:
|
||
raise ValueError(f"package_id={package_id} not found")
|
||
|
||
workspace_id = pkg.workspace_id
|
||
task_id = pkg.task_id
|
||
|
||
from app.core.config import get_settings
|
||
settings = get_settings()
|
||
upload_base = settings.UPLOAD_BASE_PATH.rstrip("/")
|
||
|
||
# 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,
|
||
).order_by(TextCandidate.id).all()
|
||
if not selected_texts:
|
||
raise ValueError("无已选文案,请先选择文案")
|
||
|
||
# 整套全打:一套内全部图按 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 前缀;工作目录 /app,lstrip 当相对路径读,勿再拼 base(防 uploads/uploads)
|
||
try:
|
||
with open(ic.url.lstrip("/"), "rb") as f:
|
||
img_bytes = f.read()
|
||
except OSError as e:
|
||
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,
|
||
}
|
||
|
||
# 按 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 留最新
|
||
|
||
# 文案按 strategy 建映射,供图片组按套精准配对(A8:文图同套对齐,不靠脆弱 idx)。
|
||
# 同套多条选中取第 1 条;老数据 strategy=None 的归入 fallback 列表。
|
||
texts_by_strategy: dict = {}
|
||
texts_no_strategy: list = []
|
||
for tc in selected_texts:
|
||
if tc.strategy:
|
||
texts_by_strategy.setdefault(tc.strategy, tc)
|
||
else:
|
||
texts_no_strategy.append(tc)
|
||
_text_fallback = iter(texts_no_strategy or selected_texts)
|
||
|
||
notes = []
|
||
for idx, (_strategy, slot) in enumerate(groups.items()):
|
||
images_data = [_read_image(slot[k]) for k in sorted(slot)]
|
||
# 配对优先级:①同 strategy 文案精准对齐 ②无同套则按顺序取无套文案
|
||
# ③再兜底用第 idx 条/第 1 条,确保每组图都有文案不漏。
|
||
tc = texts_by_strategy.get(_strategy)
|
||
if tc is None:
|
||
tc = next(_text_fallback, None)
|
||
if tc is None:
|
||
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}/ 分开
|
||
packages_base = f"{upload_base}/packages"
|
||
zip_path = do_build(workspace_id, task_id, notes, base_path=packages_base)
|
||
|
||
pkg.package_path = zip_path
|
||
pkg.download_url = f"/api/v1/delivery-packages/{package_id}/download-file"
|
||
pkg.status = PackageStatus.READY
|
||
db.commit()
|
||
|
||
logger.info("delivery package ready: package_id=%s path=%s", package_id, zip_path)
|
||
return {"package_id": package_id, "status": "ready", "path": zip_path}
|
||
|
||
except Exception as exc:
|
||
logger.error("build_delivery_package failed: package_id=%s err=%s", package_id, exc)
|
||
raise self.retry(exc=exc)
|
||
finally:
|
||
db.close()
|