diff --git a/CHANGELOG.md b/CHANGELOG.md index 93bf5aa..291baa6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,16 @@ All notable changes to RigDoctor are recorded here. Format follows (`MAJOR.MINOR.PATCH`, pre-1.0). `__version__` and `pyproject.toml` must match the git release tag (so the auto-updater, D18, can compare versions). +## [0.28.0] - 2026-05-22 +### Added +- **AI explanations now include recent game logs.** When you press "Explain with AI" on a + diagnostic, RigDoctor also gathers recent **Proton** (`~/steam-.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 diff --git a/pyproject.toml b/pyproject.toml index f657848..a0fc176 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "rigdoctor" -version = "0.27.1" +version = "0.28.0" description = "Modular hardware monitoring & crash diagnostics for Linux gamers." readme = "README.md" requires-python = ">=3.11" diff --git a/src/rigdoctor/__init__.py b/src/rigdoctor/__init__.py index 379d0a2..b93f7f6 100644 --- a/src/rigdoctor/__init__.py +++ b/src/rigdoctor/__init__.py @@ -1,3 +1,3 @@ """RigDoctor — modular hardware monitoring & crash diagnostics for Linux gamers.""" -__version__ = "0.27.1" +__version__ = "0.28.0" diff --git a/src/rigdoctor/core/ai.py b/src/rigdoctor/core/ai.py index 12fe33f..b4925e6 100644 --- a/src/rigdoctor/core/ai.py +++ b/src/rigdoctor/core/ai.py @@ -34,12 +34,14 @@ ANTHROPIC_VERSION = "2023-06-01" SYSTEM_PROMPT = ( "You are RigDoctor's hardware-diagnostics assistant for Linux gamers. You are given the " - "structured findings RigDoctor collected from this machine, and a set of reference facts. " - "Explain in plain language what the findings mean, identify the most likely root cause of " - "any problem, and give concrete, ordered next steps (exact commands where useful). Base " - "your reasoning ONLY on the findings and reference facts provided — do not invent readings, " - "hardware, or log lines. Be concise and practical. Present fixes as suggestions, and clearly " - "warn before any step that could cause data loss or instability." + "structured findings RigDoctor collected from this machine — which may include recent game, " + "Proton, and system log excerpts — plus a set of reference facts. Explain in plain language " + "what they mean, correlate any log errors with the findings to pinpoint WHEN and WHY things " + "went wrong, identify the most likely root cause, and give concrete, ordered next steps " + "(exact commands where useful). Base your reasoning ONLY on the data and reference facts " + "provided — do not invent readings, hardware, or log lines. Be concise and practical. " + "Present fixes as suggestions, and clearly warn before any step that could cause data loss " + "or instability. Format your answer in Markdown." ) diff --git a/src/rigdoctor/core/gamelogs.py b/src/rigdoctor/core/gamelogs.py new file mode 100644 index 0000000..f0c5a9b --- /dev/null +++ b/src/rigdoctor/core/gamelogs.py @@ -0,0 +1,67 @@ +"""Collect recent game / Proton / Steam logs to enrich an AI diagnostic (M14). + +Reads logs that already exist on disk — no change to how the game is launched. Two reliable +sources: Proton's per-app log (``~/steam-.log``, written when ``PROTON_LOG=1``) and +Steam's own console log. Each is tail-read and size-bounded so the AI prompt stays small. The +text is fed to the AI alongside the findings so it can see *when* something went wrong (a +vkd3d/DXVK error, a crash line, the exit code) rather than only the sensor summary. +""" + +from __future__ import annotations + +import os +from pathlib import Path + +# Steam keeps logs under its install root; ~/.steam/steam usually symlinks to the real one. +_STEAM_LOG_DIRS = ("~/.steam/steam/logs", "~/.local/share/Steam/logs", "~/.steam/root/logs") +_STEAM_LOG_FILES = ("console-linux.txt", "console_log.txt", "stderr.txt") + + +def _tail(path: Path, max_bytes: int) -> str: + """Last ``max_bytes`` of a file, decoded leniently (empty string on error).""" + try: + size = path.stat().st_size + with path.open("rb") as fh: + if size > max_bytes: + fh.seek(size - max_bytes) + return fh.read().decode("utf-8", "replace") + except OSError: + return "" + + +def _proton_logs() -> list[Path]: + try: + logs = list(Path.home().glob("steam-*.log")) + except OSError: + return [] + return sorted(logs, key=lambda p: p.stat().st_mtime, reverse=True) + + +def _steam_console() -> Path | None: + for directory in _STEAM_LOG_DIRS: + base = Path(os.path.expanduser(directory)) + for name in _STEAM_LOG_FILES: + candidate = base / name + if candidate.exists(): + return candidate + return None + + +def available() -> bool: + return bool(_proton_logs() or _steam_console()) + + +def collect(max_bytes: int = 6000) -> str: + """Recent Proton + Steam log tails as one labelled text block ('' if none).""" + sections: list[str] = [] + protons = _proton_logs() + if protons: + tail = _tail(protons[0], max_bytes).strip() + if tail: + sections.append(f"--- Proton log ({protons[0].name}) ---\n{tail}") + console = _steam_console() + if console: + tail = _tail(console, max_bytes).strip() + if tail: + sections.append(f"--- Steam log ({console.name}) ---\n{tail}") + return "\n\n".join(sections) diff --git a/src/rigdoctor/gui/diagnostic_dialog.py b/src/rigdoctor/gui/diagnostic_dialog.py index a806110..09d6edc 100644 --- a/src/rigdoctor/gui/diagnostic_dialog.py +++ b/src/rigdoctor/gui/diagnostic_dialog.py @@ -111,10 +111,13 @@ class DiagnosticDialog(QDialog): threading.Thread(target=self._work_explain, daemon=True).start() def _work_explain(self) -> None: - from ..core import ai + from ..core import ai, gamelogs text = ai.format_findings(self._result.findings, header="Diagnostic findings:") text += "\n\nCapture summary:\n" + render_summary(self._result.summary) + logs = gamelogs.collect() + if logs: + text += "\n\nRecent game/Proton/Steam logs (newest at the end):\n" + logs self._explained.emit(ai.explain(text)) def _on_explained(self, result) -> None: @@ -133,7 +136,7 @@ class DiagnosticDialog(QDialog): view = QTextEdit() view.setObjectName("Report") view.setReadOnly(True) - view.setPlainText(text) + view.setMarkdown(text) # the model replies in Markdown — render it lay.addWidget(view) note = QLabel("AI-generated suggestions — verify before acting, especially anything that changes settings or data.") note.setObjectName("Muted") diff --git a/tests/test_gamelogs.py b/tests/test_gamelogs.py new file mode 100644 index 0000000..abe4c5d --- /dev/null +++ b/tests/test_gamelogs.py @@ -0,0 +1,49 @@ +"""Tests for M14 game/Proton/Steam log collection.""" + +import tempfile +import unittest +from pathlib import Path +from unittest import mock + +from rigdoctor.core import gamelogs + + +class TailTests(unittest.TestCase): + def test_tail_returns_last_bytes(self): + path = Path(tempfile.mkdtemp()) / "x.log" + path.write_text("A" * 100 + "TAIL") + out = gamelogs._tail(path, 4) + self.assertEqual(out, "TAIL") + + def test_tail_short_file(self): + path = Path(tempfile.mkdtemp()) / "x.log" + path.write_text("short") + self.assertEqual(gamelogs._tail(path, 9999), "short") + + def test_tail_missing(self): + self.assertEqual(gamelogs._tail(Path("/nope/x.log"), 10), "") + + +class CollectTests(unittest.TestCase): + def test_collect_includes_proton_and_steam(self): + tmp = Path(tempfile.mkdtemp()) + proton = tmp / "steam-570.log" + proton.write_text("err: vkd3d device lost") + console = tmp / "console-linux.txt" + console.write_text("Game removed AppID 570 ... exit") + with mock.patch.object(gamelogs, "_proton_logs", return_value=[proton]), \ + mock.patch.object(gamelogs, "_steam_console", return_value=console): + out = gamelogs.collect() + self.assertIn("Proton log", out) + self.assertIn("vkd3d", out) + self.assertIn("Steam log", out) + self.assertIn("exit", out) + + def test_collect_empty_when_none(self): + with mock.patch.object(gamelogs, "_proton_logs", return_value=[]), \ + mock.patch.object(gamelogs, "_steam_console", return_value=None): + self.assertEqual(gamelogs.collect(), "") + + +if __name__ == "__main__": + unittest.main()