"""节点级兜底校验工具。 设计目标:LangGraph 流水线里每个节点都必须"失败不影响整体"。 提供两类兜底: 1. with_fallback(node_name):装饰器,节点函数抛异常时捕获,把错误写入 state['errors'], 并返回最小更新(不破坏其它字段),整图继续往下走。 2. 数据校验函数:validate_rows / validate_brief,对节点产出的数据进行结构校验, 剔除非法记录并记录原因,保证下游拿到的数据"形状正确"。 """ import functools import traceback from typing import Any, Dict, List def with_fallback(node_name: str): """装饰器:捕获节点异常,转为 state['errors'] 中的一条记录,返回空更新。 节点内部仍建议自己做精细兜底(降级/默认),with_fallback 是最后一道保险: 任何未预料的异常都不会让整张图中断。 """ 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) # 只更新 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)