Compare commits

..

20 Commits

Author SHA1 Message Date
jessey bd6cad5a42 Merge pull request 'feat(ai): stream explanations live (Ollama NDJSON + Claude SSE) — 0.33.0' (#27) from feat/syslogs into main
release / test (push) Successful in 12s
tests / core (push) Successful in 12s
tests / gui-smoke (push) Successful in 25s
release / release (push) Successful in 15s
Reviewed-on: #27
2026-05-22 12:35:11 +00:00
jessey 7fa9b63661 Merge branch 'main' into feat/syslogs
tests / core (push) Successful in 12s
tests / gui-smoke (push) Successful in 25s
tests / core (pull_request) Successful in 11s
tests / gui-smoke (pull_request) Successful in 28s
2026-05-22 12:28:59 +00:00
jessey c443a8b9f8 ci: add tests workflow + gate releases on tests passing
tests / core (push) Successful in 12s
tests / gui-smoke (push) Successful in 38s
tests / core (pull_request) Successful in 13s
tests / gui-smoke (pull_request) Successful in 27s
- .gitea/workflows/tests.yml: run `unittest discover` on push + pull_request.
  `core` job (stdlib install, GUI tests skip) is bulletproof; `gui-smoke` job
  installs the GUI extra + offscreen Qt libs and runs the suite headless.
- release.yml: add a `test` job and `release: needs: test` so a push to main
  can't publish if the tests fail.

No version bump — CI config only; nothing in the shipped app changed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 14:26:47 +02:00
jessey bbc22fa288 feat(ai): stream explanations live (Ollama NDJSON + Claude SSE) — 0.33.0
ai.explain_stream(findings_text, on_chunk) streams token deltas and returns
(ok, full_text). Ollama: stream=True NDJSON; Claude: stream=True SSE (parse
content_block_delta text deltas). The diagnostic dialog opens an explanation
window immediately and fills it token-by-token via a _chunk signal, then
re-renders the finished answer as Markdown — no more multi-second freeze on a
local model. Non-streaming explain() kept for the CLI. Tests for both parsers;
verified live against qwen2.5:7b.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 14:23:15 +02:00
jessey 5502251789 Merge pull request 'feat(m15): collect session-scoped system logs (kernel + coredumps) — 0.31.0' (#26) from feat/syslogs into main
release / release (push) Successful in 15s
Reviewed-on: #26
2026-05-22 12:16:52 +00:00
jessey 4bd51a40c3 feat(m15): nvidia-smi snapshot + display logs + inventory in reports — 0.32.0
Expand diagnostic/report collection (all stored per-diagnostic, in the Report zip;
logs also fed to the AI on "Explain"):
- syslogs: nvidia-smi -q snapshot (driver/throttle/clocks/power/temps/PCIe/ECC/
  retired pages) + display-server log auto-detected — Xorg.0.log on X11, or the
  compositor user-journal slice (gnome-shell/kwin/sway/gamescope) on Wayland.
- diagstore: include the full M5 inventory (inventory.txt + .json) — invaluable
  for larger/shared debugging. inventory.collect() degrades gracefully (no root
  prompt). Best-effort throughout.
- Tests for nvidia/display + inventory in store; docs (M15/SPEC).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 14:16:23 +02:00
jessey 984292c368 feat(m15): collect session-scoped system logs (kernel + coredumps) — 0.31.0
core/syslogs.py gathers, scoped to the diagnostic window:
- kernel-log slice (journalctl -k): Xid, OOM, MCE, PCIe AER, thermal, hung tasks
- crashed-process records (coredumpctl): exe, signal, when
Stored as syslogs.txt in the diagnostic dir, included in the Report bundle, and
fed to the AI on "Explain" alongside the game logs. Best-effort (degrades if the
tools are missing/denied); treats journalctl's "-- No entries --" as empty.
Tests + docs (M15/SPEC).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 14:10:30 +02:00
jessey bffaf73ad4 Merge pull request 'fix(ai): analyse the actual session, not stale/benign logs — 0.28.1' (#25) from feat/m14-ai into main
release / release (push) Successful in 15s
Reviewed-on: #25
2026-05-22 11:57:03 +00:00
jessey 7f0ab9a635 feat(m15): opt-in logging + per-diagnostic storage + Report bundles — 0.30.0
One `logging_enabled` toggle (default off) gates everything (D25):
- core/applog.py: rotating app.log (no-op unless enabled); setup() at GUI/CLI start.
- core/diagstore.py: each diagnostic stored in DATA_DIR/diagnostics/<id>/ (capture,
  result.json, report.txt, scoped gamelogs, ai/ records of exactly what was sent to
  the model + which model + the reply). make_report() zips a diagnostic (+ app.log)
  into DATA_DIR/reports/.
- diagnostic.finish()/analyze_crash() store when enabled; DiagnosticResult.dir.
- GUI: Settings → Logging toggle; "Report" button on the diagnostic dialog; AI
  interactions recorded into the diagnostic dir on "Explain with AI".
- CLI: `rigdoctor bundle` (report is taken by the M4 health report).
- Tests for store/record_ai/make_report + applog gating; docs (D25, M15, Phase 8).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 13:56:31 +02:00
jessey 12339c3282 feat(ai): resolve Steam app IDs from the library, don't make the model guess — 0.29.0
The model guessed "Rainbow Six Siege" for appID 2694490 (Path of Exile 2). We
already know the names locally, so ground it: steam.appid_names() maps appid→name
from the scanned library, and ai.build_prompt scans the text for app IDs and
injects a resolved glossary. Only locally-known IDs are listed; no network, no
fine-tuning. Tests + verified live (2694490 = Path of Exile 2).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 13:40:34 +02:00
jessey c7e50ba4cb fix(ai): analyse the actual session, not stale/benign logs — 0.28.1
The user ran a game ~20s with no crash but the AI dredged up old log lines,
guessed the wrong game, and gave Windows advice. Fixes:
- Prompt now includes the real game name + capture duration + outcome (clean vs
  crash), so the model uses the known game instead of guessing from log paths.
- gamelogs.collect(since=…): scope Steam-console lines by timestamp and skip a
  stale per-app Proton log (mtime before the session) — no unrelated past run.
- ai_knowledge: flag benign Steam/Proton lines (libnvidia-ml.so.1 assertion,
  routine minidumps, "fork without exec") as non-causal.
- System prompt: Linux-only steps (no "run as administrator"); don't manufacture
  a problem on a clean run.

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

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 13:25:04 +02:00
jessey 045f40c4de Merge pull request 'feat(m14): AI assistant — explain diagnostics, opt-in (Ollama or Claude) — 0.27.0' (#23) from feat/m14-ai into main
release / release (push) Successful in 14s
Reviewed-on: #23
2026-05-22 11:19:30 +00:00
jessey 2ff4056d89 feat(m14): AI assistant — explain diagnostics, opt-in (Ollama or Claude) — 0.27.0
New optional module (D24): explains the collected findings in plain language,
contacted ONLY on an explicit user action (never automatic).

- core/ai.py: provider chosen explicitly (no default) — ollama (local) or claude
  (Anthropic Messages API via stdlib urllib; key in keyring). Grounded prompt;
  HTTP error parsing; one-shot (no thinking/caching — snappy).
- core/ai_knowledge.py: curated reference KB (Xid/SMART/Proton/tunables),
  exact keyword/code match ("RAG-lite", no embeddings) injected into the prompt —
  lifts local models, sharpens Claude. No fine-tuning.
- config: ai_provider/ai_model/ai_endpoint + keyring-backed AI key (generalized
  the token keyring helpers).
- GUI: Settings → AI assistant (provider radios, model/endpoint/key, Save/Test);
  "Explain with AI" button on the diagnostic dialog (consent prompt for cloud).
- CLI: `rigdoctor ai status|test|explain`.
- Docs: D24, SPEC/MODULES/ROADMAP (Phase 7); tests for providers/grounding/parse.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 13:19:11 +02:00
jessey 2fe03269e4 Merge pull request 'fix(gui): style radio buttons + checkbox states in the setup wizard — 0.26.1' (#22) from feat/share-terminal into main
release / release (push) Successful in 14s
Reviewed-on: #22
2026-05-22 08:28:34 +00:00
jessey ac2a3981fc fix(gui): style radio buttons + checkbox states in the setup wizard — 0.26.1
QRadioButton was unstyled, so the selected trigger option was invisible on the
dark theme. Added QRadioButton::indicator styling (accent ring + center dot when
checked) and explicit QCheckBox :checked/:disabled states. Bundle checkboxes stay
selectable even when already installed so the page isn't dead when all deps are
present.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 10:27:44 +02:00
jessey 2684e5c8ab Merge pull request 'feat(m9): graphical first-run setup wizard — 0.26.0' (#21) from feat/share-terminal into main
release / release (push) Successful in 13s
Reviewed-on: #21
2026-05-22 08:18:32 +00:00
jessey 4386838b69 feat(m9): graphical first-run setup wizard — 0.26.0
The full installer experience as a GUI wizard (gui/setup_wizard.py): environment
summary → pick dependency bundles (from the catalog, grouped) → install missing
apt packages → choose recording trigger → readiness summary.

- Shown on first launch (config setup_done) and via `rigdoctor-gui --setup`;
  re-runnable from Settings → Run setup wizard.
- install.sh launches it after a fresh install when a desktop session is present.
- catalog.by_bundle() groups components; config gains setup_done.
- Tests: by_bundle grouping + wizard construction smoke.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-22 10:13:51 +02:00
32 changed files with 2331 additions and 49 deletions
+13
View File
@@ -11,7 +11,20 @@ on:
branches: [main] branches: [main]
jobs: jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install (core only)
run: python -m pip install -e .
- name: Run tests
run: python -m unittest discover -s tests -v
release: release:
needs: test # don't publish a release if the tests fail
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
+43
View File
@@ -0,0 +1,43 @@
name: tests
run-name: Run test suite
# Runs the unittest suite on every push and pull request. Two jobs:
# core — stdlib-only install; the GUI tests skip (@skipUnless HAVE_QT). Bulletproof.
# gui-smoke — installs the GUI extra + offscreen Qt libs and runs the same suite headless,
# exercising the MainWindow/SetupWizard/DiagnosticDialog construction tests.
# Make `core` a required status check on `main` so a PR can't merge with failing tests.
on:
push:
pull_request:
jobs:
core:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install (core only — no PySide6)
run: python -m pip install -e .
- name: Run tests (GUI tests skip without PySide6)
run: python -m unittest discover -s tests -v
gui-smoke:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: System libraries for offscreen Qt
run: |
sudo apt-get update
sudo apt-get install -y libegl1 libgl1 libxkbcommon0 libdbus-1-3 libglib2.0-0
- name: Install (with GUI extra)
run: python -m pip install -e ".[gui]"
- name: Run tests (headless)
env:
QT_QPA_PLATFORM: offscreen
run: python -m unittest discover -s tests -v
+103
View File
@@ -5,6 +5,109 @@ 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.33.0] - 2026-05-22
### Added
- **AI explanations stream live.** "Explain with AI" now fills token-by-token as the model
generates (Ollama NDJSON + Claude SSE, both via stdlib `urllib`) instead of a multi-second
freeze, then re-renders the finished answer as Markdown. `core/ai.explain_stream()`.
## [0.32.0] - 2026-05-22
### Added
- **More for diagnostics & reports:**
- **`nvidia-smi -q` snapshot** — driver, throttle/clock-event reasons, clocks, power, temps,
PCIe link, ECC + retired pages (point-in-time at diagnostic time).
- **Display-server log** — auto-detected: `Xorg.0.log` on X11, or the compositor's user-journal
slice (gnome-shell/kwin/sway/gamescope) on Wayland.
- **Full system inventory** (M5 hardware/OS) is now included in each stored diagnostic and the
**Report** bundle — invaluable for larger/shared debugging.
These join the kernel log + coredump records in `syslogs.txt`/`inventory.*`, are saved per
diagnostic, included in the Report zip, and (logs) fed to the AI on "Explain".
## [0.31.0] - 2026-05-22
### Added
- **Diagnostics now collect session-scoped system logs** (`core/syslogs.py`): a kernel-log
slice (`journalctl -k` — Xid, OOM-killer, MCE, PCIe AER, thermal, hung tasks) and
**crashed-process records** (`coredumpctl` — which executable, signal, and when). They're saved
to the diagnostic directory (`syslogs.txt`), included in the **Report** bundle, and fed to the
AI on "Explain" alongside the game logs. Best-effort — degrades quietly if the tools are
missing or access is denied; scoped to the session window so it doesn't drag in old noise.
## [0.30.0] - 2026-05-22
### Added
- **Logging & report bundles (M15, D25)** — opt-in via one **Settings → Logging** toggle
(default off). When on: the app logs to a rotating `app.log`, and **each diagnostic is stored
in its own folder** (`~/.local/share/rigdoctor/diagnostics/<id>/`) with the capture log, a
structured `result.json`, a readable `report.txt`, a session-scoped game-log snapshot, and an
`ai/` record of every AI interaction — **the exact data sent, which model, and its reply**.
- **Report** — a button on the diagnostic dialog (and `rigdoctor bundle`) zips a diagnostic's
folder plus `app.log` into `~/.local/share/rigdoctor/reports/<id>.zip` for sharing. Everything
stays local; the zip only leaves your machine if you share it. Available only when logging is on.
## [0.29.0] - 2026-05-22
### Added
- **AI now resolves Steam app IDs from your library instead of guessing.** When app IDs appear
in the logs/findings, RigDoctor looks them up in your scanned games (`steam.appid_names()`) and
injects an "App IDs (resolved from your installed games)" glossary into the prompt — so the
model names games correctly (e.g. `2694490 = Path of Exile 2`) rather than hallucinating. Only
IDs it can resolve locally are listed; no network, no model "training" needed.
## [0.28.1] - 2026-05-22
### Fixed
- **AI explanations were misreading stale/benign logs.** Three fixes so the model analyses the
*actual* session: (1) the prompt now states the **real game name, capture duration, and
outcome** (clean vs. crash) so the model stops guessing the game from log paths; (2) game logs
are **scoped to the session window** (Steam-console lines filtered by timestamp; a stale
per-app Proton log from an earlier game is skipped); (3) the reference KB flags common
**benign** Steam/Proton lines (`libnvidia-ml.so.1` assertion, routine minidump uploads, "fork
without exec") so they aren't reported as the cause. The system prompt also forbids
Windows-only advice (no "run as administrator") and tells the model not to invent a problem
when the run was clean.
## [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
selected option was invisible on the dark theme — now styled with a clear accent ring + dot.
Bundle **checkboxes** got explicit checked/disabled states, and stay selectable even when a
bundle is already installed (the page no longer looks dead when everything's present).
## [0.26.0] - 2026-05-22
### Added
- **Graphical setup wizard (M9).** A first-run GUI wizard (`gui/setup_wizard.py`) walks through:
environment summary → pick **dependency bundles** (Diagnostics / Monitoring / Gaming / Updates,
from the component catalog) → install the missing apt packages → choose the **recording
trigger** → a readiness summary. It shows automatically on first launch (until done), is
re-runnable from **Settings → Run setup wizard** or `rigdoctor-gui --setup`, and `install.sh`
launches it after a fresh install when a desktop session is present.
## [0.25.0] - 2026-05-22 ## [0.25.0] - 2026-05-22
### Changed ### Changed
- **Share is now terminal-only (D23, amends D16).** The Share page is a single shared-terminal - **Share is now terminal-only (D23, amends D16).** The Share page is a single shared-terminal
+28 -1
View File
@@ -249,9 +249,36 @@ 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).
### D25 — Logging & report bundles (M15) — *DECIDED 2026-05-22*
Opt-in logging + shareable diagnostic reports.
- **One combined `logging_enabled` toggle** (default off) controls both application logging
(rotating `app.log`) and per-diagnostic storage. Kept as a single switch for simplicity.
- **Each diagnostic is stored in its own directory** (`DATA_DIR/diagnostics/<id>/`): capture
log, structured `result.json`, human-readable `report.txt`, a scoped game-log snapshot, and an
`ai/` folder recording each AI interaction (**exact data sent, provider+model, and the reply**).
- **"Report"** zips one diagnostic directory (plus `app.log`) into `DATA_DIR/reports/` —
auto-saved there (no save dialog), shown with its path. Available only when logging is on
(nothing is stored otherwise). CLI: `rigdoctor bundle`.
- Everything stays local; the report only leaves the machine if the user shares the zip.
## Open ## Open
None currently — all tracked decisions (D1D23) are resolved. New questions will be added None currently — all tracked decisions (D1D25) 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`).
+24 -1
View File
@@ -2,7 +2,8 @@
Status: ⬜ not started · 🟦 designing · 🟨 in progress · ✅ done Status: ⬜ not started · 🟦 designing · 🟨 in progress · ✅ done
> Module set per D14, plus **M12 (session sharing, D16)** and **M13 (auto-update, D18)**. > Module set per D14, plus **M12 (session sharing, D16)**, **M13 (auto-update, D18)**,
> **M14 (AI assistant, D24)**, and **M15 (logging & reports, D25)**.
> **M7 (stress/repro) was dropped (D7).** M10/M11 are the GUI and tray modules (D10/D11). > **M7 (stress/repro) was dropped (D7).** M10/M11 are the GUI and tray modules (D10/D11).
> GPU scope reads "all (NVIDIA first)" — NVIDIA first, others via the vendor abstraction (D4). > GPU scope reads "all (NVIDIA first)" — NVIDIA first, others via the vendor abstraction (D4).
@@ -20,6 +21,8 @@ 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 | ✅ |
| M15 | Logging & report bundles | (core) | none (stdlib logging + zip) | all | P3 | ✅ |
| ~~M7~~ | ~~Stress / repro~~ | — | — | — | — | ❌ dropped (D7) | | ~~M7~~ | ~~Stress / repro~~ | — | — | — | — | ❌ dropped (D7) |
## Notes per module ## Notes per module
@@ -117,6 +120,25 @@ 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**.
- **M15 Logging & report bundles** (D25) — opt-in via one `logging_enabled` toggle (default off):
application logging to a rotating `app.log` (`core/applog.py`) and **per-diagnostic storage**
(`core/diagstore.py`) — each diagnostic gets its own `DATA_DIR/diagnostics/<id>/`: capture,
`result.json`, `report.txt`, the full **inventory** (M5: hardware/OS), scoped **game logs**
(`core/gamelogs.py`), scoped **system logs** (`core/syslogs.py``journalctl -k`,
`coredumpctl`, an `nvidia-smi -q` snapshot, and the X11/Wayland display-server log), and an
`ai/` record of every AI interaction (exact data sent, model, reply). **"Report"** zips one
into `DATA_DIR/reports/` (GUI button on the diagnostic dialog; CLI `rigdoctor bundle`). Logs
are session-scoped and fed to the AI on "Explain". Stays local; shareable on demand.
## 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 +146,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
+19 -1
View File
@@ -65,7 +65,10 @@ Ubuntu + NVIDIA first; `.deb` distribution (see `DECISIONS.md`).
(no-root venv install, handles python3-venv prereq, CI-built); **`systemd --user` trigger (no-root venv install, handles python3-venv prereq, CI-built); **`systemd --user` trigger
modes** (`core/service.py`, `rigdoctor service mode manual|always-on|game-launch` + GUI modes** (`core/service.py`, `rigdoctor service mode manual|always-on|game-launch` + GUI
Settings "Recording trigger") incl. the zero-config **game-launch watcher** Settings "Recording trigger") incl. the zero-config **game-launch watcher**
(`core/watcher.py`, `rigdoctor watch`). *Pending:* module-selection config during install. (`core/watcher.py`, `rigdoctor watch`); and a **graphical first-run setup wizard**
(`gui/setup_wizard.py`): environment → dependency-bundle selection → install → recording
trigger → readiness, auto-launched by install.sh and re-runnable from Settings.
*Pending:* `.deb` packaging (next bullet).
- [ ] `.deb` packaging (D8) declaring per-bundle deps incl. python3-pyside6 for Desktop UI - [ ] `.deb` packaging (D8) declaring per-bundle deps incl. python3-pyside6 for Desktop UI
## Phase 5 — Breadth (later) ## Phase 5 — Breadth (later)
@@ -86,6 +89,21 @@ 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.
## Phase 8 — Logging & report bundles (M15, D25)
- [x] **Opt-in logging** (one `logging_enabled` toggle): rotating `app.log` (`core/applog.py`)
+ **per-diagnostic storage** in its own directory (`core/diagstore.py`) — capture,
result, report, scoped game logs, and AI-interaction records.
- [x] **Report** bundle — zip a diagnostic (incl. exactly what was sent to the AI, the model,
and its reply) into the reports folder. GUI button + `rigdoctor bundle`.
> **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.
+22
View File
@@ -152,6 +152,28 @@ 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).
### M15 — Logging & report bundles (D25)
Opt-in (one `logging_enabled` toggle, default off). When on: the application logs to a rotating
`app.log`, and **each diagnostic is stored in its own directory** (capture log, structured
result, human-readable report, the full **inventory** (M5 hardware/OS), session-scoped **game
logs** (Proton/Steam) and **system logs** (`journalctl -k`, `coredumpctl`, an `nvidia-smi -q`
snapshot, and the X11/Wayland display-server log), and a record of every AI interaction — the
exact data sent, the model, and its reply). The collected logs are also fed to the AI on
"Explain". Collection is best-effort (degrades if tools are missing/denied). A **Report** action zips one diagnostic's directory
(plus the app log) into a shareable bundle saved under the reports folder (GUI button; CLI
`rigdoctor bundle`). Everything stays local — a report only leaves the machine if the user
shares the zip. Stdlib only (`logging` + `zipfile`).
## 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
+8
View File
@@ -115,3 +115,11 @@ case ":$PATH:" in
*":$BIN_DIR:"*) ;; *":$BIN_DIR:"*) ;;
*) echo " Note: add $BIN_DIR to your PATH (a fresh login usually does this).";; *) echo " Note: add $BIN_DIR to your PATH (a fresh login usually does this).";;
esac esac
# Launch the graphical setup wizard if a desktop session is available (first run shows it).
if [ -n "${DISPLAY:-}${WAYLAND_DISPLAY:-}" ] && [ -x "$VENV/bin/rigdoctor-gui" ]; then
echo " Opening the setup wizard…"
("$VENV/bin/rigdoctor-gui" --setup >/dev/null 2>&1 &)
else
echo " Run 'rigdoctor-gui' to finish setup."
fi
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "rigdoctor" name = "rigdoctor"
version = "0.25.0" version = "0.33.0"
description = "Modular hardware monitoring & crash diagnostics for Linux gamers." description = "Modular hardware monitoring & crash diagnostics for Linux gamers."
readme = "README.md" readme = "README.md"
requires-python = ">=3.11" requires-python = ">=3.11"
+1 -1
View File
@@ -1,3 +1,3 @@
"""RigDoctor — modular hardware monitoring & crash diagnostics for Linux gamers.""" """RigDoctor — modular hardware monitoring & crash diagnostics for Linux gamers."""
__version__ = "0.25.0" __version__ = "0.33.0"
+64
View File
@@ -438,6 +438,57 @@ 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_bundle(args) -> int:
"""Zip the latest stored diagnostic into a report bundle (M15) — needs logging enabled."""
from .core import diagstore
if not diagstore.enabled():
print("Logging is off. Enable it (Settings → Logging, or set logging_enabled) so "
"diagnostics are stored and can be reported.")
return 1
directory = diagstore.latest_dir()
if directory is None:
print("No stored diagnostics yet — run a diagnostic first.")
return 1
out = diagstore.make_report(directory)
print(f"Report written: {out}")
return 0
def cmd_gameenv(args) -> int: def cmd_gameenv(args) -> int:
from dataclasses import asdict from dataclasses import asdict
@@ -645,10 +696,23 @@ 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)
bundle_p = sub.add_parser("bundle", help="zip the latest stored diagnostic into a report bundle (M15)")
bundle_p.set_defaults(func=cmd_bundle)
return p return p
def main(argv: list[str] | None = None) -> int: def main(argv: list[str] | None = None) -> int:
from .core import applog
applog.setup() # opt-in app logging (M15); no-op unless logging_enabled
args = build_parser().parse_args(argv) args = build_parser().parse_args(argv)
return args.func(args) return args.func(args)
+79 -37
View File
@@ -37,12 +37,23 @@ SPAWN_LOG = STATE_DIR / "recorder.out"
# not config: refreshed by the background scan on every launch). # not config: refreshed by the background scan on every launch).
GAMES_FILE = STATE_DIR / "games.json" GAMES_FILE = STATE_DIR / "games.json"
# Logging & reports (opt-in via `logging_enabled`). App log: rotating file of app events.
# Each diagnostic is stored under DIAGNOSTICS_DIR/<id>/; "Report" zips one into REPORTS_DIR.
APP_LOG = STATE_DIR / "app.log"
DIAGNOSTICS_DIR = DATA_DIR / "diagnostics"
REPORTS_DIR = DATA_DIR / "reports"
# Update access token (M13) — gates updates to Gitea account holders (D18). # Update access token (M13) — gates updates to Gitea account holders (D18).
# Stored in the OS keyring (Secret Service / GNOME Keyring) via `secret-tool` when # Stored in the OS keyring (Secret Service / GNOME Keyring) via `secret-tool` when
# available — encrypted at rest, unlocked with the login session — else a 0600 file. # available — encrypted at rest, unlocked with the login session — else a 0600 file.
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 +64,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 +93,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 +161,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
@@ -155,6 +192,11 @@ DEFAULTS: dict = {
"relay_url": "wss://rigdoctor.jesseyvanofferen.com", # session-sharing relay (M12) "relay_url": "wss://rigdoctor.jesseyvanofferen.com", # session-sharing relay (M12)
"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)
"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)
"logging_enabled": False, # opt-in: app logging + per-diagnostic storage + Report (M15)
} }
+288
View File
@@ -0,0 +1,288 @@
"""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 re
import urllib.error
import urllib.request
from .. import config
from . import ai_knowledge
_APPID_RE = re.compile(r"\b\d{5,7}\b") # Steam app IDs are 57 digits
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 (Ubuntu + NVIDIA, games "
"via Steam/Proton). You are given session context, the structured findings RigDoctor "
"collected — which may include recent game/Proton/system log excerpts scoped to this session "
"— plus reference facts. Use the GAME NAME from the session context; never guess the game "
"from log paths or app IDs. Correlate 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 with exact Linux commands where useful.\n"
"Rules: Base your reasoning ONLY on the data and reference facts provided — never invent "
"readings, hardware, or log lines. This is LINUX: never suggest Windows-only steps (e.g. "
"'run as administrator', registry edits, toggling antivirus). Treat log lines flagged BENIGN "
"in the reference facts as non-causal. If no crash was recorded and there are no warning or "
"critical findings, say plainly that the session looks healthy and do NOT manufacture a "
"problem. Be concise. Present fixes as suggestions and warn before anything that risks 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 appid_glossary(text: str) -> str:
"""Resolve Steam app IDs that appear in `text` against the user's scanned library.
We don't teach the model app IDs — we look them up locally and hand it the mapping, so it
names games correctly instead of guessing. Only IDs we can resolve are listed.
"""
candidates = set(_APPID_RE.findall(text))
if not candidates:
return ""
try:
from . import steam
names = steam.appid_names()
except Exception: # never let a glossary lookup break an explanation
return ""
known = sorted((i, names[i]) for i in candidates if i in names)
if not known:
return ""
return "App IDs (resolved from your installed games):\n" + "\n".join(
f"- {appid} = {name}" for appid, name in known)
def build_prompt(findings_text: str) -> str:
"""The user-message content: app-ID glossary + matched reference facts + the findings."""
parts = []
glossary = appid_glossary(findings_text)
if glossary:
parts.append(glossary)
parts.append("")
facts = ai_knowledge.relevant(findings_text)
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 explain_stream(findings_text: str, on_chunk, timeout: float = 180.0) -> tuple[bool, str]:
"""Like :func:`explain`, but calls ``on_chunk(text_delta)`` as tokens arrive and returns
``(ok, full_text)`` at the end. Caller MUST be a direct user action (D24)."""
content = build_prompt(findings_text)
try:
if provider() == "claude":
return _claude_stream(content, on_chunk, timeout)
if provider() == "ollama":
return _ollama_stream(content, on_chunk, 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 _stream_request(url: str, payload: dict, headers: dict, timeout: float):
req = urllib.request.Request(
url, data=json.dumps(payload).encode("utf-8"),
headers={"Content-Type": "application/json", **headers})
return urllib.request.urlopen(req, timeout=timeout)
def _ollama_stream(content: str, on_chunk, 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": True}
parts: list[str] = []
with _stream_request(endpoint().rstrip("/") + "/api/generate", payload, {}, timeout) as resp:
for raw in resp: # newline-delimited JSON objects
line = raw.decode("utf-8", "replace").strip()
if not line:
continue
obj = json.loads(line)
chunk = obj.get("response", "")
if chunk:
parts.append(chunk)
on_chunk(chunk)
if obj.get("done"):
break
return True, "".join(parts).strip() or "(the model returned an empty response)"
def _claude_stream(content: str, on_chunk, timeout: float) -> tuple[bool, str]:
key = config.load_ai_key()
if not key:
return False, "No Claude API key is set (Settings → AI assistant)."
payload = {
"model": model(), "max_tokens": CLAUDE_MAX_TOKENS, "system": SYSTEM_PROMPT,
"messages": [{"role": "user", "content": content}], "stream": True,
}
headers = {"x-api-key": key, "anthropic-version": ANTHROPIC_VERSION}
parts: list[str] = []
with _stream_request(CLAUDE_ENDPOINT, payload, headers, timeout) as resp:
for raw in resp: # SSE: parse `data:` lines, accumulate text deltas
line = raw.decode("utf-8", "replace").strip()
if not line.startswith("data:"):
continue
try:
event = json.loads(line[5:].strip())
except ValueError:
continue
etype = event.get("type")
if etype == "content_block_delta" and event.get("delta", {}).get("type") == "text_delta":
chunk = event["delta"].get("text", "")
if chunk:
parts.append(chunk)
on_chunk(chunk)
elif etype == "error":
return False, event.get("error", {}).get("message", "stream error")
elif etype == "message_stop":
break
return True, "".join(parts).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."
+91
View File
@@ -0,0 +1,91 @@
"""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."),
(("libnvidia-ml.so", "interface.h", "failed to load \"libnvidia-ml"),
"BENIGN: a Steam log assertion 'Failed to load libnvidia-ml.so.1' (from interface.h) is "
"logged on many normal launches — the Steam runtime sandbox can't see the host NVML library. "
"It is NOT by itself a crash cause. Only investigate the driver if the GPU is genuinely "
"undetected (nvidia-smi fails)."),
(("minidump", ".dmp", "uploading minidump"),
"BENIGN-by-default: a minidump upload line means a crash handler ran AND that the game/engine "
"routinely uploads dumps; it is not proof that THIS session crashed unless a hard freeze or "
"non-zero exit was also recorded. Don't treat a routine minidump line as the root cause."),
(("fork without exec", "skipping destruction"),
"BENIGN: 'pid X != Y, skipping destruction (fork without exec?)' is routine Steam/Proton "
"process bookkeeping, not an error."),
]
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
+63
View File
@@ -0,0 +1,63 @@
"""Application logging (M15) — opt-in via the `logging_enabled` setting.
When enabled, app events/errors are written to a rotating file (`config.APP_LOG`); when
disabled, nothing is written (no file is created). All RigDoctor code logs through
``applog.get_logger(__name__)``; the handler is attached once at startup by :func:`setup`.
Stdlib ``logging`` only.
"""
from __future__ import annotations
import logging
from logging.handlers import RotatingFileHandler
from .. import config
_ROOT = "rigdoctor"
_configured = False
def setup(force: bool = False) -> bool:
"""Attach the file handler if logging is enabled. Idempotent. Returns whether it's on."""
global _configured
logger = logging.getLogger(_ROOT)
enabled = bool(config.load_config().get("logging_enabled", False))
if not enabled:
if force: # toggled off at runtime — detach so we stop writing
for h in list(logger.handlers):
logger.removeHandler(h)
h.close()
_configured = False
return False
if _configured and not force:
return True
for h in list(logger.handlers): # avoid duplicate handlers on re-setup
logger.removeHandler(h)
h.close()
try:
config.STATE_DIR.mkdir(parents=True, exist_ok=True)
handler = RotatingFileHandler(config.APP_LOG, maxBytes=2_000_000, backupCount=3,
encoding="utf-8")
handler.setFormatter(logging.Formatter(
"%(asctime)s %(levelname)-7s %(name)s: %(message)s"))
logger.addHandler(handler)
logger.setLevel(logging.INFO)
logger.propagate = False
_configured = True
logger.info("logging started (rigdoctor %s)", _version())
except OSError:
return False
return True
def get_logger(name: str) -> logging.Logger:
"""A child logger. Safe to call before setup — it just won't write until enabled."""
short = name.split(".")[-1]
return logging.getLogger(f"{_ROOT}.{short}")
def _version() -> str:
from .. import __version__
return __version__
+8
View File
@@ -65,3 +65,11 @@ COMPONENTS: tuple[Component, ...] = (
def by_id(component_id: str) -> Component | None: def by_id(component_id: str) -> Component | None:
"""Look up a catalog component by its id (None if unknown).""" """Look up a catalog component by its id (None if unknown)."""
return next((c for c in COMPONENTS if c.id == component_id), None) return next((c for c in COMPONENTS if c.id == component_id), None)
def by_bundle() -> dict[str, list[Component]]:
"""Components grouped by bundle, preserving catalog order (for the setup wizard)."""
groups: dict[str, list[Component]] = {}
for c in COMPONENTS:
groups.setdefault(c.bundle, []).append(c)
return groups
+20 -2
View File
@@ -28,6 +28,7 @@ class DiagnosticResult:
game: str | None game: str | None
summary: Summary # capture window: peak temps/power, events, last samples (M3) summary: Summary # capture window: peak temps/power, events, last samples (M3)
findings: list[Finding] # health findings: Xid/SMART/driver/etc. (M4) findings: list[Finding] # health findings: Xid/SMART/driver/etc. (M4)
dir: str | None = None # storage directory when logging is on (M15); else None
@dataclass @dataclass
@@ -97,7 +98,22 @@ def finish(last_n: int = 10, log_path=None) -> DiagnosticResult:
summary = summarize(path, last_n=last_n) summary = summarize(path, last_n=last_n)
game = _game_from_summary(summary) or (reccontrol.read_status() or {}).get("game") game = _game_from_summary(summary) or (reccontrol.read_status() or {}).get("game")
findings = run_health_checks() findings = run_health_checks()
return DiagnosticResult(game=game, summary=summary, findings=findings) result = DiagnosticResult(game=game, summary=summary, findings=findings)
_store(result, path, summary)
return result
def _store(result: DiagnosticResult, capture_path, summary: Summary) -> None:
"""Persist the diagnostic to its own directory when logging is enabled (M15)."""
try:
from . import diagstore
since = (summary.start - 60) if summary.start else None
directory = diagstore.store(result, capture_path, since=since)
if directory:
result.dir = str(directory)
except Exception: # storage must never break a diagnostic
pass
# --- hard-crash detection & post-crash analysis ----------------------------------- # --- hard-crash detection & post-crash analysis -----------------------------------
@@ -184,4 +200,6 @@ def analyze_crash(last_n: int = 15) -> DiagnosticResult:
findings += check_previous_boot() # the crashed boot's kernel log findings += check_previous_boot() # the crashed boot's kernel log
findings += run_health_checks(include_journal=False) # SMART/driver/persistence/temps findings += run_health_checks(include_journal=False) # SMART/driver/persistence/temps
findings.sort(key=lambda f: _SEV_ORDER.get(f.severity, 9)) findings.sort(key=lambda f: _SEV_ORDER.get(f.severity, 9))
return DiagnosticResult(game=_game_from_summary(summary), summary=summary, findings=findings) result = DiagnosticResult(game=_game_from_summary(summary), summary=summary, findings=findings)
_store(result, _crash_path(), summary)
return result
+152
View File
@@ -0,0 +1,152 @@
"""Per-diagnostic storage + Report bundles (M15) — opt-in via `logging_enabled`.
When logging is on, each finished diagnostic is persisted to its own directory under
``config.DIAGNOSTICS_DIR/<id>/`` (capture log, structured result, human-readable report, a
game-log snapshot, and any AI interactions). "Report" zips one directory — including exactly
**what was sent to the AI, which model, and its reply** — into ``config.REPORTS_DIR``.
"""
from __future__ import annotations
import json
import shutil
import time
import zipfile
from dataclasses import asdict, is_dataclass
from pathlib import Path
from .. import config
def enabled() -> bool:
return bool(config.load_config().get("logging_enabled", False))
def _slug(name: str | None) -> str:
s = "".join(c if c.isalnum() else "-" for c in (name or "session").lower())
return s.strip("-")[:40] or "session"
def _new_dir(game: str | None) -> Path:
base = config.DIAGNOSTICS_DIR
stamp = time.strftime("%Y%m%d-%H%M%S")
name = f"{stamp}-{_slug(game)}"
target = base / name
n = 1
while target.exists():
target = base / f"{name}-{n}"
n += 1
target.mkdir(parents=True, exist_ok=True)
return target
def _as_dict(obj):
if is_dataclass(obj):
return asdict(obj)
return getattr(obj, "__dict__", {}) or str(obj)
def store(result, capture_path=None, since: float | None = None) -> Path | None:
"""Persist a finished diagnostic to its own directory. Returns the dir, or None if off."""
if not enabled():
return None
from ..render import render_summary
from . import ai, gamelogs, syslogs
target = _new_dir(getattr(result, "game", None))
if capture_path and Path(capture_path).exists():
try:
shutil.copyfile(capture_path, target / "capture.jsonl")
except OSError:
pass
payload = {
"game": getattr(result, "game", None),
"stored_at": time.time(),
"summary": _as_dict(result.summary),
"findings": [_as_dict(f) for f in result.findings],
}
_write(target / "result.json", json.dumps(payload, indent=2, default=str))
report = [f"Game: {getattr(result, 'game', None) or 'unknown'}", "",
render_summary(result.summary), "",
ai.format_findings(result.findings, header="Findings:")]
_write(target / "report.txt", "\n".join(report))
try:
logs = gamelogs.collect(since=since)
if logs:
_write(target / "gamelogs.txt", logs)
except OSError:
pass
try:
sys_logs = syslogs.collect(since=since)
if sys_logs:
_write(target / "syslogs.txt", sys_logs)
except OSError:
pass
try: # full hardware/OS inventory (M5) — invaluable for larger debugging in a shared report
from . import inventory
sections = inventory.collect()
_write(target / "inventory.txt", inventory.render_text(sections))
_write(target / "inventory.json", inventory.render_json(sections))
except Exception: # inventory probes vary by machine; never let it break storage
pass
return target
def record_ai(diag_dir, *, provider: str, model: str, system: str, prompt: str, response: str) -> None:
"""Save one AI interaction (exact data sent, model, reply) into the diagnostic's `ai/` dir."""
if not diag_dir:
return
out = Path(diag_dir) / "ai"
try:
out.mkdir(parents=True, exist_ok=True)
except OSError:
return
stamp = time.strftime("%Y%m%d-%H%M%S")
record = {
"timestamp": time.time(), "provider": provider, "model": model,
"system_prompt": system, "data_sent_to_model": prompt, "model_reply": response,
}
_write(out / f"explain-{stamp}.json", json.dumps(record, indent=2, default=str))
readable = (
f"Provider: {provider}\nModel: {model}\n\n"
f"=== System prompt ===\n{system}\n\n"
f"=== Data sent to the model ===\n{prompt}\n\n"
f"=== Model reply ===\n{response}\n"
)
_write(out / f"explain-{stamp}.txt", readable)
def make_report(diag_dir) -> Path:
"""Zip a diagnostic directory (plus the app log) into REPORTS_DIR; return the zip path."""
diag_dir = Path(diag_dir)
config.REPORTS_DIR.mkdir(parents=True, exist_ok=True)
out = config.REPORTS_DIR / f"report-{diag_dir.name}.zip"
with zipfile.ZipFile(out, "w", zipfile.ZIP_DEFLATED) as zf:
for path in sorted(diag_dir.rglob("*")):
if path.is_file():
zf.write(path, arcname=str(Path(diag_dir.name) / path.relative_to(diag_dir)))
if config.APP_LOG.exists(): # the application log, for context around the session
zf.write(config.APP_LOG, arcname=str(Path(diag_dir.name) / "app.log"))
return out
def latest_dir() -> Path | None:
try:
dirs = [d for d in config.DIAGNOSTICS_DIR.iterdir() if d.is_dir()]
except OSError:
return None
return max(dirs, key=lambda d: d.stat().st_mtime) if dirs else None
def _write(path: Path, text: str) -> None:
try:
path.write_text(text, encoding="utf-8")
except OSError:
pass
+116
View File
@@ -0,0 +1,116 @@
"""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
import re
import time
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")
_TS = re.compile(r"^\[(\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2})\]")
def _line_epoch(line: str) -> float | None:
m = _TS.match(line)
if not m:
return None
try:
return time.mktime(time.strptime(m.group(1), "%Y-%m-%d %H:%M:%S"))
except ValueError:
return None
def _since_filter(text: str, since: float) -> str:
"""Keep lines from the first timestamp >= `since` onward (logs are chronological).
Untimestamped lines before the window are dropped; once inside the window every line is
kept (so multi-line entries survive). This scopes a long-lived Steam log to one session.
"""
out: list[str] = []
including = False
for line in text.splitlines():
epoch = _line_epoch(line)
if epoch is not None and epoch >= since:
including = True
if including:
out.append(line)
return "\n".join(out)
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(since: float | None = None, max_bytes: int = 8000) -> str:
"""Recent Proton + Steam log tails as one labelled text block ('' if none).
With ``since`` (epoch), scope to that session: skip a Proton log not written during/after
the session (a stale per-app log from an earlier game), and keep only Steam-console lines
timestamped at/after ``since`` — so we don't feed the model an unrelated past session.
"""
sections: list[str] = []
protons = _proton_logs()
if protons:
log = protons[0]
fresh = since is None or _mtime(log) >= since
tail = _tail(log, max_bytes).strip() if fresh else ""
if tail:
sections.append(f"--- Proton log ({log.name}) ---\n{tail}")
console = _steam_console()
if console:
raw = _tail(console, 40000 if since else max_bytes)
if since is not None:
raw = _since_filter(raw, since)
raw = raw.strip()[-max_bytes:].strip()
if raw:
sections.append(f"--- Steam log ({console.name}) ---\n{raw}")
return "\n\n".join(sections)
def _mtime(path: Path) -> float:
try:
return path.stat().st_mtime
except OSError:
return 0.0
+5
View File
@@ -318,6 +318,11 @@ def cached_games() -> list[Game]:
return [Game(**{k: g[k] for k in Game.__dataclass_fields__ if k in g}) for g in cache.get("games", [])] return [Game(**{k: g[k] for k in Game.__dataclass_fields__ if k in g}) for g in cache.get("games", [])]
def appid_names() -> dict[str, str]:
"""{appid: name} for the user's scanned games — lets us resolve IDs seen in logs (M14)."""
return {g.appid: g.name for g in cached_games() if g.appid and g.name}
def rescan(cfg: dict | None = None) -> ScanResult: def rescan(cfg: dict | None = None) -> ScanResult:
"""Scan the selected libraries, diff against the cache, and persist the result. """Scan the selected libraries, diff against the cache, and persist the result.
+141
View File
@@ -0,0 +1,141 @@
"""Session-scoped system logs for diagnostics (M15): kernel, coredumps, NVIDIA, display.
Covers what the *system* logged when something went wrong, so the report bundle and the AI both
see it:
* kernel ring-buffer slice (`journalctl -k`) Xid, OOM-killer, MCE, PCIe AER, thermal, hung tasks
* systemd-coredump records (`coredumpctl`) did the game/wine dump core (SIGSEGV/ABRT), when
* an `nvidia-smi -q` snapshot driver, throttle/clock-event reasons, clocks, power, temps, PCIe,
ECC + retired pages (point-in-time at diagnostic time)
* the display-server log `Xorg.0.log` on X11, or the compositor's user-journal slice on Wayland
Best-effort and size-bounded: degrades silently if a tool is missing or access is denied. Stdlib only.
"""
from __future__ import annotations
import os
import shutil
import subprocess
import time
from pathlib import Path
_MAX = 8000 # cap each log section so the prompt/report stays small
_NV_MAX = 10000 # nvidia-smi -q is structured + valuable; allow a bit more (head-truncated)
# Compositors whose user-journal entries are the "Wayland log" (OR-matched by journalctl).
_COMPOSITORS = ("gnome-shell", "mutter", "kwin_wayland", "Xwayland", "sway", "gamescope")
_XORG_LOGS = ("~/.local/share/xorg/Xorg.0.log", "/var/log/Xorg.0.log")
def _since_arg(since: float | None) -> str | None:
return time.strftime("%Y-%m-%d %H:%M:%S", time.localtime(since)) if since else None
def _run(cmd: list[str], timeout: float = 15.0) -> str:
try:
proc = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
except (OSError, subprocess.SubprocessError):
return ""
return (proc.stdout or "").strip()
def kernel_log(since: float | None = None, max_bytes: int = _MAX) -> str:
if not shutil.which("journalctl"):
return ""
cmd = ["journalctl", "-k", "--no-pager"]
since_arg = _since_arg(since)
if since_arg:
cmd += ["--since", since_arg]
out = _run(cmd)
if not out or out.strip().lower() == "-- no entries --": # journalctl's empty marker
return ""
return out[-max_bytes:]
def coredumps(since: float | None = None, max_bytes: int = _MAX) -> str:
if not shutil.which("coredumpctl"):
return ""
cmd = ["coredumpctl", "list", "--no-pager"]
since_arg = _since_arg(since)
if since_arg:
cmd += ["--since", since_arg]
out = _run(cmd)
if not out or "no coredumps" in out.lower():
return ""
return out[-max_bytes:]
def nvidia_snapshot(max_bytes: int = _NV_MAX) -> str:
"""Point-in-time `nvidia-smi -q` (head-truncated — driver/temps/clocks/ECC sit near the top)."""
if not shutil.which("nvidia-smi"):
return ""
out = _run(["nvidia-smi", "-q"])
return out[:max_bytes] if out else ""
def _xorg_log() -> Path | None:
for cand in _XORG_LOGS:
path = Path(os.path.expanduser(cand))
if path.exists():
return path
return None
def _session_type() -> str:
declared = os.environ.get("XDG_SESSION_TYPE", "").lower()
if declared in ("x11", "wayland"):
return declared
if os.environ.get("WAYLAND_DISPLAY"):
return "wayland"
return "x11" if _xorg_log() else "unknown"
def _tail_file(path: Path, max_bytes: int) -> str:
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 display_log(since: float | None = None, max_bytes: int = _MAX) -> str:
"""Xorg.0.log on X11, or the compositor's user-journal slice on Wayland ('' if none)."""
if _session_type() == "wayland":
if not shutil.which("journalctl"):
return ""
cmd = ["journalctl", "--user", "--no-pager"]
since_arg = _since_arg(since)
if since_arg:
cmd += ["--since", since_arg]
cmd += [f"_COMM={comp}" for comp in _COMPOSITORS] # OR-matched
out = _run(cmd)
if not out or out.strip().lower() == "-- no entries --":
return ""
return out[-max_bytes:]
log = _xorg_log() # X11: Xorg log isn't wall-clock-timestamped, so tail rather than scope
return _tail_file(log, max_bytes) if log else ""
def available() -> bool:
return bool(shutil.which("journalctl") or shutil.which("coredumpctl")
or shutil.which("nvidia-smi") or _xorg_log())
def collect(since: float | None = None) -> str:
"""Kernel + coredumps + NVIDIA snapshot + display log as one labelled block ('' if none)."""
sections: list[str] = []
kern = kernel_log(since)
if kern:
sections.append(f"--- Kernel log (journalctl -k) ---\n{kern}")
cores = coredumps(since)
if cores:
sections.append(f"--- Crashed processes (coredumpctl) ---\n{cores}")
nvidia = nvidia_snapshot()
if nvidia:
sections.append(f"--- NVIDIA snapshot (nvidia-smi -q) ---\n{nvidia}")
display = display_log(since)
if display:
sections.append(f"--- Display server log ({_session_type()}) ---\n{display}")
return "\n\n".join(sections)
+11 -1
View File
@@ -17,6 +17,10 @@ ICON = Path(__file__).parent / "assets" / "rigdoctor.svg"
def main(argv: list[str] | None = None) -> int: def main(argv: list[str] | None = None) -> int:
from ..core import applog
applog.setup() # opt-in app logging (M15); no-op unless logging_enabled
applog.get_logger(__name__).info("GUI starting")
desktop.ensure() # self-register icon + .desktop so updates show it without re-installing desktop.ensure() # self-register icon + .desktop so updates show it without re-installing
app = QApplication(argv if argv is not None else sys.argv) app = QApplication(argv if argv is not None else sys.argv)
app.setApplicationName("RigDoctor") app.setApplicationName("RigDoctor")
@@ -28,7 +32,8 @@ def main(argv: list[str] | None = None) -> int:
app.setStyle("Fusion") app.setStyle("Fusion")
app.setStyleSheet(STYLESHEET) app.setStyleSheet(STYLESHEET)
interval = float(load_config().get("interval", 1.0)) cfg = load_config()
interval = float(cfg.get("interval", 1.0))
window = MainWindow(interval=interval) window = MainWindow(interval=interval)
# `--tray` starts hidden to the system tray (for autostart); if no tray is available, # `--tray` starts hidden to the system tray (for autostart); if no tray is available,
# fall back to showing the window so the app is never invisible-and-unreachable. # fall back to showing the window so the app is never invisible-and-unreachable.
@@ -37,6 +42,11 @@ def main(argv: list[str] | None = None) -> int:
window.start_minimized_note() window.start_minimized_note()
else: else:
window.show() window.show()
# First run (or `--setup`): the graphical setup wizard (M9).
if "--setup" in args or not cfg.get("setup_done", False):
from .setup_wizard import SetupWizard
SetupWizard(window).exec()
return app.exec() return app.exec()
+150 -2
View File
@@ -2,15 +2,19 @@
from __future__ import annotations from __future__ import annotations
from PySide6.QtCore import Qt import threading
from PySide6.QtGui import QFont
from PySide6.QtCore import Qt, Signal
from PySide6.QtGui import QFont, QTextCursor
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,16 @@ from .widgets import finding_card
class DiagnosticDialog(QDialog): class DiagnosticDialog(QDialog):
_chunk = Signal(str) # streamed token delta (worker thread -> GUI)
_explained = Signal(object) # (ok, full_text) when the AI stream finishes
def __init__(self, result, parent=None) -> None: def __init__(self, result, parent=None) -> None:
super().__init__(parent) super().__init__(parent)
self._result = result
self._stream_view = None
self._stream_status = None
self._chunk.connect(self._on_chunk)
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 +85,145 @@ 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)
self._report_btn = QPushButton("Report") # zip this diagnostic's logs (M15)
self._report_btn.clicked.connect(self._make_report)
self._report_btn.setVisible(bool(result.dir)) # only when logging stored the session
buttons.addWidget(self._report_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) — streamed; 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)
dialog = self._open_stream_dialog()
threading.Thread(target=self._work_explain, daemon=True).start()
dialog.exec() # streaming fills the view live via signals during this nested loop
self._stream_view = self._stream_status = None
self._explain_btn.setEnabled(True)
def _work_explain(self) -> None:
from ..core import ai, gamelogs, syslogs
result = self._result
summary = result.summary
events = {kind for _ts, kind, _detail in summary.events}
clean = "session-stop" in events
gpu_lost = "gpu-lost" in events
lines = [f"Game: {result.game or 'unknown'}"]
if summary.start and summary.end:
lines.append(f"Capture duration: ~{int(summary.end - summary.start)}s")
outcome = "ended cleanly (no crash detected)" if clean else \
"ended without a clean stop (possible crash/freeze)"
if gpu_lost:
outcome += "; a GPU-lost event was recorded"
lines.append(f"Outcome: {outcome}")
lines.append("")
lines.append(ai.format_findings(result.findings, header="Findings:"))
lines.append("\nCapture summary:\n" + render_summary(summary))
since = (summary.start - 60) if summary.start else None
logs = gamelogs.collect(since=since) # scoped to this session
if logs:
lines.append("\nGame/Proton/Steam logs for this session:\n" + logs)
sys_logs = syslogs.collect(since=since) # kernel log + crashed-process records
if sys_logs:
lines.append("\nSystem logs for this session (kernel + crashed processes):\n" + sys_logs)
text = "\n".join(lines)
ok, reply = ai.explain_stream(text, on_chunk=lambda d: self._chunk.emit(d))
if result.dir: # record exactly what was sent, the model, and the reply (M15)
from ..core import diagstore
diagstore.record_ai(
result.dir, provider=ai.provider(), model=ai.model(),
system=ai.SYSTEM_PROMPT, prompt=ai.build_prompt(text),
response=reply if ok else f"[error] {reply}")
self._explained.emit((ok, reply))
def _on_chunk(self, delta: str) -> None:
if self._stream_view is None:
return
self._stream_view.moveCursor(QTextCursor.MoveOperation.End)
self._stream_view.insertPlainText(delta) # live plain text as tokens arrive
self._stream_view.ensureCursorVisible()
def _on_explained(self, result) -> None:
ok, text = result
if self._stream_view is not None:
if ok:
self._stream_view.setMarkdown(text) # re-render the finished answer as Markdown
else:
self._stream_view.setPlainText(f"AI explanation failed:\n\n{text}")
if self._stream_status is not None:
self._stream_status.setText(
"AI-generated suggestions — verify before acting, especially anything that changes "
"settings or data." if ok else "The request failed.")
# --- Report bundle (M15) ------------------------------------------------------
def _make_report(self) -> None:
from PySide6.QtCore import QUrl
from PySide6.QtGui import QDesktopServices
from ..core import diagstore
self._report_btn.setEnabled(False)
try:
out = diagstore.make_report(self._result.dir)
except OSError as exc:
self._report_btn.setEnabled(True)
QMessageBox.warning(self, "Report failed", str(exc))
return
self._report_btn.setEnabled(True)
box = QMessageBox(self)
box.setWindowTitle("Report created")
box.setText(f"Saved report:\n{out}\n\nIt contains this diagnostic's logs and any AI "
"interaction (data sent, model, and reply).")
open_btn = box.addButton("Open folder", QMessageBox.ButtonRole.ActionRole)
box.addButton("OK", QMessageBox.ButtonRole.AcceptRole)
box.exec()
if box.clickedButton() is open_btn:
QDesktopServices.openUrl(QUrl.fromLocalFile(str(out.parent)))
def _open_stream_dialog(self) -> QDialog:
"""A live dialog the AI streams into; finalized to rendered Markdown when done."""
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)
lay.addWidget(view)
status = QLabel("Streaming from the model…")
status.setObjectName("Muted")
status.setWordWrap(True)
lay.addWidget(status)
close = QPushButton("Close")
close.setObjectName("PrimaryButton")
close.clicked.connect(dlg.accept)
lay.addWidget(close, alignment=Qt.AlignmentFlag.AlignRight)
self._stream_view = view
self._stream_status = status
return dlg
+149 -1
View File
@@ -8,6 +8,7 @@ from PySide6.QtCore import Qt, QUrl, Signal
from PySide6.QtGui import QDesktopServices from PySide6.QtGui import QDesktopServices
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QApplication, QApplication,
QButtonGroup,
QCheckBox, QCheckBox,
QComboBox, QComboBox,
QDoubleSpinBox, QDoubleSpinBox,
@@ -18,6 +19,7 @@ from PySide6.QtWidgets import (
QLineEdit, QLineEdit,
QMessageBox, QMessageBox,
QPushButton, QPushButton,
QRadioButton,
QSizePolicy, QSizePolicy,
QTextEdit, QTextEdit,
QVBoxLayout, QVBoxLayout,
@@ -25,7 +27,7 @@ from PySide6.QtWidgets import (
) )
from .. import config from .. import config
from ..core import alerts, installer, service, sysenv, uninstall, updates from ..core import ai, alerts, installer, service, sysenv, uninstall, updates
from .theme import GOOD, MUTED, WARN from .theme import GOOD, MUTED, WARN
@@ -54,6 +56,7 @@ class SetupPage(QWidget):
_installed = Signal(int, str) _installed = Signal(int, str)
_upd_state = Signal(object) _upd_state = Signal(object)
_mode_applied = Signal(object) # (mode, ok, message) from a trigger-mode change _mode_applied = Signal(object) # (mode, ok, message) from a trigger-mode change
_ai_tested = Signal(object) # (ok, message) from an AI connectivity test
changed = Signal() # alert settings saved — main window re-applies them live changed = Signal() # alert settings saved — main window re-applies them live
def __init__(self) -> None: def __init__(self) -> None:
@@ -62,6 +65,7 @@ class SetupPage(QWidget):
self._installed.connect(self._on_installed) self._installed.connect(self._on_installed)
self._upd_state.connect(self._on_upd_state) self._upd_state.connect(self._on_upd_state)
self._mode_applied.connect(self._on_mode_applied) self._mode_applied.connect(self._on_mode_applied)
self._ai_tested.connect(self._on_ai_tested)
root = QVBoxLayout(self) root = QVBoxLayout(self)
root.setContentsMargins(20, 18, 20, 18) root.setContentsMargins(20, 18, 20, 18)
@@ -87,8 +91,11 @@ class SetupPage(QWidget):
self._install_btn.clicked.connect(self._install) self._install_btn.clicked.connect(self._install)
self._refresh_btn = QPushButton("Re-check") self._refresh_btn = QPushButton("Re-check")
self._refresh_btn.clicked.connect(self._refresh) self._refresh_btn.clicked.connect(self._refresh)
wizard_btn = QPushButton("Run setup wizard")
wizard_btn.clicked.connect(self._run_wizard)
controls.addWidget(self._install_btn) controls.addWidget(self._install_btn)
controls.addWidget(self._refresh_btn) controls.addWidget(self._refresh_btn)
controls.addWidget(wizard_btn)
controls.addStretch(1) controls.addStretch(1)
comp_layout.addLayout(controls) comp_layout.addLayout(controls)
root.addWidget(comp_card) root.addWidget(comp_card)
@@ -155,6 +162,76 @@ class SetupPage(QWidget):
self._trigger_status.setText("systemd --user isn't available on this system.") self._trigger_status.setText("systemd --user isn't available on this system.")
root.addWidget(trig_card) root.addWidget(trig_card)
# AI assistant (M14, D24): explain diagnostics. Strictly opt-in — the model is only
# contacted when the user presses "Explain with AI"; this panel just configures it.
ai_card, ai_layout = _panel("AI assistant")
ai_desc = QLabel(
"Optionally let an AI explain your diagnostics in plain language. It runs <b>only</b> "
"when you press “Explain with AI” — never automatically. Choose a provider:\n"
"• Ollama — a local model on your machine (private, no key; needs Ollama running).\n"
"• Claude — Anthropic's API (higher quality; sends findings to Anthropic; needs a key)."
)
ai_desc.setObjectName("Muted")
ai_desc.setWordWrap(True)
ai_layout.addWidget(ai_desc)
prov_row = QHBoxLayout()
self._ai_group = QButtonGroup(self)
self._ai_ollama = QRadioButton("Ollama (local)")
self._ai_claude = QRadioButton("Claude (Anthropic)")
self._ai_group.addButton(self._ai_ollama)
self._ai_group.addButton(self._ai_claude)
self._ai_ollama.toggled.connect(self._on_ai_provider_changed)
prov_row.addWidget(self._ai_ollama)
prov_row.addWidget(self._ai_claude)
prov_row.addStretch(1)
ai_layout.addLayout(prov_row)
self._ai_model = QLineEdit()
self._ai_model.setPlaceholderText(
f"Model (e.g. {ai.OLLAMA_SUGGESTED_MODEL} for Ollama; blank = Claude default)")
ai_layout.addWidget(self._ai_model)
self._ai_endpoint = QLineEdit()
self._ai_endpoint.setPlaceholderText("Ollama server URL (default http://localhost:11434)")
ai_layout.addWidget(self._ai_endpoint)
self._ai_key = QLineEdit()
self._ai_key.setEchoMode(QLineEdit.EchoMode.Password)
self._ai_key.setPlaceholderText("Claude API key (stored in your keyring)")
ai_layout.addWidget(self._ai_key)
ai_btn_row = QHBoxLayout()
ai_save = QPushButton("Save")
ai_save.setObjectName("PrimaryButton")
ai_save.clicked.connect(self._save_ai)
self._ai_test_btn = QPushButton("Test")
self._ai_test_btn.clicked.connect(self._test_ai)
ai_btn_row.addWidget(ai_save)
ai_btn_row.addWidget(self._ai_test_btn)
ai_btn_row.addStretch(1)
ai_layout.addLayout(ai_btn_row)
self._ai_status = QLabel("")
self._ai_status.setObjectName("Muted")
self._ai_status.setWordWrap(True)
ai_layout.addWidget(self._ai_status)
root.addWidget(ai_card)
# Logging (M15): opt-in app logging + per-diagnostic storage (enables the Report bundle).
log_card, log_layout = _panel("Logging")
log_desc = QLabel(
"Save application logs and store each diagnostic in its own folder so you can review "
"or <b>Report</b> it. Off by default; everything stays on your machine.\n"
f"• Diagnostics: {config.DIAGNOSTICS_DIR}\n"
f"• Reports: {config.REPORTS_DIR}"
)
log_desc.setObjectName("Muted")
log_desc.setWordWrap(True)
log_layout.addWidget(log_desc)
self._logging = QCheckBox("Enable logging (application + diagnostics)")
self._logging.setChecked(config.load_config().get("logging_enabled", False))
self._logging.toggled.connect(self._toggle_logging)
log_layout.addWidget(self._logging)
root.addWidget(log_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. "
@@ -200,8 +277,79 @@ class SetupPage(QWidget):
self._refresh() self._refresh()
self._load_alerts() self._load_alerts()
self._trigger.setCurrentText(config.load_config().get("trigger_mode", "manual")) self._trigger.setCurrentText(config.load_config().get("trigger_mode", "manual"))
self._load_ai()
self._refresh_update_status() self._refresh_update_status()
# --- AI assistant (M14) ---------------------------------------------------
def _load_ai(self) -> None:
cfg = config.load_config()
prov = cfg.get("ai_provider", "")
self._ai_claude.setChecked(prov == "claude")
self._ai_ollama.setChecked(prov == "ollama")
self._ai_model.setText(cfg.get("ai_model", ""))
self._ai_endpoint.setText(cfg.get("ai_endpoint", "http://localhost:11434"))
if config.load_ai_key():
self._ai_key.setPlaceholderText("Claude API key saved — type to replace")
self._on_ai_provider_changed()
def _ai_provider(self) -> str:
if self._ai_claude.isChecked():
return "claude"
if self._ai_ollama.isChecked():
return "ollama"
return ""
def _on_ai_provider_changed(self) -> None:
prov = self._ai_provider()
self._ai_endpoint.setVisible(prov == "ollama")
self._ai_key.setVisible(prov == "claude")
self._ai_test_btn.setEnabled(prov != "")
if prov == "ollama" and not self._ai_model.text().strip():
self._ai_model.setText(ai.OLLAMA_SUGGESTED_MODEL) # suggested default; user can change
def _save_ai(self) -> None:
prov = self._ai_provider()
config.update_config(
ai_provider=prov,
ai_model=self._ai_model.text().strip(),
ai_endpoint=self._ai_endpoint.text().strip() or "http://localhost:11434",
)
if prov == "claude" and self._ai_key.text().strip():
config.save_ai_key(self._ai_key.text().strip())
self._ai_key.clear()
self._ai_key.setPlaceholderText("Claude API key saved — type to replace")
self._ai_status.setText("Saved." if prov else "Saved — no provider selected (AI stays off).")
def _test_ai(self) -> None:
self._save_ai()
self._ai_status.setText("Testing… contacting the provider.")
self._ai_test_btn.setEnabled(False)
threading.Thread(target=self._work_test_ai, daemon=True).start()
def _work_test_ai(self) -> None:
from ..core import ai
ok, msg = ai.explain("Connectivity test — reply exactly: RigDoctor AI is working.")
self._ai_tested.emit((ok, msg))
def _on_ai_tested(self, result) -> None:
ok, msg = result
self._ai_test_btn.setEnabled(True)
self._ai_status.setText(("" if ok else "") + (msg[:200] if msg else ""))
def _toggle_logging(self, on: bool) -> None:
from ..core import applog
config.update_config(logging_enabled=on)
applog.setup(force=True) # attach/detach the file handler immediately
def _run_wizard(self) -> None:
from .setup_wizard import SetupWizard
SetupWizard(self).exec()
self._refresh()
self._trigger.setCurrentText(config.load_config().get("trigger_mode", "manual"))
# --- recording trigger (M9) ----------------------------------------------- # --- recording trigger (M9) -----------------------------------------------
def _apply_trigger(self) -> None: def _apply_trigger(self) -> None:
mode = self._trigger.currentText() mode = self._trigger.currentText()
+259
View File
@@ -0,0 +1,259 @@
"""First-run GUI setup wizard (M9): the full graphical installer/setup.
Bootstrap (Python venv + PySide6) is done by install.sh/.run; this wizard handles the rest
graphically environment summary pick dependency bundles install the missing apt packages
choose the recording trigger readiness summary. Shown automatically on first launch (until
`setup_done`), re-runnable from Settings, and launched by install.sh after a fresh install.
"""
from __future__ import annotations
import threading
from PySide6.QtCore import Qt, Signal
from PySide6.QtWidgets import (
QButtonGroup,
QCheckBox,
QDialog,
QHBoxLayout,
QLabel,
QPushButton,
QRadioButton,
QStackedWidget,
QTextEdit,
QVBoxLayout,
QWidget,
)
from .. import config
from ..core import catalog, installer, service, sysenv
class SetupWizard(QDialog):
_installed = Signal(int, str)
def __init__(self, parent=None) -> None:
super().__init__(parent)
self.setWindowTitle("RigDoctor Setup")
self.resize(620, 560)
self.setObjectName("Page")
self._installed.connect(self._on_installed)
self._bundle_checks: dict[str, QCheckBox] = {}
self._installing = False
root = QVBoxLayout(self)
root.setContentsMargins(22, 20, 22, 16)
root.setSpacing(14)
self._stack = QStackedWidget()
self._stack.addWidget(self._page_welcome()) # 0
self._stack.addWidget(self._page_bundles()) # 1
self._stack.addWidget(self._page_install()) # 2
self._stack.addWidget(self._page_trigger()) # 3
self._stack.addWidget(self._page_finish()) # 4
root.addWidget(self._stack, 1)
nav = QHBoxLayout()
self._skip_btn = QPushButton("Skip")
self._skip_btn.clicked.connect(self._skip)
self._back_btn = QPushButton("Back")
self._back_btn.clicked.connect(lambda: self._go(-1))
self._next_btn = QPushButton("Next")
self._next_btn.setObjectName("PrimaryButton")
self._next_btn.clicked.connect(lambda: self._go(1))
nav.addWidget(self._skip_btn)
nav.addStretch(1)
nav.addWidget(self._back_btn)
nav.addWidget(self._next_btn)
root.addLayout(nav)
self._index = 0
self._update_nav()
# --- pages -----------------------------------------------------------------
def _page(self, title: str, subtitle: str = "") -> tuple[QWidget, QVBoxLayout]:
page = QWidget()
v = QVBoxLayout(page)
v.setContentsMargins(0, 0, 0, 0)
v.setSpacing(10)
head = QLabel(title)
head.setObjectName("PageTitle")
v.addWidget(head)
if subtitle:
sub = QLabel(subtitle)
sub.setObjectName("Muted")
sub.setWordWrap(True)
v.addWidget(sub)
return page, v
def _page_welcome(self) -> QWidget:
page, v = self._page(
"Welcome to RigDoctor",
"Let's set up monitoring and diagnostics for your machine. This takes a minute and "
"needs no root for the app itself — only installing optional tools may ask for your "
"password.",
)
env = QLabel(
f"Detected:\n"
f" • Distro: {sysenv.distro_name()}\n"
f" • Package manager: {sysenv.package_manager() or 'none (apt required for extras)'}\n"
f" • GPU: {', '.join(sysenv.gpu_vendors()) or 'unknown'}"
)
env.setObjectName("Muted")
v.addWidget(env)
v.addStretch(1)
return page
def _page_bundles(self) -> QWidget:
page, v = self._page(
"Choose what to set up",
"Pick the optional tool bundles to install. Core monitoring, crash capture, and the "
"health report work without any of these — they just add capability.",
)
present = {c.id: ok for c, ok in installer.component_status()}
for bundle, comps in catalog.by_bundle().items():
missing = [c for c in comps if not present.get(c.id)]
names = ", ".join(c.name for c in comps)
tag = " — all installed ✓" if not missing else f"{len(missing)} to install"
cb = QCheckBox(f"{bundle}: {names}{tag}")
cb.setChecked(bool(missing)) # default-check bundles with something to add
cb.setEnabled(sysenv.package_manager() == "apt") # selectable even if already installed
self._bundle_checks[bundle] = cb
v.addWidget(cb)
if sysenv.package_manager() != "apt":
note = QLabel("Only apt is supported for installing tools, so these are read-only here.")
note.setObjectName("Muted")
note.setWordWrap(True)
v.addWidget(note)
v.addStretch(1)
return page
def _page_install(self) -> QWidget:
page, v = self._page("Install tools", "Installing the selected packages…")
self._install_status = QLabel("")
self._install_status.setObjectName("Muted")
self._install_status.setWordWrap(True)
v.addWidget(self._install_status)
self._install_output = QTextEdit()
self._install_output.setObjectName("Report")
self._install_output.setReadOnly(True)
v.addWidget(self._install_output, 1)
return page
def _page_trigger(self) -> QWidget:
page, v = self._page(
"Recording trigger",
"When the crash logger runs. You can change this any time in Settings.",
)
self._trigger_group = QButtonGroup(self)
labels = {
"manual": "Manual — start/stop recording yourself.",
"always-on": "Always-on — a background service records continuously.",
"game-launch": "Game-launch — auto-record while a Steam game runs.",
}
for i, (mode, text) in enumerate(labels.items()):
rb = QRadioButton(text)
rb.setProperty("mode", mode)
rb.setChecked(mode == config.load_config().get("trigger_mode", "manual"))
self._trigger_group.addButton(rb, i)
v.addWidget(rb)
if not service.available():
note = QLabel("systemd --user isn't available, so always-on / game-launch can't be enabled here.")
note.setObjectName("Muted")
note.setWordWrap(True)
v.addWidget(note)
v.addStretch(1)
return page
def _page_finish(self) -> QWidget:
page, v = self._page("You're all set", "")
self._finish_summary = QLabel("")
self._finish_summary.setObjectName("Muted")
self._finish_summary.setWordWrap(True)
v.addWidget(self._finish_summary)
v.addStretch(1)
return page
# --- navigation ------------------------------------------------------------
def _go(self, delta: int) -> None:
if self._installing:
return
new = self._index + delta
if new < 0:
return
if new >= self._stack.count(): # past the last page → finish
self._finish()
return
self._index = new
self._stack.setCurrentIndex(new)
self._update_nav()
if new == 2: # entering the install page
self._run_install()
elif new == 4: # entering the finish page
self._fill_summary()
def _update_nav(self) -> None:
self._back_btn.setEnabled(self._index > 0 and not self._installing)
last = self._index == self._stack.count() - 1
self._next_btn.setText("Finish" if last else "Next")
self._skip_btn.setVisible(not last)
def _selected_components(self):
present = {c.id: ok for c, ok in installer.component_status()}
chosen = []
for bundle, comps in catalog.by_bundle().items():
if self._bundle_checks.get(bundle) and self._bundle_checks[bundle].isChecked():
chosen += [c for c in comps if not present.get(c.id)]
return chosen
def _run_install(self) -> None:
packages = installer.missing_packages(self._selected_components())
if not packages:
self._install_status.setText("Nothing to install — your selected tools are already present.")
self._install_output.setVisible(False)
return
self._installing = True
self._update_nav()
self._next_btn.setEnabled(False)
self._install_status.setText("Installing… you may be asked for your password.")
self._install_output.setVisible(True)
self._install_output.setPlainText(f"Installing: {' '.join(packages)}\n")
threading.Thread(target=lambda: self._installed.emit(*installer.install_packages(packages)), daemon=True).start()
def _on_installed(self, rc: int, out: str) -> None:
self._installing = False
self._install_output.setPlainText(out[-4000:])
self._install_status.setText("Done." if rc == 0 else "Some packages may not have installed — see the log.")
self._next_btn.setEnabled(True)
self._update_nav()
def _fill_summary(self) -> None:
from ..core.sources import available_sources
status = installer.component_status()
present = sum(1 for _c, ok in status if ok)
sources = len(available_sources())
mode = self._chosen_mode()
self._finish_summary.setText(
f"• Optional tools present: {present}/{len(status)}\n"
f"• Sensor sources detected: {sources}\n"
f"• Recording trigger: {mode}\n\n"
"You can re-run this wizard or change anything from Settings."
)
def _chosen_mode(self) -> str:
btn = self._trigger_group.checkedButton()
return btn.property("mode") if btn else "manual"
def _finish(self) -> None:
mode = self._chosen_mode()
if service.available():
service.apply_mode(mode)
else:
config.update_config(trigger_mode=mode)
config.update_config(setup_done=True)
self.accept()
def _skip(self) -> None:
config.update_config(setup_done=True)
self.reject()
+18
View File
@@ -144,6 +144,24 @@ QCheckBox::indicator:hover {{ border-color: {ACCENT}; }}
QCheckBox::indicator:checked {{ QCheckBox::indicator:checked {{
background: {ACCENT}; border-color: {ACCENT}; image: url("{_CHECK}"); background: {ACCENT}; border-color: {ACCENT}; image: url("{_CHECK}");
}} }}
QCheckBox::indicator:disabled {{ border-color: #3a414d; background: #1c2026; }}
QCheckBox::indicator:checked:disabled {{ background: #2a6175; border-color: #2a6175; }}
QCheckBox:disabled {{ color: {MUTED}; }}
/* Radio buttons same dark treatment as checkboxes; the selected one gets a clear
accent dot (Fusion leaves these unstyled = the selection is invisible on dark). */
QRadioButton {{ spacing: 8px; background: transparent; }}
QRadioButton::indicator {{
width: 17px; height: 17px; border-radius: 9px;
border: 1px solid {MUTED}; background: #262b34;
}}
QRadioButton::indicator:hover {{ border-color: {ACCENT}; }}
QRadioButton::indicator:checked {{
border: 1px solid {ACCENT};
background: qradialgradient(cx:0.5, cy:0.5, radius:0.5, fx:0.5, fy:0.5,
stop:0 {ACCENT}, stop:0.5 {ACCENT}, stop:0.55 #262b34, stop:1 #262b34);
}}
QRadioButton:disabled {{ color: {MUTED}; }}
/* Dialogs (update prompt, changelog) match the dark theme so text is readable. */ /* Dialogs (update prompt, changelog) match the dark theme so text is readable. */
QDialog {{ background: {BG}; }} QDialog {{ background: {BG}; }}
+164
View File
@@ -0,0 +1,164 @@
"""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)
def test_appid_glossary_resolves_known_ids(self):
from rigdoctor.core import steam
with mock.patch.object(steam, "appid_names", return_value={"2694490": "Path of Exile 2"}):
glossary = ai.appid_glossary("Steam log: removed AppID 2694490 ... pid 130544")
self.assertIn("2694490 = Path of Exile 2", glossary)
def test_appid_glossary_ignores_unknown_ids(self):
from rigdoctor.core import steam
with mock.patch.object(steam, "appid_names", return_value={"570": "Dota 2"}):
self.assertEqual(ai.appid_glossary("pid 130544 used 8192 MiB"), "") # not in library
def test_build_prompt_includes_glossary(self):
from rigdoctor.core import steam
with mock.patch.object(steam, "appid_names", return_value={"2694490": "Path of Exile 2"}):
prompt = ai.build_prompt("AppID 2694490 launched")
self.assertIn("Path of Exile 2", prompt)
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")
class _FakeResp:
"""A context-managed iterable of byte lines, like urlopen() returns."""
def __init__(self, lines):
self._lines = [l.encode("utf-8") for l in lines]
def __enter__(self):
return iter(self._lines)
def __exit__(self, *a):
return False
class StreamTests(unittest.TestCase):
def _cfg(self, **over):
base = {"ai_provider": "", "ai_model": "", "ai_endpoint": "http://localhost:11434"}
base.update(over)
return base
def test_ollama_stream_accumulates_and_callbacks(self):
lines = ['{"response": "It is ", "done": false}',
'{"response": "the PSU.", "done": false}',
'{"response": "", "done": true}']
chunks = []
with mock.patch.object(ai.config, "load_config",
return_value=self._cfg(ai_provider="ollama", ai_model="qwen2.5:7b")), \
mock.patch.object(ai, "_stream_request", return_value=_FakeResp(lines)):
ok, full = ai.explain_stream("Xid 79", on_chunk=chunks.append)
self.assertTrue(ok)
self.assertEqual(full, "It is the PSU.")
self.assertEqual(chunks, ["It is ", "the PSU."])
def test_claude_stream_parses_sse(self):
lines = [
'event: content_block_delta',
'data: {"type":"content_block_delta","delta":{"type":"text_delta","text":"Failing "}}',
'data: {"type":"content_block_delta","delta":{"type":"text_delta","text":"disk."}}',
'data: {"type":"message_stop"}',
]
chunks = []
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, "_stream_request", return_value=_FakeResp(lines)):
ok, full = ai.explain_stream("SMART 197", on_chunk=chunks.append)
self.assertTrue(ok)
self.assertEqual(full, "Failing disk.")
self.assertEqual(chunks, ["Failing ", "disk."])
if __name__ == "__main__":
unittest.main()
+104
View File
@@ -0,0 +1,104 @@
"""Tests for M15 per-diagnostic storage + Report bundles + app logging."""
import json
import tempfile
import unittest
import zipfile
from dataclasses import dataclass, field
from pathlib import Path
from unittest import mock
from rigdoctor.core import applog, diagstore
@dataclass
class FakeSummary:
start: float = 1.0
end: float = 2.0
samples: int = 3
events: list = field(default_factory=list)
@dataclass
class FakeFinding:
severity: str = "ok"
category: str = "GPU"
title: str = "Looks fine"
detail: str = "no issues"
@dataclass
class FakeResult:
game: str = "Path of Exile 2"
summary: FakeSummary = field(default_factory=FakeSummary)
findings: list = field(default_factory=lambda: [FakeFinding()])
dir: str | None = None
class StoreTests(unittest.TestCase):
def setUp(self):
self.tmp = Path(tempfile.mkdtemp())
def test_disabled_returns_none(self):
with mock.patch.object(diagstore, "enabled", return_value=False):
self.assertIsNone(diagstore.store(FakeResult()))
def test_store_writes_artifacts(self):
with mock.patch.object(diagstore, "enabled", return_value=True), \
mock.patch("rigdoctor.render.render_summary", return_value="SUMMARY-TEXT"), \
mock.patch("rigdoctor.core.gamelogs.collect", return_value="LOG-TEXT"), \
mock.patch("rigdoctor.core.syslogs.collect", return_value="SYS-LOG"), \
mock.patch("rigdoctor.core.inventory.collect", return_value=[]), \
mock.patch.object(diagstore.config, "DIAGNOSTICS_DIR", self.tmp / "diagnostics"):
directory = diagstore.store(FakeResult())
self.assertTrue((directory / "result.json").exists())
self.assertTrue((directory / "report.txt").exists())
self.assertEqual((directory / "gamelogs.txt").read_text(), "LOG-TEXT")
self.assertEqual((directory / "syslogs.txt").read_text(), "SYS-LOG")
self.assertTrue((directory / "inventory.txt").exists()) # inventory included for debugging
data = json.loads((directory / "result.json").read_text())
self.assertEqual(data["game"], "Path of Exile 2")
self.assertEqual(len(data["findings"]), 1)
def test_record_ai_then_report_includes_ai_and_applog(self):
diag = self.tmp / "20260522-poe2"
diag.mkdir()
diagstore.record_ai(diag, provider="claude", model="claude-opus-4-7",
system="SYS", prompt="EXACT DATA SENT", response="THE REPLY")
ai_files = list((diag / "ai").glob("explain-*.json"))
self.assertTrue(ai_files)
record = json.loads(ai_files[0].read_text())
self.assertEqual(record["model"], "claude-opus-4-7")
self.assertEqual(record["data_sent_to_model"], "EXACT DATA SENT")
self.assertEqual(record["model_reply"], "THE REPLY")
app_log = self.tmp / "app.log"
app_log.write_text("app log line")
with mock.patch.object(diagstore.config, "REPORTS_DIR", self.tmp / "reports"), \
mock.patch.object(diagstore.config, "APP_LOG", app_log):
out = diagstore.make_report(diag)
self.assertTrue(out.exists())
with zipfile.ZipFile(out) as zf:
names = zf.namelist()
self.assertTrue(any(n.endswith("app.log") for n in names))
self.assertTrue(any("/ai/explain-" in n for n in names))
class AppLogTests(unittest.TestCase):
def test_disabled_is_noop(self):
with mock.patch.object(applog.config, "load_config", return_value={"logging_enabled": False}):
self.assertFalse(applog.setup(force=True))
def test_enabled_writes_file(self):
tmp = Path(tempfile.mkdtemp())
with mock.patch.object(applog.config, "load_config", return_value={"logging_enabled": True}), \
mock.patch.object(applog.config, "STATE_DIR", tmp), \
mock.patch.object(applog.config, "APP_LOG", tmp / "app.log"):
self.assertTrue(applog.setup(force=True))
applog.get_logger("test").info("hello world")
applog.setup(force=True) # cleanup path: re-run detaches/reattaches cleanly
self.assertTrue((tmp / "app.log").exists())
if __name__ == "__main__":
unittest.main()
+77
View File
@@ -0,0 +1,77 @@
"""Tests for M14 game/Proton/Steam log collection."""
import os
import tempfile
import time
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(), "")
class SinceScopingTests(unittest.TestCase):
def test_since_filter_keeps_window_only(self):
text = (
"[2026-05-22 13:00:00] old session line\n"
"[2026-05-22 13:00:01] another old line\n"
"[2026-05-22 14:30:00] new session launch\n"
"[2026-05-22 14:30:05] new session error\n"
)
since = time.mktime(time.strptime("2026-05-22 14:00:00", "%Y-%m-%d %H:%M:%S"))
out = gamelogs._since_filter(text, since)
self.assertIn("new session launch", out)
self.assertIn("new session error", out)
self.assertNotIn("old session", out)
def test_collect_skips_stale_proton_log(self):
tmp = Path(tempfile.mkdtemp())
proton = tmp / "steam-9999.log"
proton.write_text("stale proton output from an earlier game")
old_mtime = time.time() - 3600
os.utime(proton, (old_mtime, old_mtime))
since = time.time() - 60 # session started a minute ago
with mock.patch.object(gamelogs, "_proton_logs", return_value=[proton]), \
mock.patch.object(gamelogs, "_steam_console", return_value=None):
self.assertEqual(gamelogs.collect(since=since), "") # stale log excluded
if __name__ == "__main__":
unittest.main()
+7
View File
@@ -64,6 +64,13 @@ class GuiSmokeTests(unittest.TestCase):
self.assertIn("14.2 / 31.0 GB", tray._mem_act.text()) self.assertIn("14.2 / 31.0 GB", tray._mem_act.text())
self.assertEqual(tray._status_act.text(), "● Normal") self.assertEqual(tray._status_act.text(), "● Normal")
def test_setup_wizard_constructs(self):
from rigdoctor.gui.setup_wizard import SetupWizard
wizard = SetupWizard()
self.assertEqual(wizard._stack.count(), 5) # welcome/bundles/install/trigger/finish
self.assertTrue(wizard._bundle_checks)
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()
+8 -1
View File
@@ -2,7 +2,7 @@
import unittest import unittest
from rigdoctor.core import installer from rigdoctor.core import catalog, installer
from rigdoctor.core.catalog import Component from rigdoctor.core.catalog import Component
from rigdoctor.core.updates import is_newer from rigdoctor.core.updates import is_newer
@@ -31,6 +31,13 @@ class InstallerTests(unittest.TestCase):
rc, _ = installer.install_packages([]) rc, _ = installer.install_packages([])
self.assertEqual(rc, 0) self.assertEqual(rc, 0)
def test_by_bundle_groups_all_components(self):
groups = catalog.by_bundle()
flat = [c for comps in groups.values() for c in comps]
self.assertEqual(len(flat), len(catalog.COMPONENTS))
self.assertIn("Gaming", groups)
self.assertIn("Diagnostics", groups)
class UpdateTests(unittest.TestCase): class UpdateTests(unittest.TestCase):
def test_is_newer(self): def test_is_newer(self):
+95
View File
@@ -0,0 +1,95 @@
"""Tests for M15 session-scoped system-log collection (kernel + coredumps)."""
import unittest
from unittest import mock
from rigdoctor.core import syslogs
class KernelLogTests(unittest.TestCase):
def test_passes_since_and_tails(self):
with mock.patch("shutil.which", return_value="/usr/bin/journalctl"), \
mock.patch.object(syslogs, "_run", return_value="X" * 100 + "TAILLINE") as run:
out = syslogs.kernel_log(since=1_000_000_000, max_bytes=8)
self.assertEqual(out, "TAILLINE")
cmd = run.call_args[0][0]
self.assertIn("-k", cmd)
self.assertIn("--since", cmd)
def test_missing_tool_returns_empty(self):
with mock.patch("shutil.which", return_value=None):
self.assertEqual(syslogs.kernel_log(), "")
class CoredumpTests(unittest.TestCase):
def test_empty_when_no_coredumps(self):
with mock.patch("shutil.which", return_value="/usr/bin/coredumpctl"), \
mock.patch.object(syslogs, "_run", return_value="No coredumps found."):
self.assertEqual(syslogs.coredumps(), "")
def test_returns_list(self):
with mock.patch("shutil.which", return_value="/usr/bin/coredumpctl"), \
mock.patch.object(syslogs, "_run", return_value="TIME PID SIG EXE\n... SEGV PathOfExile"):
out = syslogs.coredumps()
self.assertIn("PathOfExile", out)
class NvidiaTests(unittest.TestCase):
def test_missing_tool(self):
with mock.patch("shutil.which", return_value=None):
self.assertEqual(syslogs.nvidia_snapshot(), "")
def test_snapshot_head_truncated(self):
with mock.patch("shutil.which", return_value="/usr/bin/nvidia-smi"), \
mock.patch.object(syslogs, "_run", return_value="DRIVER\n" + "x" * 99999):
out = syslogs.nvidia_snapshot(max_bytes=10)
self.assertEqual(out, "DRIVER\nxxx") # head, not tail
class DisplayTests(unittest.TestCase):
def test_session_type_env(self):
with mock.patch.dict("os.environ", {"XDG_SESSION_TYPE": "wayland"}):
self.assertEqual(syslogs._session_type(), "wayland")
def test_x11_tails_xorg_log(self):
import tempfile
from pathlib import Path
log = Path(tempfile.mkdtemp()) / "Xorg.0.log"
log.write_text("(EE) NVIDIA(GPU-0): something failed")
with mock.patch.object(syslogs, "_session_type", return_value="x11"), \
mock.patch.object(syslogs, "_xorg_log", return_value=log):
out = syslogs.display_log()
self.assertIn("(EE) NVIDIA", out)
def test_wayland_uses_user_journal(self):
with mock.patch.object(syslogs, "_session_type", return_value="wayland"), \
mock.patch("shutil.which", return_value="/usr/bin/journalctl"), \
mock.patch.object(syslogs, "_run", return_value="gnome-shell: GPU error") as run:
out = syslogs.display_log(since=1_000_000_000)
self.assertIn("GPU error", out)
cmd = run.call_args[0][0]
self.assertIn("--user", cmd)
self.assertTrue(any(a.startswith("_COMM=") for a in cmd))
class CollectTests(unittest.TestCase):
def test_collect_combines_sections(self):
with mock.patch.object(syslogs, "kernel_log", return_value="NVRM: Xid 79"), \
mock.patch.object(syslogs, "coredumps", return_value="game SIGSEGV"), \
mock.patch.object(syslogs, "nvidia_snapshot", return_value="Driver Version 595"), \
mock.patch.object(syslogs, "display_log", return_value="(EE) NVIDIA"):
out = syslogs.collect()
for needle in ("Kernel log", "Xid 79", "Crashed processes", "SIGSEGV",
"NVIDIA snapshot", "595", "Display server log"):
self.assertIn(needle, out)
def test_collect_empty_when_nothing(self):
with mock.patch.object(syslogs, "kernel_log", return_value=""), \
mock.patch.object(syslogs, "coredumps", return_value=""), \
mock.patch.object(syslogs, "nvidia_snapshot", return_value=""), \
mock.patch.object(syslogs, "display_log", return_value=""):
self.assertEqual(syslogs.collect(), "")
if __name__ == "__main__":
unittest.main()