Skip to content

Commit 1e1d2ed

Browse files
committed
error_assistant: Doctor-style JSON pattern packs + provenance
- Externalize all 21 Tier-1 patterns to patterns/builtin/core.json with id/category/priority/confidence/exc_types/regex/cause/fixes fields (Doctor schema) - Add JSON pack loader with mtime-based hot-reload across patterns/**/*.json (community packs can drop files in patterns/community/) - Keep in-Python list as _BOOTSTRAP_PATTERNS fallback if JSON is missing/corrupt on first run - explain() now returns category, confidence, and provenance {pack, source, priority} on every match (and on no_match / LLM tiers) - Public reload_patterns() helper for forced re-scan - Smoke-tested: 21 patterns load, OOM/stitch_data match with correct metadata, hot-reload triggers on file touch
1 parent e7c69f5 commit 1e1d2ed

2 files changed

Lines changed: 426 additions & 3 deletions

File tree

nodes/error_assistant.py

Lines changed: 131 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@
3535
log = logging.getLogger("MEC.error_assistant")
3636

3737
# =====================================================================
38-
# Tier 1 — deterministic patterns
38+
# Tier 1 — deterministic patterns (Doctor-style: JSON pattern packs)
3939
# =====================================================================
4040
@dataclass
4141
class Pattern:
@@ -45,14 +45,115 @@ class Pattern:
4545
message_re: re.Pattern[str] # case-insensitive by default
4646
cause: str
4747
fixes: List[str] = field(default_factory=list)
48+
# Doctor-style metadata
49+
category: str = "uncategorized"
50+
priority: int = 100
51+
confidence: float = 0.8
52+
source: str = "builtin" # provenance: which pack file
4853

4954

5055
def _r(pat: str) -> re.Pattern[str]:
5156
return re.compile(pat, re.IGNORECASE | re.DOTALL)
5257

5358

