- 新增韩国(KR)适配:countries/pinterest 种子词、K-pop 提示词、UI 国家列表 - 爬虫跨运行持久化已采集 URL(.collected_urls.json),同关键词重复搜索采新图 - 模板导出增强:SPU 商品属性列名前缀剥离匹配、尺码下拉框 INDIRECT 动态引用、 SPU 字段映射(袖长/门襟/胸垫等)、季节/印花图案女装映射、建议售价统一必填、 SKU 分类/数量/单位直接填默认值 - 自定义模式:多模态提示词独立(custom_analyze_*)、生图提示词模板可配置 - Pinterest:空选品守卫、连续空分析保护(max_empty_analyze)、flat_prompt 配置覆盖 - 生图可靠性:开始/完成进度日志、异步任务轮询超时 60s→300s
308 lines
15 KiB
Python
308 lines
15 KiB
Python
"""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}」的原创印花设计",
|
||
"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
|
||
# 按目标简报数收敛:最多分析到满足选品清单所需的简报数即可(target=spu_tasks,每款一个设计),
|
||
# 普通与自定义模式一致——避免单一产品任务也把整池未消费图片全部分析成冗余简报、浪费配额;
|
||
# 分析出的简报若不适合可丢弃,缺口不足时再由路由补分析/search。
|
||
_t = int(state.get("pinterest_target") or 1) or 1
|
||
need = max(1, 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,
|
||
custom_mode=bool(state.get("custom_mode"))) 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)
|
||
|
||
# 连续无产出计数器:本次无新增简报 → 递增(防"逐一取下一张参考图"空转);
|
||
# 有新增简报 → 重置为 0
|
||
empty_rounds = int(state.get("pinterest_empty_rounds") or 0)
|
||
if new_briefs:
|
||
empty_rounds = 0
|
||
else:
|
||
empty_rounds += 1
|
||
print(f"[pinterest_analyze] 本轮无新增简报(连续 {empty_rounds} 轮无产出),"
|
||
f"超过上限后将自动结束")
|
||
|
||
# 推送实际新增且保留的简报到并发生成流水线(简报池):边分析边生成设计/三合一/种草图
|
||
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,
|
||
"pinterest_empty_rounds": empty_rounds}
|