149 lines
6.5 KiB
Python
149 lines
6.5 KiB
Python
"""Pinterest 参考模式节点 3/3:LLM 多模态分析图片 → 原创设计简报(pinterest_analyze)。
|
||
|
||
对 pinterest_scrape 爬到的每个搜索词图片,调 LLM 多模态分析(analyze_pinterest_images)
|
||
提取视觉概念(风格/情绪/主体/配色/构图)→ 生成原创设计简报
|
||
(motif/art_style/color_palette/composition/concept/negative_prompt),
|
||
再经 prompt_node 装配最终 image/wearable/composite 提示词,产出标准 briefs 供 compose 用。
|
||
|
||
兜底链:LLM 多模态 → 纯文本降级(后端内部)→ mock 规则简报 → 空列表(下游跳过)。
|
||
带 with_fallback:任何异常都不中断。
|
||
"""
|
||
from typing import Any, Dict, List
|
||
|
||
from graph.llms import get_backend
|
||
from graph.nodes.prompt_node import prompt_node
|
||
from graph.validate import with_fallback
|
||
|
||
|
||
def _enrich_briefs(raw_briefs: List[Dict[str, Any]], country: str) -> List[Dict[str, Any]]:
|
||
"""富化原始简报 → screened 格式(唯一 topic / safe / 分类 / 分数),供 prompt_node 装配。
|
||
|
||
同一搜索词的多张图会产出多条简报,topic 相同 → 追加序号保证唯一
|
||
(product_node 按 topic 绑定简报,重复 topic 会互相覆盖)。
|
||
"""
|
||
from graph.classify import classify
|
||
seen_topics: Dict[str, int] = {}
|
||
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": "safe",
|
||
"safe_for_print": True,
|
||
"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(),
|
||
"ref_images": [str(p) for p in (b.get("ref_images") or []) if str(p)],
|
||
"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]:
|
||
images: Dict[str, List[str]] = state.get("pinterest_images") or {}
|
||
if not images:
|
||
print("[pinterest_analyze] 无爬取图片,跳过分析")
|
||
return {"pinterest_briefs": [], "briefs": [], "errors": state.get("errors") or []}
|
||
|
||
country = state["country"]
|
||
config = state["config"]
|
||
errors = list(state.get("errors") or [])
|
||
|
||
pcfg = config.get("pinterest") or {}
|
||
analyze_per_term = int(pcfg.get("analyze_per_term", 6))
|
||
max_designs = int(pcfg.get("max_designs", 10))
|
||
provider = str(pcfg.get("provider") or "openai").strip().lower()
|
||
|
||
# 1) 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
|
||
|
||
# 2) 逐搜索词分析图片 → 原始设计简报
|
||
raw_briefs: List[Dict[str, Any]] = []
|
||
if llm is not None and hasattr(llm, "analyze_pinterest_images"):
|
||
for term, paths in images.items():
|
||
sample = list(paths)[:analyze_per_term]
|
||
if not sample:
|
||
continue
|
||
try:
|
||
res = llm.analyze_pinterest_images(sample, term, country)
|
||
res = res or []
|
||
raw_briefs.extend(res)
|
||
print(f"[pinterest_analyze] 「{term}」分析 {len(sample)} 张图 → {len(res)} 条简报")
|
||
except Exception as e: # noqa: BLE001
|
||
errors.append({"node": "pinterest_analyze", "type": type(e).__name__,
|
||
"message": f"term[{term}]: {e}", "trace": ""})
|
||
print(f"[pinterest_analyze] 「{term}」分析失败: {e}")
|
||
|
||
# 3) 兜底:LLM 无结果 → mock 规则简报(零 API 成本,保证有设计可生成)
|
||
if not raw_briefs and llm is not None:
|
||
try:
|
||
for term, paths in images.items():
|
||
sample = list(paths)[:analyze_per_term]
|
||
if sample:
|
||
raw_briefs.extend(llm.analyze_pinterest_images(sample, term, country) or [])
|
||
print(f"[pinterest_analyze] 兜底:mock 规则简报 {len(raw_briefs)} 条")
|
||
except Exception as e: # noqa: BLE001
|
||
print(f"[pinterest_analyze] mock 兜底失败: {e}")
|
||
|
||
# 4) 上限 + 去重(同 motif+style 指纹只留一条)
|
||
raw_briefs = raw_briefs[:max_designs]
|
||
seen: set = set()
|
||
uniq: List[Dict[str, Any]] = []
|
||
for b in raw_briefs:
|
||
if not isinstance(b, dict):
|
||
continue
|
||
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)
|
||
raw_briefs = uniq
|
||
|
||
# 5) 富化 → screened → prompt_node 装配提示词 → 标准 briefs
|
||
screened = _enrich_briefs(raw_briefs, country)
|
||
if not screened:
|
||
print("[pinterest_analyze] 无有效设计简报,跳过")
|
||
return {"pinterest_briefs": [], "briefs": [], "errors": errors}
|
||
|
||
r = prompt_node({**state, "screened": screened})
|
||
briefs = r.get("briefs") or []
|
||
|
||
stats = dict(state.get("stats") or {})
|
||
stats["pinterest_analyze"] = {
|
||
"provider": provider,
|
||
"images_analyzed": sum(len(v) for v in images.values()),
|
||
"briefs": len(briefs),
|
||
}
|
||
print(f"[pinterest_analyze] 设计简报 {len(briefs)} 条({country})")
|
||
return {"pinterest_briefs": raw_briefs, "briefs": briefs, "stats": stats, "errors": errors}
|