Files
pod_trend_agent/graph/nodes/pinterest_analyze_node.py
T
3218485270 71a48e4ed5 v89-v91 模板增强 + 图源映射 + 多模态提示词可配置化
- 图源映射统一:热点采集与 Pinterest 模式均走 config.product.mark_dirs 配置,按任务序号随机抽模特图/平铺图
- 商品产地固定:统一为「中国大陆」+「产地省份=广东省」(不再读站点/字典映射)
- 模板 SKU 字段检测:按建议售价同一套路检测 SKU分类/SKU数量/SKU数量单位,必填时填入单品/1/件
- 多模态分析提示词可配置:prompts/pinterest_analyze_system.md + user.md,支持国家覆盖,不丢文件回退内置
- 自定义图片模式:新增 pinterest_custom_load_node,图片数量硬校验,选品清单 ≤ 有效图片数
- 模板导出优化:写入前按货号末 3 位升序排序,不再产生空白 xlsx
- 修复 v90 project review 10 项(503 致命终止、线程安全、原子写入等)
2026-08-28 16:24:50 +08:00

297 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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
# 自定义模式:本地图池是唯一且有限的图源,最多分析「选品清单总数」张即可,
# 避免多余分析(超出的图源在简报达标后由自定义路由结束,不浪费配额)。
if bool(state.get("custom_mode")):
_t = int(state.get("pinterest_target") or 1) or 1
need = max(0, min(need, _t))
# 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}