312 lines
12 KiB
Python
312 lines
12 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" | "network",
|
|
"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",
|
|
"sources": [
|
|
{
|
|
"title": "headline or page title",
|
|
"outlet": "who published it",
|
|
"owner": "who owns that outlet, as the context gives it",
|
|
"bias": "that outlet's politics, as the context gives it",
|
|
"url": "https://...",
|
|
"quote": "verbatim excerpt"
|
|
}
|
|
],
|
|
"globe_markers": [
|
|
{
|
|
"lat": 0.0,
|
|
"lon": 0.0,
|
|
"label": "string",
|
|
"icon": "star|hammer-sickle|default",
|
|
"color": "#hex",
|
|
"glow": true,
|
|
"summary": "what is happening at this location",
|
|
"sources": [ ... same shape as above ... ]
|
|
}
|
|
]
|
|
}
|
|
],
|
|
"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.
|
|
|
|
`sources` is optional everywhere and may appear on any window and on any marker.
|
|
The renderer folds it into a collapsed "Sources (n)" block — on screen the claim
|
|
is what you read and the citations are one tap underneath, so a section can be
|
|
fully sourced without turning the canvas into a bibliography. A marker's
|
|
`summary` is its briefing, rendered under the globe rather than as a tooltip,
|
|
because the globe rotates and a marker's own face is not always toward you.
|
|
|
|
WHICH SECTIONS RUN
|
|
------------------
|
|
`SECTIONS` is every section this component knows how to generate; which of them a
|
|
given run actually generates is decided by the household, not here — see
|
|
../preferences.py and generate_all()'s `sections` argument. A section nobody has
|
|
ticked in the identity admin panel costs no call at all.
|
|
|
|
DETAIL LEVELS
|
|
-------------
|
|
Each enabled section is generated twice per run, once at `compact` and once at
|
|
`full`, 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 one extra call per enabled section 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__)
|
|
|
|
# Every section this component can generate, in the order they are laid out on the
|
|
# canvas. `network` was carved out of the household section rather than added next to
|
|
# it: the household prompt was already carrying the firewall's IDS summary as "one
|
|
# small household item", and a household that wants to know about its calendar without
|
|
# a nightly intrusion-detection readout (or the reverse) had no way to say so while the
|
|
# two shared one document.
|
|
SECTIONS = ("personal", "political", "household", "network")
|
|
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_sources(raw):
|
|
"""Normalises a window's or a marker's `sources` list, dropping anything that
|
|
isn't at least identifiable. Kept permissive on purpose: a citation with a
|
|
title and no URL is still a citation, and dropping it would make the digest
|
|
look better-sourced than it is by hiding the weak entries. What it is NOT
|
|
permissive about is shape — a string, or a dict of nothing, is not a source.
|
|
"""
|
|
sources = []
|
|
for entry in raw or []:
|
|
if not isinstance(entry, dict):
|
|
continue
|
|
source = {
|
|
key: str(entry.get(key)).strip()
|
|
for key in ("title", "outlet", "owner", "bias", "url", "quote")
|
|
if entry.get(key)
|
|
}
|
|
if source:
|
|
sources.append(source)
|
|
return sources
|
|
|
|
|
|
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", ""),
|
|
}
|
|
sources = _coerce_sources(window.get("sources"))
|
|
if sources:
|
|
coerced["sources"] = sources
|
|
if kind == "globe":
|
|
markers = []
|
|
for marker in window.get("globe_markers") or []:
|
|
if not isinstance(marker, dict):
|
|
continue
|
|
try:
|
|
coerced_marker = {
|
|
"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
|
|
# The marker's own briefing: what is happening there, and the
|
|
# citations behind it, rendered under the globe by render.js. Both
|
|
# optional — a marker with neither is still a marker.
|
|
if marker.get("summary"):
|
|
coerced_marker["summary"] = str(marker["summary"])
|
|
marker_sources = _coerce_sources(marker.get("sources"))
|
|
if marker_sources:
|
|
coerced_marker["sources"] = marker_sources
|
|
markers.append(coerced_marker)
|
|
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, sections=None):
|
|
"""`sections` is the subset the household actually asked for (../preferences.py);
|
|
None means all of them. Ordering always follows SECTIONS rather than the caller's
|
|
list, so the canvas doesn't reshuffle itself when somebody edits a checkbox.
|
|
"""
|
|
wanted = SECTIONS if sections is None else [s for s in SECTIONS if s in sections]
|
|
documents = {level: [] for level in DETAIL_LEVELS}
|
|
for detail_level in DETAIL_LEVELS:
|
|
for section in wanted:
|
|
LOG.info("synth: generating %s/%s", section, detail_level)
|
|
documents[detail_level].append(
|
|
generate_section(section, detail_level, section_contexts.get(section, {}))
|
|
)
|
|
return documents
|