Files
beige/backend/app/workers/pipeline_io.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

431 lines
19 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/workers/pipeline_io.py — 生产链 Step5-8
Step5: 文案生成generate_text_variants
Step6: 图片生成generate_storyboard_imagesasyncio.gather
Step7: 图片后处理image_postprocessor
Step8: 存 text_candidates / image_candidates → 更新状态 → 推 task_done
"""
import asyncio
import json
import logging
import os
logger = logging.getLogger(__name__)
def _resolve_image_path(img_path: str) -> str:
"""
解析产品参考图路径,兼容绝对路径(新)与历史相对路径(旧)。
新数据存绝对路径(/app/uploads/...)直接返回;
旧数据存相对路径(uploads/packages/...)锚定到 UPLOAD_ABS_ROOT 的父级,
避免 worker(cwd=/) 解析失败。
"""
if not img_path:
return ""
if os.path.isabs(img_path):
return img_path
from app.core.config import get_settings
# UPLOAD_ABS_ROOT=/app/uploads其父级 /app 是相对路径(uploads/...)的锚点
root_parent = os.path.dirname(get_settings().UPLOAD_ABS_ROOT.rstrip("/"))
return os.path.join(root_parent, img_path)
def run_text_generation(db, clients, task, product_dict: dict, flywheel_fragment: str,
push_fn, workspace_id: int, seq_start: int) -> tuple[list, int, bool, str | None, int]:
"""
Step5: 调 generate_text_variants → 存 TextCandidate → 推 SSE → 写 ai_call_logs。
S1: 存库前过滤——只存 passed且score>=QUALITY_PASS_SCORE(80,红线)且banned_word_status!='hard_block' 的文案。
合格数 < task.text_count 时 needs_replenish=True由主任务发起后台补充子任务
返回 (candidates_raw, next_seq, needs_replenish, text_fail_reason, saved_count)。
text_fail_reason: None|scoring_unavailable|generation_failed|quality_filtered|replenishing(B1透传0条原因)。
"""
import time
from app.services.ai_engine.text_variants import generate_text_variants
from app.services.ai_engine.llm_scorer import ScoringUnavailableError
from app.models.product import BannedWord
from app.models.task import TextCandidate
from app.models.flywheel import AiCallLog
from app.models.workspace import UserApiKey
from app.constants.enums import CandidateSource, BannedWordStatus
from app.services.ai_engine.constants import QUALITY_PASS_SCORE, TEXT_NARRATIVE_BY_STRATEGY
banned_rows = db.query(BannedWord).filter(
BannedWord.workspace_id == workspace_id
).all()
banned_dicts = [{"word": b.word, "level": b.level, "replacement": b.replacement}
for b in banned_rows]
# 查 key_id只取 id不解密不违反基石B
key_row = db.query(UserApiKey).filter(
UserApiKey.user_id == task.operator_id,
UserApiKey.workspace_id == workspace_id,
).first()
key_id = key_row.id if key_row else None
# A8文案按 A/B/C 三套正交叙事分别生成,每套不同角度避免套路化重复,
# 与图片侧同轴(A痛点/B场景/C成分)。text_count 均摊三套(余数前补)
# 逐套把已生成的喂作 previous_copies 做跨套去重。
_strategies = ("A", "B", "C")
_base, _rem = divmod(task.text_count, 3)
_per = {s: _base + (1 if i < _rem else 0) for i, s in enumerate(_strategies)}
t0 = time.monotonic()
llm_success = True
scoring_unavailable = False # B1评分通道(apiports+codeproxy)整套都挂,用于区分"评分不可用"vs"质量不合格"
candidates_raw: list = []
for s in _strategies:
n = _per[s]
if n <= 0:
continue
try:
part = asyncio.run(generate_text_variants(
llm_client=clients,
product=product_dict,
count=n,
previous_copies=candidates_raw,
banned_word_rows=banned_dicts,
flywheel_context=flywheel_fragment,
strategy_narrative=TEXT_NARRATIVE_BY_STRATEGY.get(s, ""),
))
except ScoringUnavailableError as exc:
# B1评分两通道均不可用——明确记号绝不当"质量不合格"糊弄用户
scoring_unavailable = True
llm_success = False
logger.error("generate_text_variants(套%s) 评分通道不可用: %s", s, exc)
part = []
except Exception as exc:
llm_success = False
logger.error("generate_text_variants(套%s) 失败: %s", s, exc)
part = []
for c in part:
c["_strategy"] = s
candidates_raw.extend(part)
latency_ms = int((time.monotonic() - t0) * 1000)
# 写 ai_call_logs留痕不含明文key
try:
log = AiCallLog(
workspace_id=workspace_id,
user_id=task.operator_id,
key_id=key_id,
task_id=task.id,
provider="apiports",
model=clients._model,
call_type="text",
success=llm_success,
latency_ms=latency_ms,
)
db.add(log)
db.flush()
except Exception as log_exc:
logger.warning("ai_call_logs 写入失败(非阻断): %s", log_exc)
# S1: 存库前过滤——只存合格文案passed + score>=QUALITY_PASS_SCORE(80) + 非hard_block
seq = seq_start
saved_count = 0
partial_scoring_unavailable = False # 部分条目评分通道挂(整套没全挂故没抛异常),用于精确归因
for i, c in enumerate(candidates_raw):
score = c.get("score", 0)
passed = c.get("passed", False)
bw_status = c.get("banned_word_status", "pass")
if c.get("scoring_unavailable"):
partial_scoring_unavailable = True
if not (passed and score >= QUALITY_PASS_SCORE and bw_status != "hard_block"):
logger.info(
"文案[%d] 过滤丢弃: passed=%s score=%s banned=%s",
i, passed, score, bw_status,
)
continue
tc = TextCandidate(
workspace_id=workspace_id,
task_id=task.id,
source=CandidateSource.AI,
strategy=c.get("_strategy"),
angle_label=c.get("angle_label") or c.get("angle", ""),
content=json.dumps(c, ensure_ascii=False),
score_json=json.dumps(c.get("score_detail", []), ensure_ascii=False),
banned_word_status=BannedWordStatus(bw_status),
)
db.add(tc)
db.flush()
saved_count += 1
seq += 1
push_fn(task.id, workspace_id, "text_candidate", {
"candidate_id": tc.id, "angle_label": tc.angle_label,
"strategy": tc.strategy,
"content": c.get("content", ""), "score": score,
}, seq)
seq += 1
push_fn(task.id, workspace_id, "text_progress", {
"done": saved_count, "total": task.text_count
}, seq)
db.commit()
# S1: 合格数不足时标记需要后台补充
needs_replenish = saved_count < task.text_count
if needs_replenish:
logger.warning(
"文案合格数不足: task_id=%s 目标=%s 实得=%s,将后台异步补充",
task.id, task.text_count, saved_count,
)
# 整套全挂(scoring_unavailable)或部分条目评分挂(partial)都按"评分不可用"归因,
# 不被 quality_filtered 掩盖——评分通道问题≠文案质量问题(B1红线)
scoring_unavailable = scoring_unavailable or partial_scoring_unavailable
# B1透传 0 条的真实原因,前端据此区分提示,不再把"评分挂了"显示成"没有合格文案"
if saved_count == 0 and scoring_unavailable:
text_fail_reason = "scoring_unavailable" # 评分服务不可用
elif saved_count == 0 and not llm_success:
text_fail_reason = "generation_failed" # 文案生成本身失败(非评分)
elif saved_count == 0:
text_fail_reason = "quality_filtered" # 生成成功但全部未达标
elif needs_replenish:
text_fail_reason = "replenishing" # 有产出但不足,补充中
else:
text_fail_reason = None # 正常足量
return candidates_raw, seq, needs_replenish, text_fail_reason, saved_count
def run_image_generation(db, clients, task, product_dict: dict,
push_fn, workspace_id: int, seq_start: int,
notes_by_strategy: dict[str, dict], upload_base_path: str,
regen_strategy: str | None = None,
regen_role: str | None = None,
custom_prompt: str | None = None,
flywheel_fragment: str | None = None,
reuse_text: bool = False) -> int:
"""
Step6+7+8(image): 调 generate_storyboard_images → 后处理 → 存 ImageCandidate → 推 SSE。
返回 next_seq。
R2 局部重生(均 None=全量A/B/C)regen_strategy 限定只跑该套regen_role 配合限定该套该张;
custom_prompt 人工追加提示词。重生产出 is_regen=True 新增不删旧。
reuse_text=True(导入轨):只遍历库内真有导入文案的套(notes_by_strategy 的键)
导入几套出几套,未导入的套不刷 batch_failed 噪声。
"""
import time
from app.services.ai_engine.image_gen import generate_storyboard_images
from app.services.ai_engine.image_postprocessor import process_image
from app.models.task import ImageCandidate
from app.models.flywheel import AiCallLog
from app.models.workspace import UserApiKey
from app.constants.enums import ImageRole as IR
# 取 key_id不解密不记录明文 key
key_row = db.query(UserApiKey).filter(
UserApiKey.user_id == task.operator_id,
UserApiKey.workspace_id == workspace_id,
).first()
key_id = key_row.id if key_row else None
# TODO: 尺寸字段后续加产品级配置products 表现无 aspect_ratio 字段)
# 本轮固定 '3:4'=1024×1536与 gpt-image-2 原生尺寸一致,免后处理二次拉伸
aspect_ratio = "3:4"
# image_count=0 直接跳过(纯文案任务/测试),不空跑生图通道触发无谓失败日志。
if not task.image_count or task.image_count <= 0:
logger.info("image_count=0跳过生图: task_id=%s", task.id)
return seq_start
reference_images: list[bytes] = []
_img_path = _resolve_image_path(product_dict.get("image_path", ""))
if _img_path and os.path.isfile(_img_path):
try:
with open(_img_path, "rb") as _f:
reference_images = [_f.read()]
logger.info("产品参考图已加载:%s (%d bytes)", _img_path, len(reference_images[0]))
except Exception as _e:
logger.warning("产品参考图读取失败,退化为空列表:%s %s", _img_path, _e)
else:
logger.warning(
"product.image_path 未设置或文件不存在(%r),生图将以无参考图模式运行,"
"可能导致产品包装跑偏。", _img_path
)
# 禁降级兜底:本次产品入镜但无参考图 → 硬失败,绝不降级纯文生图(建任务已拦一道,这是防绕过)
if getattr(task, "need_product_image", True) and not reference_images:
raise ValueError(
"本次产品入镜(need_product_image=True)但未获取到产品参考图,"
"拒绝降级纯文生图。请确认产品已上传参考图。"
)
# 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)
# 主图缺失告警:无 scene=primary 入表时,所有 primary 角色只能靠 image_path 兜底,
# 若用户把瓶身误标成 texture主图角色会取不到真瓶身 → 提前暴露在日志(不硬拦,纯测试场景仍放行)
if not images_by_scene.get("primary"):
logger.warning(
"task_id=%s 无 scene=primary 产品图,主图角色将无瓶身锚点,"
"请确认产品已正确标注主图(瓶身本体)。", task.id
)
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)则只跑该套;
# reuse_text(导入轨): 只跑库内真有导入文案的套(按 A/B/C 顺序),导入几套出几套;
# 否则全量 A/B/C 三套正交叙事。
if regen_strategy:
_strategies = (regen_strategy,)
elif reuse_text:
_strategies = tuple(s for s in ("A", "B", "C") if notes_by_strategy.get(s))
# 存量导入文案 strategy 可能为 NULL(归到键"_")A/B/C 全匹配不上→_strategies 空。
# 必须显式报错,否则循环静默跳过=零图产出但任务不报错,极难排查。
if not _strategies:
logger.error(
"导入文案均未分配套别(A/B/C),无法生图: task_id=%s keys=%s",
task.id, list(notes_by_strategy.keys()),
)
push_fn(task.id, workspace_id, "error", {
"code": 40003,
"message": "导入文案未分配套别(A/B/C),请重新导入文案后再去生图",
}, seq + 1)
return seq + 1
else:
_strategies = ("A", "B", "C")
_is_regen = bool(regen_strategy or regen_role)
# 进度总数:单张重生=1单套=image_count全量/导入=image_count×实际套数
if regen_role:
_img_total = 1
elif regen_strategy:
_img_total = task.image_count
else:
_img_total = task.image_count * len(_strategies)
for si, strategy in enumerate(_strategies):
t0 = time.monotonic()
img_success = True
img_error_code = None
try:
note_for_strategy = notes_by_strategy.get(strategy)
if not note_for_strategy:
logger.error("%s缺少合格文案,拒绝复用其他套文案生图: task_id=%s", strategy, task.id)
seq += 1
push_fn(task.id, workspace_id, "batch_failed", {
"batch": f"{strategy}_missing_text",
"reason": f"{strategy}缺少合格文案,未生成该套图片",
"strategy": strategy,
"retryable": False,
}, seq)
continue
image_results = asyncio.run(generate_storyboard_images(
client=clients,
note=note_for_strategy,
product=product_dict,
image_count=task.image_count,
reference_images=reference_images or None,
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
img_error_code = type(exc).__name__
logger.error("generate_storyboard_images 套%s 失败: %s", strategy, exc)
image_results = []
latency_ms = int((time.monotonic() - t0) * 1000)
fail_count = 0
first_img_error: str | None = None
for i, img_result in enumerate(image_results):
if img_result.get("error"):
fail_count += 1
if first_img_error is None:
first_img_error = str(img_result["error"])[:32]
seq += 1
push_fn(task.id, workspace_id, "batch_failed", {
"batch": img_result["role"], "reason": img_result["error"],
"strategy": strategy, "retryable": True,
}, seq)
continue
raw_bytes = img_result["image_bytes"]
try:
processed = process_image(raw_bytes, aspect_ratio=aspect_ratio, resample_strength=1)
except Exception as e:
logger.warning("图片后处理失败,使用原图: %s", e)
processed = raw_bytes
img_dir = os.path.join(upload_base_path, str(workspace_id), str(task.id))
os.makedirs(img_dir, exist_ok=True)
filename = f"{strategy}_{i+1:02d}_{img_result['role']}.jpg"
img_path = os.path.join(img_dir, filename)
with open(img_path, "wb") as f:
f.write(processed)
img_url = f"/uploads/{workspace_id}/{task.id}/{filename}"
role_enum = IR.MAIN
try:
role_enum = IR(img_result["role"])
except ValueError:
pass
ic = ImageCandidate(
workspace_id=workspace_id,
task_id=task.id,
role=role_enum,
url=img_url,
seq=i + 1,
strategy=strategy, # 写入 A/B/C非 hardcode
is_regen=_is_regen, # R2 重生标记新增不删旧前端同strategy+role默认展示最新
# E12 AI评图分只落展示分,绝不碰 eval_score(留 NULL)AI 分不进飞轮权重
ai_visual_score=img_result.get("ai_visual_score"),
ai_visual_note=img_result.get("ai_visual_note"),
)
db.add(ic)
db.flush()
seq += 1
push_fn(task.id, workspace_id, "image_candidate", {
"candidate_id": ic.id, "strategy": strategy, "seq": i + 1,
"url": img_url, "role": img_result["role"],
"is_regen": _is_regen,
"ai_visual_score": img_result.get("ai_visual_score"),
"ai_visual_note": img_result.get("ai_visual_note"),
}, seq)
seq += 1
push_fn(task.id, workspace_id, "image_progress", {
"done": si * task.image_count + (i + 1),
"total": _img_total, "strategy": strategy,
}, seq)
# 写 ai_call_logs每套一条失败不阻断
actual_provider = os.environ.get("IMAGE_PROVIDER_PRIMARY", "gpt")
final_error_code = first_img_error or img_error_code
try:
img_log = AiCallLog(
workspace_id=workspace_id,
user_id=task.operator_id,
key_id=key_id,
task_id=task.id,
provider=actual_provider,
call_type="image",
success=(img_success and fail_count == 0),
latency_ms=latency_ms,
error_code=final_error_code,
)
db.add(img_log)
db.flush()
except Exception as log_exc:
logger.warning("ai_call_logs(image) 套%s 写入失败(非阻断): %s", strategy, log_exc)
db.commit()
return seq