Files
rigdoctor/src/rigdoctor/core/ai.py
T
jessey bbc22fa288 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>
2026-05-22 14:23:15 +02:00

289 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""AI assistant (M14, D24): explain the collected diagnostics in plain language.
**Strictly opt-in and never automatic** — the model is contacted ONLY from a direct user
action ("Explain with AI" / ``rigdoctor ai explain``), never on launch, after a diagnostic, or
in any loop. Choosing/configuring a provider does not contact anything. The user must pick a
provider explicitly (there is no default).
Two providers, both over stdlib ``urllib`` (no pip deps in core):
* **ollama** — a local server (data stays on the machine, no key).
* **claude** — the Anthropic Messages API (key in the keyring).
Answers are *grounded*: we pass the actual findings plus matched reference facts
(:mod:`ai_knowledge`) and ask the model to reason over them. Output is advisory (D9).
"""
from __future__ import annotations
import json
import re
import urllib.error
import urllib.request
from .. import config
from . import ai_knowledge
_APPID_RE = re.compile(r"\b\d{5,7}\b") # Steam app IDs are 57 digits
PROVIDERS = ("ollama", "claude")
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_DEFAULT_MODEL = "claude-opus-4-7"
CLAUDE_MAX_TOKENS = 2000
ANTHROPIC_VERSION = "2023-06-01"
SYSTEM_PROMPT = (
"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."
)
def provider() -> str:
return config.load_config().get("ai_provider", "")
def model() -> str:
m = config.load_config().get("ai_model", "").strip()
if m:
return m
return CLAUDE_DEFAULT_MODEL if provider() == "claude" else ""
def endpoint() -> str:
ep = config.load_config().get("ai_endpoint", OLLAMA_DEFAULT_ENDPOINT).strip()
return ep or OLLAMA_DEFAULT_ENDPOINT
def is_local() -> bool:
return provider() == "ollama"
def is_configured() -> bool:
"""Whether the chosen provider is ready (does NOT contact anything)."""
p = provider()
if p == "claude":
return bool(config.load_ai_key())
if p == "ollama":
return bool(model()) # a model name is required; endpoint has a default
return False # no provider chosen
def provider_label() -> str:
p = provider()
if p == "claude":
return f"Claude ({model()})"
if p == "ollama":
return f"Ollama ({model() or '?'} @ {endpoint()})"
return "not configured"
def appid_glossary(text: str) -> str:
"""Resolve Steam app IDs that appear in `text` against the user's scanned library.
We don't teach the model app IDs — we look them up locally and hand it the mapping, so it
names games correctly instead of guessing. Only IDs we can resolve are listed.
"""
candidates = set(_APPID_RE.findall(text))
if not candidates:
return ""
try:
from . import steam
names = steam.appid_names()
except Exception: # never let a glossary lookup break an explanation
return ""
known = sorted((i, names[i]) for i in candidates if i in names)
if not known:
return ""
return "App IDs (resolved from your installed games):\n" + "\n".join(
f"- {appid} = {name}" for appid, name in known)
def build_prompt(findings_text: str) -> str:
"""The user-message content: app-ID glossary + matched reference facts + the findings."""
parts = []
glossary = appid_glossary(findings_text)
if glossary:
parts.append(glossary)
parts.append("")
facts = ai_knowledge.relevant(findings_text)
if facts:
parts.append("Reference facts (use these to interpret the findings):")
parts += [f"- {f}" for f in facts]
parts.append("")
parts.append("Collected findings:")
parts.append(findings_text.strip() or "(no findings provided)")
return "\n".join(parts)
def explain(findings_text: str, timeout: float = 120.0) -> tuple[bool, str]:
"""Contact the configured provider to explain the findings. Returns (ok, text | error).
The caller MUST be a direct user action (D24) — this never runs automatically.
"""
content = build_prompt(findings_text)
try:
if provider() == "claude":
return _claude(content, timeout)
if provider() == "ollama":
return _ollama(content, 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 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"),
headers={"Content-Type": "application/json", **headers},
)
with urllib.request.urlopen(req, timeout=timeout) as resp:
return json.load(resp)
def _ollama(content: str, 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": False}
out = _post(endpoint().rstrip("/") + "/api/generate", payload, {}, timeout)
return True, (out.get("response") or "").strip() or "(the model returned an empty response)"
def _claude(content: str, timeout: float) -> tuple[bool, str]:
key = config.load_ai_key()
if not key:
return False, "No Claude API key is set (Settings → AI assistant)."
# One-shot call: no prompt caching (single request, short system prompt) and no thinking
# (keeps a button-press snappy). Sampling params are omitted (removed on current Opus).
payload = {
"model": model(),
"max_tokens": CLAUDE_MAX_TOKENS,
"system": SYSTEM_PROMPT,
"messages": [{"role": "user", "content": content}],
}
headers = {"x-api-key": key, "anthropic-version": ANTHROPIC_VERSION}
out = _post(CLAUDE_ENDPOINT, payload, headers, timeout)
text = "\n".join(b.get("text", "") for b in out.get("content", []) if b.get("type") == "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:
detail = ""
try:
body = exc.read().decode("utf-8", "replace")
detail = json.loads(body).get("error", {}).get("message", "") or ""
except (ValueError, OSError):
pass
hint = " — check your API key in Settings → AI assistant." if exc.code in (401, 403) else ""
return f"AI request failed (HTTP {exc.code}){hint}{(': ' + detail) if detail else ''}"
def format_findings(findings, header: str = "") -> str:
"""Render M4 Finding objects (or similar) into the plain-text block we send the model."""
lines = [header] if header else []
for f in findings:
severity = str(getattr(f, "severity", "")).upper()
category = getattr(f, "category", "")
title = getattr(f, "title", "")
detail = getattr(f, "detail", "")
line = f"- [{severity}] {category}: {title}".rstrip()
if detail:
line += f"{detail}"
lines.append(line)
return "\n".join(lines) if lines else "No findings."