#!/usr/bin/env python3 """digest-engine entrypoint — one digest run, then exit. Invoked as `docker compose run --rm digest-engine` by the smart-home-digest.timer systemd timer, 4x/day. It is deliberately a oneshot, not a daemon: scheduling lives in systemd, exactly like the restic backup job in hosts/container-host/scripts/setup-container-host.sh. Every ingestion source is independently toggleable and independently fallible. A source that is disabled, misconfigured, or simply broken (a stale Telegram session, a signal-cli container that is down) logs a warning and contributes an empty list — it must never take down the rest of the run, because a digest with most of its sections is worth far more than no digest at all. Everything here is read-only. See docs/project-plan.md Phase 12 step 8. WHICH DIGESTS GET GENERATED --------------------------- Not necessarily all four. Each person in `identity` has a per-person set of the sections they want (network, household, personal/social, political/news), and this run generates the union of what the household asked for — a section nobody wants costs no LLM call and never reaches output/. See preferences.py, including why every failure in that lookup means "generate everything" rather than "generate nothing". Before anything is written to output/, every generated document passes through synth/counter_run.py — a second LLM call that checks the document against the same context it was generated from and drops anything that doesn't trace back to it. That's the final filter against hallucinated quotes, figures, or theoretical connections; see that module's docstring for how it fails safe. WHICH RUN IS THIS? ------------------ One feature — the evening recipe suggestion in synth/prompts/household.md — only applies to one of the four daily runs. The slot is derived here from the local wall clock rather than passed in by the caller: the systemd unit installed by hosts/container-host/scripts/setup-container-host.sh runs a bare `docker compose run --rm digest-engine` with no arguments, and a manual run uses exactly the same command, so anything argument- or unit-based would have to be threaded through both and would silently do the wrong thing on a hand-run digest. The container already has the host's timezone (`TZ` plus a bind-mounted /etc/localtime), which is the same clock systemd's `OnCalendar` fires against. The run is attributed to the most recent DIGEST_SCHEDULE slot at or before now, not to an exact hour match, because the timer is `Persistent=true`: a host that was asleep at 18:00 fires the run late, and an exact match would drop the evening feature precisely on the days the digest is read late. """ import json import logging import os import sys from datetime import datetime, timezone from pathlib import Path from ingest import ( caldav as caldav_ingest, discord_ingest, email_imap, financial, flight_traffic, grocy, naval_traffic, news_rss, opnsense_ids, rci_social, signal_ingest, telegram_ingest, whatsapp_ingest, ) from synth import counter_run, llm_client import agenda import archive import notify import preferences import viewed_tracker LOG = logging.getLogger("digest") # (env toggle, context key, module) — order is the order they run in. SOURCES = ( ("ENABLE_EMAIL_INGEST", "email", email_imap), ("ENABLE_SIGNAL_INGEST", "signal", signal_ingest), ("ENABLE_TELEGRAM_INGEST", "telegram", telegram_ingest), ("ENABLE_DISCORD_INGEST", "discord", discord_ingest), ("ENABLE_WHATSAPP_INGEST", "whatsapp", whatsapp_ingest), ("ENABLE_NEWS_INGEST", "news", news_rss), ("ENABLE_RCI_SOCIAL_INGEST", "rci_social", rci_social), ("ENABLE_FINANCIAL_INGEST", "financial", financial), ("ENABLE_FLIGHT_TRAFFIC_INGEST", "flight_traffic", flight_traffic), ("ENABLE_NAVAL_TRAFFIC_INGEST", "naval_traffic", naval_traffic), ("ENABLE_OPNSENSE_IDS_INGEST", "opnsense_ids", opnsense_ids), ("ENABLE_CALDAV_INGEST", "calendar", caldav_ingest), ("ENABLE_GROCY_INGEST", "grocy", grocy), ) # Which ingested sources feed which section. Two jobs: it is what the personal # context's `messages` map is built from, and it is how collect() knows that a source # whose only consumers are switched off for this run needs no fetching at all — a # digest section nobody asked for should cost neither an LLM call nor the WAN round # trips its sources would have made. `email` appears twice on purpose: mail is both # personal correspondence and, occasionally, politically relevant evidence. SECTION_SOURCES = { "personal": ("email", "signal", "telegram", "discord", "whatsapp"), # `calendar` is here for the agendas, not for the diary: the political section # owns the branch agenda's contents and the to-do list that comes out of it (see # agenda.py), and it cannot match an agenda to a meeting it has never fetched. "political": ("news", "rci_social", "financial", "email", "flight_traffic", "naval_traffic", "calendar"), "household": ("calendar", "grocy"), "network": ("opnsense_ids",), } DEFAULT_SCHEDULE = "00,06,12,18" DEFAULT_EVENING_HOUR = 18 def env_flag(name, default="false"): return os.environ.get(name, default).strip().lower() == "true" def schedule_hours(): hours = sorted( {int(part.strip()) for part in os.environ.get("DIGEST_SCHEDULE", DEFAULT_SCHEDULE).split(",") if part.strip().isdigit() and 0 <= int(part.strip()) <= 23} ) if not hours: LOG.warning("DIGEST_SCHEDULE is unusable, falling back to %s", DEFAULT_SCHEDULE) return [int(part) for part in DEFAULT_SCHEDULE.split(",")] return hours def slot_hour(now_local, hours): earlier = [hour for hour in hours if hour <= now_local.hour] # Before the first slot of the day the run still belongs to yesterday's last one. return earlier[-1] if earlier else hours[-1] def resolve_slot(): now_local = datetime.now().astimezone() hours = schedule_hours() try: evening_hour = int(os.environ.get("DIGEST_EVENING_HOUR", DEFAULT_EVENING_HOUR)) except ValueError: # Unlike an ingestion module, this runs outside collect()'s backstop, and a # typo in one env var must not take down a whole digest. LOG.warning("DIGEST_EVENING_HOUR is not a number, falling back to %s", DEFAULT_EVENING_HOUR) evening_hour = DEFAULT_EVENING_HOUR if evening_hour not in hours: LOG.warning( "DIGEST_EVENING_HOUR=%s is not one of the DIGEST_SCHEDULE slots %s, " "so no run will ever be the evening one", evening_hour, hours, ) current = slot_hour(now_local, hours) is_evening = current == evening_hour or env_flag("DIGEST_FORCE_EVENING") LOG.info("run attributed to the %02d:00 slot (evening run: %s)", current, is_evening) return current, evening_hour, is_evening def collect(lookback_hours, sections=None): """`sections` is the set of digest sections this run is generating; a source no enabled section reads is skipped entirely rather than fetched and then dropped. None means "generate everything", i.e. fetch everything that is switched on. """ needed = None if sections is not None: needed = {source for section in sections for source in SECTION_SOURCES.get(section, ())} collected = {} for toggle, key, module in SOURCES: if needed is not None and key not in needed: LOG.info("no enabled digest section reads %s, skipping its ingestion", key) collected[key] = [] continue if not env_flag(toggle): LOG.info("%s is not enabled, skipping %s ingestion", toggle, key) collected[key] = [] continue try: collected[key] = module.fetch(lookback_hours) or [] except Exception: # Modules already swallow their own failures; this is the backstop for # anything they miss (an import-time error, an unexpected exception type). LOG.warning("%s ingestion raised, continuing without it", key, exc_info=True) collected[key] = [] return collected def load_previous_run(output_dir): """Best-effort read of the run currently pointed at by output/latest — the candidate for merging if it turns out not to have been viewed. Any failure (first run ever, corrupt JSON, missing file) is silently "no previous run", which just means no merge is attempted, never a crash.""" try: return json.loads((output_dir / "latest" / "digest.json").read_text(encoding="utf-8")) except (OSError, ValueError): return None def should_merge(previous_run, previous_viewed_at): if previous_run is None: return False # None (unknown) is handled identically to "viewed" — see viewed_tracker's # last_viewed_at() docstring for why treating unknown as unviewed is the riskier # default, not the safer one. if previous_viewed_at is None: return False return previous_viewed_at < previous_run.get("generated_at", "") def previous_section_document(previous_run, section): """The previous run's own rendered document for one section, at `full` detail — the richest version, since what gets folded in is content, not layout, and the merge instruction in synth/llm_client.py asks the model to write fresh prose at whatever detail_level this pass actually is, not to reuse this verbatim.""" if not previous_run: return None for doc in (previous_run.get("sections") or {}).get("full") or []: if isinstance(doc, dict) and doc.get("section") == section: return doc return None def household_calendar(events): """The calendar as the household section sees it: an event that has an agenda says so, without carrying what the agenda says. The points and the text go to the political section only (see agenda.upcoming()), because a branch agenda is party work and reaches the people who asked for that digest. Enforcing it here rather than only in the prompt means a household-only run never has the contents in front of it to leak in the first place — a rule the model cannot break is worth more than one it is told. """ trimmed = [] for event in events: documents = event.get("agenda_documents") if isinstance(event, dict) else None if not documents: trimmed.append(event) continue copy = dict(event) copy["agenda_documents"] = [ { key: document.get(key) for key in ("filename", "from", "source", "received_at", "match_confidence", "match_reason", "text_extracted") } for document in documents ] trimmed.append(copy) return trimmed def build_section_contexts(collected, lookback_hours, is_evening_run, previous_run=None, sections=None, section_history=None, unattached_agendas=None, upcoming_agendas=None): # The full pantry is only ever needed to work out what the evening recipe still # requires. On the other three runs it is a few hundred lines of prompt that # buys nothing, so it is dropped rather than sent and then ignored. grocy_entries = [ entry for entry in collected.get("grocy", []) if is_evening_run or entry.get("category") != "in_stock" ] contexts = { "personal": { "lookback_hours": lookback_hours, "messages": {key: collected.get(key, []) for key in SECTION_SOURCES["personal"]}, }, "political": { "lookback_hours": lookback_hours, "news": collected.get("news", []), # The organisation's own posts, kept in their own key rather than folded # into `news`: they are the section's voice, not reporting, and the prompt # is told to read them differently. See ingest/rci_social.py. "rci_social": collected.get("rci_social", []), "financial": collected.get("financial", []), "mail": collected.get("email", []), # Extra evidence for the existing political synthesis, not a fourth # window type — see the traffic-data section of synth/prompts/political.md. "flight_traffic": collected.get("flight_traffic", []), "naval_traffic": collected.get("naval_traffic", []), # What the household is about to sit down and discuss, from the agendas # that arrived by mail or messenger. Steers curation: a story about the # subject of Thursday's branch meeting is worth more than one that isn't. # Topics only — the full agenda text stays in the household section. "upcoming_agendas": upcoming_agendas or [], }, # Both keys stay present even when their source is off or broken, so the # prompt sees the shape it is promised and says "nothing scheduled" instead # of hallucinating an event. "household": { "lookback_hours": lookback_hours, "calendar": household_calendar(collected.get("calendar", [])), "grocy": grocy_entries, # Drives the evening-only recipe/shopping-list section of # synth/prompts/household.md. Nothing is ever written back to Grocy. "is_evening_run": is_evening_run, # Agendas that arrived by mail or message and could not be tied to any # event — reported rather than dropped, because "an agenda came and I # can't tell which meeting it's for" is worth a line. Reduced to the same # fact-level fields as the attached ones: what arrived and from whom, not # what it says. See household_calendar() above. "unattached_agendas": [ { key: document.get(key) for key in ("filename", "from", "source", "received_at", "text_extracted") } for document in (unattached_agendas or []) ], }, } # Its own section rather than a couple of lines inside the household one: a # household member who wants the calendar but not a nightly intrusion-detection # readout (or the reverse) can only say so if the two are separately generated. # See synth/prompts/network.md. contexts["network"] = { "lookback_hours": lookback_hours, "network_security": collected.get("opnsense_ids", []), } # A section nobody asked for is dropped here, before its context is ever built into # a prompt — see preferences.py. Done by subtraction from the full set rather than # by building each context conditionally, so adding a section later can't silently # forget this filter. if sections is not None: contexts = {name: context for name, context in contexts.items() if name in sections} # Earlier readings from the archive, as their own clearly-labelled block with its # own timestamps — never merged into this run's entries, so a prompt can't mistake # last month's figure for something that happened in this window. See archive.py. for section, block in (section_history or {}).items(): if section in contexts: contexts[section]["history"] = block if previous_run is not None: for section, context in contexts.items(): doc = previous_section_document(previous_run, section) if doc: context["previous_unviewed_digest"] = { "generated_at": doc.get("generated_at"), "windows": doc.get("windows", []), "narration": doc.get("narration", ""), } return contexts def write_output(output_dir, run_id, context, documents, sections, people): run_dir = output_dir / run_id run_dir.mkdir(parents=True, exist_ok=True) # Persisted for the Phase 12 step 7 follow-up voice Q&A: a spoken follow-up # re-queries Ollama against this cached context instead of re-ingesting. (run_dir / "context.json").write_text( json.dumps(context, indent=2, ensure_ascii=False, default=str), encoding="utf-8" ) digest = { "run_id": run_id, "generated_at": context["generated_at"], "sections": documents, # What this run actually generated, so a surface can tell "nobody asked for # this section" apart from "it was generated and came back empty". "sections_generated": sections, # Who wants which of them, carried alongside the documents so the renderer can # filter to the person Home Assistant resolved without a second fetch (and # without digest-web needing to talk to identity at all). Empty when # preferences could not be read — see preferences.py. THIS IS A DISPLAY # FILTER, NOT AN ACCESS CONTROL: this whole file is served read-only to the # LAN, so it hides a section from a screen, not from a person. "people": people, } payload = json.dumps(digest, indent=2, ensure_ascii=False, default=str) (run_dir / "digest.json").write_text(payload, encoding="utf-8") # digest-web serves output/ read-only and has no idea which run is newest, so # the pointer has to live in the served tree itself. latest.json holds the whole # digest so a template needs exactly one fetch; the `latest` symlink is for # humans poking around the volume. (output_dir / "latest.json").write_text(payload, encoding="utf-8") symlink = output_dir / "latest" try: if symlink.is_symlink() or symlink.exists(): symlink.unlink() symlink.symlink_to(run_id, target_is_directory=True) except OSError: LOG.warning("could not update the output/latest symlink", exc_info=True) return run_dir def main(): logging.basicConfig( level=os.environ.get("LOG_LEVEL", "INFO").upper(), format="%(asctime)s %(levelname)-7s %(name)s: %(message)s", ) lookback_hours = float(os.environ.get("DIGEST_LOOKBACK_HOURS", "6")) output_dir = Path(os.environ.get("DIGEST_OUTPUT_DIR", "/output")) started_at = datetime.now(timezone.utc) run_id = started_at.strftime("%Y%m%dT%H%M%SZ") LOG.info("digest run %s starting (lookback %sh)", run_id, lookback_hours) current_slot, evening_hour, is_evening_run = resolve_slot() # Read before ingestion so a section nobody wants costs nothing at all — not an # LLM call, and not the WAN round trips its sources would have made either. prefs = preferences.fetch(llm_client.SECTIONS) sections = preferences.sections_to_generate(prefs, llm_client.SECTIONS) if not sections: LOG.warning( "every household member has opted out of every digest section — " "generating nothing, which is what was asked for" ) collected = collect(lookback_hours, sections) # The long memory: this run goes into the archive, and every item it just # collected comes back annotated with when this system first saw it. History for # the sections being generated is read afterwards, so a series includes the # reading taken a moment ago rather than stopping at the previous run. archive.record(run_id, collected) section_history = archive.history(sections) # A "Tagesordnung" PDF that arrived by mail or WhatsApp belongs to the meeting it # is for, not to a list of attachments. Done before the contexts are built so the # calendar entries the household section sees already carry theirs. Only works # when the message sources are being fetched at all — see SECTION_SOURCES: a run # generating only the household section deliberately does not go and read the # household's mail. unattached_agendas = agenda.attach(collected) previous_run = load_previous_run(output_dir) previous_viewed_at = viewed_tracker.last_viewed_at() merge_previous = should_merge(previous_run, previous_viewed_at) LOG.info( "previous run viewed_at=%s, previous run generated_at=%s -> merging: %s", previous_viewed_at, previous_run.get("generated_at") if previous_run else None, merge_previous, ) context = { "run_id": run_id, "generated_at": started_at.replace(microsecond=0).isoformat().replace("+00:00", "Z"), "lookback_hours": lookback_hours, "slot_hour": current_slot, "evening_hour": evening_hour, "is_evening_run": is_evening_run, "enabled_sources": [key for toggle, key, _ in SOURCES if env_flag(toggle)], "item_counts": {key: len(items) for key, items in collected.items()}, "sources": collected, "merged_unviewed_previous_run": merge_previous, # Kept in the persisted context (and so in the Phase 12 follow-up voice Q&A's # view of this run) because "why is there no political section today" is # answered by this and by nothing else in the bundle. "sections_generated": sections, "digest_preferences_known": prefs is not None, } section_contexts = build_section_contexts( collected, lookback_hours, is_evening_run, previous_run=previous_run if merge_previous else None, sections=sections, section_history=section_history, unattached_agendas=unattached_agendas, upcoming_agendas=agenda.upcoming(collected, unattached_agendas), ) documents = llm_client.generate_all(section_contexts, sections=sections) # The final filter, per docs/project-plan.md: a second, independent pass over # each document against the same context it was generated from, before anything # is written to output/. See synth/counter_run.py for what it catches and how it # fails safe. documents = counter_run.verify_all(documents, section_contexts) run_dir = write_output( output_dir, run_id, context, documents, sections=sections, people=prefs["people"] if prefs else [], ) # After the digest is on disk, never before: a push is a promise that there is # something to open. Best-effort — see notify.py. notify.send(context, documents, sections, prefs) LOG.info( "digest run %s complete: %s -> %s (sections: %s)", run_id, context["item_counts"], run_dir, ", ".join(sections) or "none", ) return 0 if __name__ == "__main__": sys.exit(main())