"""Pinterest 参考模式节点 3/3:从图池取图 → 多并发 LLM 分析 → 原创设计简报(pinterest_analyze)。 图池机制: - 从持久化图池(image_pool.json)取「未消费」图片(md5 不在 used_images.json)。 - 大图先压缩(内存占用过大 → 缩放/重编码)再送 LLM。 - 多并发分析(每批 1 张,并发 analyze_concurrency 线程;一次 API 请求一张图,利于 LLM 注意力)。 - 每张被分析的图片 md5 一律拉黑(used_images.json)——合适→产出简报→生成设计(设计 md5 全局拉黑见 compose); 不合适→图片 md5 已拉黑→下一轮自动取下一张,不重复分析。 - 图池无未消费图片时返回空,由路由触发新一轮搜索。 兜底链:LLM 多模态分析 → 失败/无图直接放弃本轮(不降级纯文本、不做 mock 兜底)。 带 with_fallback:任何异常都不中断。 """ import concurrent.futures import uuid 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 ThreadSafeErrors, with_fallback def _brief_suitable(b: Dict[str, Any]) -> bool: """简报是否适合做 T 恤印花:非侵权(blocked 拦截)+ LLM 判定适合印花(suitable_for_print)。""" if str(b.get("risk_level") or "").strip().lower() == "blocked": return False if not bool(b.get("suitable_for_print", True)): 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}" if not bool(b.get("suitable_for_print", True)): continue # LLM 判定不适合做印花 → 丢弃(md5 已拉黑,下轮取新图) 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": True, "design_category": classify(term), "concept": f"围绕「{term}」的原创印花设计", "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(), "brief_id": str(b.get("brief_id") or "").strip(), "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 = 1 # 固定一次 API 请求分析 1 张图(利于 LLM 注意力;不再用 analyze_per_term 配置) 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() # 简报池还有待处理/在途简报 → 不分析新图(等后台消化完再由路由决定下一步) pipe = state.get("pinterest_pipeline") if pipe is not None and hasattr(pipe, "pending_count") and pipe.pending_count() > 0: print(f"[pinterest_analyze] 简报池还有 {pipe.pending_count()} 条在途,本轮不分析") return {"pinterest_briefs": [], "briefs": state.get("briefs") or [], "errors": errors} # 按需分析:一次把图池当前未消费图片全部分析成简报(搜集完一批→生成一批简报), # 不再按目标任务数截断(不够用了才由路由触发新采集)。analyze_batch 可设上限保护配额。 batch_size = int(pcfg.get("analyze_batch", 0)) if batch_size <= 0: batch_size = max_designs # 自动:一次最多分析 max_designs 张(每张图→1条简报) need = batch_size # 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} 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) img["brief_id"] = str(uuid.uuid4()) # md5 校验后分配全局唯一 id,用于图-简报对应校验 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]]: """每批只分析 1 张图:简报按顺序对应 chunk 里唯一一张图,写入 source_md5 + ref_images + brief_id。 全局 id 校验:分析前已给每张图分配 uuid4(brief_id),简报回填同一 brief_id, 保证图-简报严格对应(一次 API 请求一张图,无 image_index 错位问题)。 """ chunk_paths = [img["path"] for img in chunk] chunk_md5s = [str(img.get("md5") or "").strip().lower() for img in chunk] chunk_ids = [str(img.get("brief_id") or "") for img in chunk] out: List[Dict[str, Any]] = [] for i, b in enumerate(res): if not isinstance(b, dict): continue idx = i if i < len(chunk_paths) else 0 # 一次一张:简报按顺序对应唯一图 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 "" b["brief_id"] = chunk_ids[idx] if idx < len(chunk_ids) 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]] = [] _safe_errors = ThreadSafeErrors() 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 _safe_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} 线程,每批 1 张)…") 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()) # 合并并发线程收集的错误(线程安全收集器 → 主线程统一追加) if len(_safe_errors): errors.extend(list(_safe_errors)) # 5) 真实 LLM 图片分析无结果(失败/无有效图片)→ 直接放弃本轮,跳过后续流程 # 不再 mock 兜底生成设计(用户要求:分析失败即放弃该产品) if not raw_briefs: print("[pinterest_analyze] 图片分析无结果,放弃本轮简报(跳过后续流程)") # 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) 上限 + 去重(同 brief_id 只留一条;brief_id 为每张图 uuid4,天然唯一) kept = kept[:max_designs] seen: set = set() uniq: List[Dict[str, Any]] = [] for b in kept: fp = str(b.get("brief_id") or b.get("image_prompt") or "").strip().lower() if not fp or 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) # 推送实际新增且保留的简报到并发生成流水线(简报池):边分析边生成设计/三合一/种草图 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}