- 缓存热点批量流程(有采集缓存不触发 Google) - 简报不足直接从采集缓存生成(轻量补齐) - 三图合成(模特/印花/底图)+ 底图压缩 <2MB - 热点去重→风格去重自动切换 + 不适合类目 review 兜底 - 透明背景(background=transparent)+ 提示词清洗(敏感词/背景描述) - 任务前 basemap 校验 + 模板国家校验 + 模特任务级分配
191 lines
7.9 KiB
Python
191 lines
7.9 KiB
Python
"""节点 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_trending,filter_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,
|
||
}
|