Files
pod_trend_agent/graph/nodes/pinterest_analyze_node.py
T
3218485270 2a96ec0870 v88 功能增强:产品落盘持久化 + 生图网关适配 + 模板导出优化
- 产品持久化:每完成一个产品立即追加写入 products_pending.jsonl,崩溃不丢已完成产品,finish 读盘合并后统一写模板
- 503 致命错误提前终止:compose/product/seed_shot 端到端识别,提前终止搜索分析,丢弃未完成简报,保留已完成落盘产品直接合成模板
- 模特分配:material_library 合格模特图按任务序号独立随机,同 SPU 多款不再共用同一模特
- 图像网关适配:execution_mode/background 默认不再传入 yunfei 等标准网关,base_url 需带 /v1;429/5xx/空响应退避重试
- Pinterest 分析:删除 term 注入与纯文本降级,失败直接放弃;图片上传前 PIL 完整性校验;suitable_for_print=False 过滤丢弃
- 模板导出:不再产生空白 xlsx,文件名=模板原文件名_已填写;写入前按货号末 3 位升序排序
- 删除对接文档.md,更新 README,gitignore 排除测试产物
2026-08-28 10:28:35 +08:00

292 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
# 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}