From 2ff4056d894e1eceb53ac0fbba807fcad641b08e Mon Sep 17 00:00:00 2001 From: Jessey van Offeren Date: Fri, 22 May 2026 13:19:11 +0200 Subject: [PATCH] =?UTF-8?q?feat(m14):=20AI=20assistant=20=E2=80=94=20expla?= =?UTF-8?q?in=20diagnostics,=20opt-in=20(Ollama=20or=20Claude)=20=E2=80=94?= =?UTF-8?q?=200.27.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New optional module (D24): explains the collected findings in plain language, contacted ONLY on an explicit user action (never automatic). - core/ai.py: provider chosen explicitly (no default) — ollama (local) or claude (Anthropic Messages API via stdlib urllib; key in keyring). Grounded prompt; HTTP error parsing; one-shot (no thinking/caching — snappy). - core/ai_knowledge.py: curated reference KB (Xid/SMART/Proton/tunables), exact keyword/code match ("RAG-lite", no embeddings) injected into the prompt — lifts local models, sharpens Claude. No fine-tuning. - config: ai_provider/ai_model/ai_endpoint + keyring-backed AI key (generalized the token keyring helpers). - GUI: Settings → AI assistant (provider radios, model/endpoint/key, Save/Test); "Explain with AI" button on the diagnostic dialog (consent prompt for cloud). - CLI: `rigdoctor ai status|test|explain`. - Docs: D24, SPEC/MODULES/ROADMAP (Phase 7); tests for providers/grounding/parse. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 13 ++ docs/DECISIONS.md | 17 ++- docs/MODULES.md | 11 ++ docs/ROADMAP.md | 8 ++ docs/SPEC.md | 10 ++ pyproject.toml | 2 +- src/rigdoctor/__init__.py | 2 +- src/rigdoctor/cli.py | 41 ++++++ src/rigdoctor/config.py | 108 +++++++++------ src/rigdoctor/core/ai.py | 173 +++++++++++++++++++++++++ src/rigdoctor/core/ai_knowledge.py | 79 +++++++++++ src/rigdoctor/gui/diagnostic_dialog.py | 67 +++++++++- src/rigdoctor/gui/setup_page.py | 112 ++++++++++++++++ tests/test_ai.py | 101 +++++++++++++++ 14 files changed, 703 insertions(+), 41 deletions(-) create mode 100644 src/rigdoctor/core/ai.py create mode 100644 src/rigdoctor/core/ai_knowledge.py create mode 100644 tests/test_ai.py diff --git a/CHANGELOG.md b/CHANGELOG.md index e5ec5f1..446906b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,19 @@ All notable changes to RigDoctor are recorded here. Format follows (`MAJOR.MINOR.PATCH`, pre-1.0). `__version__` and `pyproject.toml` must match the git release tag (so the auto-updater, D18, can compare versions). +## [0.27.0] - 2026-05-22 +### Added +- **AI assistant (M14, D24)** — optional, **strictly opt-in, never automatic**. Explains your + diagnostics in plain language only when you press **"Explain with AI"** on the diagnostic + dialog (or run `rigdoctor ai explain`). You choose a provider explicitly (no default): + **Ollama** (local, private, no key) or **Claude** (Anthropic; key stored in the keyring, with + a consent prompt before any data is sent). Configure in **Settings → AI assistant**. +- Answers are **grounded**: RigDoctor passes the actual findings plus matched reference facts + from a curated knowledge base (`core/ai_knowledge.py` — exact keyword/code match, no + embeddings, stdlib only), so even a small local model gets the domain facts it needs. Stdlib + `urllib` only — no new core dependency. Output is advisory (D9). +- CLI: `rigdoctor ai status|test|explain`. + ## [0.26.1] - 2026-05-22 ### Fixed - **Setup wizard contrast.** The **radio buttons** (Recording trigger) were unstyled, so the diff --git a/docs/DECISIONS.md b/docs/DECISIONS.md index b54147d..e1f3045 100644 --- a/docs/DECISIONS.md +++ b/docs/DECISIONS.md @@ -249,9 +249,24 @@ duplicated what the GUI already shows and added surface area. Concretely: (preserves fish/ls/git theming), full-screen-able, with the guest read-only unless the host ticks "Allow the guest to type" (the D9 consent exception). Account-gated by the Gitea token. +### D24 — AI assistant module (M14) — *DECIDED 2026-05-22; adds to D14* +A new optional module that **explains the collected diagnostics in plain language** (likely +root cause + suggested next steps). Adds M14 to the D14 set. +- **Strictly opt-in, never automatic.** The model is contacted **only** on an explicit user + action (an "Explain with AI" button / `rigdoctor ai explain`) — never on launch, after a + diagnostic, in the sample/record loop, or in the background. **Configuring** a provider does + not trigger any call. +- **Local-first.** Defaults to a local **Ollama** server (data never leaves the machine, no + key, stdlib `urllib`). An **OpenAI-compatible** endpoint (cloud or local) can be used with a + key (stored in the keyring like the update token). Cloud use shows a "this sends your data to + X" consent before the first call. +- **Grounded & advisory.** The prompt carries only the findings we collected; output is framed + as suggestions (consistent with D9 — it explains/recommends, applying fixes stays + consent-gated). No new runtime dependency (HTTP via stdlib). + ## Open -None currently — all tracked decisions (D1–D23) are resolved. New questions will be added +None currently — all tracked decisions (D1–D24) are resolved. New questions will be added here as they arise. Remaining detail to flesh out during build: the tray's supporting-action set (D13), per-module apt package names, M12's tunnel/token specifics, and M13's update mechanism (APT repo vs. self-installed `.deb`). diff --git a/docs/MODULES.md b/docs/MODULES.md index 76ad5b0..b018784 100644 --- a/docs/MODULES.md +++ b/docs/MODULES.md @@ -20,6 +20,7 @@ Status: ⬜ not started · 🟦 designing · 🟨 in progress · ✅ done | M9 | Installer | (meta) | none | all | P1 | 🟨 | | M12 | Session sharing (shared terminal) | Sharing | none (relay) | all | P3 | ✅ | | M13 | Auto-update | (core) | none (stdlib; user-local file swap) | all | P3 | ✅ | +| M14 | AI assistant (explain diagnostics) | (optional) | none (stdlib urllib; Ollama or Claude) | all | P3 | ✅ | | ~~M7~~ | ~~Stress / repro~~ | — | — | — | — | ❌ dropped (D7) | ## Notes per module @@ -117,6 +118,15 @@ Status: ⬜ not started · 🟦 designing · 🟨 in progress · ✅ done atomic symlink swap → restart, incl. the daemon). HTTPS-only, version-check-only (no telemetry), opt-out-able. Surfaced in the GUI; `rigdoctor update` in the CLI. (`.deb` users update via apt instead.) +- **M14 AI assistant** (D24) — optional, **strictly opt-in, never automatic**: explains the + collected diagnostics in plain language only when the user presses **"Explain with AI"** + (`core/ai.py`, GUI button on the diagnostic dialog, `rigdoctor ai explain`). The user picks a + provider explicitly (no default): **Ollama** (local, private, no key) or **Claude** (Anthropic + Messages API, key in the keyring; consent prompt before sending). Answers are **grounded** — + we pass the actual findings plus matched reference facts from a curated knowledge base + (`core/ai_knowledge.py`, "RAG-lite": exact keyword/code match, no embeddings, stdlib only), + which lifts a small local model and sharpens Claude. Stdlib `urllib` (no pip deps); output is + advisory (D9). Configure in **Settings → AI assistant**. ## Bundles (final — D14) - **Essential:** M1 + M3 + M4 *(the MVP, NVIDIA-only — D5)* @@ -124,6 +134,7 @@ Status: ⬜ not started · 🟦 designing · 🟨 in progress · ✅ done - **Diagnostics:** M5 + M6 - **Desktop UI:** M10 + M11 *(adds PySide6)* - **Sharing:** M12 *(session sharing / remote assist — D16)* +- **AI:** M14 *(optional AI explanations — D24)* ## MVP candidate — *confirmed (D5)* **M1 + M3 + M4 (Essential), NVIDIA-only, CLI-first.** Gives a working tool that captures the diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index 12b9891..702f8c5 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -89,6 +89,14 @@ Ubuntu + NVIDIA first; `.deb` distribution (see `DECISIONS.md`). - [removed] The read-only stats view (`share serve`) and bundle export — dropped per D23; the shared terminal is the only sharing mode. +## Phase 7 — AI assistant (M14, D24) +- [x] **Explain diagnostics with AI** — opt-in, never automatic (`core/ai.py`, "Explain with AI" + button + `rigdoctor ai explain`). Provider chosen explicitly: **Ollama** (local) or + **Claude** (Anthropic). Grounded with a curated reference KB (`core/ai_knowledge.py`, + RAG-lite, exact match — no embeddings); stdlib `urllib`. Settings → AI assistant. +- [ ] *Possible follow-ups:* interactive chat grounded in the data; more reference-KB entries; + an "Explain" button on the System Health page. + > **Out of scope:** stress/repro module (D7); multi-distro support and packaging beyond > Ubuntu/apt + `.deb` (D15) — a thin seam is kept but not built out. diff --git a/docs/SPEC.md b/docs/SPEC.md index 41b96f3..e192f0f 100644 --- a/docs/SPEC.md +++ b/docs/SPEC.md @@ -152,6 +152,16 @@ type too (e.g. a sudo password, which stays local and is never sent to B). Accou Gitea token; per-session share code. The shared terminal preserves colors/theming and can be viewed full-screen. *(The earlier read-only stats view / bundle export were dropped — D23.)* +### M14 — AI assistant (D24) +Optional module that explains the collected diagnostics in plain language. **Strictly opt-in and +never automatic** — the model is contacted only when the user presses "Explain with AI" (GUI) or +runs `rigdoctor ai explain`; configuring it contacts nothing. The user explicitly chooses a +provider (no default): **Ollama** (local, private, no key) or **Claude** (Anthropic Messages +API, key in the keyring, with a consent prompt before sending data). Answers are **grounded** in +the actual findings plus matched reference facts from a curated, exact-match knowledge base +("RAG-lite" — no embeddings/vector store, stdlib only); no fine-tuning. HTTP via stdlib `urllib` +(no new core dependency); output is advisory (consistent with D9). + ## 5. Non-functional requirements - **Zero hard deps for the core/CLI/daemon** — Python stdlib + tools already present. **Qt (PySide6) is required only by the GUI (M10) and tray (M11) modules**, declared in the diff --git a/pyproject.toml b/pyproject.toml index 0bcfd57..c9ed6e8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "rigdoctor" -version = "0.26.1" +version = "0.27.0" description = "Modular hardware monitoring & crash diagnostics for Linux gamers." readme = "README.md" requires-python = ">=3.11" diff --git a/src/rigdoctor/__init__.py b/src/rigdoctor/__init__.py index d3e6e54..461cf7c 100644 --- a/src/rigdoctor/__init__.py +++ b/src/rigdoctor/__init__.py @@ -1,3 +1,3 @@ """RigDoctor — modular hardware monitoring & crash diagnostics for Linux gamers.""" -__version__ = "0.26.1" +__version__ = "0.27.0" diff --git a/src/rigdoctor/cli.py b/src/rigdoctor/cli.py index 0a58fa3..ad7e7f3 100644 --- a/src/rigdoctor/cli.py +++ b/src/rigdoctor/cli.py @@ -438,6 +438,40 @@ def cmd_service(args) -> int: return 0 +def cmd_ai(args) -> int: + """AI assistant (M14) — opt-in; only contacts a provider on `test`/`explain`.""" + from .core import ai + + sub = args.ai_cmd or "status" + if sub == "status": + print(f"Provider: {ai.provider() or 'not configured'}") + if ai.provider(): + print(f" {ai.provider_label()}") + print(f" ready: {'yes' if ai.is_configured() else 'no'}") + else: + print(" Configure it in the GUI: Settings → AI assistant.") + return 0 + + if not ai.is_configured(): + print("AI is not configured. Set it up in the GUI (Settings → AI assistant).") + return 1 + + if sub == "test": + ok, msg = ai.explain("Connectivity test — reply exactly: RigDoctor AI is working.") + print(msg) + return 0 if ok else 1 + + # explain: gather the current health findings and ask the provider to explain them. + from .core import health + + findings = health.run_health_checks() + text = ai.format_findings(findings) + print(f"Asking {ai.provider_label()} to explain the current health findings…\n") + ok, msg = ai.explain(text) + print(msg) + return 0 if ok else 1 + + def cmd_gameenv(args) -> int: from dataclasses import asdict @@ -645,6 +679,13 @@ def build_parser() -> argparse.ArgumentParser: mode_p.add_argument("mode", choices=("manual", "always-on", "game-launch")) mode_p.set_defaults(func=cmd_service) svc_p.set_defaults(func=cmd_service, service_cmd=None) + + ai_p = sub.add_parser("ai", help="AI assistant (M14): explain diagnostics — opt-in, never automatic") + ai_sub = ai_p.add_subparsers(dest="ai_cmd") + ai_sub.add_parser("status", help="show the configured provider (contacts nothing)").set_defaults(func=cmd_ai) + ai_sub.add_parser("test", help="send a tiny probe to verify connectivity").set_defaults(func=cmd_ai) + ai_sub.add_parser("explain", help="explain the current health findings with AI").set_defaults(func=cmd_ai) + ai_p.set_defaults(func=cmd_ai, ai_cmd=None) return p diff --git a/src/rigdoctor/config.py b/src/rigdoctor/config.py index cac58ad..777e5f9 100644 --- a/src/rigdoctor/config.py +++ b/src/rigdoctor/config.py @@ -43,6 +43,11 @@ GAMES_FILE = STATE_DIR / "games.json" TOKEN_FILE = CONFIG_DIR / "token" _SECRET_ATTRS = ["application", "rigdoctor", "type", "update-token"] +# AI assistant (M14, D24) — API key for the Claude provider, stored in the keyring like the +# update token (Ollama is local and needs none). Separate keyring entry + file fallback. +AI_KEY_FILE = CONFIG_DIR / "ai-key" +_AI_SECRET_ATTRS = ["application", "rigdoctor", "type", "ai-key"] + def _secret_tool() -> str | None: return shutil.which("secret-tool") @@ -53,27 +58,27 @@ def keyring_available() -> bool: return _secret_tool() is not None -def _keyring_store(token: str) -> bool: +def _keyring_store(value: str, attrs: list[str], label: str) -> bool: tool = _secret_tool() if not tool: return False try: proc = subprocess.run( - [tool, "store", "--label", "RigDoctor update token", *_SECRET_ATTRS], - input=token, text=True, capture_output=True, timeout=20, + [tool, "store", "--label", label, *attrs], + input=value, text=True, capture_output=True, timeout=20, ) return proc.returncode == 0 except (subprocess.SubprocessError, OSError): return False -def _keyring_lookup() -> str | None: +def _keyring_lookup(attrs: list[str]) -> str | None: tool = _secret_tool() if not tool: return None try: proc = subprocess.run( - [tool, "lookup", *_SECRET_ATTRS], text=True, capture_output=True, timeout=20 + [tool, "lookup", *attrs], text=True, capture_output=True, timeout=20 ) if proc.returncode == 0 and proc.stdout.strip(): return proc.stdout.strip() @@ -82,54 +87,67 @@ def _keyring_lookup() -> str | None: return None -def _keyring_clear() -> None: +def _keyring_clear(attrs: list[str]) -> None: tool = _secret_tool() if not tool: return try: - subprocess.run([tool, "clear", *_SECRET_ATTRS], capture_output=True, timeout=20) + subprocess.run([tool, "clear", *attrs], capture_output=True, timeout=20) except (subprocess.SubprocessError, OSError): pass +def _load_secret(env_var: str | None, attrs: list[str], file: Path) -> str | None: + if env_var: + env = os.environ.get(env_var) + if env and env.strip(): + return env.strip() + from_keyring = _keyring_lookup(attrs) + if from_keyring: + return from_keyring + try: + value = file.read_text().strip() + return value or None + except OSError: + return None + + +def _save_secret(value: str, attrs: list[str], label: str, file: Path) -> None: + value = value.strip() + if _keyring_store(value, attrs, label): + try: # don't leave a plaintext copy once it's in the keyring + file.unlink() + except OSError: + pass + return + CONFIG_DIR.mkdir(parents=True, exist_ok=True) + file.write_text(value + "\n") + try: + file.chmod(0o600) + except OSError: + pass + + +def _clear_secret(attrs: list[str], file: Path) -> None: + _keyring_clear(attrs) + try: + file.unlink() + except OSError: + pass + + def load_token() -> str | None: """Token from $RIGDOCTOR_TOKEN, then the OS keyring, then a 0600 file.""" - env = os.environ.get("RIGDOCTOR_TOKEN") - if env and env.strip(): - return env.strip() - from_keyring = _keyring_lookup() - if from_keyring: - return from_keyring - try: - token = TOKEN_FILE.read_text().strip() - return token or None - except OSError: - return None + return _load_secret("RIGDOCTOR_TOKEN", _SECRET_ATTRS, TOKEN_FILE) def save_token(token: str) -> None: """Save to the OS keyring if possible (encrypted); else a 0600 file.""" - token = token.strip() - if _keyring_store(token): - try: # don't leave a plaintext copy once it's in the keyring - TOKEN_FILE.unlink() - except OSError: - pass - return - CONFIG_DIR.mkdir(parents=True, exist_ok=True) - TOKEN_FILE.write_text(token + "\n") - try: - TOKEN_FILE.chmod(0o600) - except OSError: - pass + _save_secret(token, _SECRET_ATTRS, "RigDoctor update token", TOKEN_FILE) def clear_token() -> None: - _keyring_clear() - try: - TOKEN_FILE.unlink() - except OSError: - pass + _clear_secret(_SECRET_ATTRS, TOKEN_FILE) def token_backend() -> str: @@ -137,12 +155,25 @@ def token_backend() -> str: env = os.environ.get("RIGDOCTOR_TOKEN") if env and env.strip(): return "env" - if _keyring_lookup() is not None: + if _keyring_lookup(_SECRET_ATTRS) is not None: return "keyring" if TOKEN_FILE.exists(): return "file" return "none" + +def load_ai_key() -> str | None: + """Claude API key from $RIGDOCTOR_AI_KEY, then the OS keyring, then a 0600 file (M14).""" + return _load_secret("RIGDOCTOR_AI_KEY", _AI_SECRET_ATTRS, AI_KEY_FILE) + + +def save_ai_key(key: str) -> None: + _save_secret(key, _AI_SECRET_ATTRS, "RigDoctor AI key", AI_KEY_FILE) + + +def clear_ai_key() -> None: + _clear_secret(_AI_SECRET_ATTRS, AI_KEY_FILE) + DEFAULTS: dict = { "interval": 1.0, # sampling interval in seconds (default ≤1 Hz — NFR) "log_max_bytes": 20_000_000, # rotate a log segment past this size @@ -156,6 +187,9 @@ DEFAULTS: dict = { "steam_libraries": [], # Steam library paths to scan for games (M6); empty = none picked yet "trigger_mode": "manual", # crash-logger trigger (D6): manual | always-on | game-launch "setup_done": False, # first-run GUI setup wizard completed (M9) + "ai_provider": "", # AI assistant (M14, D24): "" (unset) | "ollama" | "claude" + "ai_model": "", # model name (e.g. "llama3.1" for Ollama; blank = Claude default) + "ai_endpoint": "http://localhost:11434", # Ollama server base URL (Claude uses a fixed endpoint) } diff --git a/src/rigdoctor/core/ai.py b/src/rigdoctor/core/ai.py new file mode 100644 index 0000000..8fc931b --- /dev/null +++ b/src/rigdoctor/core/ai.py @@ -0,0 +1,173 @@ +"""AI assistant (M14, D24): explain the collected diagnostics in plain language. + +**Strictly opt-in and never automatic** — the model is contacted ONLY from a direct user +action ("Explain with AI" / ``rigdoctor ai explain``), never on launch, after a diagnostic, or +in any loop. Choosing/configuring a provider does not contact anything. The user must pick a +provider explicitly (there is no default). + +Two providers, both over stdlib ``urllib`` (no pip deps in core): + * **ollama** — a local server (data stays on the machine, no key). + * **claude** — the Anthropic Messages API (key in the keyring). + +Answers are *grounded*: we pass the actual findings plus matched reference facts +(:mod:`ai_knowledge`) and ask the model to reason over them. Output is advisory (D9). +""" + +from __future__ import annotations + +import json +import urllib.error +import urllib.request + +from .. import config +from . import ai_knowledge + +PROVIDERS = ("ollama", "claude") +OLLAMA_DEFAULT_ENDPOINT = "http://localhost:11434" +CLAUDE_ENDPOINT = "https://api.anthropic.com/v1/messages" +CLAUDE_DEFAULT_MODEL = "claude-opus-4-7" +CLAUDE_MAX_TOKENS = 2000 +ANTHROPIC_VERSION = "2023-06-01" + +SYSTEM_PROMPT = ( + "You are RigDoctor's hardware-diagnostics assistant for Linux gamers. You are given the " + "structured findings RigDoctor collected from this machine, and a set of reference facts. " + "Explain in plain language what the findings mean, identify the most likely root cause of " + "any problem, and give concrete, ordered next steps (exact commands where useful). Base " + "your reasoning ONLY on the findings and reference facts provided — do not invent readings, " + "hardware, or log lines. Be concise and practical. Present fixes as suggestions, and clearly " + "warn before any step that could cause data loss or instability." +) + + +def provider() -> str: + return config.load_config().get("ai_provider", "") + + +def model() -> str: + m = config.load_config().get("ai_model", "").strip() + if m: + return m + return CLAUDE_DEFAULT_MODEL if provider() == "claude" else "" + + +def endpoint() -> str: + ep = config.load_config().get("ai_endpoint", OLLAMA_DEFAULT_ENDPOINT).strip() + return ep or OLLAMA_DEFAULT_ENDPOINT + + +def is_local() -> bool: + return provider() == "ollama" + + +def is_configured() -> bool: + """Whether the chosen provider is ready (does NOT contact anything).""" + p = provider() + if p == "claude": + return bool(config.load_ai_key()) + if p == "ollama": + return bool(model()) # a model name is required; endpoint has a default + return False # no provider chosen + + +def provider_label() -> str: + p = provider() + if p == "claude": + return f"Claude ({model()})" + if p == "ollama": + return f"Ollama ({model() or '?'} @ {endpoint()})" + return "not configured" + + +def build_prompt(findings_text: str) -> str: + """The user-message content: matched reference facts + the collected findings.""" + facts = ai_knowledge.relevant(findings_text) + parts = [] + if facts: + parts.append("Reference facts (use these to interpret the findings):") + parts += [f"- {f}" for f in facts] + parts.append("") + parts.append("Collected findings:") + parts.append(findings_text.strip() or "(no findings provided)") + return "\n".join(parts) + + +def explain(findings_text: str, timeout: float = 120.0) -> tuple[bool, str]: + """Contact the configured provider to explain the findings. Returns (ok, text | error). + + The caller MUST be a direct user action (D24) — this never runs automatically. + """ + content = build_prompt(findings_text) + try: + if provider() == "claude": + return _claude(content, timeout) + if provider() == "ollama": + return _ollama(content, timeout) + return False, "No AI provider is configured (Settings → AI assistant)." + except urllib.error.HTTPError as exc: + return False, _http_error(exc) + except (urllib.error.URLError, OSError, TimeoutError) as exc: + return False, f"Couldn't reach the AI provider: {exc}" + except (ValueError, KeyError, IndexError) as exc: + return False, f"Unexpected response from the AI provider: {exc}" + + +def _post(url: str, payload: dict, headers: dict, timeout: float) -> dict: + req = urllib.request.Request( + url, data=json.dumps(payload).encode("utf-8"), + headers={"Content-Type": "application/json", **headers}, + ) + with urllib.request.urlopen(req, timeout=timeout) as resp: + return json.load(resp) + + +def _ollama(content: str, timeout: float) -> tuple[bool, str]: + if not model(): + return False, "No Ollama model is set (Settings → AI assistant)." + payload = {"model": model(), "system": SYSTEM_PROMPT, "prompt": content, "stream": False} + out = _post(endpoint().rstrip("/") + "/api/generate", payload, {}, timeout) + return True, (out.get("response") or "").strip() or "(the model returned an empty response)" + + +def _claude(content: str, timeout: float) -> tuple[bool, str]: + key = config.load_ai_key() + if not key: + return False, "No Claude API key is set (Settings → AI assistant)." + # One-shot call: no prompt caching (single request, short system prompt) and no thinking + # (keeps a button-press snappy). Sampling params are omitted (removed on current Opus). + payload = { + "model": model(), + "max_tokens": CLAUDE_MAX_TOKENS, + "system": SYSTEM_PROMPT, + "messages": [{"role": "user", "content": content}], + } + headers = {"x-api-key": key, "anthropic-version": ANTHROPIC_VERSION} + out = _post(CLAUDE_ENDPOINT, payload, headers, timeout) + text = "\n".join(b.get("text", "") for b in out.get("content", []) if b.get("type") == "text") + return True, text.strip() or "(the model returned no text)" + + +def _http_error(exc: urllib.error.HTTPError) -> str: + detail = "" + try: + body = exc.read().decode("utf-8", "replace") + detail = json.loads(body).get("error", {}).get("message", "") or "" + except (ValueError, OSError): + pass + hint = " — check your API key in Settings → AI assistant." if exc.code in (401, 403) else "" + return f"AI request failed (HTTP {exc.code}){hint}{(': ' + detail) if detail else ''}" + + +def format_findings(findings, header: str = "") -> str: + """Render M4 Finding objects (or similar) into the plain-text block we send the model.""" + lines = [header] if header else [] + for f in findings: + severity = str(getattr(f, "severity", "")).upper() + category = getattr(f, "category", "") + title = getattr(f, "title", "") + detail = getattr(f, "detail", "") + line = f"- [{severity}] {category}: {title}".rstrip() + if detail: + line += f" — {detail}" + lines.append(line) + return "\n".join(lines) if lines else "No findings." diff --git a/src/rigdoctor/core/ai_knowledge.py b/src/rigdoctor/core/ai_knowledge.py new file mode 100644 index 0000000..dc302f5 --- /dev/null +++ b/src/rigdoctor/core/ai_knowledge.py @@ -0,0 +1,79 @@ +"""Curated reference knowledge for the AI assistant (M14, D24) — "RAG-lite". + +A small, hand-written set of domain facts (Xid codes, SMART attributes, common Linux-gaming +error signatures, tunable meanings). At explain-time we select the entries whose triggers +appear in the collected findings and inject them into the prompt, so even a small local model +gets the relevant facts instead of having to recall them. Provider-agnostic — it sharpens +Claude too. + +Retrieval is exact keyword/substring matching, not embeddings: the keys here (``Xid 79``, +``SMART 197``, ``fallen off the bus``) are precise, so a vector store would be overkill and +would break the stdlib-only rule. Each entry is ``(triggers, fact)``; a trigger matches +case-insensitively against the findings text. +""" + +from __future__ import annotations + +# (triggers, fact). Keep facts short, factual, and cause-oriented — they go into the prompt. +ENTRIES: list[tuple[tuple[str, ...], str]] = [ + (("xid 79", "fallen off the bus", "gpu has fallen"), + "NVIDIA Xid 79 / 'GPU has fallen off the bus' = the driver lost PCIe contact with the GPU " + "mid-operation. Usual causes, in order: insufficient/unstable PSU power or a bad power " + "cable, an unstable overclock/undervolt, PCIe link or riser issues, or overheating. Often " + "fatal to the session (hard freeze)."), + (("xid 13", "graphics engine exception"), + "NVIDIA Xid 13 = graphics engine exception, frequently an unstable GPU overclock or a " + "faulty application shader; revert any OC/UV and test."), + (("xid 31", "fifo: mmu fault", "mmu fault"), + "NVIDIA Xid 31 = MMU fault (illegal memory access by the app/driver) — often a game/driver " + "bug or unstable VRAM overclock."), + (("xid 8", "xid 62", "xid 63", "xid 64"), + "These Xid codes commonly indicate VRAM/ECC or memory-training problems — suspect failing " + "VRAM or an unstable memory overclock."), + (("smart 197", "current_pending_sector", "pending sector"), + "SMART 197 (Current Pending Sector) > 0 = sectors the drive can't read and is waiting to " + "reallocate — early sign of a failing disk. Back up now and run an extended self-test."), + (("smart 198", "offline_uncorrectable", "uncorrectable"), + "SMART 198 (Offline Uncorrectable) > 0 = sectors that failed to read/write — the drive is " + "degrading; back up immediately."), + (("smart 5", "reallocated_sector", "reallocated sector"), + "SMART 5 (Reallocated Sectors) climbing over time = the drive is using spares for bad " + "sectors; a rising count predicts failure."), + (("media and data integrity errors", "percentage used", "available spare"), + "NVMe health: 'Media and Data Integrity Errors' > 0 is concerning; 'Percentage Used' near " + "or over 100% and 'Available Spare' below the threshold mean the SSD is near end-of-life."), + (("thermal throttling", "throttle", "tjmax", "package id 0"), + "Sustained CPU/GPU temperatures at the thermal limit cause throttling (clocks drop to shed " + "heat) — check cooling, fan curves, paste, and case airflow."), + (("oom", "out of memory", "oom-killer", "killed process"), + "The kernel OOM-killer terminates processes when RAM (and swap) are exhausted — a freeze " + "or a game crashing to desktop under memory pressure points here; check swap and " + "vm.swappiness, and watch for a memory leak."), + (("segfault", "general protection fault", "segmentation fault"), + "A segfault/GP-fault is a process accessing invalid memory — for games under Proton it's " + "often a Proton/Wine or anticheat incompatibility, or unstable RAM (run memtest)."), + (("proton", "wine", "d3d", "vkd3d", "dxvk"), + "Proton/Wine issues: mismatched Proton version, missing vkd3d/DXVK, or shader-cache " + "corruption are common. Try a known-good Proton version and clear the shader cache."), + (("pcie_aspm", "aspm"), + "PCIe ASPM (Active State Power Management) can cause GPU/NVMe instability on some boards; " + "setting pcie_aspm=off is a common stability fix at a small idle-power cost."), + (("cpu_governor", "powersave", "schedutil", "performance governor"), + "The CPU frequency governor sets the clock policy; 'performance' avoids latency spikes from " + "ramp-up at a higher power draw, while 'powersave'/'schedutil' favor efficiency."), + (("nvidia persistence", "persistence mode"), + "NVIDIA persistence mode keeps the driver loaded when no app is using the GPU, avoiding " + "re-init stalls — harmless to enable."), +] + + +def relevant(findings_text: str, limit: int = 8) -> list[str]: + """Reference facts whose triggers appear in the findings text (case-insensitive).""" + haystack = findings_text.lower() + hits: list[str] = [] + for triggers, fact in ENTRIES: + if any(t in haystack for t in triggers): + hits.append(fact) + if len(hits) >= limit: + break + return hits diff --git a/src/rigdoctor/gui/diagnostic_dialog.py b/src/rigdoctor/gui/diagnostic_dialog.py index 6347d38..a806110 100644 --- a/src/rigdoctor/gui/diagnostic_dialog.py +++ b/src/rigdoctor/gui/diagnostic_dialog.py @@ -2,15 +2,19 @@ from __future__ import annotations -from PySide6.QtCore import Qt +import threading + +from PySide6.QtCore import Qt, Signal from PySide6.QtGui import QFont from PySide6.QtWidgets import ( QDialog, QFrame, QHBoxLayout, QLabel, + QMessageBox, QPushButton, QScrollArea, + QTextEdit, QVBoxLayout, QWidget, ) @@ -20,8 +24,12 @@ from .widgets import finding_card class DiagnosticDialog(QDialog): + _explained = Signal(object) # (ok, text) from a user-triggered AI explanation + def __init__(self, result, parent=None) -> None: super().__init__(parent) + self._result = result + self._explained.connect(self._on_explained) self.setWindowTitle(f"Diagnostic — {result.game}" if result.game else "Diagnostic") self.resize(660, 680) @@ -73,9 +81,66 @@ class DiagnosticDialog(QDialog): root.addWidget(scroll, 1) buttons = QHBoxLayout() + self._explain_btn = QPushButton("Explain with AI") + self._explain_btn.clicked.connect(self._explain_with_ai) + from ..core import ai + self._explain_btn.setVisible(ai.is_configured()) # opt-in only; hidden if not set up + buttons.addWidget(self._explain_btn) buttons.addStretch(1) close = QPushButton("Close") close.setObjectName("PrimaryButton") close.clicked.connect(self.accept) buttons.addWidget(close) root.addLayout(buttons) + + # --- AI explanation (M14, D24) — runs only on this button press ---------------- + def _explain_with_ai(self) -> None: + from ..core import ai + + if not ai.is_local(): # cloud provider → explicit consent before sending data + confirm = QMessageBox.question( + self, "Send to AI provider", + f"This sends your diagnostic findings to {ai.provider_label()}.\n\nContinue?", + QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No, + QMessageBox.StandardButton.No, + ) + if confirm != QMessageBox.StandardButton.Yes: + return + self._explain_btn.setEnabled(False) + self._explain_btn.setText("Asking the AI…") + threading.Thread(target=self._work_explain, daemon=True).start() + + def _work_explain(self) -> None: + from ..core import ai + + text = ai.format_findings(self._result.findings, header="Diagnostic findings:") + text += "\n\nCapture summary:\n" + render_summary(self._result.summary) + self._explained.emit(ai.explain(text)) + + def _on_explained(self, result) -> None: + ok, text = result + self._explain_btn.setEnabled(True) + self._explain_btn.setText("Explain with AI") + self._show_explanation(text if ok else f"AI explanation failed:\n\n{text}") + + def _show_explanation(self, text: str) -> None: + from ..core import ai + + dlg = QDialog(self) + dlg.setWindowTitle(f"AI explanation — {ai.provider_label()}") + dlg.resize(620, 520) + lay = QVBoxLayout(dlg) + view = QTextEdit() + view.setObjectName("Report") + view.setReadOnly(True) + view.setPlainText(text) + lay.addWidget(view) + note = QLabel("AI-generated suggestions — verify before acting, especially anything that changes settings or data.") + note.setObjectName("Muted") + note.setWordWrap(True) + lay.addWidget(note) + close = QPushButton("Close") + close.setObjectName("PrimaryButton") + close.clicked.connect(dlg.accept) + lay.addWidget(close, alignment=Qt.AlignmentFlag.AlignRight) + dlg.exec() diff --git a/src/rigdoctor/gui/setup_page.py b/src/rigdoctor/gui/setup_page.py index 61eeb72..9e19cc7 100644 --- a/src/rigdoctor/gui/setup_page.py +++ b/src/rigdoctor/gui/setup_page.py @@ -8,6 +8,7 @@ from PySide6.QtCore import Qt, QUrl, Signal from PySide6.QtGui import QDesktopServices from PySide6.QtWidgets import ( QApplication, + QButtonGroup, QCheckBox, QComboBox, QDoubleSpinBox, @@ -18,6 +19,7 @@ from PySide6.QtWidgets import ( QLineEdit, QMessageBox, QPushButton, + QRadioButton, QSizePolicy, QTextEdit, QVBoxLayout, @@ -54,6 +56,7 @@ class SetupPage(QWidget): _installed = Signal(int, str) _upd_state = Signal(object) _mode_applied = Signal(object) # (mode, ok, message) from a trigger-mode change + _ai_tested = Signal(object) # (ok, message) from an AI connectivity test changed = Signal() # alert settings saved — main window re-applies them live def __init__(self) -> None: @@ -62,6 +65,7 @@ class SetupPage(QWidget): self._installed.connect(self._on_installed) self._upd_state.connect(self._on_upd_state) self._mode_applied.connect(self._on_mode_applied) + self._ai_tested.connect(self._on_ai_tested) root = QVBoxLayout(self) root.setContentsMargins(20, 18, 20, 18) @@ -158,6 +162,58 @@ class SetupPage(QWidget): self._trigger_status.setText("systemd --user isn't available on this system.") root.addWidget(trig_card) + # AI assistant (M14, D24): explain diagnostics. Strictly opt-in — the model is only + # contacted when the user presses "Explain with AI"; this panel just configures it. + ai_card, ai_layout = _panel("AI assistant") + ai_desc = QLabel( + "Optionally let an AI explain your diagnostics in plain language. It runs only " + "when you press “Explain with AI” — never automatically. Choose a provider:\n" + "• Ollama — a local model on your machine (private, no key; needs Ollama running).\n" + "• Claude — Anthropic's API (higher quality; sends findings to Anthropic; needs a key)." + ) + ai_desc.setObjectName("Muted") + ai_desc.setWordWrap(True) + ai_layout.addWidget(ai_desc) + + prov_row = QHBoxLayout() + self._ai_group = QButtonGroup(self) + self._ai_ollama = QRadioButton("Ollama (local)") + self._ai_claude = QRadioButton("Claude (Anthropic)") + self._ai_group.addButton(self._ai_ollama) + self._ai_group.addButton(self._ai_claude) + self._ai_ollama.toggled.connect(self._on_ai_provider_changed) + prov_row.addWidget(self._ai_ollama) + prov_row.addWidget(self._ai_claude) + prov_row.addStretch(1) + ai_layout.addLayout(prov_row) + + self._ai_model = QLineEdit() + self._ai_model.setPlaceholderText("Model (e.g. llama3.1 for Ollama; blank = Claude default)") + ai_layout.addWidget(self._ai_model) + self._ai_endpoint = QLineEdit() + self._ai_endpoint.setPlaceholderText("Ollama server URL (default http://localhost:11434)") + ai_layout.addWidget(self._ai_endpoint) + self._ai_key = QLineEdit() + self._ai_key.setEchoMode(QLineEdit.EchoMode.Password) + self._ai_key.setPlaceholderText("Claude API key (stored in your keyring)") + ai_layout.addWidget(self._ai_key) + + ai_btn_row = QHBoxLayout() + ai_save = QPushButton("Save") + ai_save.setObjectName("PrimaryButton") + ai_save.clicked.connect(self._save_ai) + self._ai_test_btn = QPushButton("Test") + self._ai_test_btn.clicked.connect(self._test_ai) + ai_btn_row.addWidget(ai_save) + ai_btn_row.addWidget(self._ai_test_btn) + ai_btn_row.addStretch(1) + ai_layout.addLayout(ai_btn_row) + self._ai_status = QLabel("") + self._ai_status.setObjectName("Muted") + self._ai_status.setWordWrap(True) + ai_layout.addWidget(self._ai_status) + root.addWidget(ai_card) + # Account access (M13/M12): one Gitea token gates updates and session sharing. upd_card, upd_layout = _panel("Account access") hint = QLabel("A Gitea access token unlocks updates and session sharing. " @@ -203,8 +259,64 @@ class SetupPage(QWidget): self._refresh() self._load_alerts() self._trigger.setCurrentText(config.load_config().get("trigger_mode", "manual")) + self._load_ai() self._refresh_update_status() + # --- AI assistant (M14) --------------------------------------------------- + def _load_ai(self) -> None: + cfg = config.load_config() + prov = cfg.get("ai_provider", "") + self._ai_claude.setChecked(prov == "claude") + self._ai_ollama.setChecked(prov == "ollama") + self._ai_model.setText(cfg.get("ai_model", "")) + self._ai_endpoint.setText(cfg.get("ai_endpoint", "http://localhost:11434")) + if config.load_ai_key(): + self._ai_key.setPlaceholderText("Claude API key saved — type to replace") + self._on_ai_provider_changed() + + def _ai_provider(self) -> str: + if self._ai_claude.isChecked(): + return "claude" + if self._ai_ollama.isChecked(): + return "ollama" + return "" + + def _on_ai_provider_changed(self) -> None: + prov = self._ai_provider() + self._ai_endpoint.setVisible(prov == "ollama") + self._ai_key.setVisible(prov == "claude") + self._ai_test_btn.setEnabled(prov != "") + + def _save_ai(self) -> None: + prov = self._ai_provider() + config.update_config( + ai_provider=prov, + ai_model=self._ai_model.text().strip(), + ai_endpoint=self._ai_endpoint.text().strip() or "http://localhost:11434", + ) + if prov == "claude" and self._ai_key.text().strip(): + config.save_ai_key(self._ai_key.text().strip()) + self._ai_key.clear() + self._ai_key.setPlaceholderText("Claude API key saved — type to replace") + self._ai_status.setText("Saved." if prov else "Saved — no provider selected (AI stays off).") + + def _test_ai(self) -> None: + self._save_ai() + self._ai_status.setText("Testing… contacting the provider.") + self._ai_test_btn.setEnabled(False) + threading.Thread(target=self._work_test_ai, daemon=True).start() + + def _work_test_ai(self) -> None: + from ..core import ai + + ok, msg = ai.explain("Connectivity test — reply exactly: RigDoctor AI is working.") + self._ai_tested.emit((ok, msg)) + + def _on_ai_tested(self, result) -> None: + ok, msg = result + self._ai_test_btn.setEnabled(True) + self._ai_status.setText(("✓ " if ok else "✗ ") + (msg[:200] if msg else "")) + def _run_wizard(self) -> None: from .setup_wizard import SetupWizard diff --git a/tests/test_ai.py b/tests/test_ai.py new file mode 100644 index 0000000..d0307d0 --- /dev/null +++ b/tests/test_ai.py @@ -0,0 +1,101 @@ +"""Tests for the M14 AI assistant: provider selection, grounding, parsing (no network).""" + +import unittest +from unittest import mock + +from rigdoctor.core import ai, ai_knowledge + + +class KnowledgeTests(unittest.TestCase): + def test_matches_xid_and_smart(self): + facts = ai_knowledge.relevant("Kernel: NVRM: Xid 79: GPU has fallen off the bus") + self.assertTrue(any("fallen off the bus" in f for f in facts)) + + def test_matches_smart_pending(self): + facts = ai_knowledge.relevant("SMART 197 Current_Pending_Sector = 8") + self.assertTrue(any("Pending Sector" in f for f in facts)) + + def test_no_match_returns_empty(self): + self.assertEqual(ai_knowledge.relevant("everything is fine"), []) + + +class ConfigStateTests(unittest.TestCase): + def _cfg(self, **over): + base = {"ai_provider": "", "ai_model": "", "ai_endpoint": "http://localhost:11434"} + base.update(over) + return base + + def test_unconfigured_by_default(self): + with mock.patch.object(ai.config, "load_config", return_value=self._cfg()): + self.assertFalse(ai.is_configured()) + + def test_ollama_needs_model(self): + with mock.patch.object(ai.config, "load_config", return_value=self._cfg(ai_provider="ollama")): + self.assertFalse(ai.is_configured()) + with mock.patch.object(ai.config, "load_config", + return_value=self._cfg(ai_provider="ollama", ai_model="llama3.1")): + self.assertTrue(ai.is_configured()) + + def test_claude_needs_key(self): + with mock.patch.object(ai.config, "load_config", return_value=self._cfg(ai_provider="claude")), \ + mock.patch.object(ai.config, "load_ai_key", return_value=None): + self.assertFalse(ai.is_configured()) + with mock.patch.object(ai.config, "load_config", return_value=self._cfg(ai_provider="claude")), \ + mock.patch.object(ai.config, "load_ai_key", return_value="sk-ant-x"): + self.assertTrue(ai.is_configured()) + + def test_claude_default_model(self): + with mock.patch.object(ai.config, "load_config", return_value=self._cfg(ai_provider="claude")): + self.assertEqual(ai.model(), ai.CLAUDE_DEFAULT_MODEL) + + +class PromptTests(unittest.TestCase): + def test_build_prompt_includes_facts_and_findings(self): + prompt = ai.build_prompt("Xid 79: GPU has fallen off the bus") + self.assertIn("Reference facts", prompt) + self.assertIn("Collected findings", prompt) + self.assertIn("fallen off the bus", prompt) + + def test_format_findings(self): + class F: + severity, category, title, detail = "warn", "GPU", "Hot", "92C" + text = ai.format_findings([F()]) + self.assertIn("[WARN] GPU: Hot — 92C", text) + + +class ExplainTests(unittest.TestCase): + def _cfg(self, **over): + base = {"ai_provider": "", "ai_model": "", "ai_endpoint": "http://localhost:11434"} + base.update(over) + return base + + def test_no_provider(self): + with mock.patch.object(ai.config, "load_config", return_value=self._cfg()): + ok, msg = ai.explain("x") + self.assertFalse(ok) + self.assertIn("No AI provider", msg) + + def test_ollama_parses_response(self): + with mock.patch.object(ai.config, "load_config", + return_value=self._cfg(ai_provider="ollama", ai_model="llama3.1")), \ + mock.patch.object(ai, "_post", return_value={"response": "It's the PSU."}) as post: + ok, msg = ai.explain("Xid 79") + self.assertTrue(ok) + self.assertEqual(msg, "It's the PSU.") + self.assertIn("/api/generate", post.call_args[0][0]) + + def test_claude_parses_content_blocks(self): + with mock.patch.object(ai.config, "load_config", return_value=self._cfg(ai_provider="claude")), \ + mock.patch.object(ai.config, "load_ai_key", return_value="sk-ant-x"), \ + mock.patch.object(ai, "_post", return_value={"content": [ + {"type": "text", "text": "Likely a failing disk."}]}) as post: + ok, msg = ai.explain("SMART 197") + self.assertTrue(ok) + self.assertEqual(msg, "Likely a failing disk.") + headers = post.call_args[0][2] + self.assertEqual(headers["anthropic-version"], ai.ANTHROPIC_VERSION) + self.assertEqual(headers["x-api-key"], "sk-ant-x") + + +if __name__ == "__main__": + unittest.main()