Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a3caabc0d5 | |||
| b59f202891 | |||
| e6d94fbd59 | |||
| 045f40c4de | |||
| 2ff4056d89 |
@@ -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
|
||||
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
|
||||
### Fixed
|
||||
- **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
|
||||
ticks "Allow the guest to type" (the D9 consent exception). Account-gated by the Gitea token.
|
||||
|
||||
### D24 — AI assistant module (M14) — *DECIDED 2026-05-22; adds to D14*
|
||||
A new optional module that **explains the collected diagnostics in plain language** (likely
|
||||
root cause + suggested next steps). Adds M14 to the D14 set.
|
||||
- **Strictly opt-in, never automatic.** The model is contacted **only** on an explicit user
|
||||
action (an "Explain with AI" button / `rigdoctor ai explain`) — never on launch, after a
|
||||
diagnostic, in the sample/record loop, or in the background. **Configuring** a provider does
|
||||
not trigger any call.
|
||||
- **Local-first.** Defaults to a local **Ollama** server (data never leaves the machine, no
|
||||
key, stdlib `urllib`). An **OpenAI-compatible** endpoint (cloud or local) can be used with a
|
||||
key (stored in the keyring like the update token). Cloud use shows a "this sends your data to
|
||||
X" consent before the first call.
|
||||
- **Grounded & advisory.** The prompt carries only the findings we collected; output is framed
|
||||
as suggestions (consistent with D9 — it explains/recommends, applying fixes stays
|
||||
consent-gated). No new runtime dependency (HTTP via stdlib).
|
||||
|
||||
## Open
|
||||
|
||||
None currently — all tracked decisions (D1–D23) are resolved. New questions will be added
|
||||
None currently — all tracked decisions (D1–D24) are resolved. New questions will be added
|
||||
here as they arise. Remaining detail to flesh out during build: the tray's supporting-action
|
||||
set (D13), per-module apt package names, M12's tunnel/token specifics, and M13's
|
||||
update mechanism (APT repo vs. self-installed `.deb`).
|
||||
|
||||
@@ -20,6 +20,7 @@ Status: ⬜ not started · 🟦 designing · 🟨 in progress · ✅ done
|
||||
| M9 | Installer | (meta) | none | all | P1 | 🟨 |
|
||||
| M12 | Session sharing (shared terminal) | Sharing | none (relay) | all | P3 | ✅ |
|
||||
| M13 | Auto-update | (core) | none (stdlib; user-local file swap) | all | P3 | ✅ |
|
||||
| M14 | AI assistant (explain diagnostics) | (optional) | none (stdlib urllib; Ollama or Claude) | all | P3 | ✅ |
|
||||
| ~~M7~~ | ~~Stress / repro~~ | — | — | — | — | ❌ dropped (D7) |
|
||||
|
||||
## Notes per module
|
||||
@@ -117,6 +118,15 @@ Status: ⬜ not started · 🟦 designing · 🟨 in progress · ✅ done
|
||||
atomic symlink swap → restart, incl. the daemon). HTTPS-only, version-check-only (no
|
||||
telemetry), opt-out-able. Surfaced in the GUI; `rigdoctor update` in the CLI. (`.deb` users
|
||||
update via apt instead.)
|
||||
- **M14 AI assistant** (D24) — optional, **strictly opt-in, never automatic**: explains the
|
||||
collected diagnostics in plain language only when the user presses **"Explain with AI"**
|
||||
(`core/ai.py`, GUI button on the diagnostic dialog, `rigdoctor ai explain`). The user picks a
|
||||
provider explicitly (no default): **Ollama** (local, private, no key) or **Claude** (Anthropic
|
||||
Messages API, key in the keyring; consent prompt before sending). Answers are **grounded** —
|
||||
we pass the actual findings plus matched reference facts from a curated knowledge base
|
||||
(`core/ai_knowledge.py`, "RAG-lite": exact keyword/code match, no embeddings, stdlib only),
|
||||
which lifts a small local model and sharpens Claude. Stdlib `urllib` (no pip deps); output is
|
||||
advisory (D9). Configure in **Settings → AI assistant**.
|
||||
|
||||
## Bundles (final — D14)
|
||||
- **Essential:** M1 + M3 + M4 *(the MVP, NVIDIA-only — D5)*
|
||||
@@ -124,6 +134,7 @@ Status: ⬜ not started · 🟦 designing · 🟨 in progress · ✅ done
|
||||
- **Diagnostics:** M5 + M6
|
||||
- **Desktop UI:** M10 + M11 *(adds PySide6)*
|
||||
- **Sharing:** M12 *(session sharing / remote assist — D16)*
|
||||
- **AI:** M14 *(optional AI explanations — D24)*
|
||||
|
||||
## MVP candidate — *confirmed (D5)*
|
||||
**M1 + M3 + M4 (Essential), NVIDIA-only, CLI-first.** Gives a working tool that captures the
|
||||
|
||||
@@ -89,6 +89,14 @@ Ubuntu + NVIDIA first; `.deb` distribution (see `DECISIONS.md`).
|
||||
- [removed] The read-only stats view (`share serve`) and bundle export — dropped per D23; the
|
||||
shared terminal is the only sharing mode.
|
||||
|
||||
## Phase 7 — AI assistant (M14, D24)
|
||||
- [x] **Explain diagnostics with AI** — opt-in, never automatic (`core/ai.py`, "Explain with AI"
|
||||
button + `rigdoctor ai explain`). Provider chosen explicitly: **Ollama** (local) or
|
||||
**Claude** (Anthropic). Grounded with a curated reference KB (`core/ai_knowledge.py`,
|
||||
RAG-lite, exact match — no embeddings); stdlib `urllib`. Settings → AI assistant.
|
||||
- [ ] *Possible follow-ups:* interactive chat grounded in the data; more reference-KB entries;
|
||||
an "Explain" button on the System Health page.
|
||||
|
||||
> **Out of scope:** stress/repro module (D7); multi-distro support and packaging beyond
|
||||
> Ubuntu/apt + `.deb` (D15) — a thin seam is kept but not built out.
|
||||
|
||||
|
||||
@@ -152,6 +152,16 @@ type too (e.g. a sudo password, which stays local and is never sent to B). Accou
|
||||
Gitea token; per-session share code. The shared terminal preserves colors/theming and can be
|
||||
viewed full-screen. *(The earlier read-only stats view / bundle export were dropped — D23.)*
|
||||
|
||||
### M14 — AI assistant (D24)
|
||||
Optional module that explains the collected diagnostics in plain language. **Strictly opt-in and
|
||||
never automatic** — the model is contacted only when the user presses "Explain with AI" (GUI) or
|
||||
runs `rigdoctor ai explain`; configuring it contacts nothing. The user explicitly chooses a
|
||||
provider (no default): **Ollama** (local, private, no key) or **Claude** (Anthropic Messages
|
||||
API, key in the keyring, with a consent prompt before sending data). Answers are **grounded** in
|
||||
the actual findings plus matched reference facts from a curated, exact-match knowledge base
|
||||
("RAG-lite" — no embeddings/vector store, stdlib only); no fine-tuning. HTTP via stdlib `urllib`
|
||||
(no new core dependency); output is advisory (consistent with D9).
|
||||
|
||||
## 5. Non-functional requirements
|
||||
- **Zero hard deps for the core/CLI/daemon** — Python stdlib + tools already present. **Qt
|
||||
(PySide6) is required only by the GUI (M10) and tray (M11) modules**, declared in the
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "rigdoctor"
|
||||
version = "0.26.1"
|
||||
version = "0.28.0"
|
||||
description = "Modular hardware monitoring & crash diagnostics for Linux gamers."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
"""RigDoctor — modular hardware monitoring & crash diagnostics for Linux gamers."""
|
||||
|
||||
__version__ = "0.26.1"
|
||||
__version__ = "0.28.0"
|
||||
|
||||
@@ -438,6 +438,40 @@ def cmd_service(args) -> int:
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_ai(args) -> int:
|
||||
"""AI assistant (M14) — opt-in; only contacts a provider on `test`/`explain`."""
|
||||
from .core import ai
|
||||
|
||||
sub = args.ai_cmd or "status"
|
||||
if sub == "status":
|
||||
print(f"Provider: {ai.provider() or 'not configured'}")
|
||||
if ai.provider():
|
||||
print(f" {ai.provider_label()}")
|
||||
print(f" ready: {'yes' if ai.is_configured() else 'no'}")
|
||||
else:
|
||||
print(" Configure it in the GUI: Settings → AI assistant.")
|
||||
return 0
|
||||
|
||||
if not ai.is_configured():
|
||||
print("AI is not configured. Set it up in the GUI (Settings → AI assistant).")
|
||||
return 1
|
||||
|
||||
if sub == "test":
|
||||
ok, msg = ai.explain("Connectivity test — reply exactly: RigDoctor AI is working.")
|
||||
print(msg)
|
||||
return 0 if ok else 1
|
||||
|
||||
# explain: gather the current health findings and ask the provider to explain them.
|
||||
from .core import health
|
||||
|
||||
findings = health.run_health_checks()
|
||||
text = ai.format_findings(findings)
|
||||
print(f"Asking {ai.provider_label()} to explain the current health findings…\n")
|
||||
ok, msg = ai.explain(text)
|
||||
print(msg)
|
||||
return 0 if ok else 1
|
||||
|
||||
|
||||
def cmd_gameenv(args) -> int:
|
||||
from dataclasses import asdict
|
||||
|
||||
@@ -645,6 +679,13 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
mode_p.add_argument("mode", choices=("manual", "always-on", "game-launch"))
|
||||
mode_p.set_defaults(func=cmd_service)
|
||||
svc_p.set_defaults(func=cmd_service, service_cmd=None)
|
||||
|
||||
ai_p = sub.add_parser("ai", help="AI assistant (M14): explain diagnostics — opt-in, never automatic")
|
||||
ai_sub = ai_p.add_subparsers(dest="ai_cmd")
|
||||
ai_sub.add_parser("status", help="show the configured provider (contacts nothing)").set_defaults(func=cmd_ai)
|
||||
ai_sub.add_parser("test", help="send a tiny probe to verify connectivity").set_defaults(func=cmd_ai)
|
||||
ai_sub.add_parser("explain", help="explain the current health findings with AI").set_defaults(func=cmd_ai)
|
||||
ai_p.set_defaults(func=cmd_ai, ai_cmd=None)
|
||||
return p
|
||||
|
||||
|
||||
|
||||
+71
-37
@@ -43,6 +43,11 @@ GAMES_FILE = STATE_DIR / "games.json"
|
||||
TOKEN_FILE = CONFIG_DIR / "token"
|
||||
_SECRET_ATTRS = ["application", "rigdoctor", "type", "update-token"]
|
||||
|
||||
# AI assistant (M14, D24) — API key for the Claude provider, stored in the keyring like the
|
||||
# update token (Ollama is local and needs none). Separate keyring entry + file fallback.
|
||||
AI_KEY_FILE = CONFIG_DIR / "ai-key"
|
||||
_AI_SECRET_ATTRS = ["application", "rigdoctor", "type", "ai-key"]
|
||||
|
||||
|
||||
def _secret_tool() -> str | None:
|
||||
return shutil.which("secret-tool")
|
||||
@@ -53,27 +58,27 @@ def keyring_available() -> bool:
|
||||
return _secret_tool() is not None
|
||||
|
||||
|
||||
def _keyring_store(token: str) -> bool:
|
||||
def _keyring_store(value: str, attrs: list[str], label: str) -> bool:
|
||||
tool = _secret_tool()
|
||||
if not tool:
|
||||
return False
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
[tool, "store", "--label", "RigDoctor update token", *_SECRET_ATTRS],
|
||||
input=token, text=True, capture_output=True, timeout=20,
|
||||
[tool, "store", "--label", label, *attrs],
|
||||
input=value, text=True, capture_output=True, timeout=20,
|
||||
)
|
||||
return proc.returncode == 0
|
||||
except (subprocess.SubprocessError, OSError):
|
||||
return False
|
||||
|
||||
|
||||
def _keyring_lookup() -> str | None:
|
||||
def _keyring_lookup(attrs: list[str]) -> str | None:
|
||||
tool = _secret_tool()
|
||||
if not tool:
|
||||
return None
|
||||
try:
|
||||
proc = subprocess.run(
|
||||
[tool, "lookup", *_SECRET_ATTRS], text=True, capture_output=True, timeout=20
|
||||
[tool, "lookup", *attrs], text=True, capture_output=True, timeout=20
|
||||
)
|
||||
if proc.returncode == 0 and proc.stdout.strip():
|
||||
return proc.stdout.strip()
|
||||
@@ -82,54 +87,67 @@ def _keyring_lookup() -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
def _keyring_clear() -> None:
|
||||
def _keyring_clear(attrs: list[str]) -> None:
|
||||
tool = _secret_tool()
|
||||
if not tool:
|
||||
return
|
||||
try:
|
||||
subprocess.run([tool, "clear", *_SECRET_ATTRS], capture_output=True, timeout=20)
|
||||
subprocess.run([tool, "clear", *attrs], capture_output=True, timeout=20)
|
||||
except (subprocess.SubprocessError, OSError):
|
||||
pass
|
||||
|
||||
|
||||
def _load_secret(env_var: str | None, attrs: list[str], file: Path) -> str | None:
|
||||
if env_var:
|
||||
env = os.environ.get(env_var)
|
||||
if env and env.strip():
|
||||
return env.strip()
|
||||
from_keyring = _keyring_lookup(attrs)
|
||||
if from_keyring:
|
||||
return from_keyring
|
||||
try:
|
||||
value = file.read_text().strip()
|
||||
return value or None
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def _save_secret(value: str, attrs: list[str], label: str, file: Path) -> None:
|
||||
value = value.strip()
|
||||
if _keyring_store(value, attrs, label):
|
||||
try: # don't leave a plaintext copy once it's in the keyring
|
||||
file.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
return
|
||||
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
file.write_text(value + "\n")
|
||||
try:
|
||||
file.chmod(0o600)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _clear_secret(attrs: list[str], file: Path) -> None:
|
||||
_keyring_clear(attrs)
|
||||
try:
|
||||
file.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def load_token() -> str | None:
|
||||
"""Token from $RIGDOCTOR_TOKEN, then the OS keyring, then a 0600 file."""
|
||||
env = os.environ.get("RIGDOCTOR_TOKEN")
|
||||
if env and env.strip():
|
||||
return env.strip()
|
||||
from_keyring = _keyring_lookup()
|
||||
if from_keyring:
|
||||
return from_keyring
|
||||
try:
|
||||
token = TOKEN_FILE.read_text().strip()
|
||||
return token or None
|
||||
except OSError:
|
||||
return None
|
||||
return _load_secret("RIGDOCTOR_TOKEN", _SECRET_ATTRS, TOKEN_FILE)
|
||||
|
||||
|
||||
def save_token(token: str) -> None:
|
||||
"""Save to the OS keyring if possible (encrypted); else a 0600 file."""
|
||||
token = token.strip()
|
||||
if _keyring_store(token):
|
||||
try: # don't leave a plaintext copy once it's in the keyring
|
||||
TOKEN_FILE.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
return
|
||||
CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
TOKEN_FILE.write_text(token + "\n")
|
||||
try:
|
||||
TOKEN_FILE.chmod(0o600)
|
||||
except OSError:
|
||||
pass
|
||||
_save_secret(token, _SECRET_ATTRS, "RigDoctor update token", TOKEN_FILE)
|
||||
|
||||
|
||||
def clear_token() -> None:
|
||||
_keyring_clear()
|
||||
try:
|
||||
TOKEN_FILE.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
_clear_secret(_SECRET_ATTRS, TOKEN_FILE)
|
||||
|
||||
|
||||
def token_backend() -> str:
|
||||
@@ -137,12 +155,25 @@ def token_backend() -> str:
|
||||
env = os.environ.get("RIGDOCTOR_TOKEN")
|
||||
if env and env.strip():
|
||||
return "env"
|
||||
if _keyring_lookup() is not None:
|
||||
if _keyring_lookup(_SECRET_ATTRS) is not None:
|
||||
return "keyring"
|
||||
if TOKEN_FILE.exists():
|
||||
return "file"
|
||||
return "none"
|
||||
|
||||
|
||||
def load_ai_key() -> str | None:
|
||||
"""Claude API key from $RIGDOCTOR_AI_KEY, then the OS keyring, then a 0600 file (M14)."""
|
||||
return _load_secret("RIGDOCTOR_AI_KEY", _AI_SECRET_ATTRS, AI_KEY_FILE)
|
||||
|
||||
|
||||
def save_ai_key(key: str) -> None:
|
||||
_save_secret(key, _AI_SECRET_ATTRS, "RigDoctor AI key", AI_KEY_FILE)
|
||||
|
||||
|
||||
def clear_ai_key() -> None:
|
||||
_clear_secret(_AI_SECRET_ATTRS, AI_KEY_FILE)
|
||||
|
||||
DEFAULTS: dict = {
|
||||
"interval": 1.0, # sampling interval in seconds (default ≤1 Hz — NFR)
|
||||
"log_max_bytes": 20_000_000, # rotate a log segment past this size
|
||||
@@ -156,6 +187,9 @@ DEFAULTS: dict = {
|
||||
"steam_libraries": [], # Steam library paths to scan for games (M6); empty = none picked yet
|
||||
"trigger_mode": "manual", # crash-logger trigger (D6): manual | always-on | game-launch
|
||||
"setup_done": False, # first-run GUI setup wizard completed (M9)
|
||||
"ai_provider": "", # AI assistant (M14, D24): "" (unset) | "ollama" | "claude"
|
||||
"ai_model": "", # model name (e.g. "llama3.1" for Ollama; blank = Claude default)
|
||||
"ai_endpoint": "http://localhost:11434", # Ollama server base URL (Claude uses a fixed endpoint)
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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."
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -2,15 +2,19 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtCore import Qt
|
||||
import threading
|
||||
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
from PySide6.QtGui import QFont
|
||||
from PySide6.QtWidgets import (
|
||||
QDialog,
|
||||
QFrame,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QMessageBox,
|
||||
QPushButton,
|
||||
QScrollArea,
|
||||
QTextEdit,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
@@ -20,8 +24,12 @@ from .widgets import finding_card
|
||||
|
||||
|
||||
class DiagnosticDialog(QDialog):
|
||||
_explained = Signal(object) # (ok, text) from a user-triggered AI explanation
|
||||
|
||||
def __init__(self, result, parent=None) -> None:
|
||||
super().__init__(parent)
|
||||
self._result = result
|
||||
self._explained.connect(self._on_explained)
|
||||
self.setWindowTitle(f"Diagnostic — {result.game}" if result.game else "Diagnostic")
|
||||
self.resize(660, 680)
|
||||
|
||||
@@ -73,9 +81,69 @@ class DiagnosticDialog(QDialog):
|
||||
root.addWidget(scroll, 1)
|
||||
|
||||
buttons = QHBoxLayout()
|
||||
self._explain_btn = QPushButton("Explain with AI")
|
||||
self._explain_btn.clicked.connect(self._explain_with_ai)
|
||||
from ..core import ai
|
||||
self._explain_btn.setVisible(ai.is_configured()) # opt-in only; hidden if not set up
|
||||
buttons.addWidget(self._explain_btn)
|
||||
buttons.addStretch(1)
|
||||
close = QPushButton("Close")
|
||||
close.setObjectName("PrimaryButton")
|
||||
close.clicked.connect(self.accept)
|
||||
buttons.addWidget(close)
|
||||
root.addLayout(buttons)
|
||||
|
||||
# --- AI explanation (M14, D24) — runs only on this button press ----------------
|
||||
def _explain_with_ai(self) -> None:
|
||||
from ..core import ai
|
||||
|
||||
if not ai.is_local(): # cloud provider → explicit consent before sending data
|
||||
confirm = QMessageBox.question(
|
||||
self, "Send to AI provider",
|
||||
f"This sends your diagnostic findings to {ai.provider_label()}.\n\nContinue?",
|
||||
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
|
||||
QMessageBox.StandardButton.No,
|
||||
)
|
||||
if confirm != QMessageBox.StandardButton.Yes:
|
||||
return
|
||||
self._explain_btn.setEnabled(False)
|
||||
self._explain_btn.setText("Asking the AI…")
|
||||
threading.Thread(target=self._work_explain, daemon=True).start()
|
||||
|
||||
def _work_explain(self) -> None:
|
||||
from ..core import ai, 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()
|
||||
|
||||
@@ -8,6 +8,7 @@ from PySide6.QtCore import Qt, QUrl, Signal
|
||||
from PySide6.QtGui import QDesktopServices
|
||||
from PySide6.QtWidgets import (
|
||||
QApplication,
|
||||
QButtonGroup,
|
||||
QCheckBox,
|
||||
QComboBox,
|
||||
QDoubleSpinBox,
|
||||
@@ -18,6 +19,7 @@ from PySide6.QtWidgets import (
|
||||
QLineEdit,
|
||||
QMessageBox,
|
||||
QPushButton,
|
||||
QRadioButton,
|
||||
QSizePolicy,
|
||||
QTextEdit,
|
||||
QVBoxLayout,
|
||||
@@ -25,7 +27,7 @@ from PySide6.QtWidgets import (
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
|
||||
@@ -54,6 +56,7 @@ class SetupPage(QWidget):
|
||||
_installed = Signal(int, str)
|
||||
_upd_state = Signal(object)
|
||||
_mode_applied = Signal(object) # (mode, ok, message) from a trigger-mode change
|
||||
_ai_tested = Signal(object) # (ok, message) from an AI connectivity test
|
||||
changed = Signal() # alert settings saved — main window re-applies them live
|
||||
|
||||
def __init__(self) -> None:
|
||||
@@ -62,6 +65,7 @@ class SetupPage(QWidget):
|
||||
self._installed.connect(self._on_installed)
|
||||
self._upd_state.connect(self._on_upd_state)
|
||||
self._mode_applied.connect(self._on_mode_applied)
|
||||
self._ai_tested.connect(self._on_ai_tested)
|
||||
|
||||
root = QVBoxLayout(self)
|
||||
root.setContentsMargins(20, 18, 20, 18)
|
||||
@@ -158,6 +162,59 @@ class SetupPage(QWidget):
|
||||
self._trigger_status.setText("systemd --user isn't available on this system.")
|
||||
root.addWidget(trig_card)
|
||||
|
||||
# AI assistant (M14, D24): explain diagnostics. Strictly opt-in — the model is only
|
||||
# contacted when the user presses "Explain with AI"; this panel just configures it.
|
||||
ai_card, ai_layout = _panel("AI assistant")
|
||||
ai_desc = QLabel(
|
||||
"Optionally let an AI explain your diagnostics in plain language. It runs <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.
|
||||
upd_card, upd_layout = _panel("Account access")
|
||||
hint = QLabel("A Gitea access token unlocks updates and session sharing. "
|
||||
@@ -203,8 +260,66 @@ class SetupPage(QWidget):
|
||||
self._refresh()
|
||||
self._load_alerts()
|
||||
self._trigger.setCurrentText(config.load_config().get("trigger_mode", "manual"))
|
||||
self._load_ai()
|
||||
self._refresh_update_status()
|
||||
|
||||
# --- AI assistant (M14) ---------------------------------------------------
|
||||
def _load_ai(self) -> None:
|
||||
cfg = config.load_config()
|
||||
prov = cfg.get("ai_provider", "")
|
||||
self._ai_claude.setChecked(prov == "claude")
|
||||
self._ai_ollama.setChecked(prov == "ollama")
|
||||
self._ai_model.setText(cfg.get("ai_model", ""))
|
||||
self._ai_endpoint.setText(cfg.get("ai_endpoint", "http://localhost:11434"))
|
||||
if config.load_ai_key():
|
||||
self._ai_key.setPlaceholderText("Claude API key saved — type to replace")
|
||||
self._on_ai_provider_changed()
|
||||
|
||||
def _ai_provider(self) -> str:
|
||||
if self._ai_claude.isChecked():
|
||||
return "claude"
|
||||
if self._ai_ollama.isChecked():
|
||||
return "ollama"
|
||||
return ""
|
||||
|
||||
def _on_ai_provider_changed(self) -> None:
|
||||
prov = self._ai_provider()
|
||||
self._ai_endpoint.setVisible(prov == "ollama")
|
||||
self._ai_key.setVisible(prov == "claude")
|
||||
self._ai_test_btn.setEnabled(prov != "")
|
||||
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:
|
||||
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()
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user