上线版: 产品表单统一+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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user