SmartestHome/digest-engine/preferences.py

125 lines
5.0 KiB
Python

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