Files
pod_trend_agent/graph/validate.py
T
3218485270 71a48e4ed5 v89-v91 模板增强 + 图源映射 + 多模态提示词可配置化
- 图源映射统一:热点采集与 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 致命终止、线程安全、原子写入等)
2026-08-28 16:24:50 +08:00

151 lines
5.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""节点级兜底校验工具。
设计目标: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)