feat(ai): stream explanations live (Ollama NDJSON + Claude SSE) — 0.33.0
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) <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
(`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.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
|
## [0.32.0] - 2026-05-22
|
||||||
### Added
|
### Added
|
||||||
- **More for diagnostics & reports:**
|
- **More for diagnostics & reports:**
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "rigdoctor"
|
name = "rigdoctor"
|
||||||
version = "0.32.0"
|
version = "0.33.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.32.0"
|
__version__ = "0.33.0"
|
||||||
|
|||||||
@@ -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}"
|
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:
|
def _post(url: str, payload: dict, headers: dict, timeout: float) -> dict:
|
||||||
req = urllib.request.Request(
|
req = urllib.request.Request(
|
||||||
url, data=json.dumps(payload).encode("utf-8"),
|
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)"
|
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:
|
def _http_error(exc: urllib.error.HTTPError) -> str:
|
||||||
detail = ""
|
detail = ""
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from __future__ import annotations
|
|||||||
import threading
|
import threading
|
||||||
|
|
||||||
from PySide6.QtCore import Qt, Signal
|
from PySide6.QtCore import Qt, Signal
|
||||||
from PySide6.QtGui import QFont
|
from PySide6.QtGui import QFont, QTextCursor
|
||||||
from PySide6.QtWidgets import (
|
from PySide6.QtWidgets import (
|
||||||
QDialog,
|
QDialog,
|
||||||
QFrame,
|
QFrame,
|
||||||
@@ -24,11 +24,15 @@ from .widgets import finding_card
|
|||||||
|
|
||||||
|
|
||||||
class DiagnosticDialog(QDialog):
|
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:
|
def __init__(self, result, parent=None) -> None:
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self._result = result
|
self._result = result
|
||||||
|
self._stream_view = None
|
||||||
|
self._stream_status = None
|
||||||
|
self._chunk.connect(self._on_chunk)
|
||||||
self._explained.connect(self._on_explained)
|
self._explained.connect(self._on_explained)
|
||||||
self.setWindowTitle(f"Diagnostic — {result.game}" if result.game else "Diagnostic")
|
self.setWindowTitle(f"Diagnostic — {result.game}" if result.game else "Diagnostic")
|
||||||
self.resize(660, 680)
|
self.resize(660, 680)
|
||||||
@@ -97,7 +101,7 @@ class DiagnosticDialog(QDialog):
|
|||||||
buttons.addWidget(close)
|
buttons.addWidget(close)
|
||||||
root.addLayout(buttons)
|
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:
|
def _explain_with_ai(self) -> None:
|
||||||
from ..core import ai
|
from ..core import ai
|
||||||
|
|
||||||
@@ -111,8 +115,11 @@ class DiagnosticDialog(QDialog):
|
|||||||
if confirm != QMessageBox.StandardButton.Yes:
|
if confirm != QMessageBox.StandardButton.Yes:
|
||||||
return
|
return
|
||||||
self._explain_btn.setEnabled(False)
|
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()
|
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:
|
def _work_explain(self) -> None:
|
||||||
from ..core import ai, gamelogs, syslogs
|
from ..core import ai, gamelogs, syslogs
|
||||||
@@ -143,7 +150,8 @@ class DiagnosticDialog(QDialog):
|
|||||||
if sys_logs:
|
if sys_logs:
|
||||||
lines.append("\nSystem logs for this session (kernel + crashed processes):\n" + sys_logs)
|
lines.append("\nSystem logs for this session (kernel + crashed processes):\n" + sys_logs)
|
||||||
text = "\n".join(lines)
|
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)
|
if result.dir: # record exactly what was sent, the model, and the reply (M15)
|
||||||
from ..core import diagstore
|
from ..core import diagstore
|
||||||
diagstore.record_ai(
|
diagstore.record_ai(
|
||||||
@@ -152,11 +160,24 @@ class DiagnosticDialog(QDialog):
|
|||||||
response=reply if ok else f"[error] {reply}")
|
response=reply if ok else f"[error] {reply}")
|
||||||
self._explained.emit((ok, 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:
|
def _on_explained(self, result) -> None:
|
||||||
ok, text = result
|
ok, text = result
|
||||||
self._explain_btn.setEnabled(True)
|
if self._stream_view is not None:
|
||||||
self._explain_btn.setText("Explain with AI")
|
if ok:
|
||||||
self._show_explanation(text if ok else f"AI explanation failed:\n\n{text}")
|
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) ------------------------------------------------------
|
# --- Report bundle (M15) ------------------------------------------------------
|
||||||
def _make_report(self) -> None:
|
def _make_report(self) -> None:
|
||||||
@@ -183,7 +204,8 @@ class DiagnosticDialog(QDialog):
|
|||||||
if box.clickedButton() is open_btn:
|
if box.clickedButton() is open_btn:
|
||||||
QDesktopServices.openUrl(QUrl.fromLocalFile(str(out.parent)))
|
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
|
from ..core import ai
|
||||||
|
|
||||||
dlg = QDialog(self)
|
dlg = QDialog(self)
|
||||||
@@ -193,14 +215,15 @@ class DiagnosticDialog(QDialog):
|
|||||||
view = QTextEdit()
|
view = QTextEdit()
|
||||||
view.setObjectName("Report")
|
view.setObjectName("Report")
|
||||||
view.setReadOnly(True)
|
view.setReadOnly(True)
|
||||||
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.")
|
status = QLabel("Streaming from the model…")
|
||||||
note.setObjectName("Muted")
|
status.setObjectName("Muted")
|
||||||
note.setWordWrap(True)
|
status.setWordWrap(True)
|
||||||
lay.addWidget(note)
|
lay.addWidget(status)
|
||||||
close = QPushButton("Close")
|
close = QPushButton("Close")
|
||||||
close.setObjectName("PrimaryButton")
|
close.setObjectName("PrimaryButton")
|
||||||
close.clicked.connect(dlg.accept)
|
close.clicked.connect(dlg.accept)
|
||||||
lay.addWidget(close, alignment=Qt.AlignmentFlag.AlignRight)
|
lay.addWidget(close, alignment=Qt.AlignmentFlag.AlignRight)
|
||||||
dlg.exec()
|
self._stream_view = view
|
||||||
|
self._stream_status = status
|
||||||
|
return dlg
|
||||||
|
|||||||
@@ -114,5 +114,51 @@ class ExplainTests(unittest.TestCase):
|
|||||||
self.assertEqual(headers["x-api-key"], "sk-ant-x")
|
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__":
|
if __name__ == "__main__":
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
Reference in New Issue
Block a user