Self-hosted, Docker-based agentic troubleshooting platform: FastAPI backend + LangGraph agent, Next.js UI, tiered LLM routing (local Ollama -> Gemini -> DeepSeek -> OpenRouter), MCP server manager, encrypted device credentials, RBAC, audit log, project-memory + Obsidian integrations, and editable troubleshooting decision rules tuned for the GeneseasX vessel stack. Co-authored-by: Cursor <cursoragent@cursor.com>
298 lines
9.0 KiB
Python
298 lines
9.0 KiB
Python
"""Editable troubleshooting decision rules (YAML on disk).
|
|
|
|
Rules standardize which devices to check and in what order, plus hints for the LLM.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import os
|
|
import re
|
|
from dataclasses import dataclass
|
|
|
|
import yaml
|
|
|
|
from app.config import settings
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
_rules_cache: dict | None = None
|
|
_rules_mtime: float = -1.0
|
|
|
|
|
|
@dataclass
|
|
class MatchedRule:
|
|
id: str
|
|
name: str
|
|
severity: str
|
|
devices: list[str]
|
|
order: list[str]
|
|
hints: list[str]
|
|
steps: list[str]
|
|
broad: bool = False
|
|
|
|
def as_dict(self) -> dict:
|
|
return {
|
|
"id": self.id,
|
|
"name": self.name,
|
|
"severity": self.severity,
|
|
"devices": self.devices,
|
|
"order": self.order,
|
|
"hints": self.hints,
|
|
"steps": self.steps,
|
|
"broad": self.broad,
|
|
}
|
|
|
|
|
|
def rules_path() -> str:
|
|
return os.environ.get("TROUBLESHOOTING_RULES_PATH", settings.troubleshooting_rules_path)
|
|
|
|
|
|
def _normalize(data: dict) -> dict:
|
|
data.setdefault("version", 1)
|
|
data.setdefault("broad_triggers", {"keywords": []})
|
|
data.setdefault("device_keywords", {})
|
|
data.setdefault("rules", [])
|
|
data.setdefault("default", {"severity": "medium", "devices": ["all"], "hints": []})
|
|
return data
|
|
|
|
|
|
def invalidate_rules_cache() -> None:
|
|
global _rules_cache, _rules_mtime
|
|
_rules_cache = None
|
|
_rules_mtime = -1.0
|
|
|
|
|
|
def load_rules(*, force: bool = False) -> dict:
|
|
global _rules_cache, _rules_mtime
|
|
path = rules_path()
|
|
try:
|
|
mtime = os.path.getmtime(path)
|
|
except OSError:
|
|
logger.warning("troubleshooting rules file missing: %s", path)
|
|
return _normalize({})
|
|
|
|
if not force and _rules_cache is not None and mtime == _rules_mtime:
|
|
return _rules_cache
|
|
|
|
try:
|
|
with open(path, encoding="utf-8") as fh:
|
|
data = _normalize(yaml.safe_load(fh) or {})
|
|
except yaml.YAMLError as exc:
|
|
logger.warning("invalid rules YAML %s: %s", path, exc)
|
|
data = _normalize({})
|
|
|
|
_rules_cache = data
|
|
_rules_mtime = mtime
|
|
return data
|
|
|
|
|
|
def read_rules_raw() -> str:
|
|
path = rules_path()
|
|
try:
|
|
with open(path, encoding="utf-8") as fh:
|
|
return fh.read()
|
|
except OSError:
|
|
return ""
|
|
|
|
|
|
def save_rules_raw(content: str) -> dict:
|
|
parsed = yaml.safe_load(content)
|
|
if not isinstance(parsed, dict):
|
|
raise ValueError("Rules file must be a YAML mapping at the top level")
|
|
normalized = _normalize(parsed)
|
|
path = rules_path()
|
|
parent = os.path.dirname(path)
|
|
if parent:
|
|
os.makedirs(parent, exist_ok=True)
|
|
with open(path, "w", encoding="utf-8") as fh:
|
|
fh.write(content if content.endswith("\n") else content + "\n")
|
|
invalidate_rules_cache()
|
|
return load_rules(force=True)
|
|
|
|
|
|
def _text_has_term(text: str, term: str) -> bool:
|
|
term = term.lower().strip()
|
|
if not term:
|
|
return False
|
|
if " " in term:
|
|
return term in text
|
|
if len(term) <= 2:
|
|
return term in text.split()
|
|
return bool(re.search(rf"\b{re.escape(term)}\b", text))
|
|
|
|
|
|
def is_broad_issue(title: str, issue: str, data: dict | None = None) -> bool:
|
|
data = data or load_rules()
|
|
text = f"{title} {issue}".lower()
|
|
keywords = data.get("broad_triggers", {}).get("keywords") or []
|
|
return any(kw in text for kw in keywords)
|
|
|
|
|
|
def _rule_match_score(text: str, rule: dict) -> int:
|
|
"""Higher score = more specific match. Zero means no match."""
|
|
match = rule.get("match") or {}
|
|
keywords = match.get("keywords") or []
|
|
all_of = match.get("all_of") or []
|
|
|
|
if all_of and not all(_text_has_term(text, t) for t in all_of):
|
|
return 0
|
|
|
|
score = 0
|
|
for kw in keywords:
|
|
if _text_has_term(text, kw):
|
|
# Multi-word phrases are more specific than single tokens like "sip".
|
|
score += 2 if " " in kw.strip() else 1
|
|
|
|
if all_of:
|
|
score += len(all_of) * 2
|
|
|
|
if score == 0 and not all_of:
|
|
return 0
|
|
return score
|
|
|
|
|
|
def match_rule(title: str, issue: str, data: dict | None = None) -> MatchedRule | None:
|
|
data = data or load_rules()
|
|
text = f"{title} {issue}".lower()
|
|
|
|
if is_broad_issue(title, issue, data):
|
|
default = data.get("default", {})
|
|
return MatchedRule(
|
|
id="broad-health",
|
|
name="Full stack health check",
|
|
severity=default.get("severity", "medium"),
|
|
devices=default.get("devices", ["all"]),
|
|
order=[],
|
|
hints=list(default.get("hints") or ["Check all onboard devices layer by layer"]),
|
|
steps=[],
|
|
broad=True,
|
|
)
|
|
|
|
rules = data.get("rules") or []
|
|
best: dict | None = None
|
|
best_score = 0
|
|
best_priority = -1
|
|
|
|
for rule in rules:
|
|
score = _rule_match_score(text, rule)
|
|
if score <= 0:
|
|
continue
|
|
priority = rule.get("priority") or 0
|
|
if score > best_score or (score == best_score and priority > best_priority):
|
|
best = rule
|
|
best_score = score
|
|
best_priority = priority
|
|
|
|
if not best:
|
|
return None
|
|
|
|
return MatchedRule(
|
|
id=str(best.get("id") or "unnamed"),
|
|
name=str(best.get("name") or best.get("id") or "Rule"),
|
|
severity=str(best.get("severity") or "medium"),
|
|
devices=list(best.get("devices") or ["all"]),
|
|
order=list(best.get("order") or []),
|
|
hints=list(best.get("hints") or []),
|
|
steps=list(best.get("steps") or []),
|
|
)
|
|
|
|
|
|
def device_keyword_map(data: dict | None = None) -> dict[str, list[str]]:
|
|
data = data or load_rules()
|
|
return {k: list(v) for k, v in (data.get("device_keywords") or {}).items()}
|
|
|
|
|
|
def devices_mentioned_in_text(text: str, devices: list[dict], data: dict | None = None) -> list[dict]:
|
|
"""Return onboard devices whose catalog keywords appear in *text*."""
|
|
data = data or load_rules()
|
|
kw_map = device_keyword_map(data)
|
|
matched_devices: list[dict] = []
|
|
for device in devices:
|
|
key = (device.get("catalog_key") or device.get("device_type") or "").lower()
|
|
keywords = kw_map.get(key, [])
|
|
terms = set(keywords + [key, device.get("device_type", ""), device.get("name", "")])
|
|
terms = {t.lower() for t in terms if t}
|
|
if any(_text_has_term(text, term) for term in terms if len(term) > 2):
|
|
matched_devices.append(device)
|
|
return matched_devices
|
|
|
|
|
|
def select_devices_for_issue(
|
|
devices: list[dict],
|
|
issue: str,
|
|
*,
|
|
title: str = "",
|
|
) -> tuple[list[dict], MatchedRule | None]:
|
|
if not devices:
|
|
return [], None
|
|
|
|
data = load_rules()
|
|
matched = match_rule(title, issue, data)
|
|
|
|
text = f"{title} {issue}".lower()
|
|
|
|
if matched:
|
|
if "all" in matched.devices:
|
|
selected = list(devices)
|
|
else:
|
|
want = {d.lower() for d in matched.devices}
|
|
selected = [d for d in devices if (d.get("catalog_key") or "").lower() in want]
|
|
# Union devices explicitly named in the issue (e.g. "proxmox and pfsense").
|
|
if not matched.broad:
|
|
seen = {(d.get("catalog_key") or "").lower() for d in selected}
|
|
for device in devices_mentioned_in_text(text, devices, data):
|
|
key = (device.get("catalog_key") or "").lower()
|
|
if key not in seen:
|
|
selected.append(device)
|
|
seen.add(key)
|
|
|
|
if matched.order:
|
|
order_map = {k.lower(): i for i, k in enumerate(matched.order)}
|
|
selected.sort(
|
|
key=lambda d: order_map.get((d.get("catalog_key") or "").lower(), 999)
|
|
)
|
|
|
|
if selected:
|
|
return selected, matched
|
|
|
|
if is_broad_issue(title, issue, data):
|
|
return devices, matched
|
|
|
|
matched_devices = devices_mentioned_in_text(text, devices, data)
|
|
|
|
# Never fall back to all devices — only run what the issue mentions.
|
|
return matched_devices, matched
|
|
|
|
|
|
def rule_guidance_block(matched: MatchedRule | dict | None) -> str:
|
|
if not matched:
|
|
return ""
|
|
if isinstance(matched, dict):
|
|
name = matched.get("name", "")
|
|
rid = matched.get("id", "")
|
|
severity = matched.get("severity", "medium")
|
|
devices = matched.get("devices") or []
|
|
order = matched.get("order") or []
|
|
hints = matched.get("hints") or []
|
|
steps = matched.get("steps") or []
|
|
else:
|
|
name, rid, severity = matched.name, matched.id, matched.severity
|
|
devices, order, hints, steps = matched.devices, matched.order, matched.hints, matched.steps
|
|
|
|
lines = [
|
|
"\n\n--- Troubleshooting rule (standardized) ---",
|
|
f"Rule: {name} ({rid})",
|
|
f"Severity hint: {severity}",
|
|
]
|
|
if devices:
|
|
lines.append(f"Target devices: {', '.join(devices)}")
|
|
if order:
|
|
lines.append(f"Diagnose order: {' → '.join(order)}")
|
|
if hints:
|
|
lines.append("Hints:")
|
|
lines.extend(f"- {h}" for h in hints)
|
|
if steps:
|
|
lines.append("Suggested steps:")
|
|
lines.extend(f"- {s}" for s in steps)
|
|
return "\n".join(lines)
|