- 图源映射统一:热点采集与 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 致命终止、线程安全、原子写入等)
151 lines
5.8 KiB
Python
151 lines
5.8 KiB
Python
"""节点级兜底校验工具。
|
||
|
||
设计目标:LangGraph 流水线里每个节点都必须"失败不影响整体"。
|
||
提供两类兜底:
|
||
1. with_fallback(node_name):装饰器,节点函数抛异常时捕获,把错误写入 state['errors'],
|
||
并返回最小更新(不破坏其它字段),整图继续往下走。
|
||
2. 数据校验函数:validate_rows / validate_brief,对节点产出的数据进行结构校验,
|
||
剔除非法记录并记录原因,保证下游拿到的数据"形状正确"。
|
||
"""
|
||
import functools
|
||
import threading
|
||
import traceback
|
||
from typing import Any, Dict, List
|
||
|
||
|
||
class ThreadSafeErrors:
|
||
"""线程安全的错误收集器:并发节点(compose/product 等)内 append 错误用。
|
||
|
||
避免多个 worker 线程直接写共享 list 造成竞态;主线程统一合并到 state['errors']。
|
||
"""
|
||
|
||
def __init__(self) -> None:
|
||
self._lock = threading.Lock()
|
||
self._items: List[Dict[str, Any]] = []
|
||
|
||
def append(self, item: Dict[str, Any]) -> None:
|
||
with self._lock:
|
||
self._items.append(item)
|
||
|
||
def __iter__(self):
|
||
with self._lock:
|
||
return iter(list(self._items))
|
||
|
||
def __len__(self) -> int:
|
||
with self._lock:
|
||
return len(self._items)
|
||
|
||
|
||
def _mark_fatal_503(state: Dict[str, Any], exc: Exception) -> None:
|
||
"""致命图像服务错误(503 / No available compatible accounts)→ 标记提前终止,不静默吞掉。
|
||
|
||
with_fallback 原本把所有异常都转成一条错误记录并继续,导致致命的 503 被「吞掉」:
|
||
路由看不到终止信号,任务会继续做无意义的搜索/分析(重试必然失败)。
|
||
检测到致命 503 时同步标记 Pinterest 流水线终止(record_503 + abort_unfinished),
|
||
让 _pinterest_route 短路到 pinterest_finalize → template_export(合成模板,保留已完成产品)。
|
||
非 Pinterest 节点无流水线对象时,此函数为空操作(不改变原有兜底行为)。
|
||
"""
|
||
if not _is_fatal_image_error(exc):
|
||
return
|
||
pipe = state.get("pinterest_pipeline")
|
||
if pipe is not None and hasattr(pipe, "record_503") and hasattr(pipe, "abort_unfinished"):
|
||
try:
|
||
pipe.record_503()
|
||
pipe.abort_unfinished()
|
||
except Exception: # noqa: BLE001
|
||
pass
|
||
|
||
|
||
def _is_fatal_image_error(exc: Exception) -> bool:
|
||
"""判断异常是否为致命图像服务错误(503 / 账户不可用)。"""
|
||
try:
|
||
from graph.pinterest_pipeline import PinterestPipeline
|
||
return bool(PinterestPipeline.is_fatal_503(exc))
|
||
except Exception: # noqa: BLE001
|
||
return False
|
||
|
||
|
||
def with_fallback(node_name: str):
|
||
"""装饰器:捕获节点异常,转为 state['errors'] 中的一条记录,返回空更新。
|
||
|
||
节点内部仍建议自己做精细兜底(降级/默认),with_fallback 是最后一道保险:
|
||
任何未预料的异常都不会让整张图中断。唯一例外——致命图像服务错误(503/账户不可用)
|
||
不静默吞掉:会同步标记流水线终止,让任务提前收尾合成模板(见 _mark_fatal_503)。
|
||
"""
|
||
|
||
def deco(fn):
|
||
@functools.wraps(fn)
|
||
def wrapper(state: Dict[str, Any]):
|
||
try:
|
||
return fn(state)
|
||
except Exception as e: # noqa: BLE001
|
||
tb = traceback.format_exc(limit=3)
|
||
err = {
|
||
"node": node_name,
|
||
"type": type(e).__name__,
|
||
"message": str(e)[:300],
|
||
"trace": tb[-400:],
|
||
}
|
||
errors = list(state.get("errors") or [])
|
||
errors.append(err)
|
||
# 致命 503:不静默吞掉,标记流水线终止(路由据此短路到收尾合成模板)
|
||
_mark_fatal_503(state, e)
|
||
# 只更新 errors,其它字段保持上一节点结果,下游继续
|
||
return {"errors": errors}
|
||
|
||
return wrapper
|
||
|
||
return deco
|
||
|
||
|
||
def validate_rows(rows: List[Dict[str, Any]], node: str) -> List[Dict[str, Any]]:
|
||
"""校验抓取/过滤后的行结构,剔除缺 topic 或非法记录,返回干净列表。
|
||
|
||
同时保证每个 row 至少含 country/topic/source/kind/raw_score,缺失时给默认。
|
||
"""
|
||
clean: List[Dict[str, Any]] = []
|
||
dropped = 0
|
||
for r in rows or []:
|
||
if not isinstance(r, dict):
|
||
dropped += 1
|
||
continue
|
||
topic = (r.get("topic") or "").strip()
|
||
if not topic:
|
||
dropped += 1
|
||
continue
|
||
r.setdefault("country", "")
|
||
r.setdefault("source", "unknown")
|
||
r.setdefault("kind", "unknown")
|
||
r.setdefault("raw_score", 0.0)
|
||
if r.get("raw_score") is None:
|
||
r["raw_score"] = 0.0
|
||
clean.append(r)
|
||
if dropped:
|
||
# 简单记录到返回数据的副作用里(调用方会再汇总到 stats)
|
||
pass
|
||
return clean
|
||
|
||
|
||
def validate_brief(b: Dict[str, Any]) -> Dict[str, Any]:
|
||
"""校验单条设计简报结构,补齐缺失字段,保证下游 compose 不会因缺键崩溃。"""
|
||
b = dict(b)
|
||
b.setdefault("topic", "")
|
||
b.setdefault("country", "")
|
||
b.setdefault("design_category", "Niche")
|
||
b.setdefault("risk_level", "safe")
|
||
b.setdefault("motif", b.get("topic", ""))
|
||
b.setdefault("art_style", "clean vector illustration")
|
||
b.setdefault("color_palette", "balanced modern palette")
|
||
b.setdefault("composition", "centered emblem with balanced negative space")
|
||
b.setdefault("concept", b.get("topic", ""))
|
||
b.setdefault("negative_prompt", "")
|
||
b.setdefault("image_prompt", "")
|
||
b.setdefault("wearable_prompt", "")
|
||
b.setdefault("composite_prompt", "")
|
||
b.setdefault("composite_negative", "")
|
||
return b
|
||
|
||
|
||
def safe_get(state: Dict[str, Any], key: str, default=None):
|
||
return state.get(key, default)
|