"""节点级兜底校验工具。 设计目标: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)