59+
# ---------------------------------------------------------------------
60+
# JSON pattern-pack loader (hot-reload on file mtime change)
61+
# ---------------------------------------------------------------------
62+
def _patterns_root() -> str:
63+
here = os.path.dirname(os.path.abspath(__file__))
64+
pack_root = os.path.dirname(here)
65+
return os.path.join(pack_root, "patterns")
66+
67+
68+
_PATTERNS_LOCK = threading.Lock()
69+
_PATTERNS_CACHE: List[Pattern] = []
70+
_PATTERNS_MTIME: Dict[str, float] = {} # path -> last-seen mtime
71+
72+
73+
def _scan_pattern_files() -> List[str]:
74+
root = _patterns_root()
75+
out: List[str] = []
76+
if not os.path.isdir(root):
77+
return out
78+
for dirpath, _dirs, files in os.walk(root):
79+
for f in files:
80+
if f.endswith(".json"):
81+
out.append(os.path.join(dirpath, f))
82+
return sorted(out)
83+
84+
85+
def _parse_pattern_dict(d: Dict[str, Any], source: str) -> Optional[Pattern]:
86+
try:
87+
pid = str(d["id"])
88+
regex = d["regex"]
89+
cause = d.get("cause", "")
90+
exc_types = tuple(d.get("exc_types") or ())
91+
return Pattern(
92+
name=pid,
93+
exc_types=exc_types,
94+
message_re=_r(regex),
95+
cause=cause,
96+
fixes=list(d.get("fixes") or []),
97+
category=str(d.get("category", "uncategorized")),
98+
priority=int(d.get("priority", 100)),
99+
confidence=float(d.get("confidence", 0.8)),
100+
source=source,
101+
)
102+
except Exception as e:
103+
log.warning("[error_assistant] bad pattern in %s: %s", source, e)
104+
return None
105+
106+
107+
def _load_patterns_from_json() -> List[Pattern]:
108+
import json
109+
out: List[Pattern] = []
110+
for path in _scan_pattern_files():
111+
try:
112+
with open(path, "r", encoding="utf-8") as f:
113+
data = json.load(f)
114+
rel = os.path.relpath(path, _patterns_root())
115+
for entry in data.get("patterns", []):
116+
p = _parse_pattern_dict(entry, source=rel.replace("\\", "/"))
117+
if p is not None:
118+
out.append(p)
119+
except Exception as e:
120+
log.warning("[error_assistant] failed to load pattern pack %s: %s", path, e)
121+
# Lower priority value = evaluated first (more specific). Stable sort.
122+
out.sort(key=lambda p: (p.priority, p.name))
123+
return out
124+
125+
126+
def _get_patterns() -> List[Pattern]:
127+
"""Return current patterns; hot-reload on any pack file mtime change."""
128+
global _PATTERNS_CACHE, _PATTERNS_MTIME
129+
with _PATTERNS_LOCK:
130+
files = _scan_pattern_files()
131+
cur_mtimes: Dict[str, float] = {}
132+
for p in files:
133+
try:
134+
cur_mtimes[p] = os.path.getmtime(p)
135+
except OSError:
136+
cur_mtimes[p] = 0.0
137+
if cur_mtimes != _PATTERNS_MTIME or not _PATTERNS_CACHE:
138+
loaded = _load_patterns_from_json()
139+
if loaded:
140+
_PATTERNS_CACHE = loaded
141+
_PATTERNS_MTIME = cur_mtimes
142+
log.info("[error_assistant] loaded %d patterns from %d pack(s)",
143+
len(loaded), len(files))
144+
elif not _PATTERNS_CACHE:
145+
# JSON missing/corrupt on first run: fall back to bootstrap list.
146+
_PATTERNS_CACHE = list(_BOOTSTRAP_PATTERNS)
147+
_PATTERNS_MTIME = cur_mtimes
148+
log.warning("[error_assistant] no JSON packs found; using %d "
149+
"bootstrap patterns", len(_PATTERNS_CACHE))
150+
return _PATTERNS_CACHE
151+
152+
54153
# Order matters — first match wins. Most-specific patterns first.
55-
PATTERNS: List[Pattern] = [
154+
# This list is ONLY used as a bootstrap fallback when patterns/builtin/core.json
155+
# is missing or unreadable. The canonical source of truth is the JSON pack.
156+
_BOOTSTRAP_PATTERNS: List[Pattern] = [
56157
# ── VRAM / CUDA ────────────────────────────────────────────────
57158
Pattern(
58159
"cuda_oom",
@@ -304,7 +405,7 @@ def _r(pat: str) -> re.Pattern[str]:
304405

305406

306407
def match_pattern(exc_type: str, msg: str) -> Optional[Pattern]:
307-
for p in PATTERNS:
408+
for p in _get_patterns():
308409
if p.exc_types and exc_type not in p.exc_types and "Exception" not in p.exc_types:
309410
continue
310411
if p.message_re.search(msg):
@@ -481,6 +582,13 @@ def explain(exc: BaseException,
481582
"cause": p.cause,
482583
"fixes": list(p.fixes),
483584
"pattern_id": p.name,
585+
"category": p.category,
586+
"confidence": p.confidence,
587+
"provenance": {
588+
"pack": "ComfyUI-CustomNodePacks",
589+
"source": p.source,
590+
"priority": p.priority,
591+
},
484592
}
485593

486594
if mode == "deterministic_only":
@@ -526,9 +634,21 @@ def _fallback(exc_type: str, msg: str) -> Dict[str, Any]:
526634
"Enable Tier 2 (local model) or Tier 3 (cloud) in Settings → MEC Error Assistant for richer explanations.",
527635
],
528636
"pattern_id": "no_match",
637+
"category": "uncategorized",
638+
"confidence": 0.0,
639+
"provenance": {"pack": "ComfyUI-CustomNodePacks", "source": "fallback"},
529640
}
530641

531642

643+
def reload_patterns() -> int:
644+
"""Force a re-scan of patterns/ pack files. Returns number of patterns loaded."""
645+
global _PATTERNS_CACHE, _PATTERNS_MTIME
646+
with _PATTERNS_LOCK:
647+
_PATTERNS_CACHE = []
648+
_PATTERNS_MTIME = {}
649+
return len(_get_patterns())
650+
651+
532652
def _format_llm_result(text: str, *, tier: int, tier1: Optional[dict],
533653
exc_type: str, msg: str, provider: str, model: str) -> Dict[str, Any]:
534654
"""Parse loose CAUSE/FIXES sections out of the LLM's reply."""
@@ -553,4 +673,12 @@ def _format_llm_result(text: str, *, tier: int, tier1: Optional[dict],
553673
"provider": provider,
554674
"model": model,
555675
"tier1_match": (tier1 or {}).get("pattern_id"),
676+
"category": (tier1 or {}).get("category", "uncategorized"),
677+
"confidence": (tier1 or {}).get("confidence", 0.5),
678+
"provenance": {
679+
"pack": "ComfyUI-CustomNodePacks",
680+
"source": (tier1 or {}).get("provenance", {}).get("source", "llm"),
681+
"llm_provider": provider,
682+
"llm_model": model,
683+
},
556684
}

0 commit comments

Comments
 (0)