From 3fdb5048876c0fd70068b07738b26cc6cbfabb85 Mon Sep 17 00:00:00 2001 From: Arthur Date: Tue, 6 Oct 2026 15:16:40 +0200 Subject: [PATCH] fix(diary): know every family member before a filing names them The diary decoded voice notes against the names on the wiki's person pages, and named a card's people from the same pages. A family member no filing had mentioned yet had no page: Bart was missing from the transcription hint and from every card about him. The household now starts from everyone in `users.toml`, rendered into the bot runner as `FAMILY_NAMES` through the new `{family_names}` template variable. The person pages add nicknames, other spellings and the people without an account, for the transcription hint and for the people a card can name. --- docs/stack-reference.md | 1 + lib/stack/stack.py | 6 ++ stacklets/core/stacklet.toml | 2 + stacklets/memory/bot/cli/diary.py | 37 +++++------- stacklets/memory/bot/diary.py | 32 ++++++++++ tests/unit/framework/test_stack_class.py | 14 +++++ .../stacklets/test_memory_diary_people.py | 58 +++++++++++++++++++ 7 files changed, 126 insertions(+), 24 deletions(-) create mode 100644 tests/unit/stacklets/test_memory_diary_people.py diff --git a/docs/stack-reference.md b/docs/stack-reference.md index 2a459e10..828ae4bc 100644 --- a/docs/stack-reference.md +++ b/docs/stack-reference.md @@ -193,6 +193,7 @@ Available template variables: | `{admin_username}` | Tech admin username (`stackadmin`) | | `{admin_email}` | Tech admin email (`stackadmin@home.local`) | | `{admin_password}` | Tech admin password, generated and stored in `secrets.toml` | +| `{family_names}` | Comma-separated first names of everyone in `users.toml` | | `{ai_openai_url}` | Derived from `stack.toml` → `[ai].openai_url` | | `{ai_openai_url_docker}` | Same, rewritten for container access via `host.docker.internal` | | `{ai_openai_key}` | `stack.toml` → `[ai].openai_key` | diff --git a/lib/stack/stack.py b/lib/stack/stack.py index bf1b3d8a..ed242e9a 100644 --- a/lib/stack/stack.py +++ b/lib/stack/stack.py @@ -447,6 +447,12 @@ def _build_template_vars(self) -> dict: ] template_vars["admin_user_ids"] = ",".join(admin_ids) + # Every family member's first name, account or not, for the bots + # that have to recognise people before any filing has named them. + template_vars["family_names"] = ",".join( + u["name"] for u in load_users(self.instance_dir) if u.get("name") + ) + # Mail accounts — stack.toml [mail] rendered into one JSON env var # for the mail bot. IMAP passwords come from the secret store # (mail___IMAP_PASSWORD), embedded in the JSON the same way diff --git a/stacklets/core/stacklet.toml b/stacklets/core/stacklet.toml index 333aa2af..3a839279 100644 --- a/stacklets/core/stacklet.toml +++ b/stacklets/core/stacklet.toml @@ -46,6 +46,8 @@ MATRIX_ADMIN_PASSWORD = "{admin_password}" # Stacker-bot: only these users can run up/down/restart (comma-separated user IDs) STACK_ADMIN_USER_IDS = "{admin_user_ids}" +# Everyone in users.toml: who the diary and transcription expect to hear about. +FAMILY_NAMES = "{family_names}" # AI services — bots reach native host services via Docker gateway OPENAI_URL = "{ai_openai_url_docker}" diff --git a/stacklets/memory/bot/cli/diary.py b/stacklets/memory/bot/cli/diary.py index 83ef5ed2..889065ca 100644 --- a/stacklets/memory/bot/cli/diary.py +++ b/stacklets/memory/bot/cli/diary.py @@ -290,32 +290,20 @@ def _length(ms: int | None) -> str: def _household_people() -> list[str]: - """The household's names, from the wiki's person pages. + """The household's names: everyone in users.toml, then the wiki's person pages. The pages carry the family's own spelling of each name and its - variants. Best-effort: no vault means an empty list. + variants. Best-effort: no brain means the users.toml names alone. """ - people: list[str] = [] brain = Path(os.environ.get("BRAIN_REPO_DIR", "")) - if brain.is_dir(): - for about in sorted(brain.glob("*/about.md")): - front = _frontmatter(about) - if front.get("type") != "person": - continue - for key in ("canonical", "title"): - if value := front.get(key): - people.append(str(value)) - break - synonyms = front.get("synonyms") - if isinstance(synonyms, list): - people.extend(str(x) for x in synonyms) - return people + pages = [_frontmatter(about) for about in sorted(brain.glob("*/about.md"))] if brain.is_dir() else [] + return diary.household(os.environ.get("FAMILY_NAMES", ""), pages) def _household_vocabulary() -> str: """The names and subjects this family uses, for whisper to decode against. - People come from the wiki's person pages. The ontology's topics + People come from users.toml and the wiki's person pages. The ontology's topics follow, because a family's proper nouns are not only its people -- a campsite, a school, a pet -- and those mishear just as readily. @@ -1329,13 +1317,14 @@ def _vocabulary(bucket: str): # Person pages are generated, so they live in the brain, not the vault. brain_dir = Path(os.environ.get("BRAIN_REPO_DIR", "")) persons = load_persons_from_vault(brain_dir, bucket) if brain_dir.is_dir() else [] - people = {name.lower(): p.canonical for p in persons for name in p.all_known_names()} - if not people: - # No person pages yet: the household's names from the wiki, as - # whisper is primed with, so a first compile still knows them. - people = {name.lower(): name for name in _household_people()} - section = (persons_prompt_section(persons) - or "Family members: " + ", ".join(sorted(set(people.values())))) + people = diary.card_people(persons, _household_people()) + # The pages describe the people they know; the rest of the household + # joins the list by name, so the model can still say a note is about them. + others = sorted(set(people.values()) - {p.canonical for p in persons}) + if persons: + section = persons_prompt_section(persons) + "".join(f"\n - {n}" for n in others) + else: + section = "Family members: " + ", ".join(others) return ontology, people, section, language_code diff --git a/stacklets/memory/bot/diary.py b/stacklets/memory/bot/diary.py index 99b0da0e..8d2812d5 100644 --- a/stacklets/memory/bot/diary.py +++ b/stacklets/memory/bot/diary.py @@ -327,6 +327,38 @@ def spoken_vocabulary(people, topics=()) -> str: return " ".join(parts) +def household(family_names: str, pages) -> list[str]: + """The names this family's people go by: users.toml, then the wiki. + + `family_names` is the comma-separated FAMILY_NAMES from users.toml, + so everyone counts from the first day, before any filing names them. + The person pages (their frontmatter) add the family's own spellings, + nicknames and the people without an account. A name already listed is + not repeated, whatever its case. + """ + names = [n.strip() for n in family_names.split(",") if n.strip()] + for page in pages: + if page.get("type") != "person": + continue + name = page.get("canonical") or page.get("title") + names.extend(str(n) for n in [name, *(page.get("synonyms") or [])] if n) + seen: set[str] = set() + return [n for n in names if not (n.lower() in seen or seen.add(n.lower()))] + + +def card_people(persons, household) -> dict[str, str]: + """Every name a card may use, lower-cased, to the person it means. + + The person pages map each known name to its canonical form; everyone + else in the household counts under their own name, so a member no + filing has named yet can still be who a note is about. + """ + people = {name.lower(): p.canonical for p in persons for name in p.all_known_names()} + for name in household: + people.setdefault(name.lower(), name) + return people + + def _unique(values): seen = [] for v in values: diff --git a/tests/unit/framework/test_stack_class.py b/tests/unit/framework/test_stack_class.py index b237adb7..f10ae16f 100644 --- a/tests/unit/framework/test_stack_class.py +++ b/tests/unit/framework/test_stack_class.py @@ -97,6 +97,20 @@ def test_renders_template_vars(self, tmp_path): assert env["TZ"] == "Europe/Berlin" assert env["DATA"] == "/tmp/test-data/myapp" + def test_family_names_come_from_users_toml(self, tmp_path): + # Everyone in users.toml, account or not yet seen in any filing: + # transcription and the diary need the whole household from day one. + (tmp_path / "users.toml").write_text( + '[[users]]\nname = "Homer"\nrole = "admin"\n' + '[[users]]\nname = "Marge"\nrole = "member"\n' + '[[users]]\nname = "Bart"\nrole = "member"\n' + ) + _create_stacklet(tmp_path, "myapp", env_defaults={"FAMILY": "{family_names}"}) + + from stack import Stack + env = Stack(root=tmp_path, data=tmp_path / "data").env("myapp") + assert env["FAMILY"] == "Homer,Marge,Bart" + def test_missing_var_resolves_to_empty(self, tmp_path): _create_stacklet(tmp_path, "myapp", env_defaults={ "TOKEN": "{nonexistent_var}", diff --git a/tests/unit/stacklets/test_memory_diary_people.py b/tests/unit/stacklets/test_memory_diary_people.py new file mode 100644 index 00000000..39f83c03 --- /dev/null +++ b/tests/unit/stacklets/test_memory_diary_people.py @@ -0,0 +1,58 @@ +"""Who the diary knows: the household as transcription and the cards see it. + +Whisper decodes a voice note against the family's names, and the diary +cards list the people a note is about from the same list. A name missing +here is misheard ("Maggie" as "Eggie") and left off the card. So the list +starts from everyone in users.toml, rendered into FAMILY_NAMES, and the +wiki's person pages add the family's own spellings and nicknames. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +_REPO_ROOT = Path(__file__).resolve().parents[3] +sys.path.insert(0, str(_REPO_ROOT / "lib")) +sys.path.insert(0, str(_REPO_ROOT / "stacklets" / "memory" / "bot")) + +import diary # noqa: E402 + + +def _page(name: str, synonyms=()) -> dict: + return {"type": "person", "canonical": name, "synonyms": list(synonyms)} + + +class TestTheHouseholdTheDiaryKnows: + + def test_everyone_in_users_toml_before_any_filing_names_them(self): + assert diary.household("Homer,Marge,Bart,Lisa", []) == ["Homer", "Marge", "Bart", "Lisa"] + + def test_person_pages_add_nicknames_without_repeating_a_name(self): + assert diary.household("Homer,Marge", [_page("Marge", ["Margie"])]) == ["Homer", "Marge", "Margie"] + + def test_a_person_page_for_someone_without_an_account_counts(self): + assert diary.household("Homer", [_page("Maggie")]) == ["Homer", "Maggie"] + + def test_pages_that_are_not_people_add_nothing(self): + assert diary.household("Homer", [{"type": "topic", "canonical": "Camping"}]) == ["Homer"] + + def test_no_names_and_no_pages_is_an_empty_household(self): + assert diary.household("", []) == [] + + +class TestThePeopleACardCanBeAbout: + """A card names the people a note is about, matched by any name they go by.""" + + @staticmethod + def _person(canonical, *names): + from types import SimpleNamespace + return SimpleNamespace(canonical=canonical, all_known_names=lambda: [canonical, *names]) + + def test_a_member_without_a_page_is_still_someone_a_card_can_name(self): + people = diary.card_people([self._person("Marge", "Margie")], ["Homer", "Marge", "Bart"]) + + assert people == {"marge": "Marge", "margie": "Marge", "homer": "Homer", "bart": "Bart"} + + def test_before_any_page_the_household_alone(self): + assert diary.card_people([], ["Homer", "Bart"]) == {"homer": "Homer", "bart": "Bart"}