上线版: 产品表单统一+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:
yangqianqian
2026-06-30 18:08:13 +08:00
parent a77212781c
commit df1856d793
150 changed files with 8616 additions and 1765 deletions

View File

@@ -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,