"""Who wants which digest — read from `identity` at the start of every run. `identity` (Phase 6) is this project's source of truth for per-person household facts: it already owns chore exemptions, reminder styles and arrival-notification settings for exactly the same reason it owns this one. Nothing about a person's preferences is stored here; this module is a read of `GET /digest-preferences` and nothing else. WHAT THE PREFERENCE ACTUALLY CONTROLS ------------------------------------- Two different things, and they are worth keeping apart: 1. **What gets generated.** run.py generates the UNION of the sections the household asked for. A section nobody has ticked is never sent to the LLM at all — no synthesis call, no counter-run call, and it never reaches output/. That is the sense in which this is a per-person "should this be generated for me" toggle rather than a display setting bolted on at the end. 2. **What each surface shows.** Each person's own subset is written into the digest alongside the documents, and the renderer filters to it. That half is a **display filter, not an access control**: digest-web serves the whole output volume read-only to anything on the LAN, so anyone who can open the canvas can open the JSON behind it. Ticking a box off keeps a section off someone's screen and out of their narration; it does not make it secret from them. Say so plainly rather than implying a boundary this stack doesn't have. FAILING SAFE MEANS GENERATING MORE, NOT LESS -------------------------------------------- Every failure here — identity disabled, unreachable, a bad token, a malformed response — returns None, which run.py treats as "no preferences known" and falls back to generating every section, exactly as this component did before the setting existed. The alternative reading of a failed lookup ("nobody asked for anything, generate nothing") would let one unreachable container silently cost the household its whole digest, which is far worse than one run that shows a section someone had opted out of. The same reasoning applies one level down: a household that runs `identity` but has not registered anybody yet is treated as "no preferences known" too, because an empty person table is the state of a fresh install, not a decision. Everyone opting out of everything IS a decision, and it is honoured (loudly). """ import logging import os import requests LOG = logging.getLogger(__name__) DEFAULT_TIMEOUT = 10.0 def fetch(known_sections): """`{"people": [...], "wanted": [...]}` as identity reports it, or None. `known_sections` is this component's own section list — anything identity names that this version of digest-engine has no prompt for is dropped here rather than handed to a synthesis pass that would fail on the missing file. """ base_url = os.environ.get("IDENTITY_URL", "").strip().rstrip("/") token = os.environ.get("IDENTITY_TOKEN", "").strip() if not base_url: LOG.info("IDENTITY_URL is not set, generating every digest section") return None try: response = requests.get( f"{base_url}/digest-preferences", headers={"Authorization": f"Bearer {token}"} if token else {}, timeout=float(os.environ.get("IDENTITY_TIMEOUT", DEFAULT_TIMEOUT)), ) response.raise_for_status() payload = response.json() if not isinstance(payload, dict): raise ValueError("response was not a JSON object") except Exception: LOG.warning( "could not read digest preferences from %s, generating every section", base_url, exc_info=True, ) return None people = [] for entry in payload.get("people") or []: if not isinstance(entry, dict) or not entry.get("name"): continue people.append( { "id": entry.get("id"), "name": str(entry["name"]), "nickname": entry.get("nickname"), "digest_sections": [ section for section in known_sections if section in (entry.get("digest_sections") or []) ], } ) if not people: LOG.info("identity knows no people yet, generating every digest section") return None wanted = [ section for section in known_sections if any(section in person["digest_sections"] for person in people) ] LOG.info( "digest preferences: %d person(s), generating %s", len(people), ", ".join(wanted) or "nothing — everybody has opted out of every section", ) return {"people": people, "wanted": wanted} def sections_to_generate(prefs, known_sections): """The sections this run should actually synthesise. Everything, when nothing is known about who wants what (see the module docstring on why that is the safe direction); otherwise exactly the union the household asked for. """ if prefs is None: return list(known_sections) return list(prefs["wanted"])