POD 趋势感知 Agent:缓存热点模式 + 三图合成 + 热点去重/风格去重 + review 兜底

- 缓存热点批量流程(有采集缓存不触发 Google)
- 简报不足直接从采集缓存生成(轻量补齐)
- 三图合成(模特/印花/底图)+ 底图压缩 <2MB
- 热点去重→风格去重自动切换 + 不适合类目 review 兜底
- 透明背景(background=transparent)+ 提示词清洗(敏感词/背景描述)
- 任务前 basemap 校验 + 模板国家校验 + 模特任务级分配
This commit is contained in:
2026-08-22 14:14:01 +08:00
commit f493bde8a9
98 changed files with 10280 additions and 0 deletions
+127
View File
@@ -0,0 +1,127 @@
"""节点 4/6:合规筛选(screen)。
调用可插拔 LLM 后端做合规筛查 + 结构化四要素;后端调用失败时降级 MockBackend。
按 topic 把筛查结果映射回 scored 候选(补 score/sources/country),再做最终风险过滤
blocked 丢弃;review 按 keep_review 决定)。
"""
from pathlib import Path
from typing import Any, Dict, List
from graph.loader import load_system_prompt
from graph.llms import DEFAULT_SYSTEM_PROMPT, get_backend
from graph.style_rules import COUNTRY_AESTHETICS
from graph.validate import with_fallback
@with_fallback("screen")
def screen_node(state: Dict[str, Any]) -> Dict[str, Any]:
country = state["country"]
scored: List[Dict[str, Any]] = state.get("scored_rows") or []
config = state["config"]
cc = state["country_config"]
llm_cfg = config.get("llm_screen") or {}
provider = llm_cfg.get("provider", "mock")
keep_review = bool(llm_cfg.get("keep_review", False))
batch_size = int(llm_cfg.get("max_topics_per_call", 12))
blacklist = [str(b).lower() for b in (config.get("blacklist") or [])]
prompts_dir = Path(state["prompts_dir"])
system_prompt = load_system_prompt(prompts_dir, DEFAULT_SYSTEM_PROMPT)
aesthetic_hint = cc.get("style_hint") or COUNTRY_AESTHETICS.get(country, {}).get("style_hint", "")
# 排除已用热点(去重生效:已用 topic 不再进入本次简报,每次跑都用新热点)
try:
import json as _json
used_p = Path(state.get("cache_dir") or state.get("output_dir", "")) / "used_designs.json"
if used_p.exists():
ud = _json.loads(used_p.read_text(encoding="utf-8")).get("used", []) or []
used_topics = {str(u.get("topic", "")).strip().lower() for u in ud}
before = len(scored)
scored = [c for c in scored if str(c.get("topic", "")).strip().lower() not in used_topics]
if len(scored) < before:
print(f"[screen] 排除已用热点 {before - len(scored)} 条(去重),剩余 {len(scored)} 条可选")
except Exception: # noqa: BLE001
pass
# 简报数量 = 用多少生成多少:llm_screen.max_briefs 配置优先,否则按扩展后的总任务数
#(每个产品一个热点;数量 N=每个款-颜色条目的设计数 → 总任务=条目数×N);
# 前台显示不依赖简报(读 collected 完整池 + used_designs 剔除已用,用完即从前台消失)。
pcfg = config.get("product") or {}
ls_cfg = config.get("llm_screen") or {}
limit = int(ls_cfg.get("max_briefs") or 0)
if not limit and pcfg.get("spu_tasks"):
limit = len(pcfg.get("spu_tasks") or []) # 扩展后总任务数(=产品数)
if not limit:
limit = int(pcfg.get("spu_count") or 0)
if limit > 0:
ordered = sorted(scored, key=lambda c: -(float(c.get("score") or 0)))
scored_limited = ordered[:limit]
print(f"[screen] 简报限量 {limit} → 筛前 {len(scored_limited)} 个高分热点(共 {len(scored)} 个)")
else:
scored_limited = scored
print(f"[screen] 简报全量 {len(scored_limited)} 条(未限量,全部生成简报)")
topics = [c["topic"] for c in scored_limited]
backend = get_backend(provider)
if provider != "mock":
backend.bind_config(llm_cfg)
if not backend.has_key:
print("[screen] 未检测到 LLM api_key(请配置 llm_screen.api_key 或环境变量 "
"LLM_API_KEY/OPENAI_API_KEY),降级 Mock 兜底。")
backend = get_backend("mock")
try:
screened = backend.screen(topics, country, aesthetic_hint, system_prompt, blacklist, batch_size)
except Exception as e: # noqa: BLE001
print(f"[screen] {provider} 调用失败,降级 Mock: {e}")
backend = get_backend("mock")
screened = backend.screen(topics, country, aesthetic_hint, system_prompt, blacklist, batch_size)
# 映射回 scored 候选
by_topic = {s.get("topic", "").lower(): s for s in screened}
out: List[Dict[str, Any]] = []
missing: List[Dict[str, Any]] = []
for it in scored_limited:
s = by_topic.get(it["topic"].lower())
if s is None:
missing.append(it)
continue
s = dict(s)
s["country"] = country
s["score"] = it.get("score", 0)
s["sources"] = it.get("sources", "")
out.append(s)
# LLM 漏判的主题用 Mock 单独补全,避免丢数据(而非整批降级)
if missing:
print(f"[screen] LLM 漏判 {len(missing)} 个主题,用 Mock 单独补全:"
f"{[m['topic'] for m in missing]}")
mock = get_backend("mock")
m_res = mock.screen(
[m["topic"] for m in missing], country, aesthetic_hint,
system_prompt, blacklist, batch_size,
)
m_by = {r.get("topic", "").lower(): r for r in m_res}
for it in missing:
s = m_by.get(it["topic"].lower())
if s is None:
continue
s = dict(s)
s["country"] = country
s["score"] = it.get("score", 0)
s["sources"] = it.get("sources", "")
out.append(s)
# 最终风险过滤:只滤 blocked(硬拦截);review(待复核)保留——
# 由 assign_hotspots 的 allow_review 决定是否参与分配(openai 模式 review+concept 可用),
# 避免 review 被静默丢弃导致"任务 N 个但简报不足、设计缺失"
kept: List[Dict[str, Any]] = []
for r in out:
lvl = r.get("risk_level", "safe")
if lvl == "blocked":
continue
kept.append(r)
stats = dict(state.get("stats") or {})
stats["screen"] = {"screened": len(out), "kept": len(kept)}
return {"screened": kept, "stats": stats}