Compare commits

...

3 Commits

Author SHA1 Message Date
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
8 changed files with 155 additions and 12 deletions
+16
View File
@@ -5,6 +5,22 @@ 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.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 ## [0.27.0] - 2026-05-22
### Added ### Added
- **AI assistant (M14, D24)** — optional, **strictly opt-in, never automatic**. Explains your - **AI assistant (M14, D24)** — optional, **strictly opt-in, never automatic**. Explains your
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "rigdoctor" name = "rigdoctor"
version = "0.27.0" version = "0.28.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.27.0" __version__ = "0.28.0"
+11 -6
View File
@@ -24,6 +24,9 @@ from . import ai_knowledge
PROVIDERS = ("ollama", "claude") PROVIDERS = ("ollama", "claude")
OLLAMA_DEFAULT_ENDPOINT = "http://localhost:11434" 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_ENDPOINT = "https://api.anthropic.com/v1/messages"
CLAUDE_DEFAULT_MODEL = "claude-opus-4-7" CLAUDE_DEFAULT_MODEL = "claude-opus-4-7"
CLAUDE_MAX_TOKENS = 2000 CLAUDE_MAX_TOKENS = 2000
@@ -31,12 +34,14 @@ ANTHROPIC_VERSION = "2023-06-01"
SYSTEM_PROMPT = ( SYSTEM_PROMPT = (
"You are RigDoctor's hardware-diagnostics assistant for Linux gamers. You are given the " "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. " "structured findings RigDoctor collected from this machine — which may include recent game, "
"Explain in plain language what the findings mean, identify the most likely root cause of " "Proton, and system log excerpts — plus a set of reference facts. Explain in plain language "
"any problem, and give concrete, ordered next steps (exact commands where useful). Base " "what they mean, correlate any log errors with the findings to pinpoint WHEN and WHY things "
"your reasoning ONLY on the findings and reference facts provided — do not invent readings, " "went wrong, identify the most likely root cause, and give concrete, ordered next steps "
"hardware, or log lines. Be concise and practical. Present fixes as suggestions, and clearly " "(exact commands where useful). Base your reasoning ONLY on the data and reference facts "
"warn before any step that could cause data loss or instability." "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."
) )
+67
View File
@@ -0,0 +1,67 @@
"""Collect recent game / Proton / Steam logs to enrich an AI diagnostic (M14).
Reads logs that already exist on disk — no change to how the game is launched. Two reliable
sources: Proton's per-app log (``~/steam-<appid>.log``, written when ``PROTON_LOG=1``) and
Steam's own console log. Each is tail-read and size-bounded so the AI prompt stays small. The
text is fed to the AI alongside the findings so it can see *when* something went wrong (a
vkd3d/DXVK error, a crash line, the exit code) rather than only the sensor summary.
"""
from __future__ import annotations
import os
from pathlib import Path
# Steam keeps logs under its install root; ~/.steam/steam usually symlinks to the real one.
_STEAM_LOG_DIRS = ("~/.steam/steam/logs", "~/.local/share/Steam/logs", "~/.steam/root/logs")
_STEAM_LOG_FILES = ("console-linux.txt", "console_log.txt", "stderr.txt")
def _tail(path: Path, max_bytes: int) -> str:
"""Last ``max_bytes`` of a file, decoded leniently (empty string on error)."""
try:
size = path.stat().st_size
with path.open("rb") as fh:
if size > max_bytes:
fh.seek(size - max_bytes)
return fh.read().decode("utf-8", "replace")
except OSError:
return ""
def _proton_logs() -> list[Path]:
try:
logs = list(Path.home().glob("steam-*.log"))
except OSError:
return []
return sorted(logs, key=lambda p: p.stat().st_mtime, reverse=True)
def _steam_console() -> Path | None:
for directory in _STEAM_LOG_DIRS:
base = Path(os.path.expanduser(directory))
for name in _STEAM_LOG_FILES:
candidate = base / name
if candidate.exists():
return candidate
return None
def available() -> bool:
return bool(_proton_logs() or _steam_console())
def collect(max_bytes: int = 6000) -> str:
"""Recent Proton + Steam log tails as one labelled text block ('' if none)."""
sections: list[str] = []
protons = _proton_logs()
if protons:
tail = _tail(protons[0], max_bytes).strip()
if tail:
sections.append(f"--- Proton log ({protons[0].name}) ---\n{tail}")
console = _steam_console()
if console:
tail = _tail(console, max_bytes).strip()
if tail:
sections.append(f"--- Steam log ({console.name}) ---\n{tail}")
return "\n\n".join(sections)
+5 -2
View File
@@ -111,10 +111,13 @@ class DiagnosticDialog(QDialog):
threading.Thread(target=self._work_explain, daemon=True).start() threading.Thread(target=self._work_explain, daemon=True).start()
def _work_explain(self) -> None: 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 = ai.format_findings(self._result.findings, header="Diagnostic findings:")
text += "\n\nCapture summary:\n" + render_summary(self._result.summary) 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)) self._explained.emit(ai.explain(text))
def _on_explained(self, result) -> None: def _on_explained(self, result) -> None:
@@ -133,7 +136,7 @@ class DiagnosticDialog(QDialog):
view = QTextEdit() view = QTextEdit()
view.setObjectName("Report") view.setObjectName("Report")
view.setReadOnly(True) view.setReadOnly(True)
view.setPlainText(text) view.setMarkdown(text) # the model replies in Markdown — render it
lay.addWidget(view) lay.addWidget(view)
note = QLabel("AI-generated suggestions — verify before acting, especially anything that changes settings or data.") note = QLabel("AI-generated suggestions — verify before acting, especially anything that changes settings or data.")
note.setObjectName("Muted") note.setObjectName("Muted")
+5 -2
View File
@@ -27,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
@@ -188,7 +188,8 @@ class SetupPage(QWidget):
ai_layout.addLayout(prov_row) ai_layout.addLayout(prov_row)
self._ai_model = QLineEdit() self._ai_model = QLineEdit()
self._ai_model.setPlaceholderText("Model (e.g. llama3.1 for Ollama; blank = Claude default)") self._ai_model.setPlaceholderText(
f"Model (e.g. {ai.OLLAMA_SUGGESTED_MODEL} for Ollama; blank = Claude default)")
ai_layout.addWidget(self._ai_model) ai_layout.addWidget(self._ai_model)
self._ai_endpoint = QLineEdit() self._ai_endpoint = QLineEdit()
self._ai_endpoint.setPlaceholderText("Ollama server URL (default http://localhost:11434)") self._ai_endpoint.setPlaceholderText("Ollama server URL (default http://localhost:11434)")
@@ -286,6 +287,8 @@ class SetupPage(QWidget):
self._ai_endpoint.setVisible(prov == "ollama") self._ai_endpoint.setVisible(prov == "ollama")
self._ai_key.setVisible(prov == "claude") self._ai_key.setVisible(prov == "claude")
self._ai_test_btn.setEnabled(prov != "") 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: def _save_ai(self) -> None:
prov = self._ai_provider() prov = self._ai_provider()
+49
View File
@@ -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()