diff --git a/app/rest.py b/app/rest.py index 98d8461..156ea36 100644 --- a/app/rest.py +++ b/app/rest.py @@ -62,13 +62,21 @@ def _startup() -> None: ensure_schema(DB_PATH) +_PREFS_DEFAULTS: dict[str, str] = { + "entry_point_style": "topbar", + "llm_url": "http://localhost:11434", + "llm_model": "llama3.1:8b", +} + + def _load_prefs() -> dict[str, str]: if PREFS_PATH.exists(): try: - return json.loads(PREFS_PATH.read_text()) + saved = json.loads(PREFS_PATH.read_text()) + return {**_PREFS_DEFAULTS, **saved} except (json.JSONDecodeError, OSError): pass - return {"entry_point_style": "topbar"} + return dict(_PREFS_DEFAULTS) def _save_prefs(data: dict[str, str]) -> None: @@ -82,7 +90,9 @@ class DiagnoseRequest(BaseModel): class SettingsBody(BaseModel): - entry_point_style: str + entry_point_style: str | None = None + llm_url: str | None = None + llm_model: str | None = None class IncidentCreate(BaseModel): @@ -202,9 +212,18 @@ def diagnose_post(body: DiagnoseRequest) -> dict: }, "entries": [], } - result = _diagnose(DB_PATH, query=body.query, since=body.since, until=body.until) + prefs = _load_prefs() + result = _diagnose( + DB_PATH, + query=body.query, + since=body.since, + until=body.until, + llm_url=prefs.get("llm_url") or None, + llm_model=prefs.get("llm_model") or None, + ) return { "summary": result["summary"], + "reasoning": result.get("reasoning"), "entries": [dataclasses.asdict(r) for r in result["entries"]], } @@ -216,10 +235,15 @@ def get_settings() -> dict: @router.patch("/api/settings") def patch_settings(body: SettingsBody) -> dict: - if body.entry_point_style not in ("topbar", "fab"): - raise HTTPException(status_code=422, detail="entry_point_style must be 'topbar' or 'fab'") prefs = _load_prefs() - prefs["entry_point_style"] = body.entry_point_style + if body.entry_point_style is not None: + if body.entry_point_style not in ("topbar", "fab"): + raise HTTPException(status_code=422, detail="entry_point_style must be 'topbar' or 'fab'") + prefs["entry_point_style"] = body.entry_point_style + if body.llm_url is not None: + prefs["llm_url"] = body.llm_url + if body.llm_model is not None: + prefs["llm_model"] = body.llm_model _save_prefs(prefs) return prefs diff --git a/app/services/diagnose.py b/app/services/diagnose.py index 8665b15..516b7d8 100644 --- a/app/services/diagnose.py +++ b/app/services/diagnose.py @@ -7,6 +7,7 @@ from datetime import datetime, timedelta, timezone from pathlib import Path from typing import Any +from app.services.llm import summarize from app.services.search import SearchResult, entries_in_window, search logger = logging.getLogger(__name__) @@ -48,6 +49,8 @@ def diagnose( query: str, since: str | None = None, until: str | None = None, + llm_url: str | None = None, + llm_model: str | None = None, ) -> dict[str, Any]: """Run layered log search with NL time extraction. Returns summary + entries.""" time_detected = since is not None and until is not None @@ -79,6 +82,10 @@ def diagnose( by_severity[sev] += 1 by_source[r.source_id] = by_source.get(r.source_id, 0) + 1 + reasoning: str | None = None + if llm_url and llm_model: + reasoning = summarize(query, combined, llm_url=llm_url, llm_model=llm_model) + return { "summary": { "total": len(combined), @@ -88,6 +95,7 @@ def diagnose( "by_severity": by_severity, "by_source": by_source, }, + "reasoning": reasoning, "entries": combined, } diff --git a/app/services/llm.py b/app/services/llm.py new file mode 100644 index 0000000..6bfa542 --- /dev/null +++ b/app/services/llm.py @@ -0,0 +1,56 @@ +import logging + +import httpx + +from app.services.search import SearchResult + +logger = logging.getLogger(__name__) + +_SEVERITY_RANK = {"CRITICAL": 0, "ERROR": 1, "WARN": 2, "WARNING": 2} + +_PROMPT_TEMPLATE = """\ +You are a homelab diagnostic assistant. A user described a symptom and the system retrieved relevant log entries. + +Analyze the log entries below and write a 2-4 sentence plain-language diagnosis. Focus on errors and their likely root cause. Be specific and concise — name the services involved, not generic platitudes. + +User query: {query} + +Log entries ({n} shown, highest severity first): +{log_block} + +Diagnosis:""" + + +def _build_context(entries: list[SearchResult], max_entries: int = 25) -> str: + ranked = sorted( + entries, + key=lambda e: (_SEVERITY_RANK.get(e.severity or "", 3), e.timestamp_iso or ""), + )[:max_entries] + return "\n".join( + f"[{e.timestamp_iso or '?'}] [{e.severity or 'INFO'}] {e.text[:200]}" + for e in ranked + ) + + +def summarize( + query: str, + entries: list[SearchResult], + llm_url: str, + llm_model: str, + timeout: float = 20.0, +) -> str | None: + if not entries: + return None + log_block = _build_context(entries) + prompt = _PROMPT_TEMPLATE.format(query=query, n=min(len(entries), 25), log_block=log_block) + try: + resp = httpx.post( + f"{llm_url.rstrip('/')}/api/generate", + json={"model": llm_model, "prompt": prompt, "stream": False}, + timeout=timeout, + ) + resp.raise_for_status() + return resp.json().get("response", "").strip() or None + except Exception as exc: + logger.warning("LLM summarization failed (%s): %s", type(exc).__name__, exc) + return None diff --git a/requirements.txt b/requirements.txt index 14a443b..66b35f2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,3 +5,4 @@ pyyaml>=6.0 aiofiles>=23.0.0 python-multipart>=0.0.9 dateparser>=1.2.0 +httpx>=0.27.0 diff --git a/tests/test_services_llm.py b/tests/test_services_llm.py new file mode 100644 index 0000000..447091f --- /dev/null +++ b/tests/test_services_llm.py @@ -0,0 +1,71 @@ +"""Tests for app/services/llm.py — graceful failure and context building.""" +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +from app.services.llm import summarize, _build_context +from app.services.search import SearchResult + + +def _entry(text: str, severity: str = "INFO", ts: str = "2026-05-06T21:00:00+00:00") -> SearchResult: + return SearchResult( + entry_id="x", + source_id="svc", + sequence=0, + timestamp_iso=ts, + severity=severity, + text=text, + matched_patterns=[], + repeat_count=1, + out_of_order=False, + rank=0.0, + ) + + +def test_summarize_returns_none_on_connection_error(): + with patch("app.services.llm.httpx.post", side_effect=ConnectionError("refused")): + result = summarize("ollama crashed", [_entry("failed")], "http://bad", "llama3") + assert result is None + + +def test_summarize_returns_none_on_http_error(): + mock_resp = MagicMock() + mock_resp.raise_for_status.side_effect = Exception("404") + with patch("app.services.llm.httpx.post", return_value=mock_resp): + result = summarize("ollama crashed", [_entry("failed")], "http://host", "llama3") + assert result is None + + +def test_summarize_returns_none_on_empty_response(): + mock_resp = MagicMock() + mock_resp.raise_for_status.return_value = None + mock_resp.json.return_value = {"response": ""} + with patch("app.services.llm.httpx.post", return_value=mock_resp): + result = summarize("query", [_entry("x")], "http://host", "llama3") + assert result is None + + +def test_summarize_returns_text_on_success(): + mock_resp = MagicMock() + mock_resp.raise_for_status.return_value = None + mock_resp.json.return_value = {"response": "Ollama exited with code 1."} + with patch("app.services.llm.httpx.post", return_value=mock_resp): + result = summarize("ollama crashed", [_entry("Failed")], "http://host", "llama3") + assert result == "Ollama exited with code 1." + + +def test_build_context_sorts_errors_first(): + entries = [ + _entry("info message", severity="INFO"), + _entry("critical crash", severity="CRITICAL"), + _entry("warn spike", severity="WARN"), + ] + ctx = _build_context(entries) + lines = ctx.splitlines() + assert "CRITICAL" in lines[0] + assert "WARN" in lines[1] + + +def test_summarize_empty_entries_returns_none(): + result = summarize("query", [], "http://host", "model") + assert result is None diff --git a/web/src/components/QuickCapture.vue b/web/src/components/QuickCapture.vue index 8f41c3e..5061d17 100644 --- a/web/src/components/QuickCapture.vue +++ b/web/src/components/QuickCapture.vue @@ -45,6 +45,18 @@ + +
{{ reasoning }}
+@@ -29,14 +30,49 @@
- {{ saveStatus.msg }} -
+ Ollama endpoint used to generate plain-language diagnoses. Leave blank to disable. +
++ {{ saveStatus.msg }} +
@@ -46,9 +82,13 @@ import { ref, onMounted } from 'vue' const BASE = import.meta.env.BASE_URL.replace(/\/$/, '') -interface Prefs { entry_point_style: 'topbar' | 'fab' } +interface Prefs { + entry_point_style: 'topbar' | 'fab' + llm_url: string + llm_model: string +} -const prefs = ref