- constants: 新增 TEXT_NARRATIVE_BY_STRATEGY(A痛点/B场景/C成分),与图片侧同轴 - build_prompt: 加 strategy_narrative 参数并注入 prompt - text_variants: 全链路透传(含优化轮) - run_text_generation: 改循环三套,text_count均摊(divmod余前补),跨套去重,打_strategy标记 - TextCandidate: 加 strategy String(4) 字段 + 迁移021(已upgrade head) - packaging: 打包按strategy精准配对文图(texts_by_strategy映射+三层兜底) - SSE text_candidate 事件携带 strategy 独立agent交叉验证7改造点全过,边界(text_count<3/无别名/不截断)无must-fix Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
196 lines
9.6 KiB
Python
196 lines
9.6 KiB
Python
"""
|
||
text_variants.py — 文案双轨主入口(≤100行)
|
||
轨A: generate_text_variants — 调 LLM 出 N 角度 JSON
|
||
轨B: text_import_handler — 导入外部文案进候选池
|
||
|
||
prompt 组装/解析见 _text_prompt.py;评分/去重见 text_scoring.py
|
||
"""
|
||
from __future__ import annotations
|
||
import asyncio
|
||
import logging
|
||
import os
|
||
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 .banned_word_checker import check_and_fix, build_entries_from_db, CheckResult
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
async def _call_llm(client: Any, prompt: str, max_tokens: int = 8192) -> str:
|
||
"""统一 LLM 调用,client 由 worker 注入,隔离 key。
|
||
G1坑修复:AIClients 没有 .chat.completions,正确方法是 .chat_complete()
|
||
S8: 503/429 指数退避重试(最多3次,2^attempt 秒),其他异常直接降级返 ''。
|
||
max_tokens 由调用方按批量缩放:opus 会尽量填满输出空间,8192 token 的生成
|
||
单批 >60s 必撞 apiports 网关上限返 503(task46 实测每请求恰挂 ~61s)。实测单条
|
||
max_tokens=1500~2500 仅 16~18s。故按条数动态收,墙钟压进 60s 网关窗口内。
|
||
"""
|
||
import httpx
|
||
|
||
# 倩倩姐2026-06-13拍板"加大重试+拉长退避":apiports负载波动时单条opus也会被
|
||
# 拖过60s返503,短退避(1/2/4s)赶不开高负载窗口。故重试5次、退避拉长到最长30s,
|
||
# 给中转站负载回落留时间。墙钟换稳定(MVP免费阶段可接受)。
|
||
max_attempts = 5
|
||
backoff = [5, 10, 20, 30] # 第1~4次重试前等待秒数,拉长跨过apiports高负载窗口
|
||
for attempt in range(max_attempts):
|
||
try:
|
||
return await client.chat_complete(
|
||
messages=[
|
||
{"role": "system", "content": COPY_SYSTEM},
|
||
{"role": "user", "content": prompt},
|
||
],
|
||
model=client._model,
|
||
max_tokens=max_tokens,
|
||
temperature=0.75,
|
||
)
|
||
except httpx.HTTPStatusError as exc:
|
||
status = exc.response.status_code if exc.response is not None else 0
|
||
if status in (503, 429) and attempt < max_attempts - 1:
|
||
wait = backoff[min(attempt, len(backoff) - 1)]
|
||
logger.warning(
|
||
"LLM 返回 %s,第%d/%d次重试,等待 %ds: %s",
|
||
status, attempt + 1, max_attempts - 1, wait, exc,
|
||
)
|
||
await asyncio.sleep(wait)
|
||
continue
|
||
logger.error("LLM HTTP错误(不可重试或已达上限): %s: %s", type(exc).__name__, exc)
|
||
return ""
|
||
except Exception as exc:
|
||
# 其他异常(超时/网络断开等)不重试,直接降级
|
||
logger.error("LLM 调用失败: %s: %s", type(exc).__name__, exc)
|
||
return ""
|
||
return ""
|
||
|
||
|
||
# apiports 网关单次响应有 ~60s 上限,claude 一次生成 >4 条长文案会超时返 503。
|
||
# 故分批:每批最多 4 条,串行调用合并。批大小可经 TEXT_BATCH_SIZE 调。
|
||
TEXT_BATCH_SIZE = int(os.environ.get("TEXT_BATCH_SIZE", "4"))
|
||
|
||
|
||
async def _generate_one_batch(llm_client: Any, product: dict, batch_n: int, extra: str,
|
||
strategy_narrative: str = "") -> list[dict]:
|
||
"""生成一批 batch_n 条,含解析重试(最多2次)。失败返回空列表。
|
||
max_tokens 按条数缩放(每条约 1800 token,封顶 8192),压进 apiports 60s 网关窗口。"""
|
||
batch_max_tokens = min(8192, max(1800, batch_n * 1800))
|
||
for attempt in range(2):
|
||
raw = await _call_llm(llm_client, build_prompt(
|
||
product, batch_n, extra_rules=extra, strategy_narrative=strategy_narrative,
|
||
), batch_max_tokens)
|
||
parsed = parse_json_array(raw)
|
||
if parsed:
|
||
return parsed
|
||
logger.warning("文案批(%d条)第%d次解析失败%s", batch_n, attempt + 1,
|
||
",重试" if attempt == 0 else ",放弃本批")
|
||
return []
|
||
|
||
|
||
async def _generate_in_batches(llm_client: Any, product: dict, count: int, extra: str,
|
||
strategy_narrative: str = "") -> list[dict]:
|
||
"""把 count 条按 TEXT_BATCH_SIZE 分批,串行调用合并。
|
||
串行而非并发:opus 单批就慢(~300s)且 apiports 限并发,多批 gather 会触发
|
||
大面积 503 雪崩(task45 实测)。故改串行,墙钟换稳定。"""
|
||
sizes: list[int] = []
|
||
remaining = count
|
||
while remaining > 0:
|
||
n = min(TEXT_BATCH_SIZE, remaining)
|
||
sizes.append(n)
|
||
remaining -= n
|
||
collected: list[dict] = []
|
||
for n in sizes:
|
||
r = await _generate_one_batch(llm_client, product, n, extra, strategy_narrative)
|
||
collected.extend(r)
|
||
return collected
|
||
|
||
|
||
async def generate_text_variants(
|
||
llm_client: Any,
|
||
product: dict,
|
||
count: int,
|
||
previous_copies: list[dict] | None = None,
|
||
banned_word_rows: list[dict] | None = None,
|
||
flywheel_context: str = "",
|
||
strategy_narrative: str = "",
|
||
) -> list[dict]:
|
||
"""轨A:一次出 count 条不同角度文案,三层兜底,自动优化循环。
|
||
strategy_narrative:本套正交叙事主线(A痛点/B场景/C成分),由调用方按套传入,
|
||
贯穿首批生成与优化轮,确保同套内文案同一叙事不串味。"""
|
||
banned_entries = build_entries_from_db(banned_word_rows or [])
|
||
extra = flywheel_context
|
||
|
||
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:
|
||
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])
|
||
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)
|
||
|
||
failed = [c for c in candidates if not c["passed"] and c["banned_word_status"] != "hard_block"]
|
||
# 优化轮默认关闭:apiports 60s 网关限制下优化轮的 _call_llm 常需白等 60s 才 503,
|
||
# 严重拖慢出文案(实测 +100s+)。质量优化等北哥 prompt 方案到位再开(架构已留位)。
|
||
optimize_enabled = os.environ.get("TEXT_OPTIMIZE_ENABLED", "false").lower() == "true"
|
||
rounds = MAX_OPTIMIZE_ROUNDS if optimize_enabled else 0
|
||
for _ in range(rounds):
|
||
if not failed:
|
||
break
|
||
# 优化轮也受 60s 网关上限约束:一次最多重生成 TEXT_BATCH_SIZE 条
|
||
batch_failed = failed[:TEXT_BATCH_SIZE]
|
||
hint = "\n".join(
|
||
f"标题「{c['title']}」{c['score']}分,需改进:" +
|
||
";".join(d["note"] for d in c.get("score_detail", []) if d["score"] < d["max"] * 0.72)
|
||
for c in batch_failed
|
||
)
|
||
raw2 = await _call_llm(llm_client, build_prompt(
|
||
product, len(batch_failed),
|
||
extra_rules=f"以下文案未达标,请重新生成并改进:\n{hint}\n不要重复已有标题和角度。",
|
||
strategy_narrative=strategy_narrative,
|
||
), min(8192, max(1800, len(batch_failed) * 1800)))
|
||
if not raw2:
|
||
# LLM 失败(如 503/超时):优化是锦上添花,原始候选已够用,不再耗时重试
|
||
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])
|
||
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", "")})
|
||
candidates.append(nc)
|
||
failed = [c for c in candidates if not c["passed"]]
|
||
|
||
return dedupe_copies(candidates, previous_copies or [])[:count]
|
||
|
||
|
||
def text_import_handler(
|
||
raw_text: str,
|
||
product: dict,
|
||
banned_word_rows: list[dict] | None = None,
|
||
) -> dict:
|
||
"""轨B:导入外部文案(豆包等)直接进候选池,source=import"""
|
||
banned_entries = build_entries_from_db(banned_word_rows or [])
|
||
lines = raw_text.strip().splitlines()
|
||
title = lines[0].strip() if lines else ""
|
||
content = "\n".join(lines[1:]).strip() if len(lines) > 1 else raw_text.strip()
|
||
candidate: dict = {"title": title, "content": content, "tags": [], "angle": "import",
|
||
"buyingPoint": "", "coverTitle": title, "imageBrief": "", "source": "import"}
|
||
ban = check_and_fix(f"{title} {content}", banned_entries or None)
|
||
# 轨B(导入外部文案)走机械 score_copy 而非 AI 评委:导入的是用户自带成品,评分仅作
|
||
# 参考展示不卡发布;且本函数同步、改 await 会扩大到调用方。AI 评委只用于轨A生成链路。
|
||
scored = score_copy(candidate, product, [e.word for e in banned_entries])
|
||
candidate.update({"score": scored["score"], "score_detail": scored["score_detail"],
|
||
"passed": scored["passed"], "banned_word_status": ban.status})
|
||
return candidate
|