Compare commits

..

5 Commits

Author SHA1 Message Date
jessey a3caabc0d5 Merge pull request 'feat(ai): pre-fill qwen2.5:7b when Ollama is selected — 0.27.1' (#24) from feat/m14-ai into main
release / release (push) Successful in 14s
Reviewed-on: #24
2026-05-22 11:32:59 +00:00
jessey b59f202891 feat(ai): render Markdown + feed game/Proton/Steam logs to the AI — 0.28.0
1) The explanation popup rendered raw Markdown (### / **). Switched to
   QTextEdit.setMarkdown and told the model to answer in Markdown.
2) On "Explain with AI", also collect recent Proton (~/steam-*.log) and Steam
   console logs (core/gamelogs.py — tail-read, size-bounded) and include them in
   the prompt so the model can correlate log errors with findings and pinpoint
   when things went wrong. Reference-fact matching runs over the logs too.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 13:32:51 +02:00
jessey e6d94fbd59 feat(ai): pre-fill qwen2.5:7b when Ollama is selected — 0.27.1
Selecting the Ollama provider pre-fills the model field with the suggested
qwen2.5:7b (fits an 8 GB GPU at Q4; grounding makes a 7B sufficient). Won't
overwrite a model the user already typed. Constant ai.OLLAMA_SUGGESTED_MODEL.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 13:25:04 +02:00
jessey 045f40c4de Merge pull request 'feat(m14): AI assistant — explain diagnostics, opt-in (Ollama or Claude) — 0.27.0' (#23) from feat/m14-ai into main
release / release (push) Successful in 14s
Reviewed-on: #23
2026-05-22 11:19:30 +00:00
jessey 2ff4056d89 feat(m14): AI assistant — explain diagnostics, opt-in (Ollama or Claude) — 0.27.0
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) <noreply@anthropic.com>
2026-05-22 13:19:11 +02:00
16 changed files with 847 additions and 42 deletions
+29
View File
@@ -5,6 +5,35 @@ All notable changes to RigDoctor are recorded here. Format follows
(`MAJOR.MINOR.PATCH`, pre-1.0). `__version__` and `pyproject.toml` must match the git (`MAJOR.MINOR.PATCH`, pre-1.0). `__version__` and `pyproject.toml` must match the git
release tag (so the auto-updater, D18, can compare versions). release tag (so the auto-updater, D18, can compare versions).
## [0.28.0] - 2026-05-22
### Added
- **AI explanations now include recent game logs.** When you press "Explain with AI" on a
diagnostic, RigDoctor also gathers recent **Proton** (`~/steam-<appid>.log`) and **Steam**
console logs (`core/gamelogs.py`, tail-read + size-bounded) and passes them to the model, so
it can correlate log errors with the sensor findings and pinpoint *when* something went wrong.
### Fixed
- The AI explanation popup now **renders Markdown** (headings, bold, lists) instead of showing
raw `###`/`**``QTextEdit.setMarkdown`, and the model is told to answer in Markdown.
## [0.27.1] - 2026-05-22
### Changed
- AI assistant: selecting **Ollama** now pre-fills the model field with **`qwen2.5:7b`** (a
strong 7B that fits an 8 GB GPU; our grounding makes a 7B sufficient). It won't overwrite a
model you've already entered, and you can change it freely.
## [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 ## [0.26.1] - 2026-05-22
### Fixed ### Fixed
- **Setup wizard contrast.** The **radio buttons** (Recording trigger) were unstyled, so the - **Setup wizard contrast.** The **radio buttons** (Recording trigger) were unstyled, so the
+16 -1
View File
@@ -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 (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. 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 ## Open
None currently — all tracked decisions (D1D23) are resolved. New questions will be added None currently — all tracked decisions (D1D24) are resolved. New questions will be added
here as they arise. Remaining detail to flesh out during build: the tray's supporting-action 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 set (D13), per-module apt package names, M12's tunnel/token specifics, and M13's
update mechanism (APT repo vs. self-installed `.deb`). update mechanism (APT repo vs. self-installed `.deb`).
+11
View File
@@ -20,6 +20,7 @@ Status: ⬜ not started · 🟦 designing · 🟨 in progress · ✅ done
| M9 | Installer | (meta) | none | all | P1 | 🟨 | | M9 | Installer | (meta) | none | all | P1 | 🟨 |
| M12 | Session sharing (shared terminal) | Sharing | none (relay) | all | P3 | ✅ | | M12 | Session sharing (shared terminal) | Sharing | none (relay) | all | P3 | ✅ |
| M13 | Auto-update | (core) | none (stdlib; user-local file swap) | 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) | | ~~M7~~ | ~~Stress / repro~~ | — | — | — | — | ❌ dropped (D7) |
## Notes per module ## 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 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 telemetry), opt-out-able. Surfaced in the GUI; `rigdoctor update` in the CLI. (`.deb` users
update via apt instead.) 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) ## Bundles (final — D14)
- **Essential:** M1 + M3 + M4 *(the MVP, NVIDIA-only — D5)* - **Essential:** M1 + M3 + M4 *(the MVP, NVIDIA-only — D5)*
@@ -124,6 +134,7 @@ Status: ⬜ not started · 🟦 designing · 🟨 in progress · ✅ done
- **Diagnostics:** M5 + M6 - **Diagnostics:** M5 + M6
- **Desktop UI:** M10 + M11 *(adds PySide6)* - **Desktop UI:** M10 + M11 *(adds PySide6)*
- **Sharing:** M12 *(session sharing / remote assist — D16)* - **Sharing:** M12 *(session sharing / remote assist — D16)*
- **AI:** M14 *(optional AI explanations — D24)*
## MVP candidate — *confirmed (D5)* ## MVP candidate — *confirmed (D5)*
**M1 + M3 + M4 (Essential), NVIDIA-only, CLI-first.** Gives a working tool that captures the **M1 + M3 + M4 (Essential), NVIDIA-only, CLI-first.** Gives a working tool that captures the
+8
View File
@@ -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 - [removed] The read-only stats view (`share serve`) and bundle export — dropped per D23; the
shared terminal is the only sharing mode. 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 > **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. > Ubuntu/apt + `.deb` (D15) — a thin seam is kept but not built out.
+10
View File
@@ -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 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.)* 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 ## 5. Non-functional requirements
- **Zero hard deps for the core/CLI/daemon** — Python stdlib + tools already present. **Qt - **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 (PySide6) is required only by the GUI (M10) and tray (M11) modules**, declared in the
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "rigdoctor" name = "rigdoctor"
version = "0.26.1" version = "0.28.0"
description = "Modular hardware monitoring & crash diagnostics for Linux gamers." description = "Modular hardware monitoring & crash diagnostics for Linux gamers."
readme = "README.md" readme = "README.md"
requires-python = ">=3.11" requires-python = ">=3.11"
+1 -1
View File
@@ -1,3 +1,3 @@
"""RigDoctor — modular hardware monitoring & crash diagnostics for Linux gamers.""" """RigDoctor — modular hardware monitoring & crash diagnostics for Linux gamers."""
__version__ = "0.26.1" __version__ = "0.28.0"
+41
View File
@@ -438,6 +438,40 @@ def cmd_service(args) -> int:
return 0 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: def cmd_gameenv(args) -> int:
from dataclasses import asdict 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.add_argument("mode", choices=("manual", "always-on", "game-launch"))
mode_p.set_defaults(func=cmd_service) mode_p.set_defaults(func=cmd_service)
svc_p.set_defaults(func=cmd_service, service_cmd=None) 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 return p
+71 -37
View File
@@ -43,6 +43,11 @@ GAMES_FILE = STATE_DIR / "games.json"
TOKEN_FILE = CONFIG_DIR / "token" TOKEN_FILE = CONFIG_DIR / "token"
_SECRET_ATTRS = ["application", "rigdoctor", "type", "update-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: def _secret_tool() -> str | None:
return shutil.which("secret-tool") return shutil.which("secret-tool")
@@ -53,27 +58,27 @@ def keyring_available() -> bool:
return _secret_tool() is not None 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() tool = _secret_tool()
if not tool: if not tool:
return False return False
try: try:
proc = subprocess.run( proc = subprocess.run(
[tool, "store", "--label", "RigDoctor update token", *_SECRET_ATTRS], [tool, "store", "--label", label, *attrs],
input=token, text=True, capture_output=True, timeout=20, input=value, text=True, capture_output=True, timeout=20,
) )
return proc.returncode == 0 return proc.returncode == 0
except (subprocess.SubprocessError, OSError): except (subprocess.SubprocessError, OSError):
return False return False
def _keyring_lookup() -> str | None: def _keyring_lookup(attrs: list[str]) -> str | None:
tool = _secret_tool() tool = _secret_tool()
if not tool: if not tool:
return None return None
try: try:
proc = subprocess.run( 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(): if proc.returncode == 0 and proc.stdout.strip():
return proc.stdout.strip() return proc.stdout.strip()
@@ -82,54 +87,67 @@ def _keyring_lookup() -> str | None:
return None return None
def _keyring_clear() -> None: def _keyring_clear(attrs: list[str]) -> None:
tool = _secret_tool() tool = _secret_tool()
if not tool: if not tool:
return return
try: 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): except (subprocess.SubprocessError, OSError):
pass 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: def load_token() -> str | None:
"""Token from $RIGDOCTOR_TOKEN, then the OS keyring, then a 0600 file.""" """Token from $RIGDOCTOR_TOKEN, then the OS keyring, then a 0600 file."""
env = os.environ.get("RIGDOCTOR_TOKEN") return _load_secret("RIGDOCTOR_TOKEN", _SECRET_ATTRS, TOKEN_FILE)
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
def save_token(token: str) -> None: def save_token(token: str) -> None:
"""Save to the OS keyring if possible (encrypted); else a 0600 file.""" """Save to the OS keyring if possible (encrypted); else a 0600 file."""
token = token.strip() _save_secret(token, _SECRET_ATTRS, "RigDoctor update token", TOKEN_FILE)
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
def clear_token() -> None: def clear_token() -> None:
_keyring_clear() _clear_secret(_SECRET_ATTRS, TOKEN_FILE)
try:
TOKEN_FILE.unlink()
except OSError:
pass
def token_backend() -> str: def token_backend() -> str:
@@ -137,12 +155,25 @@ def token_backend() -> str:
env = os.environ.get("RIGDOCTOR_TOKEN") env = os.environ.get("RIGDOCTOR_TOKEN")
if env and env.strip(): if env and env.strip():
return "env" return "env"
if _keyring_lookup() is not None: if _keyring_lookup(_SECRET_ATTRS) is not None:
return "keyring" return "keyring"
if TOKEN_FILE.exists(): if TOKEN_FILE.exists():
return "file" return "file"
return "none" 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 = { DEFAULTS: dict = {
"interval": 1.0, # sampling interval in seconds (default ≤1 Hz — NFR) "interval": 1.0, # sampling interval in seconds (default ≤1 Hz — NFR)
"log_max_bytes": 20_000_000, # rotate a log segment past this size "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 "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 "trigger_mode": "manual", # crash-logger trigger (D6): manual | always-on | game-launch
"setup_done": False, # first-run GUI setup wizard completed (M9) "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)
} }
+178
View File
@@ -0,0 +1,178 @@
"""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"
# Suggested Ollama model — strong instruction-following that fits an 8 GB GPU at Q4. Because we
# ground the prompt with reference facts, a 7B model is sufficient here.
OLLAMA_SUGGESTED_MODEL = "qwen2.5:7b"
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 — which may include recent game, "
"Proton, and system log excerpts — plus a set of reference facts. Explain in plain language "
"what they mean, correlate any log errors with the findings to pinpoint WHEN and WHY things "
"went wrong, identify the most likely root cause, and give concrete, ordered next steps "
"(exact commands where useful). Base your reasoning ONLY on the data 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. Format your answer in Markdown."
)
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."
+79
View File
@@ -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
+67
View File
@@ -0,0 +1,67 @@
"""Collect recent game / Proton / Steam logs to enrich an AI diagnostic (M14).
Reads logs that already exist on disk no change to how the game is launched. Two reliable
sources: Proton's per-app log (``~/steam-<appid>.log``, written when ``PROTON_LOG=1``) and
Steam's own console log. Each is tail-read and size-bounded so the AI prompt stays small. The
text is fed to the AI alongside the findings so it can see *when* something went wrong (a
vkd3d/DXVK error, a crash line, the exit code) rather than only the sensor summary.
"""
from __future__ import annotations
import os
from pathlib import Path
# Steam keeps logs under its install root; ~/.steam/steam usually symlinks to the real one.
_STEAM_LOG_DIRS = ("~/.steam/steam/logs", "~/.local/share/Steam/logs", "~/.steam/root/logs")
_STEAM_LOG_FILES = ("console-linux.txt", "console_log.txt", "stderr.txt")
def _tail(path: Path, max_bytes: int) -> str:
"""Last ``max_bytes`` of a file, decoded leniently (empty string on error)."""
try:
size = path.stat().st_size
with path.open("rb") as fh:
if size > max_bytes:
fh.seek(size - max_bytes)
return fh.read().decode("utf-8", "replace")
except OSError:
return ""
def _proton_logs() -> list[Path]:
try:
logs = list(Path.home().glob("steam-*.log"))
except OSError:
return []
return sorted(logs, key=lambda p: p.stat().st_mtime, reverse=True)
def _steam_console() -> Path | None:
for directory in _STEAM_LOG_DIRS:
base = Path(os.path.expanduser(directory))
for name in _STEAM_LOG_FILES:
candidate = base / name
if candidate.exists():
return candidate
return None
def available() -> bool:
return bool(_proton_logs() or _steam_console())
def collect(max_bytes: int = 6000) -> str:
"""Recent Proton + Steam log tails as one labelled text block ('' if none)."""
sections: list[str] = []
protons = _proton_logs()
if protons:
tail = _tail(protons[0], max_bytes).strip()
if tail:
sections.append(f"--- Proton log ({protons[0].name}) ---\n{tail}")
console = _steam_console()
if console:
tail = _tail(console, max_bytes).strip()
if tail:
sections.append(f"--- Steam log ({console.name}) ---\n{tail}")
return "\n\n".join(sections)
+69 -1
View File
@@ -2,15 +2,19 @@
from __future__ import annotations from __future__ import annotations
from PySide6.QtCore import Qt import threading
from PySide6.QtCore import Qt, Signal
from PySide6.QtGui import QFont from PySide6.QtGui import QFont
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QDialog, QDialog,
QFrame, QFrame,
QHBoxLayout, QHBoxLayout,
QLabel, QLabel,
QMessageBox,
QPushButton, QPushButton,
QScrollArea, QScrollArea,
QTextEdit,
QVBoxLayout, QVBoxLayout,
QWidget, QWidget,
) )
@@ -20,8 +24,12 @@ from .widgets import finding_card
class DiagnosticDialog(QDialog): class DiagnosticDialog(QDialog):
_explained = Signal(object) # (ok, text) from a user-triggered AI explanation
def __init__(self, result, parent=None) -> None: def __init__(self, result, parent=None) -> None:
super().__init__(parent) super().__init__(parent)
self._result = result
self._explained.connect(self._on_explained)
self.setWindowTitle(f"Diagnostic — {result.game}" if result.game else "Diagnostic") self.setWindowTitle(f"Diagnostic — {result.game}" if result.game else "Diagnostic")
self.resize(660, 680) self.resize(660, 680)
@@ -73,9 +81,69 @@ class DiagnosticDialog(QDialog):
root.addWidget(scroll, 1) root.addWidget(scroll, 1)
buttons = QHBoxLayout() 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) buttons.addStretch(1)
close = QPushButton("Close") close = QPushButton("Close")
close.setObjectName("PrimaryButton") close.setObjectName("PrimaryButton")
close.clicked.connect(self.accept) close.clicked.connect(self.accept)
buttons.addWidget(close) buttons.addWidget(close)
root.addLayout(buttons) 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, gamelogs
text = ai.format_findings(self._result.findings, header="Diagnostic findings:")
text += "\n\nCapture summary:\n" + render_summary(self._result.summary)
logs = gamelogs.collect()
if logs:
text += "\n\nRecent game/Proton/Steam logs (newest at the end):\n" + logs
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.setMarkdown(text) # the model replies in Markdown — render it
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()
+116 -1
View File
@@ -8,6 +8,7 @@ from PySide6.QtCore import Qt, QUrl, Signal
from PySide6.QtGui import QDesktopServices from PySide6.QtGui import QDesktopServices
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QApplication, QApplication,
QButtonGroup,
QCheckBox, QCheckBox,
QComboBox, QComboBox,
QDoubleSpinBox, QDoubleSpinBox,
@@ -18,6 +19,7 @@ from PySide6.QtWidgets import (
QLineEdit, QLineEdit,
QMessageBox, QMessageBox,
QPushButton, QPushButton,
QRadioButton,
QSizePolicy, QSizePolicy,
QTextEdit, QTextEdit,
QVBoxLayout, QVBoxLayout,
@@ -25,7 +27,7 @@ from PySide6.QtWidgets import (
) )
from .. import config from .. import config
from ..core import alerts, installer, service, sysenv, uninstall, updates from ..core import ai, alerts, installer, service, sysenv, uninstall, updates
from .theme import GOOD, MUTED, WARN from .theme import GOOD, MUTED, WARN
@@ -54,6 +56,7 @@ class SetupPage(QWidget):
_installed = Signal(int, str) _installed = Signal(int, str)
_upd_state = Signal(object) _upd_state = Signal(object)
_mode_applied = Signal(object) # (mode, ok, message) from a trigger-mode change _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 changed = Signal() # alert settings saved — main window re-applies them live
def __init__(self) -> None: def __init__(self) -> None:
@@ -62,6 +65,7 @@ class SetupPage(QWidget):
self._installed.connect(self._on_installed) self._installed.connect(self._on_installed)
self._upd_state.connect(self._on_upd_state) self._upd_state.connect(self._on_upd_state)
self._mode_applied.connect(self._on_mode_applied) self._mode_applied.connect(self._on_mode_applied)
self._ai_tested.connect(self._on_ai_tested)
root = QVBoxLayout(self) root = QVBoxLayout(self)
root.setContentsMargins(20, 18, 20, 18) root.setContentsMargins(20, 18, 20, 18)
@@ -158,6 +162,59 @@ class SetupPage(QWidget):
self._trigger_status.setText("systemd --user isn't available on this system.") self._trigger_status.setText("systemd --user isn't available on this system.")
root.addWidget(trig_card) 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 <b>only</b> "
"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(
f"Model (e.g. {ai.OLLAMA_SUGGESTED_MODEL} 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. # Account access (M13/M12): one Gitea token gates updates and session sharing.
upd_card, upd_layout = _panel("Account access") upd_card, upd_layout = _panel("Account access")
hint = QLabel("A Gitea access token unlocks updates and session sharing. " hint = QLabel("A Gitea access token unlocks updates and session sharing. "
@@ -203,8 +260,66 @@ class SetupPage(QWidget):
self._refresh() self._refresh()
self._load_alerts() self._load_alerts()
self._trigger.setCurrentText(config.load_config().get("trigger_mode", "manual")) self._trigger.setCurrentText(config.load_config().get("trigger_mode", "manual"))
self._load_ai()
self._refresh_update_status() 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 != "")
if prov == "ollama" and not self._ai_model.text().strip():
self._ai_model.setText(ai.OLLAMA_SUGGESTED_MODEL) # suggested default; user can change
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: def _run_wizard(self) -> None:
from .setup_wizard import SetupWizard from .setup_wizard import SetupWizard
+101
View File
@@ -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()
+49
View File
@@ -0,0 +1,49 @@
"""Tests for M14 game/Proton/Steam log collection."""
import tempfile
import unittest
from pathlib import Path
from unittest import mock
from rigdoctor.core import gamelogs
class TailTests(unittest.TestCase):
def test_tail_returns_last_bytes(self):
path = Path(tempfile.mkdtemp()) / "x.log"
path.write_text("A" * 100 + "TAIL")
out = gamelogs._tail(path, 4)
self.assertEqual(out, "TAIL")
def test_tail_short_file(self):
path = Path(tempfile.mkdtemp()) / "x.log"
path.write_text("short")
self.assertEqual(gamelogs._tail(path, 9999), "short")
def test_tail_missing(self):
self.assertEqual(gamelogs._tail(Path("/nope/x.log"), 10), "")
class CollectTests(unittest.TestCase):
def test_collect_includes_proton_and_steam(self):
tmp = Path(tempfile.mkdtemp())
proton = tmp / "steam-570.log"
proton.write_text("err: vkd3d device lost")
console = tmp / "console-linux.txt"
console.write_text("Game removed AppID 570 ... exit")
with mock.patch.object(gamelogs, "_proton_logs", return_value=[proton]), \
mock.patch.object(gamelogs, "_steam_console", return_value=console):
out = gamelogs.collect()
self.assertIn("Proton log", out)
self.assertIn("vkd3d", out)
self.assertIn("Steam log", out)
self.assertIn("exit", out)
def test_collect_empty_when_none(self):
with mock.patch.object(gamelogs, "_proton_logs", return_value=[]), \
mock.patch.object(gamelogs, "_steam_console", return_value=None):
self.assertEqual(gamelogs.collect(), "")
if __name__ == "__main__":
unittest.main()