bbc22fa288
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>
289 lines
12 KiB
Python
289 lines
12 KiB
Python
"""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 5–7 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."
|