上线版: 产品表单统一+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

@@ -14,7 +14,7 @@ from typing import Any
from .constants import MAX_OPTIMIZE_ROUNDS
from ._text_prompt import COPY_SYSTEM, build_prompt, parse_json_array, build_local_drafts
from .text_scoring import score_copy, dedupe_copies
from .llm_scorer import llm_score_copy
from .llm_scorer import llm_score_copy, ScoringUnavailableError
from .banned_word_checker import check_and_fix, build_entries_from_db, CheckResult
logger = logging.getLogger(__name__)
@@ -69,6 +69,35 @@ async def _call_llm(client: Any, prompt: str, max_tokens: int = 8192) -> str:
# 故分批:每批最多 4 条,串行调用合并。批大小可经 TEXT_BATCH_SIZE 调。
TEXT_BATCH_SIZE = int(os.environ.get("TEXT_BATCH_SIZE", "4"))
# 评分有限并发:评分无状态、逐条独立可并发,但仍限并发数防 apiports 限流(生成层串行
# 不动task45 实测全并发雪崩)。默认3=保守值(评分 max_tokens仅1500远轻于生成8192
# 且评分已带 503/429 重试兜底),可经 SCORE_CONCURRENCY 调。
_SCORE_CONCURRENCY = int(os.environ.get("SCORE_CONCURRENCY", "3"))
def _build_cross_set_avoidance(previous: list[dict]) -> str:
"""从已生成的其他套文案提炼回避约束,喂进生成 prompt 防三套撞车。
覆盖三个最易同质化的点:已用标签、背书人物桥段、收尾句式。"""
if not previous:
return ""
used_tags: list[str] = []
used_titles: list[str] = []
for c in previous:
used_titles.append(c.get("title", ""))
for t in (c.get("tags") or []):
tag = str(t).replace("🛒", "").strip()
if tag and tag not in used_tags:
used_tags.append(tag)
lines = ["【跨套去重·硬约束(本条务必与已发布的其它套拉开,禁止换皮重复)】"]
if used_titles:
lines.append(f"- 已用标题(禁止同主题/同句式):{ ''.join(t for t in used_titles if t) }")
if used_tags:
lines.append(f"- 已用标签(本条至少换掉一半,改用不同人群/场景/成分组合的精准长尾,扩大触达不抢同批流量):{ ''.join(used_tags) }")
lines.append("- 标签禁止与已用标签共用同一核心搜索词(别套已用的核心词,本条标签就别再都堆这个词,换成本套独有的人群/场景/卖点关键词分流)。")
lines.append("- 背书角色/桥段禁止与上面套雷同:若别套用了'闺蜜种草',本条换成自证/家人/同事/自己反复试错等不同来源;")
lines.append("- 卖点罗列的措辞顺序与 emoji 禁止与别套一致;同一个具体种草桥段全局只许出现一次。")
return "\n".join(lines)
async def _generate_one_batch(llm_client: Any, product: dict, batch_n: int, extra: str,
strategy_narrative: str = "") -> list[dict]:
@@ -119,25 +148,53 @@ async def generate_text_variants(
贯穿首批生成与优化轮,确保同套内文案同一叙事不串味。"""
banned_entries = build_entries_from_db(banned_word_rows or [])
extra = flywheel_context
# 跨套去重:把已生成套的标签/背书桥段提炼成回避约束喂进生成 prompt
# 防止三套撞车(仅事后 dedupe 删不掉"同用闺蜜背书/同套标签"的同质化)
avoid = _build_cross_set_avoidance(previous_copies or [])
if avoid:
extra = f"{extra}\n{avoid}" if extra else avoid
copies: list[dict] = await _generate_in_batches(
llm_client, product, count, extra, strategy_narrative)
if not copies:
copies = list(build_local_drafts(product, count)) # generator → list
candidates: list[dict] = []
for c in copies:
# 评分有限并发逐条独立无状态可并发sem 限并发数防 apiports 限流(生成层串行不动)。
# 每条语义与原串行完全一致:违禁词机械检查(同步,放 sem 外不占并发额度)→AI评分→
# 评分不可用则标记不计合格不炸整批auto_fixed 回写修正文案。gather 保序=候选顺序不变。
sem = asyncio.Semaphore(_SCORE_CONCURRENCY)
async def _score_one(c: dict) -> dict:
ban: CheckResult = check_and_fix(
f"{c.get('title','')} {c.get('content','')}",
banned_entries or None,
)
scored = await llm_score_copy(llm_client, c, product, [e.word for e in banned_entries])
try:
async with sem:
scored = await llm_score_copy(llm_client, c, product, [e.word for e in banned_entries])
except ScoringUnavailableError as exc:
# B1评分两通道都挂→不打机械分糊弄这条标"评分不可用·不计合格",不炸整批
logger.warning("文案评分不可用,标记不计合格(非质量问题): %s", exc)
c.update({"source": "ai", "score": 0,
"score_detail": [{"item": "评分不可用", "score": 0, "max": 100,
"note": "评分通道(apiports+codeproxy)暂不可用,非文案质量问题"}],
"passed": False, "banned_word_status": ban.status,
"scoring_unavailable": True, "verdict": "", "summary": "评分服务暂不可用"})
return c
c.update({"source": "ai", "score": scored["score"], "score_detail": scored["score_detail"],
"passed": scored["passed"], "banned_word_status": ban.status,
"verdict": scored.get("verdict", ""), "summary": scored.get("summary", "")})
if ban.status == "auto_fixed" and ban.fixed_text:
c["content"] = ban.fixed_text
candidates.append(c)
return c
candidates: list[dict] = list(await asyncio.gather(*(_score_one(c) for c in copies)))
scoring_failures = sum(1 for c in candidates if c.get("scoring_unavailable"))
# 整批评分全因通道不可用而失败 → 抛错让上层透传"评分服务不可用",区别于"质量不合格"
if copies and scoring_failures == len(copies):
raise ScoringUnavailableError(
f"本套 {len(copies)} 条文案评分全部失败:评分通道(apiports+codeproxy)均不可用")
failed = [c for c in candidates if not c["passed"] and c["banned_word_status"] != "hard_block"]
# 优化轮默认关闭apiports 60s 网关限制下优化轮的 _call_llm 常需白等 60s 才 503
@@ -164,7 +221,11 @@ async def generate_text_variants(
logger.warning("文案优化轮 LLM 失败,沿用原始候选不再重试")
break
for nc in parse_json_array(raw2):
sc2 = await llm_score_copy(llm_client, nc, product, [e.word for e in banned_entries])
try:
sc2 = await llm_score_copy(llm_client, nc, product, [e.word for e in banned_entries])
except ScoringUnavailableError as exc:
logger.warning("优化轮评分不可用,跳过该条不计入: %s", exc)
continue
nc.update({"source": "ai", "score": sc2["score"], "score_detail": sc2["score_detail"],
"passed": sc2["passed"], "banned_word_status": "pass",
"verdict": sc2.get("verdict", ""), "summary": sc2.get("summary", "")})