SmartestHome/digest-engine/synth/llm_client.py

244 lines
9.0 KiB
Python

"""Ollama client for digest synthesis.
Talks to the existing Phase 3 Ollama host over plain HTTP (`/api/generate` with
`"format": "json"`, Ollama's structured-output mode) — no SDK, matching this
project's preference for not pulling a dependency to make one POST.
DIGEST JSON SCHEMA
------------------
Every call returns exactly one document of this shape. The same schema is
restated in each prompt file under synth/prompts/ so the model sees it verbatim;
keep the two in sync when changing either.
{
"generated_at": "2026-07-28T12:00:00Z",
"detail_level": "compact" | "full",
"section": "personal" | "political" | "household",
"windows": [
{
"id": "string, unique within this section",
"title": "string",
"kind": "text" | "list" | "globe",
"content": "markdown-ish string for kind=text, or an array of strings for kind=list",
"globe_markers": [
{
"lat": 0.0,
"lon": 0.0,
"label": "string",
"icon": "star|hammer-sickle|default",
"color": "#hex",
"glow": true
}
]
}
],
"narration": "a short plain-text script suitable for TTS narration of this section, 2-4 sentences"
}
`globe_markers` is only present (and non-empty) on `kind: "globe"` windows, which
in practice only the political section produces.
DETAIL LEVELS
-------------
Each section is generated twice per run, once at `compact` and once at `full`
(6 calls total), rather than generating `full` once and truncating it client-side:
truncation gives you the first N windows of a document written to be expansive,
so a "compact" window can still hold a 400-word blob that overflows the HA
Lovelace iframe card, whereas a second pass yields prose actually written to be
terse. The cost is 3 extra calls against a local, self-hosted Ollama on a batch
timer — no per-token bill and no latency anyone is waiting on — so correctness of
the compact rendering wins. Both passes reuse one ingestion pass and one assembled
context, which is what docs/project-plan.md means by "without needing two
independent generation passes".
"""
import json
import logging
import os
from datetime import datetime, timezone
from pathlib import Path
import requests
LOG = logging.getLogger(__name__)
SECTIONS = ("personal", "political", "household")
DETAIL_LEVELS = ("compact", "full")
PROMPT_DIR = Path(__file__).parent / "prompts"
DETAIL_INSTRUCTIONS = {
"compact": "detail_level: compact — keep to 1-2 windows, terse",
"full": "detail_level: full — feel free to compose 3-6 windows with more depth",
}
def _now_iso():
return datetime.now(timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z")
def _fallback_document(section, detail_level, text, title="Digest (plain text fallback)"):
"""A schema-valid document wrapping whatever the model actually said.
The render layer already degrades gracefully on malformed input, but emitting
a valid document here means the failure shows up as one readable window
instead of a <pre> dump of a stack trace.
"""
return {
"generated_at": _now_iso(),
"detail_level": detail_level,
"section": section,
"windows": [
{
"id": f"{section}-fallback",
"title": title,
"kind": "text",
"content": text or "No digest content was produced for this section.",
}
],
"narration": "",
"degraded": True,
}
def _coerce_document(raw, section, detail_level):
if not isinstance(raw, dict):
raise ValueError("model output was not a JSON object")
windows = []
for index, window in enumerate(raw.get("windows") or []):
if not isinstance(window, dict):
continue
kind = window.get("kind") if window.get("kind") in ("text", "list", "globe") else "text"
coerced = {
"id": str(window.get("id") or f"{section}-{index}"),
"title": str(window.get("title") or ""),
"kind": kind,
"content": window.get("content", ""),
}
if kind == "globe":
markers = []
for marker in window.get("globe_markers") or []:
if not isinstance(marker, dict):
continue
try:
markers.append(
{
"lat": float(marker.get("lat", 0.0)),
"lon": float(marker.get("lon", 0.0)),
"label": str(marker.get("label") or ""),
"icon": str(marker.get("icon") or "default"),
"color": str(marker.get("color") or "#8ab4ff"),
"glow": bool(marker.get("glow", False)),
}
)
except (TypeError, ValueError):
continue
coerced["globe_markers"] = markers
windows.append(coerced)
if not windows:
raise ValueError("model output contained no usable windows")
return {
"generated_at": raw.get("generated_at") or _now_iso(),
"detail_level": detail_level,
"section": section,
"windows": windows,
"narration": str(raw.get("narration") or ""),
}
def load_prompt(section):
return (PROMPT_DIR / f"{section}.md").read_text(encoding="utf-8")
def _merge_instruction(context):
"""Added centrally here rather than in each of the three prompt files: whether the
previous run went unviewed is run-orchestration state (see ../viewed_tracker.py and
run.py's should_merge()), identical in wording for every section, and unrelated to
each section's own analytical framing — duplicating it three times in prose that's
supposed to stay in sync would be the actual maintenance burden, not this.
"""
if not context.get("previous_unviewed_digest"):
return ""
return (
"\n## Unviewed previous digest\n\n"
"`previous_unviewed_digest` in the context below is this section's own "
"content from the last run — and nobody has looked at it yet: no thin client "
"has shown a digest since it was generated (see its `generated_at`). Combine "
"it with this run's new material into ONE digest, not two: carry forward "
"whatever in it is still current, drop whatever this run's material has "
"superseded, corrected, or made irrelevant, and never state the same point "
"twice. Do not mention that a merge happened, and do not treat the previous "
"narration as something to read verbatim — write one narration for the "
"combined result.\n"
)
def build_prompt(section, detail_level, context):
return (
f"{load_prompt(section)}\n"
f"{_merge_instruction(context)}\n"
"## Run context\n\n"
"```json\n"
f"{json.dumps(context, indent=2, ensure_ascii=False, default=str)}\n"
"```\n\n"
f"{DETAIL_INSTRUCTIONS[detail_level]}\n"
f"current time (UTC): {_now_iso()}\n"
)
def generate_section(section, detail_level, context):
host = os.environ.get("OLLAMA_HOST", "http://llm-host:11434").rstrip("/")
model = os.environ.get("OLLAMA_MODEL", "qwen2.5:14b-instruct")
timeout = float(os.environ.get("OLLAMA_TIMEOUT", "600"))
payload = {
"model": model,
"prompt": build_prompt(section, detail_level, context),
"stream": False,
"format": "json",
"options": {"temperature": float(os.environ.get("OLLAMA_TEMPERATURE", "0.4"))},
}
try:
response = requests.post(f"{host}/api/generate", json=payload, timeout=timeout)
response.raise_for_status()
text = response.json().get("response", "")
except Exception:
LOG.warning(
"synth: Ollama call failed for %s/%s, emitting a degraded document",
section,
detail_level,
exc_info=True,
)
return _fallback_document(
section,
detail_level,
f"The {section} digest could not be generated: the LLM host at {host} did not respond.",
title=f"{section.title()} — unavailable",
)
try:
return _coerce_document(json.loads(text), section, detail_level)
except Exception:
LOG.warning(
"synth: %s/%s output did not match the digest schema, falling back to plain text",
section,
detail_level,
exc_info=True,
)
return _fallback_document(section, detail_level, text.strip())
def generate_all(section_contexts):
documents = {level: [] for level in DETAIL_LEVELS}
for detail_level in DETAIL_LEVELS:
for section in SECTIONS:
LOG.info("synth: generating %s/%s", section, detail_level)
documents[detail_level].append(
generate_section(section, detail_level, section_contexts.get(section, {}))
)
return documents