上线版: 产品表单统一+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>
This commit is contained in:
@@ -5,6 +5,7 @@ build_delivery_package:查已选文案+图片 → package_exporter → 存路
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
|
||||
from app.workers.celery_app import celery_app
|
||||
|
||||
@@ -40,7 +41,7 @@ def build_delivery_package(self, package_id: int) -> dict:
|
||||
|
||||
from app.core.config import get_settings
|
||||
settings = get_settings()
|
||||
upload_base = settings.UPLOAD_BASE_PATH.rstrip("/")
|
||||
upload_root = settings.UPLOAD_ABS_ROOT.rstrip("/")
|
||||
|
||||
# A8 多套打包:按 strategy(A/B/C 三套正交叙事)分组,每套成一篇独立 note。
|
||||
# 北哥拿到的交付包含完整 3 套(note_01/02/03),不再只打第 1 条文案。
|
||||
@@ -59,9 +60,15 @@ def build_delivery_package(self, package_id: int) -> dict:
|
||||
def _read_image(ic) -> dict:
|
||||
img_bytes = b""
|
||||
if ic.url:
|
||||
# url 已含 uploads 前缀;工作目录 /app,lstrip 当相对路径读,勿再拼 base(防 uploads/uploads)
|
||||
path = ic.url
|
||||
if os.path.isabs(path):
|
||||
pass
|
||||
elif path.startswith("/uploads/"):
|
||||
path = f"{upload_root}/{path.removeprefix('/uploads/')}"
|
||||
elif path.startswith("uploads/"):
|
||||
path = f"{upload_root}/{path.removeprefix('uploads/')}"
|
||||
try:
|
||||
with open(ic.url.lstrip("/"), "rb") as f:
|
||||
with open(path, "rb") as f:
|
||||
img_bytes = f.read()
|
||||
except OSError as e:
|
||||
logger.warning("图片读取失败,跳过:%s %s", ic.url, e)
|
||||
@@ -115,8 +122,8 @@ def build_delivery_package(self, package_id: int) -> dict:
|
||||
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"
|
||||
# 打包产物放持久化卷 /app/uploads/packages/,与图片目录 /app/uploads/{ws}/{task}/ 分开。
|
||||
packages_base = os.path.join(upload_root, "packages")
|
||||
zip_path = do_build(workspace_id, task_id, notes, base_path=packages_base)
|
||||
|
||||
pkg.package_path = zip_path
|
||||
|
||||
@@ -32,15 +32,17 @@ def _resolve_image_path(img_path: str) -> str:
|
||||
|
||||
|
||||
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]:
|
||||
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)。
|
||||
返回 (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
|
||||
@@ -70,6 +72,7 @@ def run_text_generation(db, clients, task, product_dict: dict, flywheel_fragment
|
||||
|
||||
t0 = time.monotonic()
|
||||
llm_success = True
|
||||
scoring_unavailable = False # B1:评分通道(apiports+codeproxy)整套都挂,用于区分"评分不可用"vs"质量不合格"
|
||||
candidates_raw: list = []
|
||||
for s in _strategies:
|
||||
n = _per[s]
|
||||
@@ -85,6 +88,12 @@ def run_text_generation(db, clients, task, product_dict: dict, flywheel_fragment
|
||||
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)
|
||||
@@ -115,10 +124,13 @@ def run_text_generation(db, clients, task, product_dict: dict, flywheel_fragment
|
||||
# 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",
|
||||
@@ -159,22 +171,40 @@ def run_text_generation(db, clients, task, product_dict: dict, flywheel_fragment
|
||||
"文案合格数不足: task_id=%s 目标=%s 实得=%s,将后台异步补充",
|
||||
task.id, task.text_count, saved_count,
|
||||
)
|
||||
return candidates_raw, seq, needs_replenish
|
||||
# 整套全挂(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,
|
||||
first_copy: dict, upload_base_path: str,
|
||||
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) -> int:
|
||||
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
|
||||
@@ -236,28 +266,66 @@ def run_image_generation(db, clients, task, product_dict: dict,
|
||||
# 主图始终保底进 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)则只跑该套,否则全量 A/B/C 三套正交叙事
|
||||
_strategies = (regen_strategy,) if regen_strategy else ("A", "B", "C")
|
||||
# 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×3
|
||||
# 进度总数:单张重生=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 * 3
|
||||
_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=first_copy,
|
||||
note=note_for_strategy,
|
||||
product=product_dict,
|
||||
image_count=task.image_count,
|
||||
reference_images=reference_images or None,
|
||||
|
||||
@@ -46,19 +46,37 @@ def decrypt_user_key(db, operator_id: int, workspace_id: int) -> str:
|
||||
return decrypt_key(api_key_row.encrypted_key)
|
||||
|
||||
|
||||
def build_clients_and_clear_key(plain_key: str):
|
||||
def decrypt_codeproxy_key(db, operator_id: int, workspace_id: int) -> str | None:
|
||||
"""
|
||||
查用户录入的 codeproxy 备用站 key → Fernet 解密,返回 plain_key 或 None。
|
||||
备用通道:用户没录则返回 None(不抛错),build_ai_clients 会回落 env,
|
||||
主生图流程绝不因没录备用 key 而中断(apiports 主通道才是必需)。
|
||||
"""
|
||||
from app.models.workspace import UserApiKey
|
||||
from app.utils.fernet_utils import decrypt_key
|
||||
|
||||
row = db.query(UserApiKey).filter(
|
||||
UserApiKey.user_id == operator_id,
|
||||
UserApiKey.workspace_id == workspace_id,
|
||||
UserApiKey.provider == "codeproxy",
|
||||
).first()
|
||||
return decrypt_key(row.encrypted_key) if row else None
|
||||
|
||||
|
||||
def build_clients_and_clear_key(plain_key: str, alt_key: str | None = None):
|
||||
"""
|
||||
Step3: 构建 AIClients,plain_key 传入后立即由调用方置 None。
|
||||
alt_key:用户录入的 codeproxy 备用 key(可选),同样由调用方查库解密后传入。
|
||||
返回 clients 对象。
|
||||
"""
|
||||
from app.services.ai_engine.gemini_factory import build_ai_clients
|
||||
return build_ai_clients(plain_key)
|
||||
return build_ai_clients(plain_key, alt_key=alt_key)
|
||||
|
||||
|
||||
def build_product_dict(product) -> dict:
|
||||
"""把 ORM product 转成 AI 引擎所需的 dict(不含任何 key)。"""
|
||||
return {
|
||||
"id": product.id,
|
||||
"id": getattr(product, "id", None),
|
||||
"name": product.name,
|
||||
"category": product.category or "通用好物",
|
||||
"selling_points": json.loads(product.selling_points or "[]"),
|
||||
@@ -133,4 +151,7 @@ def load_flywheel_context(db, workspace_id: int, product_id: int, product_dict:
|
||||
for e in recent
|
||||
]
|
||||
ctx = aggregate_preference_context(events_dicts, product_dict, workspace_id, product_id)
|
||||
# 累积感知:补该产品累计信号总数(前端"飞轮已积累N条信号"),与展示端同口径
|
||||
from app.services.flywheel_service import count_signals
|
||||
ctx["signal_count"] = count_signals(db, workspace_id, product_id)
|
||||
return ctx.get("prompt_fragment", ""), ctx
|
||||
|
||||
@@ -34,6 +34,7 @@ def replenish_text_candidates(self, task_id: int) -> dict:
|
||||
from app.workers.pipeline_steps import (
|
||||
load_task_and_product,
|
||||
decrypt_user_key,
|
||||
decrypt_codeproxy_key,
|
||||
build_clients_and_clear_key,
|
||||
build_product_dict,
|
||||
load_flywheel_context,
|
||||
@@ -57,8 +58,10 @@ def replenish_text_candidates(self, task_id: int) -> dict:
|
||||
|
||||
workspace_id = task.workspace_id
|
||||
plain_key = decrypt_user_key(db, task.operator_id, workspace_id)
|
||||
clients = build_clients_and_clear_key(plain_key)
|
||||
alt_key = decrypt_codeproxy_key(db, task.operator_id, workspace_id)
|
||||
clients = build_clients_and_clear_key(plain_key, alt_key)
|
||||
plain_key = None # 用完即清除,基石B
|
||||
alt_key = None
|
||||
product_dict = build_product_dict(product)
|
||||
flywheel_fragment, _ = load_flywheel_context(
|
||||
db, workspace_id, task.product_id, product_dict
|
||||
@@ -71,7 +74,7 @@ def replenish_text_candidates(self, task_id: int) -> dict:
|
||||
current = existing
|
||||
while current < target and rounds < MAX_REPLENISH_ROUNDS:
|
||||
rounds += 1
|
||||
run_text_generation(
|
||||
_, _, _, fail_reason, _ = run_text_generation(
|
||||
db, clients, task, product_dict, flywheel_fragment,
|
||||
_push_event_sync, workspace_id, seq,
|
||||
)
|
||||
@@ -85,6 +88,11 @@ def replenish_text_candidates(self, task_id: int) -> dict:
|
||||
"补充轮 %d: task_id=%s 库内合格=%d/%d",
|
||||
rounds, task_id, current, target,
|
||||
)
|
||||
# 评分通道不可用时再补也是空烧 token(每轮还各 35s backoff)——立即停,
|
||||
# 等通道恢复用户手动重试。区别于"质量不达标"(那个值得多补几轮)。
|
||||
if fail_reason == "scoring_unavailable":
|
||||
logger.error("补充中止: task_id=%s 评分通道不可用,停止空跑重试", task_id)
|
||||
break
|
||||
|
||||
return {
|
||||
"task_id": task_id,
|
||||
|
||||
@@ -27,6 +27,26 @@ def _json_loads_safe(raw: str | None) -> dict:
|
||||
return {"content": raw}
|
||||
|
||||
|
||||
def _load_notes_by_strategy(db, task_id: int, target_strategy: str | None = None) -> dict[str, dict]:
|
||||
"""按 A/B/C 套别取已入库文案,优先已选,其次本套最早一条。"""
|
||||
from app.models.task import TextCandidate
|
||||
|
||||
q = db.query(TextCandidate).filter(TextCandidate.task_id == task_id)
|
||||
if target_strategy:
|
||||
q = q.filter(TextCandidate.strategy == target_strategy)
|
||||
rows = q.order_by(
|
||||
TextCandidate.strategy.asc(),
|
||||
TextCandidate.is_selected.desc(),
|
||||
TextCandidate.id.asc(),
|
||||
).all()
|
||||
notes: dict[str, dict] = {}
|
||||
for row in rows:
|
||||
strategy = row.strategy or "_"
|
||||
if strategy not in notes:
|
||||
notes[strategy] = _json_loads_safe(row.content)
|
||||
return notes
|
||||
|
||||
|
||||
def _get_db():
|
||||
"""获取同步 DB Session(Celery worker 环境用同步)"""
|
||||
from app.core.database import SessionLocal
|
||||
@@ -79,7 +99,8 @@ def _release_run_lock(task_id: int) -> None:
|
||||
queue="generation",
|
||||
)
|
||||
def run_generation_pipeline(self, task_id: int, regen_strategy: str | None = None,
|
||||
regen_role: str | None = None, custom_prompt: str | None = None) -> dict:
|
||||
regen_role: str | None = None, custom_prompt: str | None = None,
|
||||
reuse_text: bool = False) -> dict:
|
||||
"""
|
||||
生产链主任务。只接收 task_id,绝不接收 key。
|
||||
子步骤委托给 pipeline_steps(查库/解密/飞轮上下文)
|
||||
@@ -88,6 +109,10 @@ def run_generation_pipeline(self, task_id: int, regen_strategy: str | None = Non
|
||||
R2 重生参数(均 None=常规全量生成):
|
||||
regen_strategy='A'/'B'/'C' → 只重生该套;regen_role 配合 → 只重生该套的该张;
|
||||
custom_prompt → 人工追加提示词。重生模式跳过文案生成,复用已有合格文案。
|
||||
|
||||
reuse_text=True:导入轨「去生图」首次出图——状态照常进 GENERATING(非重生),
|
||||
但跳过文案生成、复用库内导入文案作生图依据。与重生区别:重生改图(状态不变),
|
||||
导入是首次出图(进 GENERATING),信号语义不同,故单独标志位不复用 regen 参数。
|
||||
"""
|
||||
logger.info("run_generation_pipeline start: task_id=%s", task_id)
|
||||
# 幂等双保险:抢不到锁说明已有 worker 在跑同一 task,直接跳过(防重复烧钱)。
|
||||
@@ -103,6 +128,7 @@ def run_generation_pipeline(self, task_id: int, regen_strategy: str | None = Non
|
||||
from app.workers.pipeline_steps import (
|
||||
load_task_and_product,
|
||||
decrypt_user_key,
|
||||
decrypt_codeproxy_key,
|
||||
build_clients_and_clear_key,
|
||||
build_product_dict,
|
||||
load_flywheel_context,
|
||||
@@ -120,13 +146,17 @@ def run_generation_pipeline(self, task_id: int, regen_strategy: str | None = Non
|
||||
|
||||
# Step2: Fernet 解密(局部变量,不传出)
|
||||
plain_key = decrypt_user_key(db, task.operator_id, workspace_id)
|
||||
alt_key = decrypt_codeproxy_key(db, task.operator_id, workspace_id)
|
||||
|
||||
# Step3: 构建 AIClients
|
||||
clients = build_clients_and_clear_key(plain_key)
|
||||
clients = build_clients_and_clear_key(plain_key, alt_key)
|
||||
plain_key = None # 用完即清除,基石B
|
||||
alt_key = None
|
||||
|
||||
task.status = TaskStatus.GENERATING
|
||||
db.commit()
|
||||
_is_regen = bool(regen_strategy or regen_role)
|
||||
if not _is_regen:
|
||||
task.status = TaskStatus.GENERATING
|
||||
db.commit()
|
||||
|
||||
# Step4: 推 task_started + 飞轮上下文
|
||||
seq += 1
|
||||
@@ -145,20 +175,31 @@ def run_generation_pipeline(self, task_id: int, regen_strategy: str | None = Non
|
||||
_push_event_sync(task_id, workspace_id, "flywheel_injected", {
|
||||
"recent_preference": flywheel_ctx.get("recent_preference", ""),
|
||||
"reject_reasons": flywheel_ctx.get("reject_reasons", []),
|
||||
"injected_count": flywheel_ctx.get("injected_count", 0),
|
||||
"signal_count": flywheel_ctx.get("signal_count", 0),
|
||||
}, seq)
|
||||
|
||||
# Step5: 文案生成(S1: 返回 needs_replenish=合格数<目标数,触发后台补充)
|
||||
# R2 重生模式:跳过文案重生,复用库内已有合格文案作生图依据(重生只针对图片)
|
||||
_is_regen = bool(regen_strategy or regen_role)
|
||||
if _is_regen:
|
||||
from app.models.task import TextCandidate as _TC
|
||||
_existing = db.query(_TC).filter(
|
||||
_TC.task_id == task_id
|
||||
).order_by(_TC.id.asc()).first()
|
||||
if not _existing:
|
||||
# reuse_text(导入轨):同样跳过文案生成,复用库内导入文案;与重生共用复用分支。
|
||||
if _is_regen or reuse_text:
|
||||
notes_by_strategy = _load_notes_by_strategy(db, task_id, regen_strategy)
|
||||
if regen_strategy and regen_strategy not in notes_by_strategy:
|
||||
# 重生依赖已有文案作生图语境;一条都没有时硬失败,绝不以空文案降级生图(质量崩)
|
||||
from app.constants.enums import TaskStatus as _TS
|
||||
logger.warning("重生无可复用文案,拒绝空语境生图: task_id=%s", task_id)
|
||||
logger.warning(
|
||||
"重生无可复用文案,拒绝空语境生图: task_id=%s strategy=%s",
|
||||
task_id, regen_strategy,
|
||||
)
|
||||
task.status = _TS.PENDING_SELECTION
|
||||
db.commit()
|
||||
_push_event_sync(task_id, workspace_id, "error", {
|
||||
"code": 40002,
|
||||
"message": f"套{regen_strategy}无可复用文案,请先完成该套文案生成再重生图片",
|
||||
}, seq + 1)
|
||||
return {"task_id": task_id, "status": "regen_no_text"}
|
||||
if not notes_by_strategy:
|
||||
from app.constants.enums import TaskStatus as _TS
|
||||
task.status = _TS.PENDING_SELECTION
|
||||
db.commit()
|
||||
_push_event_sync(task_id, workspace_id, "error", {
|
||||
@@ -166,13 +207,15 @@ def run_generation_pipeline(self, task_id: int, regen_strategy: str | None = Non
|
||||
"message": "无可复用文案,请先完成文案生成再重生图片",
|
||||
}, seq + 1)
|
||||
return {"task_id": task_id, "status": "regen_no_text"}
|
||||
candidates_raw = [_json_loads_safe(_existing.content)]
|
||||
candidates_raw = list(notes_by_strategy.values())
|
||||
needs_replenish = False
|
||||
text_fail_reason = None # 重生不生成新文案,无文案失败原因
|
||||
else:
|
||||
candidates_raw, seq, needs_replenish = run_text_generation(
|
||||
candidates_raw, seq, needs_replenish, text_fail_reason, _saved = run_text_generation(
|
||||
db, clients, task, product_dict, flywheel_fragment,
|
||||
_push_event_sync, workspace_id, seq,
|
||||
)
|
||||
notes_by_strategy = _load_notes_by_strategy(db, task_id)
|
||||
if needs_replenish:
|
||||
# 合格文案不足用户目标条数:先展示已合格的,后台异步补充(先展示后台补铁律)
|
||||
# 注:candidates_raw 是本轮原始生成数(含被过滤的低分条),非合格入库数;
|
||||
@@ -187,24 +230,31 @@ def run_generation_pipeline(self, task_id: int, regen_strategy: str | None = Non
|
||||
logger.error("触发后台补充失败: task_id=%s err=%s", task_id, _re)
|
||||
|
||||
# Step6+7+8: 图片生成 + 后处理 + 存盘
|
||||
first_copy = candidates_raw[0] if candidates_raw else {}
|
||||
# A/B/C 三套必须用各自 strategy 的合格文案,不再全套复用第一条文案。
|
||||
settings = get_settings()
|
||||
seq = run_image_generation(
|
||||
db, clients, task, product_dict,
|
||||
_push_event_sync, workspace_id, seq,
|
||||
first_copy, settings.UPLOAD_BASE_PATH,
|
||||
notes_by_strategy, settings.UPLOAD_ABS_ROOT,
|
||||
regen_strategy=regen_strategy, regen_role=regen_role,
|
||||
custom_prompt=custom_prompt,
|
||||
flywheel_fragment=flywheel_fragment,
|
||||
reuse_text=reuse_text,
|
||||
)
|
||||
|
||||
# 最终状态 + task_done
|
||||
task.status = TaskStatus.PENDING_SELECTION
|
||||
db.commit()
|
||||
|
||||
# B1:透传文案结果原因 + 实际合格数,前端据此区分"评分不可用/质量不合格/补充中/正常"
|
||||
from app.models.task import TextCandidate as _TCcount
|
||||
_text_saved = db.query(_TCcount).filter(_TCcount.task_id == task_id).count()
|
||||
|
||||
seq += 1
|
||||
_push_event_sync(task_id, workspace_id, "task_done", {
|
||||
"task_id": task_id, "status": "pending_selection"
|
||||
"task_id": task_id, "status": "pending_selection",
|
||||
"text_saved": _text_saved, "text_target": task.text_count,
|
||||
"text_reason": text_fail_reason,
|
||||
}, seq)
|
||||
|
||||
logger.info("run_generation_pipeline done: task_id=%s", task_id)
|
||||
@@ -221,6 +271,11 @@ def run_generation_pipeline(self, task_id: int, regen_strategy: str | None = Non
|
||||
t = db.query(GT).filter(GT.id == task_id).first()
|
||||
if t:
|
||||
t.status = TS.FAILED if exhausted else TS.GENERATING
|
||||
# B6+#11:失败终态时落原因,供历史页/确认页查看(Redis error历史TTL仅1h会过期)。
|
||||
# 复用 reject_reason 列(Text),前端按 status 区分展示"失败原因"/"打回原因",
|
||||
# 物理不冲突(failed 不命中 rejected 展示分支),免加 Alembic 迁移。
|
||||
if exhausted:
|
||||
t.reject_reason = str(exc)[:2000]
|
||||
db.commit()
|
||||
except Exception:
|
||||
pass
|
||||
@@ -262,7 +317,7 @@ def run_fission_pipeline(self, fission_id: int, source_task_id: int) -> dict:
|
||||
db = _get_db()
|
||||
try:
|
||||
from app.models.task import GenerationTask
|
||||
from app.workers.pipeline_steps import decrypt_user_key, build_clients_and_clear_key
|
||||
from app.workers.pipeline_steps import decrypt_user_key, decrypt_codeproxy_key, build_clients_and_clear_key
|
||||
from app.services.fission_pipeline import execute_fission_pipeline
|
||||
|
||||
src = db.query(GenerationTask).filter(GenerationTask.id == source_task_id).first()
|
||||
@@ -270,8 +325,10 @@ def run_fission_pipeline(self, fission_id: int, source_task_id: int) -> dict:
|
||||
return {"fission_id": fission_id, "status": "not_found"}
|
||||
|
||||
plain_key = decrypt_user_key(db, src.operator_id, src.workspace_id)
|
||||
clients = build_clients_and_clear_key(plain_key)
|
||||
alt_key = decrypt_codeproxy_key(db, src.operator_id, src.workspace_id)
|
||||
clients = build_clients_and_clear_key(plain_key, alt_key)
|
||||
plain_key = None # 用完即清,基石B
|
||||
alt_key = None
|
||||
|
||||
return execute_fission_pipeline(db, clients, fission_id, source_task_id)
|
||||
except Exception as exc:
|
||||
@@ -320,7 +377,7 @@ def retry_fission_images(self, fission_id: int, note_id: int) -> dict:
|
||||
from app.models.task import GenerationTask
|
||||
from app.models.product import Product
|
||||
from app.workers.pipeline_steps import (
|
||||
decrypt_user_key, build_clients_and_clear_key, build_product_dict,
|
||||
decrypt_user_key, decrypt_codeproxy_key, build_clients_and_clear_key, build_product_dict,
|
||||
)
|
||||
from app.services.fission_image_retry import retry_fission_note_images
|
||||
|
||||
@@ -340,8 +397,10 @@ def retry_fission_images(self, fission_id: int, note_id: int) -> dict:
|
||||
|
||||
# key 归属源任务 operator,与首轮生图一致(基石B:不接收明文 key)
|
||||
plain_key = decrypt_user_key(db, src.operator_id, ft.workspace_id)
|
||||
clients = build_clients_and_clear_key(plain_key)
|
||||
alt_key = decrypt_codeproxy_key(db, src.operator_id, ft.workspace_id)
|
||||
clients = build_clients_and_clear_key(plain_key, alt_key)
|
||||
plain_key = None # 用完即清,基石B
|
||||
alt_key = None
|
||||
|
||||
product = build_product_dict(product_row)
|
||||
image_count = max(1, (src.image_count or 3))
|
||||
|
||||
Reference in New Issue
Block a user