"""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
 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