Files
beige/backend/app/workers/pipeline_io.py
yangqianqian c5567614b4 修注释bug(score>=90→80红线)+核销表按代码真实状态纠偏
- pipeline_io.py/replenish_task.py 注释写90与代码实际80打架,统一为80(红线)
- 核销表纠正:文案先展示后台补=真做了/合格线80/M1本体已做
- M6标杆M7裂变改'草案先提炼再请北哥'(不干等),过滤策略条作废
2026-06-16 14:11:45 +08:00

299 lines
12 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]:
"""
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)。
"""
import time
from app.services.ai_engine.text_variants import generate_text_variants
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
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
t0 = time.monotonic()
llm_success = True
try:
candidates_raw = asyncio.run(generate_text_variants(
llm_client=clients,
product=product_dict,
count=task.text_count,
banned_word_rows=banned_dicts,
flywheel_context=flywheel_fragment,
))
except Exception as exc:
llm_success = False
logger.error("generate_text_variants 失败: %s", exc)
candidates_raw = []
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
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 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,
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,
"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,
)
return candidates_raw, seq, needs_replenish
def run_image_generation(db, clients, task, product_dict: dict,
push_fn, workspace_id: int, seq_start: int,
first_copy: dict, upload_base_path: str) -> int:
"""
Step6+7+8(image): 调 generate_storyboard_images → 后处理 → 存 ImageCandidate → 推 SSE。
返回 next_seq。
"""
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)但未获取到产品参考图,"
"拒绝降级纯文生图。请确认产品已上传参考图。"
)
seq = seq_start
# 3套正交叙事 A/B/C每套各 image_count 张独立生图
for strategy in ("A", "B", "C"):
t0 = time.monotonic()
img_success = True
img_error_code = None
try:
image_results = asyncio.run(generate_storyboard_images(
client=clients,
note=first_copy,
product=product_dict,
image_count=task.image_count,
reference_images=reference_images or None,
strategy=strategy,
))
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
)
db.add(ic)
db.flush()
seq += 1
push_fn(task.id, workspace_id, "image_candidate", {
"candidate_id": ic.id, "strategy": strategy,
"url": img_url, "role": img_result["role"],
}, seq)
seq += 1
push_fn(task.id, workspace_id, "image_progress", {
"done": i + 1, "total": task.image_count, "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