"""节点 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}