"""Pinterest 参考模式节点 3/3:LLM 多模态分析图片 → 原创设计简报(pinterest_analyze)。 对 pinterest_scrape 爬到的每个搜索词图片,调 LLM 多模态分析(analyze_pinterest_images) 提取视觉概念(风格/情绪/主体/配色/构图)→ 生成原创设计简报 (motif/art_style/color_palette/composition/concept/negative_prompt), 再经 prompt_node 装配最终 image/wearable/composite 提示词,产出标准 briefs 供 compose 用。 兜底链:LLM 多模态 → 纯文本降级(后端内部)→ mock 规则简报 → 空列表(下游跳过)。 带 with_fallback:任何异常都不中断。 """ from typing import Any, Dict, List from graph.llms import get_backend from graph.nodes.prompt_node import prompt_node from graph.validate import with_fallback def _enrich_briefs(raw_briefs: List[Dict[str, Any]], country: str) -> List[Dict[str, Any]]: """富化原始简报 → screened 格式(唯一 topic / safe / 分类 / 分数),供 prompt_node 装配。 同一搜索词的多张图会产出多条简报,topic 相同 → 追加序号保证唯一 (product_node 按 topic 绑定简报,重复 topic 会互相覆盖)。 """ from graph.classify import classify seen_topics: Dict[str, int] = {} out: List[Dict[str, Any]] = [] for i, b in enumerate(raw_briefs): if not isinstance(b, dict): continue term = str(b.get("topic") or "").strip() or f"pinterest {i + 1}" base = term n = seen_topics.get(base.lower(), 0) seen_topics[base.lower()] = n + 1 topic = base if n == 0 else f"{base} #{n + 1}" motif = str(b.get("motif") or "").strip() or term if not motif: continue out.append({ "country": country, "topic": topic, "risk_level": "safe", "safe_for_print": True, "suitable_for_print": True, "design_category": classify(term), "concept": str(b.get("concept") or "").strip() or f"围绕「{term}」的原创印花设计", "motif": motif, "art_style": str(b.get("art_style") or "").strip(), "color_palette": str(b.get("color_palette") or "").strip(), "composition": str(b.get("composition") or "").strip(), "negative_prompt": str(b.get("negative_prompt") or "").strip(), "ref_images": [str(p) for p in (b.get("ref_images") or []) if str(p)], "slogan": "", "score": 1.0, "confidence": 1.0, "source": "pinterest", }) return out @with_fallback("pinterest_analyze") def pinterest_analyze_node(state: Dict[str, Any]) -> Dict[str, Any]: images: Dict[str, List[str]] = state.get("pinterest_images") or {} if not images: print("[pinterest_analyze] 无爬取图片,跳过分析") return {"pinterest_briefs": [], "briefs": [], "errors": state.get("errors") or []} country = state["country"] config = state["config"] errors = list(state.get("errors") or []) pcfg = config.get("pinterest") or {} analyze_per_term = int(pcfg.get("analyze_per_term", 6)) max_designs = int(pcfg.get("max_designs", 10)) provider = str(pcfg.get("provider") or "openai").strip().lower() # 1) LLM 后端(openai → 真多模态;mock → 规则兜底) llm = None if provider != "static": try: llm = get_backend(provider) if hasattr(llm, "bind_config"): llm.bind_config(config.get("llm_screen") or {}) if provider not in ("mock",) and not getattr(llm, "has_key", False): print(f"[pinterest_analyze] {provider} 未配置 API key,降级 mock") llm = get_backend("mock") except Exception as e: # noqa: BLE001 print(f"[pinterest_analyze] LLM 初始化失败: {e}") llm = None # 2) 逐搜索词分析图片 → 原始设计简报 raw_briefs: List[Dict[str, Any]] = [] if llm is not None and hasattr(llm, "analyze_pinterest_images"): for term, paths in images.items(): sample = list(paths)[:analyze_per_term] if not sample: continue try: res = llm.analyze_pinterest_images(sample, term, country) res = res or [] raw_briefs.extend(res) print(f"[pinterest_analyze] 「{term}」分析 {len(sample)} 张图 → {len(res)} 条简报") except Exception as e: # noqa: BLE001 errors.append({"node": "pinterest_analyze", "type": type(e).__name__, "message": f"term[{term}]: {e}", "trace": ""}) print(f"[pinterest_analyze] 「{term}」分析失败: {e}") # 3) 兜底:LLM 无结果 → mock 规则简报(零 API 成本,保证有设计可生成)。 # 注意必须切到 mock 后端,不能再调回失败的 llm(否则同样报错)。 if not raw_briefs: try: mock = get_backend("mock") for term, paths in images.items(): sample = list(paths)[:analyze_per_term] if sample: raw_briefs.extend(mock.analyze_pinterest_images(sample, term, country) or []) print(f"[pinterest_analyze] 兜底:mock 规则简报 {len(raw_briefs)} 条") except Exception as e: # noqa: BLE001 print(f"[pinterest_analyze] mock 兜底失败: {e}") # 4) 上限 + 去重(同 motif+style 指纹只留一条) raw_briefs = raw_briefs[:max_designs] seen: set = set() uniq: List[Dict[str, Any]] = [] for b in raw_briefs: if not isinstance(b, dict): continue fp = f"{str(b.get('motif', '')).strip().lower()}|{str(b.get('art_style', '')).strip().lower()}" if fp in seen: continue seen.add(fp) uniq.append(b) raw_briefs = uniq # 5) 富化 → screened → prompt_node 装配提示词 → 标准 briefs screened = _enrich_briefs(raw_briefs, country) if not screened: print("[pinterest_analyze] 无有效设计简报,跳过") return {"pinterest_briefs": [], "briefs": [], "errors": errors} r = prompt_node({**state, "screened": screened}) briefs = r.get("briefs") or [] stats = dict(state.get("stats") or {}) stats["pinterest_analyze"] = { "provider": provider, "images_analyzed": sum(len(v) for v in images.values()), "briefs": len(briefs), } print(f"[pinterest_analyze] 设计简报 {len(briefs)} 条({country})") return {"pinterest_briefs": raw_briefs, "briefs": briefs, "stats": stats, "errors": errors}