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>
This commit is contained in:
@@ -5,6 +5,17 @@ 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.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
|
## [0.29.0] - 2026-05-22
|
||||||
### Added
|
### Added
|
||||||
- **AI now resolves Steam app IDs from your library instead of guessing.** When app IDs appear
|
- **AI now resolves Steam app IDs from your library instead of guessing.** When app IDs appear
|
||||||
|
|||||||
+13
-1
@@ -264,9 +264,21 @@ root cause + suggested next steps). Adds M14 to the D14 set.
|
|||||||
as suggestions (consistent with D9 — it explains/recommends, applying fixes stays
|
as suggestions (consistent with D9 — it explains/recommends, applying fixes stays
|
||||||
consent-gated). No new runtime dependency (HTTP via stdlib).
|
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 (D1–D24) are resolved. New questions will be added
|
None currently — all tracked decisions (D1–D25) 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`).
|
||||||
|
|||||||
+10
-1
@@ -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).
|
||||||
|
|
||||||
@@ -21,6 +22,7 @@ Status: ⬜ not started · 🟦 designing · 🟨 in progress · ✅ done
|
|||||||
| 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 | ✅ |
|
| 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
|
||||||
@@ -128,6 +130,13 @@ Status: ⬜ not started · 🟦 designing · 🟨 in progress · ✅ done
|
|||||||
which lifts a small local model and sharpens Claude. Stdlib `urllib` (no pip deps); output is
|
which lifts a small local model and sharpens Claude. Stdlib `urllib` (no pip deps); output is
|
||||||
advisory (D9). Configure in **Settings → AI assistant**.
|
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`, scoped game logs, 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`). 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)*
|
||||||
- **Monitoring:** M2 + M8
|
- **Monitoring:** M2 + M8
|
||||||
|
|||||||
@@ -97,6 +97,13 @@ Ubuntu + NVIDIA first; `.deb` distribution (see `DECISIONS.md`).
|
|||||||
- [ ] *Possible follow-ups:* interactive chat grounded in the data; more reference-KB entries;
|
- [ ] *Possible follow-ups:* interactive chat grounded in the data; more reference-KB entries;
|
||||||
an "Explain" button on the System Health page.
|
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.
|
||||||
|
|
||||||
|
|||||||
@@ -162,6 +162,15 @@ the actual findings plus matched reference facts from a curated, exact-match kno
|
|||||||
("RAG-lite" — no embeddings/vector store, stdlib only); no fine-tuning. HTTP via stdlib `urllib`
|
("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).
|
(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, scoped game logs, and a record of every AI interaction — the
|
||||||
|
exact data sent, the model, and its reply). 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
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "rigdoctor"
|
name = "rigdoctor"
|
||||||
version = "0.29.0"
|
version = "0.30.0"
|
||||||
description = "Modular hardware monitoring & crash diagnostics for Linux gamers."
|
description = "Modular hardware monitoring & crash diagnostics for Linux gamers."
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
"""RigDoctor — modular hardware monitoring & crash diagnostics for Linux gamers."""
|
"""RigDoctor — modular hardware monitoring & crash diagnostics for Linux gamers."""
|
||||||
|
|
||||||
__version__ = "0.29.0"
|
__version__ = "0.30.0"
|
||||||
|
|||||||
@@ -472,6 +472,23 @@ def cmd_ai(args) -> int:
|
|||||||
return 0 if ok else 1
|
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
|
||||||
|
|
||||||
@@ -686,10 +703,16 @@ def build_parser() -> argparse.ArgumentParser:
|
|||||||
ai_sub.add_parser("test", help="send a tiny probe to verify connectivity").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_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)
|
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)
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,12 @@ 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.
|
||||||
@@ -190,6 +196,7 @@ DEFAULTS: dict = {
|
|||||||
"ai_provider": "", # AI assistant (M14, D24): "" (unset) | "ollama" | "claude"
|
"ai_provider": "", # AI assistant (M14, D24): "" (unset) | "ollama" | "claude"
|
||||||
"ai_model": "", # model name (e.g. "llama3.1" for Ollama; blank = Claude default)
|
"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)
|
"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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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__
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -0,0 +1,136 @@
|
|||||||
|
"""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
|
||||||
|
|
||||||
|
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
|
||||||
|
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
|
||||||
@@ -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")
|
||||||
|
|||||||
@@ -86,6 +86,10 @@ class DiagnosticDialog(QDialog):
|
|||||||
from ..core import ai
|
from ..core import ai
|
||||||
self._explain_btn.setVisible(ai.is_configured()) # opt-in only; hidden if not set up
|
self._explain_btn.setVisible(ai.is_configured()) # opt-in only; hidden if not set up
|
||||||
buttons.addWidget(self._explain_btn)
|
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")
|
||||||
@@ -135,7 +139,15 @@ class DiagnosticDialog(QDialog):
|
|||||||
logs = gamelogs.collect(since=since) # scoped to this session
|
logs = gamelogs.collect(since=since) # scoped to this session
|
||||||
if logs:
|
if logs:
|
||||||
lines.append("\nGame/Proton/Steam logs for this session:\n" + logs)
|
lines.append("\nGame/Proton/Steam logs for this session:\n" + logs)
|
||||||
self._explained.emit(ai.explain("\n".join(lines)))
|
text = "\n".join(lines)
|
||||||
|
ok, reply = ai.explain(text)
|
||||||
|
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_explained(self, result) -> None:
|
def _on_explained(self, result) -> None:
|
||||||
ok, text = result
|
ok, text = result
|
||||||
@@ -143,6 +155,31 @@ class DiagnosticDialog(QDialog):
|
|||||||
self._explain_btn.setText("Explain with AI")
|
self._explain_btn.setText("Explain with AI")
|
||||||
self._show_explanation(text if ok else f"AI explanation failed:\n\n{text}")
|
self._show_explanation(text if ok else f"AI explanation failed:\n\n{text}")
|
||||||
|
|
||||||
|
# --- 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 _show_explanation(self, text: str) -> None:
|
def _show_explanation(self, text: str) -> None:
|
||||||
from ..core import ai
|
from ..core import ai
|
||||||
|
|
||||||
|
|||||||
@@ -215,6 +215,23 @@ class SetupPage(QWidget):
|
|||||||
ai_layout.addWidget(self._ai_status)
|
ai_layout.addWidget(self._ai_status)
|
||||||
root.addWidget(ai_card)
|
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. "
|
||||||
@@ -320,6 +337,12 @@ class SetupPage(QWidget):
|
|||||||
self._ai_test_btn.setEnabled(True)
|
self._ai_test_btn.setEnabled(True)
|
||||||
self._ai_status.setText(("✓ " if ok else "✗ ") + (msg[:200] if msg else ""))
|
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:
|
def _run_wizard(self) -> None:
|
||||||
from .setup_wizard import SetupWizard
|
from .setup_wizard import SetupWizard
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,100 @@
|
|||||||
|
"""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.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")
|
||||||
|
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()
|
||||||
Reference in New Issue
Block a user