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>
This commit is contained in:
@@ -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
|
(`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.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
@@ -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 (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
|
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`).
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "rigdoctor"
|
name = "rigdoctor"
|
||||||
version = "0.26.1"
|
version = "0.27.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,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.27.0"
|
||||||
|
|||||||
@@ -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
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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."
|
||||||
@@ -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
|
||||||
@@ -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,66 @@ 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
|
||||||
|
|
||||||
|
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()
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -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,58 @@ 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("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.
|
# 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 +259,64 @@ 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 != "")
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
Reference in New Issue
Block a user