POD 趋势感知 Agent:缓存热点模式 + 三图合成 + 热点去重/风格去重 + review 兜底
- 缓存热点批量流程(有采集缓存不触发 Google) - 简报不足直接从采集缓存生成(轻量补齐) - 三图合成(模特/印花/底图)+ 底图压缩 <2MB - 热点去重→风格去重自动切换 + 不适合类目 review 兜底 - 透明背景(background=transparent)+ 提示词清洗(敏感词/背景描述) - 任务前 basemap 校验 + 模板国家校验 + 模特任务级分配
This commit is contained in:
@@ -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_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,
|
||||
}
|
||||
Reference in New Issue
Block a user