diff --git a/CHANGELOG.md b/CHANGELOG.md index 291baa6..c91b0c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,18 @@ 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.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 diff --git a/pyproject.toml b/pyproject.toml index a0fc176..07d3c50 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "rigdoctor" -version = "0.28.0" +version = "0.28.1" 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 b93f7f6..19e81dc 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.28.0" +__version__ = "0.28.1" diff --git a/src/rigdoctor/core/ai.py b/src/rigdoctor/core/ai.py index b4925e6..f690489 100644 --- a/src/rigdoctor/core/ai.py +++ b/src/rigdoctor/core/ai.py @@ -33,15 +33,20 @@ CLAUDE_MAX_TOKENS = 2000 ANTHROPIC_VERSION = "2023-06-01" SYSTEM_PROMPT = ( - "You are RigDoctor's hardware-diagnostics assistant for Linux gamers. You are given the " - "structured findings RigDoctor collected from this machine — which may include recent game, " - "Proton, and system log excerpts — plus a set of reference facts. Explain in plain language " - "what they mean, correlate any log errors with the findings to pinpoint WHEN and WHY things " - "went wrong, identify the most likely root cause, and give concrete, ordered next steps " - "(exact commands where useful). Base your reasoning ONLY on the data and reference facts " - "provided — do not invent readings, hardware, or log lines. Be concise and practical. " - "Present fixes as suggestions, and clearly warn before any step that could cause data loss " - "or instability. Format your answer in Markdown." + "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." ) diff --git a/src/rigdoctor/core/ai_knowledge.py b/src/rigdoctor/core/ai_knowledge.py index dc302f5..da8c38f 100644 --- a/src/rigdoctor/core/ai_knowledge.py +++ b/src/rigdoctor/core/ai_knowledge.py @@ -64,6 +64,18 @@ ENTRIES: list[tuple[tuple[str, ...], str]] = [ (("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."), ] diff --git a/src/rigdoctor/core/gamelogs.py b/src/rigdoctor/core/gamelogs.py index f0c5a9b..57ba2c5 100644 --- a/src/rigdoctor/core/gamelogs.py +++ b/src/rigdoctor/core/gamelogs.py @@ -10,11 +10,41 @@ vkd3d/DXVK error, a crash line, the exit code) rather than only the sensor summa 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: @@ -51,17 +81,36 @@ 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).""" +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: - tail = _tail(protons[0], max_bytes).strip() + 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 ({protons[0].name}) ---\n{tail}") + sections.append(f"--- Proton log ({log.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}") + 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 diff --git a/src/rigdoctor/gui/diagnostic_dialog.py b/src/rigdoctor/gui/diagnostic_dialog.py index 09d6edc..9ca2e7e 100644 --- a/src/rigdoctor/gui/diagnostic_dialog.py +++ b/src/rigdoctor/gui/diagnostic_dialog.py @@ -113,12 +113,29 @@ class DiagnosticDialog(QDialog): def _work_explain(self) -> None: from ..core import ai, gamelogs - text = ai.format_findings(self._result.findings, header="Diagnostic findings:") - text += "\n\nCapture summary:\n" + render_summary(self._result.summary) - logs = gamelogs.collect() + 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: - text += "\n\nRecent game/Proton/Steam logs (newest at the end):\n" + logs - self._explained.emit(ai.explain(text)) + lines.append("\nGame/Proton/Steam logs for this session:\n" + logs) + self._explained.emit(ai.explain("\n".join(lines))) def _on_explained(self, result) -> None: ok, text = result diff --git a/tests/test_gamelogs.py b/tests/test_gamelogs.py index abe4c5d..687e5e1 100644 --- a/tests/test_gamelogs.py +++ b/tests/test_gamelogs.py @@ -1,6 +1,8 @@ """Tests for M14 game/Proton/Steam log collection.""" +import os import tempfile +import time import unittest from pathlib import Path from unittest import mock @@ -45,5 +47,31 @@ class CollectTests(unittest.TestCase): 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()