"""Pinterest 参考模式节点 3/3:从图池取图 → 多并发 LLM 分析 → 原创设计简报(pinterest_analyze)。 图池机制: - 从持久化图池(image_pool.json)取「未消费」图片(md5 不在 used_images.json)。 - 大图先压缩(内存占用过大 → 缩放/重编码)再送 LLM。 - 多并发分析(每批 analyze_per_term 张,并发 analyze_concurrency 线程)。 - 每张被分析的图片 md5 一律拉黑(used_images.json)——合适→产出简报→生成设计(设计 md5 全局拉黑见 compose); 不合适→图片 md5 已拉黑→下一轮自动取下一张,不重复分析。 - 图池无未消费图片时返回空,由路由触发新一轮搜索。 兜底链:LLM 多模态 → 纯文本降级(后端内部)→ mock 规则简报 → 空列表(下游跳过)。 带 with_fallback:任何异常都不中断。 """ import concurrent.futures import re from pathlib import Path from typing import Any, Dict, List from graph.llms import get_backend from graph.nodes.prompt_node import prompt_node from graph.pinterest import ( compress_image, load_image_pool, load_used_images, pool_unused_images, save_used_images, ) from graph.validate import with_fallback # 明显不适合 T 恤印花的简报主体(启发式过滤;真实判定交给 LLM 搜索词引导) _BRIEF_UNSUITABLE = re.compile( r"\b(landscape|panorama|scenery|cityscape|street scene|interior|room decor|" r"food photography|meal|dinner plate|recipe|makeup|nails|manicure|" r"weather forecast|map|directions|photorealistic scene|realistic portrait)\b", re.IGNORECASE, ) def _brief_suitable(b: Dict[str, Any]) -> bool: """简报是否适合做 T 恤印花:非侵权(blocked 拦截)+ 有主体 + 非明显非印花概念。""" if str(b.get("risk_level") or "").strip().lower() == "blocked": return False motif = str(b.get("motif") or "").strip() if not motif: return False if _BRIEF_UNSUITABLE.search(motif): return False return True def _enrich_briefs(raw_briefs: List[Dict[str, Any]], country: str, existing_topics: List[str] = None) -> List[Dict[str, Any]]: """富化原始简报 → screened 格式(唯一 topic / safe / 分类 / 分数),供 prompt_node 装配。 同一搜索词的多张图会产出多条简报,topic 相同 → 追加序号保证唯一 (product_node 按 topic 绑定简报,重复 topic 会互相覆盖)。 existing_topics: 已累计简报的 topic 列表;用它初始化计数实现跨轮次去重—— 直接搜固定词时每轮 LLM 都返回相同 topic,若每轮从 #1 重新计数, 30 个产品会因 topic 重复只用到前几个唯一设计(其余全复制)。 """ from graph.classify import classify import re as _re seen_topics: Dict[str, int] = {} # 已累计简报按「基础词」计数(去掉 #N 后缀),保证跨轮次序号连续递增 for t in existing_topics or []: key = _re.sub(r"\s+#\d+$", "", str(t).strip().lower()) if key: seen_topics[key] = seen_topics.get(key, 0) + 1 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": str(b.get("risk_level") or "safe").strip().lower() or "safe", "safe_for_print": bool(b.get("safe_for_print", True)), "suitable_for_print": bool(b.get("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(), "image_prompt": str(b.get("image_prompt") or "").strip(), "ref_images": [str(p) for p in (b.get("ref_images") or []) if str(p)], "source_md5": str(b.get("source_md5") or "").strip().lower(), "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]: country = state["country"] config = state["config"] output_dir = state["output_dir"] errors = list(state.get("errors") or []) pcfg = config.get("pinterest") or {} analyze_per_term = int(pcfg.get("analyze_per_term", 1)) concurrency = int(pcfg.get("analyze_concurrency", 3)) max_designs = int(pcfg.get("max_designs", 10)) n_ref = max(1, int(pcfg.get("ref_images_per_design", 1))) provider = str(pcfg.get("provider") or "openai").strip().lower() # 按需分析:只取补齐到目标所需的图片数(batch_size 为上限,不超额分析),并按 md5 去重, # 保证同一图片内容(md5)不会同时被多条简报使用 target = int(state.get("pinterest_target") or 0) existing = state.get("briefs") or [] remaining = max(0, target - len(existing)) batch_size = int(pcfg.get("analyze_batch", 0)) if batch_size <= 0: # 自动:一次分析补齐到「目标所需」或「每词简报上限」的较小值(每张图→1条简报), # 让 pipeline 队列一次有足够任务,时刻保持并发生成(避免每轮只推 6 条导致线程空转) batch_size = min(remaining, max_designs) need = min(batch_size, remaining) if remaining > 0 else 0 # 1) 图池取未消费图片(md5 不在 used_images);无 → 返回空,路由触发搜索 pool = load_image_pool(output_dir, country) used = load_used_images(output_dir, country) unused = pool_unused_images(pool, used) if not unused: print("[pinterest_analyze] 图池无未消费图片,跳过分析(路由将触发新一轮搜索)") return {"pinterest_briefs": [], "briefs": state.get("briefs") or [], "errors": errors} if need <= 0: print("[pinterest_analyze] 简报已达标,无需分析") return {"pinterest_briefs": [], "briefs": state.get("briefs") or [], "errors": errors} seen_md5: set = set() batch: List[Dict[str, Any]] = [] for img in unused: m = str(img.get("md5") or "").strip().lower() if m and m in seen_md5: continue # 同一图片内容(md5)不重复分析 seen_md5.add(m) batch.append(img) if len(batch) >= need: break print(f"[pinterest_analyze] 图池取 {len(batch)} 张未消费图片分析(按需 {need}," f"池剩余未消费 {len(unused) - len(batch)} 张,已消费 {len(used)} 张)") def _assign_refs(res: List[Dict[str, Any]], chunk: List[Dict[str, Any]]) -> List[Dict[str, Any]]: """按简报的 image_index(LLM 返回)匹配它实际分析的图,写入 source_md5 + ref_images。 全局 id 校验:简报必须带 image_index(对应输入第几张图,0-based); 无 image_index(mock 兜底)→ 回退按顺序;无效/越界/重复 → 丢弃该简报(避免错位)。 这样 analyze_per_term 可 >1 一次分析多张图提速,简报仍严格对应各自的图。 """ chunk_paths = [img["path"] for img in chunk] chunk_md5s = [str(img.get("md5") or "").strip().lower() for img in chunk] used_idx: set = set() out: List[Dict[str, Any]] = [] for i, b in enumerate(res): if not isinstance(b, dict): continue try: idx = int(b.get("image_index")) except (TypeError, ValueError): idx = i # 无 image_index → 回退按顺序 if idx < 0 or idx >= len(chunk_paths) or idx in used_idx: print(f"[pinterest_analyze] 简报 image_index={idx} 无效/重复,丢弃(避免图-简报错位)") continue used_idx.add(idx) refs: List[str] = [] for k in range(n_ref): src = chunk_paths[(idx + k) % len(chunk_paths)] if src not in refs: refs.append(src) b["ref_images"] = refs b["source_md5"] = chunk_md5s[idx] if idx < len(chunk_md5s) else "" out.append(b) return out # 2) 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 # 3) 大图先压缩(内存占用过大 → 缩放/重编码),再按批分组 compressed_map: Dict[str, str] = {} for img in batch: compressed_map[img["path"]] = compress_image(img["path"]) chunks: List[List[Dict[str, Any]]] = [ batch[i:i + analyze_per_term] for i in range(0, len(batch), analyze_per_term) ] # 4) 多并发分析(每线程分析一个 chunk;LLM 后端只读 self._cfg,线程安全) raw_briefs: List[Dict[str, Any]] = [] def _analyze_chunk(chunk: List[Dict[str, Any]]) -> List[Dict[str, Any]]: term = str(chunk[0].get("term") or "") paths = [compressed_map.get(img["path"], img["path"]) for img in chunk] if llm is not None and hasattr(llm, "analyze_pinterest_images"): try: def _on_400(): pipe = state.get("pinterest_pipeline") if pipe is not None and hasattr(pipe, "record_400"): if pipe.record_400(): pipe._abort_current_term() res = llm.analyze_pinterest_images(paths, term, country, on_400=_on_400) or [] # 把每条简报的来源图路径回填为原始图(压缩图仅用于分析,参考图用原图) return _assign_refs(res, chunk) except Exception as e: # noqa: BLE001 errors.append({"node": "pinterest_analyze", "type": type(e).__name__, "message": f"term[{term}] batch@{len(chunk)}: {e}", "trace": ""}) print(f"[pinterest_analyze] 「{term}」分析失败: {e}") return [] return [] workers = max(1, min(concurrency, len(chunks))) if len(chunks) > 1: print(f"[pinterest_analyze] 并发分析 {len(chunks)} 批({workers} 线程,每批 {analyze_per_term} 张)…") with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as _ex: _futs = [_ex.submit(_analyze_chunk, c) for c in chunks] for _f in concurrent.futures.as_completed(_futs): raw_briefs.extend(_f.result()) # 5) 兜底:LLM 无结果 → mock 规则简报(零 API 成本,保证有设计可生成) if not raw_briefs: try: mock = get_backend("mock") for chunk in chunks: term = str(chunk[0].get("term") or "") paths = [compressed_map.get(img["path"], img["path"]) for img in chunk] res = mock.analyze_pinterest_images(paths, term, country) or [] _assign_refs(res, chunk) raw_briefs.extend(res) print(f"[pinterest_analyze] 兜底:mock 规则简报 {len(raw_briefs)} 条") except Exception as e: # noqa: BLE001 print(f"[pinterest_analyze] mock 兜底失败: {e}") # 6) 本批所有图片 md5 一律拉黑(已消费,不再复用)——合适/不合适都拉黑 for img in batch: if img.get("md5"): used.add(str(img["md5"]).lower()) save_used_images(output_dir, country, used) # 7) 简报过滤:只留适合印花的(有主体 + 非明显非印花概念) kept: List[Dict[str, Any]] = [] for b in raw_briefs: if isinstance(b, dict) and _brief_suitable(b): kept.append(b) if len(kept) < len(raw_briefs): print(f"[pinterest_analyze] 简报过滤:{len(raw_briefs)} → {len(kept)} 条适合印花") # 8) 上限 + 去重(同 motif+style 指纹只留一条) kept = kept[:max_designs] seen: set = set() uniq: List[Dict[str, Any]] = [] for b in kept: 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) kept = uniq # 9) 富化 → screened → prompt_node 装配提示词 → 标准 briefs(追加到累计,按需截断到目标数) existing_topics = [str(b.get("topic", "")).strip() for b in (state.get("briefs") or [])] screened = _enrich_briefs(kept, country, existing_topics) new_briefs: List[Dict[str, Any]] = [] if screened: r = prompt_node({**state, "screened": screened}) new_briefs = r.get("briefs") or [] old_count = len(state.get("briefs") or []) accumulated = list(state.get("briefs") or []) accumulated.extend(new_briefs) target = int(state.get("pinterest_target") or 0) if target > 0 and len(accumulated) > target: accumulated = accumulated[:target] print(f"[pinterest_analyze] 简报已达目标 {target} 条,截断多余部分") # 推送实际新增且保留的简报到并发生成流水线(简报池):边分析边生成设计/三合一/种草图 pushed = accumulated[old_count:] pipe = state.get("pinterest_pipeline") if pipe is not None and pushed: if getattr(pipe, "is_400_aborted", lambda: False)(): print("[pinterest_analyze] 当前种子词 400 超限已放弃,本轮简报不推送") pushed = [] else: try: pipe.add_briefs(pushed) except Exception as e: # noqa: BLE001 print(f"[pinterest_analyze] 简报入池失败: {e}") stats = dict(state.get("stats") or {}) stats["pinterest_analyze"] = { "provider": provider, "images_analyzed": len(batch), "pool_unused": len(unused), "used_images": len(used), "briefs": len(new_briefs), "accumulated": len(accumulated), } print(f"[pinterest_analyze] 本轮分析 {len(batch)} 张图 → 简报 {len(new_briefs)} 条," f"累计 {len(accumulated)} 条({country})") return {"pinterest_briefs": kept, "briefs": accumulated, "stats": stats, "errors": errors}