Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/stack-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down
6 changes: 6 additions & 0 deletions lib/stack/stack.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__<NAME>_IMAP_PASSWORD), embedded in the JSON the same way
Expand Down
2 changes: 2 additions & 0 deletions stacklets/core/stacklet.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
Expand Down
37 changes: 13 additions & 24 deletions stacklets/memory/bot/cli/diary.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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


Expand Down
32 changes: 32 additions & 0 deletions stacklets/memory/bot/diary.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
14 changes: 14 additions & 0 deletions tests/unit/framework/test_stack_class.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}",
Expand Down
58 changes: 58 additions & 0 deletions tests/unit/stacklets/test_memory_diary_people.py
Original file line number Diff line number Diff line change
@@ -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"}
Loading