From bbc22fa2887b71cef1810c5157c1129ea4b8e596 Mon Sep 17 00:00:00 2001 From: Jessey van Offeren Date: Fri, 22 May 2026 14:23:15 +0200 Subject: [PATCH 1/2] =?UTF-8?q?feat(ai):=20stream=20explanations=20live=20?= =?UTF-8?q?(Ollama=20NDJSON=20+=20Claude=20SSE)=20=E2=80=94=200.33.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- CHANGELOG.md | 6 ++ pyproject.toml | 2 +- src/rigdoctor/__init__.py | 2 +- src/rigdoctor/core/ai.py | 77 ++++++++++++++++++++++++++ src/rigdoctor/gui/diagnostic_dialog.py | 53 +++++++++++++----- tests/test_ai.py | 46 +++++++++++++++ 6 files changed, 169 insertions(+), 17 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b460593..626c328 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ 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.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:** diff --git a/pyproject.toml b/pyproject.toml index 07bf0a6..2004b5f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "rigdoctor" -version = "0.32.0" +version = "0.33.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 36d8de0..852713c 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.32.0" +__version__ = "0.33.0" diff --git a/src/rigdoctor/core/ai.py b/src/rigdoctor/core/ai.py index 02f4630..6be1940 100644 --- a/src/rigdoctor/core/ai.py +++ b/src/rigdoctor/core/ai.py @@ -150,6 +150,24 @@ def explain(findings_text: str, timeout: float = 120.0) -> tuple[bool, str]: 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"), @@ -185,6 +203,65 @@ def _claude(content: str, timeout: float) -> tuple[bool, str]: 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: diff --git a/src/rigdoctor/gui/diagnostic_dialog.py b/src/rigdoctor/gui/diagnostic_dialog.py index 2200279..0e1fa86 100644 --- a/src/rigdoctor/gui/diagnostic_dialog.py +++ b/src/rigdoctor/gui/diagnostic_dialog.py @@ -5,7 +5,7 @@ from __future__ import annotations import threading from PySide6.QtCore import Qt, Signal -from PySide6.QtGui import QFont +from PySide6.QtGui import QFont, QTextCursor from PySide6.QtWidgets import ( QDialog, QFrame, @@ -24,11 +24,15 @@ from .widgets import finding_card class DiagnosticDialog(QDialog): - _explained = Signal(object) # (ok, text) from a user-triggered AI explanation + _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: 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.resize(660, 680) @@ -97,7 +101,7 @@ class DiagnosticDialog(QDialog): buttons.addWidget(close) root.addLayout(buttons) - # --- AI explanation (M14, D24) — runs only on this button press ---------------- + # --- AI explanation (M14, D24) — streamed; runs only on this button press ---------- def _explain_with_ai(self) -> None: from ..core import ai @@ -111,8 +115,11 @@ class DiagnosticDialog(QDialog): if confirm != QMessageBox.StandardButton.Yes: return self._explain_btn.setEnabled(False) - self._explain_btn.setText("Asking the AI…") + 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 @@ -143,7 +150,8 @@ class DiagnosticDialog(QDialog): if sys_logs: lines.append("\nSystem logs for this session (kernel + crashed processes):\n" + sys_logs) text = "\n".join(lines) - ok, reply = ai.explain(text) + + 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( @@ -152,11 +160,24 @@ class DiagnosticDialog(QDialog): 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 - self._explain_btn.setEnabled(True) - self._explain_btn.setText("Explain with AI") - self._show_explanation(text if ok else f"AI explanation failed:\n\n{text}") + 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: @@ -183,7 +204,8 @@ class DiagnosticDialog(QDialog): if box.clickedButton() is open_btn: QDesktopServices.openUrl(QUrl.fromLocalFile(str(out.parent))) - def _show_explanation(self, text: str) -> None: + 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) @@ -193,14 +215,15 @@ class DiagnosticDialog(QDialog): view = QTextEdit() view.setObjectName("Report") view.setReadOnly(True) - 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") - note.setWordWrap(True) - lay.addWidget(note) + 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) - dlg.exec() + self._stream_view = view + self._stream_status = status + return dlg diff --git a/tests/test_ai.py b/tests/test_ai.py index ef96d7c..4402df9 100644 --- a/tests/test_ai.py +++ b/tests/test_ai.py @@ -114,5 +114,51 @@ class ExplainTests(unittest.TestCase): 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() From c443a8b9f8808f1a01c1e3b5d2528021f2acb71c Mon Sep 17 00:00:00 2001 From: Jessey van Offeren Date: Fri, 22 May 2026 14:26:47 +0200 Subject: [PATCH 2/2] ci: add tests workflow + gate releases on tests passing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - .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) --- .gitea/workflows/release.yml | 13 +++++++++++ .gitea/workflows/tests.yml | 43 ++++++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+) create mode 100644 .gitea/workflows/tests.yml diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml index f078e5c..9937413 100644 --- a/.gitea/workflows/release.yml +++ b/.gitea/workflows/release.yml @@ -11,7 +11,20 @@ on: branches: [main] 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: + needs: test # don't publish a release if the tests fail runs-on: ubuntu-latest steps: - name: Checkout diff --git a/.gitea/workflows/tests.yml b/.gitea/workflows/tests.yml new file mode 100644 index 0000000..0960d07 --- /dev/null +++ b/.gitea/workflows/tests.yml @@ -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