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
+190
View File
@@ -0,0 +1,190 @@
"""节点 0/6:动态种子词(seed)。
在 fetch 之前运行:收集「trending 派生 + 历史 safe 热点 + 月份/节日」上下文,
按 seed_provider 策略(static / mock / LLM)生成/合并种子词,注入 country_config 的
style.seeds / related.seed_keywords,供后续 fetch 的 related_queries 展开使用。
关键机制:动态种子词按 (国家, provider, 日期) 缓存(.cache/seeds/)。
同一天内多次运行使用同一套种子词 → related_queries 的 24h 缓存稳定命中,
避免「history 每次跑完都变 → 种子词震荡 → Google 反复全量重抓 → 429 限流」。
带 with_fallback:任何异常都降级为"仅用 yaml 静态种子",不阻塞整图。
"""
import datetime
import hashlib
import json
import os
from pathlib import Path
from typing import Any, Dict
from graph.llms import get_backend
from graph.llms.mock_backend import COMMON_RISK_WORDS
from graph.paths import runtime_root
from graph.scoring import filter_person_names, filter_query_noise
from graph.seeds import get_seed_strategy
from graph.seeds.holidays import build_holiday_context
from graph.sources.google_trends_source import fetch_trending
from graph.validate import with_fallback
_CACHE_DIR = runtime_root() / ".cache" / "seeds"
def _cache_key(country: str, provider: str, cfg: Dict[str, Any]) -> str:
"""缓存键含「配置指纹」:改了种子相关参数(数量/上下文上限)即换新键重新生成,
避免命中旧参数生成的种子;旧文件保留(不删缓存,取最新)。"""
fp = hashlib.md5(
json.dumps(
{k: cfg.get(k) for k in ("max_style_seeds", "max_related_seeds",
"trending_context_limit", "history_limit")},
sort_keys=True, ensure_ascii=False,
).encode("utf-8")
).hexdigest()[:8]
return f"{country}-{provider}-{fp}-{datetime.date.today().isoformat()}"
def _cache_get(key: str):
try:
p = _CACHE_DIR / f"{key}.json"
if p.exists():
return json.loads(p.read_text(encoding="utf-8"))
except Exception:
pass
return None
def _cache_set(key: str, val: Dict[str, Any]):
try:
_CACHE_DIR.mkdir(parents=True, exist_ok=True)
(_CACHE_DIR / f"{key}.json").write_text(
json.dumps(val, ensure_ascii=False), encoding="utf-8")
except Exception:
pass
@with_fallback("seed")
def seed_node(state: Dict[str, Any]) -> Dict[str, Any]:
country = state["country"]
config = state["config"]
cc = dict(state.get("country_config") or {})
errors = list(state.get("errors") or [])
provider = (config.get("seed_provider") or "mock").strip().lower()
cfg = config.get("seed_provider_cfg") or {}
trending_limit = int(cfg.get("trending_context_limit", 15))
history_limit = int(cfg.get("history_limit", 20))
max_style = int(cfg.get("max_style_seeds", 12))
max_related = int(cfg.get("max_related_seeds", 12))
guard = COMMON_RISK_WORDS + [b.lower() for b in (config.get("blacklist") or [])]
ckey = _cache_key(country, provider, cfg)
cached = _cache_get(ckey) if provider != "static" else None
from_cache = cached is not None
if cached is not None:
res = cached
context: Dict[str, Any] = {
"country": country,
"max_style_seeds": max_style,
"max_related_seeds": max_related,
}
else:
# 1) 收集上下文
context: Dict[str, Any] = {
"country": country,
"max_style_seeds": max_style,
"max_related_seeds": max_related,
}
try:
tl = int((cc.get("trending") or {}).get("limit", 40))
rows = fetch_trending(geo=country, limit=min(tl, 40))
# 保留 rows 自带的 source=gt_trendingfilter_person_names 的人名模式仅对该源生效
kept, _ = filter_query_noise(rows, enabled=True)
kept, _ = filter_person_names(kept)
kept = [r for r in kept if not any(w and w in r["topic"].lower() for w in guard)]
context["trending_seeds"] = [r["topic"] for r in kept][:trending_limit]
except Exception as e: # noqa: BLE001
print(f"[seed] trending 上下文收集失败(跳过): {e}")
context["trending_seeds"] = []
try:
p = os.path.join(state.get("output_dir", ""), "design_briefs.json")
if os.path.exists(p):
data = json.load(open(p, encoding="utf-8")).get("design_briefs", [])
safe = [d for d in data if d.get("risk_level") == "safe"]
safe.sort(key=lambda d: -(d.get("score") or 0))
hrows = [{"topic": d["topic"], "source": "history"} for d in safe]
hrows, _ = filter_person_names(hrows, pattern_sources={"history"})
hrows = [r for r in hrows if not any(w and w in r["topic"].lower() for w in guard)]
context["history_hotspots"] = [r["topic"] for r in hrows][:history_limit]
else:
context["history_hotspots"] = []
except Exception as e: # noqa: BLE001
print(f"[seed] 历史热点读取失败(跳过): {e}")
context["history_hotspots"] = []
# 月份/节日(按国家:各国节日表不同)
hol = build_holiday_context(country)
context["season"] = hol["season"]
context["year"] = hol["year"]
context["month"] = hol["month"]
context["date"] = hol["date"]
context["month_themes"] = hol["month_themes"]
context["upcoming_holidays"] = hol["upcoming_holidays"]
# 2) 选策略 + LLM 后端
strategy = get_seed_strategy(provider)
llm_backend = None
if provider != "static":
llm_backend = get_backend(provider)
# 注入 llm_screen 配置(api_key/base_url/model),否则 has_key 永远 False 降级 mock
try:
llm_backend.bind_config(config.get("llm_screen") or {})
except Exception as e: # noqa: BLE001
print(f"[seed] LLM 配置绑定失败: {e}")
if provider not in ("mock",) and not getattr(llm_backend, "has_key", False):
print(f"[seed] {provider} 未配置 API key,降级 mock 规则生成种子词")
llm_backend = get_backend("mock")
# 3) 生成/合并种子词
try:
res = strategy.resolve(country, cc, context, llm_backend)
except Exception as e: # noqa: BLE001
print(f"[seed] 策略解析失败,回退静态种子: {e}")
res = {
"style_seeds": list((cc.get("style", {}) or {}).get("seeds", []) or []),
"related_seeds": list((cc.get("related", {}) or {}).get("seed_keywords", []) or []),
"dynamic": False,
}
if provider != "static":
_cache_set(ckey, res)
# 4) 注入 cc
style_block = dict(cc.get("style") or {})
related_block = dict(cc.get("related") or {})
style_block["seeds"] = res["style_seeds"]
related_block["seed_keywords"] = res["related_seeds"]
cc["style"] = style_block
cc["related"] = related_block
stats = dict(state.get("stats") or {})
stats["seed"] = {
"provider": provider,
"dynamic": res.get("dynamic", False),
"from_cache": from_cache,
"style_count": len(res["style_seeds"]),
"related_count": len(res["related_seeds"]),
"trending_ctx": len(context.get("trending_seeds", [])),
"history_ctx": len(context.get("history_hotspots", [])),
"holidays": context.get("upcoming_holidays", []),
}
print(
f"[seed] provider={provider}{'(当日缓存命中)' if from_cache else ''} "
f"种子词 style={len(res['style_seeds'])} related={len(res['related_seeds'])}"
)
return {
"country_config": cc,
"seed_words": res,
"stats": stats,
"errors": errors,
}