From 613d4e2e9314761186bc429b835169f5bce5763a Mon Sep 17 00:00:00 2001 From: Arthur Date: Sat, 10 Oct 2026 09:52:34 +0200 Subject: [PATCH 01/11] fix(wiki): keep diary citations on their entry when a page is rebuilt A person page rebuilt after new filings cited the wrong diary entries: "heart-shaped pancakes" linked to the Halloween entry, and most dated lines on a page with diary sources pointed at a neighbouring entry. Document and note citations were right. A rebuild renumbers the previous page's citations by matching each reference to the current evidence. The References block links a diary entry to its month page, but the match looked for the entry's own path, so no diary citation matched, kept its old number, and every diary entry was offered to the model as new. - Match a source by the title and link its References row shows, read from one helper the References block uses as well. - Stamp the person pages the compiler writes. A page without the stamp is rebuilt once from the evidence alone, so the wrong citations already in a page's text are not carried forward as its baseline. Fixes: 64336b7 ("feat(wiki): restructure and polish the wiki (#159)") --- stacklets/memory/bot/cli/wiki.py | 83 ++++++--- tests/unit/stacklets/test_memory_wiki.py | 3 +- .../stacklets/test_memory_wiki_citations.py | 165 ++++++++++++++++++ 3 files changed, 221 insertions(+), 30 deletions(-) create mode 100644 tests/unit/stacklets/test_memory_wiki_citations.py diff --git a/stacklets/memory/bot/cli/wiki.py b/stacklets/memory/bot/cli/wiki.py index b6b1e178..627bbfc8 100644 --- a/stacklets/memory/bot/cli/wiki.py +++ b/stacklets/memory/bot/cli/wiki.py @@ -473,6 +473,13 @@ async def _generate_home( return rc +# Stamped on every person page the compiler writes. A page without it +# was compiled before diary citations were renumbered by the source the +# References block shows; its text may cite the wrong entry, so it is +# rebuilt from the evidence once instead of kept as the baseline. +_PERSON_PAGE_STAMP = "" + + async def _generate_member( llm: LLM, slug: str, index: list[dict], vault: Path, *, person: Person | None = None, @@ -500,28 +507,12 @@ async def _generate_member( # Anchored regen: renumber the existing page's citations to the # current evidence order, and name what's new since that page was # generated. Both jobs are mechanical here so the model carries - # neither (it fails silently at both — see _previous_generated). + # neither (it fails silently at both, see _previous_generated). previous, prev_refs = _previous_generated(vault / slug / "about.md") - new_evidence: list[str] = [] - if previous: - link_to_n = { - _relative_link(e["rel"], slug): n - for n, e in enumerate(entries, start=1) - if e.get("rel") - } - remap = { - old: link_to_n[link] - for link, old in prev_refs.items() - if link in link_to_n - } - previous = _renumber_citations(previous, remap) - for n, e in enumerate(entries, start=1): - link = _relative_link(e.get("rel") or "", slug) - if link not in prev_refs: - meta = " · ".join( - x for x in (e.get("date"), e.get("title")) if x - ) - new_evidence.append(f"[{n}] {meta}") + if _PERSON_PAGE_STAMP not in previous: + previous, prev_refs = "", {} + previous = previous.replace(_PERSON_PAGE_STAMP, "").strip() + previous, new_evidence = _anchor_to_evidence(previous, prev_refs, entries) prompt = _build_member_prompt( display, slug, entries, facts, lang=lang, @@ -535,7 +526,7 @@ async def _generate_member( # Member page lives at `/about.md`; links climb one level to # reach the shared bucket and stay relative within the member's own. - page = _with_references(page, entries, page_dir=slug) + page = _PERSON_PAGE_STAMP + "\n\n" + _with_references(page, entries, page_dir=slug) if not write: print(f"\n\n{page}") @@ -1229,10 +1220,8 @@ def _build_references_section( if not (1 <= n <= len(entries)): continue s = entries[n - 1] - title = (s.get("title") or "").strip() or "(untitled)" + title, link = _reference(s) date = (s.get("date") or "").strip() - rel = s.get("rel") or "" - link = _relative_link(_published_path(rel), page_dir) if rel else "" head = f"- [{n}] [{title}]({link})" if link else f"- [{n}] **{title}**" if date: head += f" - {date}" @@ -1240,6 +1229,42 @@ def _build_references_section( return "\n".join(rows) +def _reference(entry: dict) -> tuple[str, str]: + """The title and link a source's row in `## References` shows. + + The pair is also how a rebuilt page finds its old citations again + (`_anchor_to_evidence`), so both read it from here. The link alone + is not enough: every diary entry of a month links to the same month + page. + """ + title = (entry.get("title") or "").strip() or "(untitled)" + rel = entry.get("rel") or "" + return title, _relative_link(_published_path(rel), "") if rel else "" + + +def _anchor_to_evidence( + previous: str, prev_refs: dict[tuple[str, str], int], entries: list[dict], +) -> tuple[str, list[str]]: + """Renumber a page's previous body to `entries`, and list what is new. + + `prev_refs` is the previous References block, (title, link) to + number. A citation whose source is still in `entries` takes that + source's current number; one whose source is gone keeps its number + and the prompt tells the model to drop it. New evidence is every + entry the previous page did not reference, as `[N] date · title`. + """ + if not previous: + return previous, [] + current = {_reference(e): n for n, e in enumerate(entries, start=1)} + remap = {old: current[ref] for ref, old in prev_refs.items() if ref in current} + new_evidence = [ + f"[{n}] " + " · ".join(x for x in (e.get("date"), e.get("title")) if x) + for n, e in enumerate(entries, start=1) + if _reference(e) not in prev_refs + ] + return _renumber_citations(previous, remap), new_evidence + + # Diary entries are the source the month pages are compiled from, and the # wiki does not publish them (quartz.config.ts ignores `**/diary/entries/**`). # A citation of one points at the month page that shows it. @@ -1357,12 +1382,12 @@ def _publish_capture_indexes( # ── Bracketed-region splice ──────────────────────────────────────────────── -def _previous_generated(page_path: Path) -> tuple[str, dict[str, int]]: +def _previous_generated(page_path: Path) -> tuple[str, dict[tuple[str, str], int]]: """The current generated body of a page, for prompt anchoring. Returns ``(body, refs)``: the content between the regenerate markers with the rendered References section stripped, and the - reference map that section carried (link path → citation number). + reference map that section carried ((title, link) → citation number). The map lets the caller renumber the baseline's citations to the CURRENT evidence order before prompting — new evidence shifts the numbering, and a model asked to renumber silently doesn't @@ -1380,9 +1405,9 @@ def _previous_generated(page_path: Path) -> tuple[str, dict[str, int]]: return "", {} body = text[start + len(_BEGIN):end].strip() refs_map = { - m.group(2): int(m.group(1)) + (m.group(2), m.group(3)): int(m.group(1)) for m in re.finditer( - r"^- \[(\d+)\] \[[^\]]*\]\(([^)]+)\)", body, re.M, + r"^- \[(\d+)\] \[([^\]]*)\]\(([^)]+)\)", body, re.M, ) } refs = body.find("## References") diff --git a/tests/unit/stacklets/test_memory_wiki.py b/tests/unit/stacklets/test_memory_wiki.py index e0ec9799..df0f8e4b 100644 --- a/tests/unit/stacklets/test_memory_wiki.py +++ b/tests/unit/stacklets/test_memory_wiki.py @@ -591,7 +591,8 @@ def test_strips_references_and_returns_map(self, tmp_path: Path) -> None: body, refs = _previous_generated(p) assert "## References" not in body assert body.startswith("# Homer") - assert refs == {"../family/documents/bc.md": 1, "notes/n.md": 2} + assert refs == {("Birth Cert", "../family/documents/bc.md"): 1, + ("Note", "notes/n.md"): 2} class TestRenumberCitations: diff --git a/tests/unit/stacklets/test_memory_wiki_citations.py b/tests/unit/stacklets/test_memory_wiki_citations.py new file mode 100644 index 00000000..eb4b2886 --- /dev/null +++ b/tests/unit/stacklets/test_memory_wiki_citations.py @@ -0,0 +1,165 @@ +"""A rebuilt person page keeps every citation on its own source. + +A person page is rebuilt from the previous version (the baseline) so a +new filing changes a few lines instead of the whole page. New evidence +shifts the numbering, so the compiler renumbers the baseline's `[N]` +citations to the current order before the model sees it, and the prompt +promises the model that they already match. These tests hold the +compiler to that promise from the outside: rebuild a page after a new +diary entry, with a model that keeps the baseline as it is, and read +the published page the way a family member does, sentence then +reference. + +Diary entries are the hard case: the References block links each one +to its month page, so several entries share one link. +""" + +from __future__ import annotations + +import asyncio +import re +import sys +from pathlib import Path + +import pytest + +_REPO_ROOT = Path(__file__).resolve().parents[3] +sys.path.insert(0, str(_REPO_ROOT / "stacklets")) +sys.path.insert(0, str(_REPO_ROOT / "stacklets" / "memory" / "bot" / "cli")) + +import wiki # noqa: E402 +from stack.people import Person # noqa: E402 + +HOMER = Person("homer", "Homer Simpson", account="homer") + + +def _entry(rel: str, title: str, date: str) -> dict: + return {"rel": rel, "title": title, "date": date, "persons": ["homer"], + "summary": f"{title}, as Homer tells it."} + + +PANCAKES = _entry("family/diary/entries/2026/02/2026-02-14-aa11.md", + "Heart-shaped pancakes", "2026-02-14") +SLEDDING = _entry("family/diary/entries/2026/02/2026-02-21-bb22.md", + "Sledding on Mount Springfield", "2026-02-21") +INSURANCE = _entry("family/documents/2026/03/2026-03-15-duff-insurance-p34.md", + "Duff Insurance renewal", "2026-03-15") +# Filed later, but dated earlier: it lands first in the evidence order. +HALLOWEEN = _entry("family/diary/entries/2025/10/2025-10-31-cc33.md", + "Halloween costumes", "2025-10-31") + + +class _Model: + """Writes one line per source the first time, then keeps the baseline. + + Keeping the baseline untouched is what the prompt asks for when + nothing contradicts it, and what a model at temperature 0 mostly + does. Every number on the page is therefore the compiler's. + """ + + def __init__(self) -> None: + self.prompts: list[str] = [] + + async def complete(self, role, prompt, **kwargs) -> str: + self.prompts.append(prompt) + if "CURRENT PAGE (baseline):\n" in prompt: + baseline = prompt.split("CURRENT PAGE (baseline):\n", 1)[1] + return baseline.split("\nProduce a markdown page", 1)[0].strip() + lines = ["# Homer Simpson", "", "## Recent Activity"] + for n, title in re.findall(r"^\[(\d+)\] [\d-]+ · (.+?)(?: \(|$)", prompt, re.M): + lines.append(f"- {title}. [{n}]") + return "\n".join(lines) + + +@pytest.fixture +def brain(tmp_path, monkeypatch): + monkeypatch.setenv("BRAIN_REPO_DIR", str(tmp_path)) + (tmp_path / "homer").mkdir() + return tmp_path + + +def _build(model: _Model, brain: Path, entries: list[dict]) -> str: + rc = asyncio.run(wiki._generate_member( + model, "homer", entries, brain, person=HOMER, + shared_bucket="family", lang="en", write=True)) + assert rc == 0 + return (brain / "homer" / "about.md").read_text(encoding="utf-8") + + +def _cited(page: str) -> dict[str, str]: + """Each sentence's subject, mapped to the title its reference names.""" + refs = dict(re.findall(r"^- \[(\d+)\] \[([^\]]+)\]", page, re.M)) + return { + sentence: refs.get(n, f"(no reference {n})") + for sentence, n in re.findall(r"^- (.+?)\. \[(\d+)\]$", page, re.M) + } + + +class TestARebuildKeepsEveryCitationOnItsSource: + def test_a_new_diary_entry_does_not_shift_the_others(self, brain): + model = _Model() + _build(model, brain, [PANCAKES, SLEDDING, INSURANCE]) + + page = _build(model, brain, [HALLOWEEN, PANCAKES, SLEDDING, INSURANCE]) + + assert _cited(page) == { + "Heart-shaped pancakes": "Heart-shaped pancakes", + "Sledding on Mount Springfield": "Sledding on Mount Springfield", + "Duff Insurance renewal": "Duff Insurance renewal", + } + + def test_only_the_new_entry_is_offered_as_new(self, brain): + """The model is told what to work in. Listing sources the page + already cites as new invites it to mention them twice.""" + model = _Model() + _build(model, brain, [PANCAKES, SLEDDING, INSURANCE]) + + _build(model, brain, [HALLOWEEN, PANCAKES, SLEDDING, INSURANCE]) + + new = model.prompts[-1].split("NEW since the baseline", 1)[1] + new = new.split("CURRENT PAGE", 1)[0] + assert re.findall(r"^\[\d+\] .+$", new, re.M) == ["[1] 2025-10-31 · Halloween costumes"] + + def test_a_rebuild_with_nothing_new_changes_no_number(self, brain): + """The resting state: same evidence, same page.""" + model = _Model() + first = _build(model, brain, [PANCAKES, SLEDDING, INSURANCE]) + + assert _build(model, brain, [PANCAKES, SLEDDING, INSURANCE]) == first + assert "Nothing is new since the baseline" in model.prompts[-1] + + +class TestAPageFromBeforeTheFixIsRebuiltOnce: + """Pages compiled before the fix may already cite the wrong diary + entry in their text, and a baseline rebuild would keep that text. + Such a page is rebuilt from the evidence alone, once.""" + + OLD_PAGE = ( + "---\ntitle: Homer Simpson\nslug: homer\ntype: person\ngenerated: true\n---\n\n" + "\n\n" + "# Homer Simpson\n\n## Recent Activity\n- Heart-shaped pancakes. [1]\n\n" + "## References\n\n" + "- [1] [Sledding on Mount Springfield](/family/diary/2026/02.md) - 2026-02-21\n\n" + "\n" + ) + + def test_the_old_text_is_not_offered_as_a_baseline(self, brain): + (brain / "homer" / "about.md").write_text(self.OLD_PAGE, encoding="utf-8") + model = _Model() + + page = _build(model, brain, [PANCAKES, SLEDDING]) + + assert "CURRENT PAGE (baseline)" not in model.prompts[-1] + assert _cited(page) == { + "Heart-shaped pancakes": "Heart-shaped pancakes", + "Sledding on Mount Springfield": "Sledding on Mount Springfield", + } + + def test_the_rebuilt_page_is_a_baseline_again(self, brain): + (brain / "homer" / "about.md").write_text(self.OLD_PAGE, encoding="utf-8") + model = _Model() + _build(model, brain, [PANCAKES, SLEDDING]) + + _build(model, brain, [PANCAKES, SLEDDING]) + + assert "CURRENT PAGE (baseline)" in model.prompts[-1] From 55aeb88fce1cd9dd24cf925c2150c892c3d815ce Mon Sep 17 00:00:00 2001 From: Arthur Date: Sat, 10 Oct 2026 10:08:50 +0200 Subject: [PATCH 02/11] fix(wiki): list every declared person on the home page A person without a chat account, such as the baby, appeared nowhere on the home page: the Members list held only account holders and told the model to add nobody else, and Broader Family is for relatives outside the household. The home page now lists everyone the person files declare, linked to their page, account holders first. Fixes: b9fe4e4 ("feat(memory): give every person the family knows a file of their own") --- stacklets/memory/bot/cli/wiki.py | 20 +++++++++-------- tests/unit/stacklets/test_memory_people.py | 26 ++++++++++++++++++++++ 2 files changed, 37 insertions(+), 9 deletions(-) diff --git a/stacklets/memory/bot/cli/wiki.py b/stacklets/memory/bot/cli/wiki.py index 627bbfc8..e71ebe82 100644 --- a/stacklets/memory/bot/cli/wiki.py +++ b/stacklets/memory/bot/cli/wiki.py @@ -73,7 +73,7 @@ extract_summary_callout, ) from stack.vault import correspondents_dir, slug, slugify_person # noqa: E402 -from stack.people import Person, find_people, members, resolve # noqa: E402 +from stack.people import Person, find_people, resolve # noqa: E402 from stack.ai.client import LLM, LLMUnavailableError # noqa: E402 @@ -275,8 +275,8 @@ async def run(llm: LLM, argv: list[str]) -> int: _err("no documents with summary callouts found in vault") return 1 - # The people the wiki carries a page for, and the members among them - # the home page lists: the person files once the family has them, the + # The people the wiki carries a page for, all of whom the home page + # lists: the person files once the family has them, the # old guess (bucket dirs plus names in frontmatter) until # `stack up memory` has written them. A person file that does not # read is reported, and while any is, no page is retired: a typo in @@ -285,8 +285,10 @@ async def run(llm: LLM, argv: list[str]) -> int: people = _people(shared_bucket, problems) if people is not None: _resolve_persons(index, people) + # Every declared person has a page and a line on the home page, + # account holders first: the baby has no chat account and is still + # the first person a reader looks for. roster = _roster(vault, index, shared_bucket, people) - member_ids = [p.id for p in members(people or [])] or roster persons = {p.id: p for p in people or []} retire = people is not None and not problems @@ -314,7 +316,7 @@ async def run(llm: LLM, argv: list[str]) -> int: if home_sel: rc = await _generate_home( - llm, index, roster=member_ids, people=persons, + llm, index, roster=roster, people=persons, shared_bucket=shared_bucket, lang=lang, write=not dry_run, ) if rc == 0: @@ -382,7 +384,7 @@ async def run(llm: LLM, argv: list[str]) -> int: # ── Default loop ────────────────────────────────────────────────── # Bare invocation: home, every member, every correspondent, every topic. rc = await _generate_home( - llm, index, roster=member_ids, people=persons, + llm, index, roster=roster, people=persons, shared_bucket=shared_bucket, lang=lang, write=not dry_run, ) if rc != 0: @@ -1670,9 +1672,9 @@ def _build_home_prompt(entries: list[dict], *, roster: list[str], lang: str, for slug in roster ) member_links = ( - f"\nThe household has EXACTLY these {len(roster)} members, one bullet " - "each, in this order — fill in the full name and detail, keep the " - "link target as given:\n" + f"\nThe family has EXACTLY these {len(roster)} people, one bullet " + "each, in this order (it replaces oldest first) — fill in the full " + "name and detail, keep the link target as given:\n" f"{skeleton}\n" "A person can appear under different name variants in the sources " "(the baby Margaret is the same person as Maggie) — never list " diff --git a/tests/unit/stacklets/test_memory_people.py b/tests/unit/stacklets/test_memory_people.py index c7bbb44e..f0ee8327 100644 --- a/tests/unit/stacklets/test_memory_people.py +++ b/tests/unit/stacklets/test_memory_people.py @@ -23,6 +23,7 @@ import asyncio import base64 import json +import re import sys from pathlib import Path @@ -269,6 +270,31 @@ def test_the_declared_names_replace_what_the_page_had(self): } assert updated.endswith("---\n\nbody\n") + def test_the_home_page_lists_everyone_declared_account_holders_first( + self, instance, monkeypatch, + ): + """The baby has no chat account and is still family: the home + page is where a reader looks for her.""" + vault, brain, index = instance + _declare(vault, homer=HOMER, marge=MARGE, maggie=MAGGIE) + for entry in index: + entry.update(date="2025-01-18", summary=entry["title"]) + prompts: list[str] = [] + + class _Model: + async def complete(self, role, prompt, **kwargs): + prompts.append(prompt) + return "# The Simpsons\n" + monkeypatch.setattr(wiki, "_index_vault", lambda vault: index) + monkeypatch.setattr(wiki, "_brain_dir", lambda: brain) + (brain / "index.md").write_text("---\ntitle: Family Memory\n---\n") + + asyncio.run(wiki.run(_Model(), ["--home"])) + + listed = re.findall(r"^- \*\*\[([^\]]+)\]\((\w+)/about\)\*\*", prompts[0], re.M) + assert listed == [("Homer Simpson", "homer"), ("Marge Simpson", "marge"), + ("Maggie Simpson", "maggie")] + def test_the_home_page_is_told_the_full_names(self): prompt = wiki._build_home_prompt( [], roster=["homer", "marge"], lang="en", From 548d3b55674ced87896bd3f25055e2c24f9f98f1 Mon Sep 17 00:00:00 2001 From: Arthur Date: Sat, 10 Oct 2026 10:38:41 +0200 Subject: [PATCH 03/11] feat(wiki): title the references block in the household language A German household read "## References" under every generated page. The block is now titled in the household language ("Quellen" in German) on the home, person and topic pages, and a rebuild finds the old block under either heading, so a household that changes its language does not end up with two. --- stacklets/memory/bot/cli/wiki.py | 30 ++++++++++++++++-------- tests/unit/stacklets/test_memory_wiki.py | 20 ++++++++++++++++ 2 files changed, 40 insertions(+), 10 deletions(-) diff --git a/stacklets/memory/bot/cli/wiki.py b/stacklets/memory/bot/cli/wiki.py index e71ebe82..02189255 100644 --- a/stacklets/memory/bot/cli/wiki.py +++ b/stacklets/memory/bot/cli/wiki.py @@ -448,7 +448,7 @@ async def _generate_home( # leads somewhere a reader browses rather than looks something up. page = _with_diary_link(page, shared_bucket=shared_bucket) - page = _with_references(page, index, page_dir="") + page = _with_references(page, index, page_dir="", lang=lang) # Index pages for the shared bucket's own captures (notes dropped in the # main family room, not under a topic). The `/notes/` prefix won't @@ -528,7 +528,7 @@ async def _generate_member( # Member page lives at `/about.md`; links climb one level to # reach the shared bucket and stay relative within the member's own. - page = _PERSON_PAGE_STAMP + "\n\n" + _with_references(page, entries, page_dir=slug) + page = _PERSON_PAGE_STAMP + "\n\n" + _with_references(page, entries, page_dir=slug, lang=lang) if not write: print(f"\n\n{page}") @@ -597,7 +597,7 @@ async def _generate_topic( # two levels (out of /, out of /) to reach files in # other buckets. page_dir = f"{bucket_prefix}/{topic_slug}" - page = _with_references(page, entries + cross_refs, page_dir=page_dir) + page = _with_references(page, entries + cross_refs, page_dir=page_dir, lang=lang) if not write: print(f"\n\n{page}") @@ -1195,16 +1195,25 @@ def _with_diary_link(page: str, *, shared_bucket: str) -> str: return page.rstrip() + "\n\n" + link -def _with_references(page: str, entries: list[dict], *, page_dir: str) -> str: +# The References heading in the household's language. A rebuild finds +# the block under any of them, so a language change does not leave the +# old block inside the page body. +_REFERENCES = {"en": "References", "de": "Quellen"} +_REFERENCES_HEADING = re.compile( + r"^## (?:" + "|".join(map(re.escape, _REFERENCES.values())) + r")\s*$", re.M) + + +def _with_references(page: str, entries: list[dict], *, page_dir: str, + lang: str = "en") -> str: """Append a `## References` block for the citations the page used.""" - section = _build_references_section(page, entries, page_dir=page_dir) + section = _build_references_section(page, entries, page_dir=page_dir, lang=lang) if section: return page.rstrip() + "\n\n" + section return page def _build_references_section( - page: str, entries: list[dict], *, page_dir: str, + page: str, entries: list[dict], *, page_dir: str, lang: str = "en", ) -> str: """Render a `## References` block listing the cited source documents. @@ -1217,7 +1226,8 @@ def _build_references_section( citations = _extract_citations(page) if not citations: return "" - rows: list[str] = ["## References", ""] + heading = _REFERENCES.get((lang or "").strip().lower()[:2], _REFERENCES["en"]) + rows: list[str] = [f"## {heading}", ""] for n in citations: if not (1 <= n <= len(entries)): continue @@ -1412,9 +1422,9 @@ def _previous_generated(page_path: Path) -> tuple[str, dict[tuple[str, str], int r"^- \[(\d+)\] \[([^\]]*)\]\(([^)]+)\)", body, re.M, ) } - refs = body.find("## References") - if refs != -1: - body = body[:refs].rstrip() + refs = _REFERENCES_HEADING.search(body) + if refs: + body = body[:refs.start()].rstrip() return body, refs_map diff --git a/tests/unit/stacklets/test_memory_wiki.py b/tests/unit/stacklets/test_memory_wiki.py index df0f8e4b..d6c15b8e 100644 --- a/tests/unit/stacklets/test_memory_wiki.py +++ b/tests/unit/stacklets/test_memory_wiki.py @@ -1063,3 +1063,23 @@ def test_other_sources_keep_their_own_path(self): }] section = _build_references_section(page, entries, page_dir="") assert "(/family/documents/2009/06/2009-06-15-mortgage-p23.md)" in section + + def test_a_german_household_reads_quellen(self): + page = "Maggie sagte ihr erstes Wort. [1]" + entries = [{"title": "Maggies erstes Wort", "date": "2026-09-22", + "rel": "family/documents/2026/09/2026-09-22-wort-p1.md"}] + section = _build_references_section(page, entries, page_dir="", lang="de") + assert section.startswith("## Quellen\n") + + +class TestAChangedLanguageLeavesNoOldBlock: + def test_a_rebuild_finds_the_block_under_either_heading(self, tmp_path: Path) -> None: + """A household that switches language keeps one block, not two.""" + p = tmp_path / "about.md" + p.write_text( + "\n\n# Homer\n\nFakt [1].\n\n## Quellen\n\n" + "- [1] [Notiz](/homer/notes/n.md) - 2026-06-12\n\n\n", + encoding="utf-8") + body, refs = _previous_generated(p) + assert body == "# Homer\n\nFakt [1]." + assert refs == {("Notiz", "/homer/notes/n.md"): 1} From 70ef905865e1edd668d57441f81ffd0cd893cfd1 Mon Sep 17 00:00:00 2001 From: Arthur Date: Sat, 10 Oct 2026 10:39:33 +0200 Subject: [PATCH 04/11] feat(wiki): write person pages from a template in the family's voice Person pages read like a records report ("a complex, transactional relationship"), opened with placeholders such as "No explicit family role defined", listed diary entries as documents, and carried dates the model had rewritten. A person page is now filled from a Markdown template per language, `stacklets/memory/bot/templates/person..md`, in English and German: - The model writes only the prose, as JSON: a one-line tagline, a short About, what the person loves, one line for each of their latest entries, and optionally a section with a heading of its own. It is asked for the warm, concrete voice of a family member describing someone to a visiting relative. - The compiler fills the rest: the latest entries with their real dates, and the documents that name the person, linked. - A section with nothing in it is left out with its heading. An answer that is not the expected JSON keeps the page as it was. Every person page is regenerated once in the new layout, by the next rebuild that touches it or the nightly sweep at the latest. --- docs/design/brain/wiki-page-anatomy.md | 33 ++- stacklets/memory/bot/cli/wiki.py | 148 +++--------- stacklets/memory/bot/page_template.py | 79 +++++++ stacklets/memory/bot/person_page.py | 220 ++++++++++++++++++ stacklets/memory/bot/templates/person.de.md | 32 +++ stacklets/memory/bot/templates/person.en.md | 32 +++ .../unit/stacklets/test_memory_person_page.py | 154 ++++++++++++ .../stacklets/test_memory_wiki_citations.py | 38 +-- 8 files changed, 599 insertions(+), 137 deletions(-) create mode 100644 stacklets/memory/bot/page_template.py create mode 100644 stacklets/memory/bot/person_page.py create mode 100644 stacklets/memory/bot/templates/person.de.md create mode 100644 stacklets/memory/bot/templates/person.en.md create mode 100644 tests/unit/stacklets/test_memory_person_page.py diff --git a/docs/design/brain/wiki-page-anatomy.md b/docs/design/brain/wiki-page-anatomy.md index 23540e18..bbd11de7 100644 --- a/docs/design/brain/wiki-page-anatomy.md +++ b/docs/design/brain/wiki-page-anatomy.md @@ -79,6 +79,36 @@ their keep, spokes. 4. **Links.** Timeline, todos, interests, documents, related entities. The link graph is the API for agents that need to go deeper. +### The person page template (built) + +A person's `about.md` is filled from a Markdown template per language, +`stacklets/memory/bot/templates/person..md` (English and German). +The template's headings are what the family reads; each `{{slot}}` is +filled by one side: + +| Slot | Filled by | What it holds | +|---|---|---| +| `name`, `first_name` | compiler | the person's file | +| `tagline` | model | one warm line: who they are in the family | +| `about` | model | two to four sentences about them today | +| `loves` | model | up to five things, people, places or pastimes they love | +| `more` | model, optional | one section of its own choosing, with its own heading | +| `lately` | compiler, one line each by the model | the six newest entries naming them (not documents), with their real dates | +| `documents` | compiler | the documents naming them, newest first, linked | + +The model answers in JSON, one field per slot, and writes in the +household's voice: warm, concrete, the way a family member would +describe someone to a visiting relative. It never writes a date, never +lists a document, and cannot credit an entry to the wrong day, because +those slots are not its to fill. A paragraph whose slot is empty is +left out with its heading, so a page never says "nothing recorded". An +answer that is not the JSON object keeps the page as it was. + +Each page carries a stamp with the layout it follows. A page with an +older stamp is regenerated from the evidence once instead of being +kept as the baseline for the next rebuild, so a layout change reaches +every page by the next nightly sweep. + ### Entity types and slot templates The ontology defines which pages exist; each entity type brings a @@ -154,7 +184,8 @@ command), whether todos are also assigned to non-capturing members 1. **Profile-card prompt** for person about pages: curated, recency- weighted, anti-conflation, token-capped, trivia-if-present. The - 35B batch run is the quality reference. + 35B batch run is the quality reference. Built as the person page + template above (2026-10). 2. **One non-person entity page** (the car). Forces entity types and slot templates to become real instead of theoretical. 3. **Todos**: capture → render → nag, in that order; render alone is diff --git a/stacklets/memory/bot/cli/wiki.py b/stacklets/memory/bot/cli/wiki.py index 02189255..a68c3fcb 100644 --- a/stacklets/memory/bot/cli/wiki.py +++ b/stacklets/memory/bot/cli/wiki.py @@ -80,9 +80,11 @@ # The diary compiler owns the diary's path and its reader-facing words; # the home page only places the pointer it renders. Imported by bare # name, the way `cli/diary.py` imports it, so the two commands share one -# module object and therefore one selected language. +# module object and therefore one selected language. The person page's +# template and prose live beside it, in `person_page`. sys.path.insert(0, str(Path(__file__).resolve().parents[1])) import diary # noqa: E402 +import person_page # noqa: E402 HELP = "Regenerate the family wiki's home and member pages" @@ -475,11 +477,12 @@ async def _generate_home( return rc -# Stamped on every person page the compiler writes. A page without it -# was compiled before diary citations were renumbered by the source the -# References block shows; its text may cite the wrong entry, so it is -# rebuilt from the evidence once instead of kept as the baseline. -_PERSON_PAGE_STAMP = "" +# Stamped on every person page the compiler writes, with the layout it +# follows. A page without the current stamp is rebuilt from the evidence +# once instead of kept as the baseline: it has the previous layout, or +# predates the fix that renumbers diary citations by the source the +# References block shows, so its text may cite the wrong entry. +_PERSON_PAGE_STAMP = "" async def _generate_member( @@ -516,18 +519,32 @@ async def _generate_member( previous = previous.replace(_PERSON_PAGE_STAMP, "").strip() previous, new_evidence = _anchor_to_evidence(previous, prev_refs, entries) - prompt = _build_member_prompt( - display, slug, entries, facts, lang=lang, - previous=previous, new_evidence=new_evidence, + # The template carries the layout; the model writes only the prose, + # as JSON. Dates, the latest entries and the documents come from the + # index, so the model cannot misdate them or invent one. + first_name = person.first_name if person else display.split()[0] + lately = person_page.latest(entries) + prompt = person_page.prompt( + display=display, first_name=first_name, + evidence=_format_evidence(entries, with_persons=True), facts=facts, + lately=lately, lang=lang, previous=previous, new_evidence=new_evidence, ) try: - page = (await llm.complete("overview", prompt, temperature=_TEMPERATURE)).strip() + raw = await llm.complete("overview", prompt, json_mode=True, temperature=_TEMPERATURE) + parts = person_page.read_answer(raw, lately=lately) except LLMUnavailableError as e: _err(f"LLM unavailable: {e}") return 1 + except person_page.AnswerError as e: + _err(f"{slug}: the model's answer is unusable ({e}); the page stays as it was") + return 1 + docs = person_page.documents(entries) + page = person_page.render( + parts, name=display, first_name=first_name, entries=entries, + lately=lately, docs=docs, + links={n: _reference(entries[n - 1])[1] for n in docs}, lang=lang, + ) - # Member page lives at `/about.md`; links climb one level to - # reach the shared bucket and stay relative within the member's own. page = _PERSON_PAGE_STAMP + "\n\n" + _with_references(page, entries, page_dir=slug, lang=lang) if not write: @@ -678,6 +695,7 @@ def _index_vault(vault: Path) -> list[dict]: _fb = fm.get("filed_by") out.append({ "title": fm.get("title") or md.stem, + "type": fm.get("type") or "", "date": fm.get("date") or "", "summary": summary, "rel": rel, @@ -1743,112 +1761,6 @@ def _build_home_prompt(entries: list[dict], *, roster: list[str], lang: str, """ -def _build_member_prompt( - display: str, slug: str, entries: list[dict], - facts: list[tuple[str, str]], *, lang: str, - previous: str = "", new_evidence: list[str] | None = None, -) -> str: - """Prompt for one member's profile card: summaries plus facts. - - The page is a curated overview for assistants and browsing family, - not an archive (docs/design/brain/wiki-page-anatomy.md): recency- - weighted About, themed interests, capped activity, no ledger - content. Curated facts ride in as ground truth (the family typed - them); the document summaries are the cited evidence. Section - layout is fixed for the same reason the home page's is. - - ``previous`` is the page's current generated body (sans rendered - references): with it the prompt anchors the regen to the existing - wording so a new document produces a reviewable diff instead of a - full resample — temp 0 alone does NOT give that (evidence+1 changes - the input, and everything redraws; measured 2026-06-12). - - The evidence slice is shared by design: Lisa's birth certificate is - on Lisa's page AND describes her parents. Small models conflate - those roles onto the page's subject ("Lisa ... her husband Homer"), - so the prompt names who each document involves and carries explicit - attribution rules. Weakest-model-first: every rule here earned its - place by a 9B failure. - """ - evidence = _format_evidence(entries, with_persons=True) - if facts: - fact_lines = "\n".join(f"- ({kind}) {text}" for kind, text in facts) - facts_block = ( - f"Hand-curated facts about {display} " - f"(ground truth the family typed in):\n{fact_lines}" - ) - else: - facts_block = f"No hand-curated facts on file for {display}." - - if previous: - if new_evidence: - fresh = "\n".join(new_evidence) - fresh_block = ( - "NEW since the baseline (not yet on the page) — work it " - "into the right sections, citing its [N]:\n" + fresh - ) - else: - fresh_block = ( - "Nothing is new since the baseline — reproduce it, " - "applying corrections only where it contradicts the " - "evidence above." - ) - anchor_block = f""" -The page already exists. Its current version is below — treat it as the baseline, not as something to rewrite: -- Keep its wording, section names, themes, and bullet order EXACTLY wherever the evidence still supports them. -- The baseline's [N] citations already match the evidence numbering above. Do not change them. -- Touch existing lines only where new evidence forces it (e.g. the Recent Activity cap pushing out the oldest entry). -- Drop a line only when its evidence no longer appears above. -- Never rephrase, restyle, or reorganize anything else. - -{fresh_block} - -CURRENT PAGE (baseline): -{previous} -""" - else: - anchor_block = "" - - return f"""You are composing the profile page for one member of a family's private, self-hosted memory wiki. It is an OVERVIEW, not an archive: the card a family member (or the family's assistant) reads to get a feel for who this person is right now — their role, their current life, their interests and quirks. Detail lives in the cited documents, one click away. - -This page is about: {display} (vault slug: {slug}). - -{facts_block} - -Source summaries involving {display} (cite as [N] inline where the fact came from a specific document): - -{evidence} -{anchor_block} -Produce a markdown page with this EXACT structure and section order (omit a section entirely when nothing supports it): - -# {display} -> - -## About -One short paragraph: who {display} is TODAY — role in the family, occupation or school, current life context. When sources span years, prefer the most recent picture; old contracts and certificates are background, not news. [N] - -## Interests & Preferences -Themed bullets that give a feel for the person: hobbies, habits, favorite places, pets, preferences, quirks. Merge the hand-curated facts above with what the documents reveal (a league membership, a regular haunt). Format: **:** . [N] - -## Recent Activity -The most recent events involving them, newest first, AT MOST 8 bullets: — . [N] - -## Key Documents -The load-bearing documents naming them (identity, contracts, insurance, medical, school, financial). One bullet each. [N] Receipts and everyday ephemera stay out — they remain reachable through Recent Activity citations. - -Rules: -- The whole page stays under roughly 300 words. It is a profile card, not a ledger: never copy line items, account numbers, or ID numbers onto the page — the citations carry that detail. -- The H1 is the person's full name as it appears in the documents; fall back to "{display}". -- The documents above also involve OTHER family members (see each entry's "involves:" list). A birth certificate is mostly about the parents; school papers name a parent. State a fact about {display} ONLY when the source ties it to {display} by name. Never give {display} another person's role, profession, or relationship (mother/father, spouse, caretaker, employer). -- When a summary says "the mother", "her husband", or similar without naming {display}, that fact belongs to someone else — leave it off this page. -- Unsure who a fact is about? Leave it out. A short page is correct; a wrong page is not. -- Respond in: {lang}. -- Use ONLY what the sources and facts above support. Never invent. Omit empty sections — no placeholder lines. -- Cite the source document as [N] inline. Multiple sources: [1, 3]. -- Do NOT add a "References" section — it is appended programmatically after your response. Don't list source paths or URLs in your output. -""" - - def _entry_kind(rel: str, slug: str) -> str: """The capture kind of an entry, read from its vault path. diff --git a/stacklets/memory/bot/page_template.py b/stacklets/memory/bot/page_template.py new file mode 100644 index 00000000..45c9513e --- /dev/null +++ b/stacklets/memory/bot/page_template.py @@ -0,0 +1,79 @@ +"""Markdown page templates for the wiki: one file per page kind and language. + +A template is the layout of a generated page as the family reads it, +`templates/..md`. Its `{{slot}}`s are filled by the compiler, +from facts it knows or prose the model wrote. A paragraph with an empty +slot is left out whole, heading included, so a page never shows an empty +section or a line saying that nothing was recorded. + + >>> fill("# {{name}}\\n\\n## Pets\\n{{pets}}", {"name": "Homer", "pets": ""}) + '# Homer\\n' + +The comment at the top of each template explains its slots to whoever +edits it, and is not published. Pure functions: no I/O beyond reading a +template file. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +TEMPLATES = Path(__file__).resolve().parent / "templates" + +_SLOT = re.compile(r"\{\{(\w+)\}\}") +_TEMPLATE_COMMENT = re.compile(r"\A\s*\s*", re.S) + +LANGUAGE_NAMES = {"en": "English", "de": "German"} + +_MONTHS = { + "en": ["Jan", "Feb", "Mar", "Apr", "May", "Jun", + "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"], + "de": ["Jan.", "Feb.", "März", "Apr.", "Mai", "Juni", + "Juli", "Aug.", "Sep.", "Okt.", "Nov.", "Dez."], +} + + +def language(lang: str) -> str: + """The template language for a household language code; English otherwise.""" + code = (lang or "").strip().lower()[:2] + return code if code in LANGUAGE_NAMES else "en" + + +def format_date(iso: str, lang: str) -> str: + """`2026-02-14` as a reader writes it: `14 Feb 2026`, `14. Feb. 2026`.""" + m = re.match(r"^(\d{4})-(\d{2})-(\d{2})", iso or "") + if not m: + return iso or "" + year, month, day = int(m[1]), int(m[2]), int(m[3]) + name = _MONTHS[language(lang)][month - 1] + return f"{day}. {name} {year}" if language(lang) == "de" else f"{day} {name} {year}" + + +def template(kind: str, lang: str) -> str: + """The template text for a page kind and language, without its comment.""" + text = (TEMPLATES / f"{kind}.{language(lang)}.md").read_text(encoding="utf-8") + return _TEMPLATE_COMMENT.sub("", text, count=1) + + +def fill(template_text: str, slots: dict[str, str]) -> str: + """Fill `{{slot}}`s, leaving out every paragraph that has an empty one.""" + kept: list[str] = [] + for block in re.split(r"\n\s*\n", template_text.strip()): + names = _SLOT.findall(block) + if any(not (slots.get(n) or "").strip() for n in names): + continue + kept.append(_SLOT.sub(lambda m: slots[m[1]].strip(), block)) + return "\n\n".join(kept) + "\n" + + +def text(value) -> str: + """A model's string field on one line, or "" for anything else.""" + return " ".join(value.split()) if isinstance(value, str) else "" + + +def sentence(line: str) -> str: + """`line` ending in a full stop unless it already ends a sentence.""" + line = line.strip() + return line if not line or line[-1] in ".!?…" else line + "." + diff --git a/stacklets/memory/bot/person_page.py b/stacklets/memory/bot/person_page.py new file mode 100644 index 00000000..7c4eabbe --- /dev/null +++ b/stacklets/memory/bot/person_page.py @@ -0,0 +1,220 @@ +"""A person's wiki page: a Markdown template, the model writes the prose. + +The page is a profile card a family member opens to see who someone is +right now. It has two kinds of content, and each comes from where it +is reliable: + +- **Facts the compiler knows.** The name, the latest entries that name + the person with their real dates, and the documents that name them. + Rendered here from the index, so a date or a document is never made + up and never credited to the wrong entry. +- **Prose only a model can write.** A one-line tagline, a short About, + what the person loves, one warm line for each of the latest entries, + and optionally one section of its own choosing. The model answers in + JSON, one field each, so nothing here parses free text. + +The layout is a Markdown template per language (`templates/person..md`, +see `page_template`): +its headings are what the family reads. A paragraph whose slot is empty +is left out with its heading, so an empty section never reads "nothing +recorded". + +Pure functions: no I/O beyond reading the template, no model calls. +The wiki compiler (`wiki._generate_member`) owns the evidence, the call +and the publish. + + >>> parts = read_answer('{"tagline": "The baby.", "about": "Maggie is one.", ' + ... '"loves": ["Her pacifier. [1]"]}', lately=[]) + >>> parts.loves + ['Her pacifier. [1]'] +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass, field + +from page_template import ( + LANGUAGE_NAMES, fill, format_date, language, sentence, template, text, +) + +# A card, not an archive: what fits on a phone screen above the fold +# plus a little more. The documents list is the way into the rest. +LATELY_MAX = 6 +DOCUMENTS_MAX = 6 +LOVES_MAX = 5 + + +# ── What the compiler knows ────────────────────────────────────────────── + +def latest(entries: list[dict]) -> list[int]: + """Citation numbers of the newest dated entries, newest first. + + Numbers are positions in `entries` (1-based), the same numbering the + prompt's evidence and the References block use. Documents are left + out: they have their own list, and a policy renewal is not something + that happened to a person. + """ + dated = [(e.get("date") or "", n) for n, e in enumerate(entries, start=1) + if e.get("date") and e.get("type") != "document"] + dated.sort(key=lambda d: d[0], reverse=True) + return [n for _, n in dated[:LATELY_MAX]] + + +def documents(entries: list[dict]) -> list[int]: + """Citation numbers of the documents naming the person, newest first.""" + docs = [(e.get("date") or "", n) for n, e in enumerate(entries, start=1) + if e.get("type") == "document"] + docs.sort(key=lambda d: d[0], reverse=True) + return [n for _, n in docs[:DOCUMENTS_MAX]] + + +# ── What the model writes ──────────────────────────────────────────────── + +@dataclass +class Parts: + """The prose the model wrote for one page; every field may be empty.""" + + tagline: str = "" + about: str = "" + loves: list[str] = field(default_factory=list) + more_heading: str = "" + more_text: str = "" + lately: dict[int, str] = field(default_factory=dict) + + +class AnswerError(ValueError): + """The model's answer is not the JSON object the prompt asked for.""" + + +def read_answer(raw: str, *, lately: list[int]) -> Parts: + """The model's JSON answer as `Parts`, or `AnswerError`. + + A field of the wrong shape is dropped rather than guessed at; the + page then leaves that section out. A line for an entry the prompt + did not offer is ignored. + """ + try: + data = json.loads(raw) + except json.JSONDecodeError as e: + raise AnswerError(f"not JSON: {e}") from e + if not isinstance(data, dict): + raise AnswerError("not a JSON object") + + more = data.get("more") + more = more if isinstance(more, dict) else {} + lines = data.get("lately") + lines = lines if isinstance(lines, dict) else {} + loves = data.get("loves") + loves = loves if isinstance(loves, list) else [] + more_text = more.get("text") + return Parts( + tagline=text(data.get("tagline")), + about=text(data.get("about")), + loves=[t for t in map(text, loves) if t][:LOVES_MAX], + more_heading=text(more.get("heading")), + more_text=more_text.strip() if isinstance(more_text, str) else "", + lately={n: text(lines.get(str(n))) for n in lately if text(lines.get(str(n)))}, + ) + + +# ── The page ───────────────────────────────────────────────────────────── + +def render(parts: Parts, *, name: str, first_name: str, entries: list[dict], + lately: list[int], docs: list[int], links: dict[int, str], lang: str) -> str: + """The page body, from the template for `lang`. + + `links` maps a citation number to the published path of its source, + for the Documents list. Entries the model wrote no line for fall + back to their title, so Lately never loses an entry. + """ + lang = language(lang) + lately_lines = [ + f"- **{format_date(entries[n - 1]['date'], lang)}** · " + f"{sentence(parts.lately.get(n) or entries[n - 1].get('title') or '')} [{n}]" + for n in lately + ] + doc_lines = [ + f"- [{entries[n - 1].get('title') or ''}]({links[n]}) · " + f"{format_date(entries[n - 1].get('date') or '', lang)}" + for n in docs if links.get(n) + ] + more = f"## {parts.more_heading}\n{parts.more_text}" if parts.more_heading and parts.more_text else "" + slots = { + "name": name, + "first_name": first_name, + "tagline": parts.tagline, + "about": parts.about, + "loves": "\n".join(f"- {line}" for line in parts.loves), + "more": more, + "lately": "\n".join(lately_lines), + "documents": "\n".join(doc_lines), + } + return fill(template("person", lang), slots) + + +# ── The prompt ─────────────────────────────────────────────────────────── + +def prompt(*, display: str, first_name: str, evidence: str, facts: list[tuple[str, str]], + lately: list[int], lang: str, previous: str = "", + new_evidence: list[str] | None = None) -> str: + """The instructions for one person's prose, asking for a JSON object. + + `previous` is the page's current body: the model keeps its wording + where the evidence still supports it, so a new filing changes a few + lines rather than the whole page. Citation numbers in it already + match `evidence` (the compiler renumbered them). + """ + if facts: + facts_block = (f"Facts the family typed in about {display} (always true):\n" + + "\n".join(f"- ({kind}) {text}" for kind, text in facts)) + else: + facts_block = f"The family typed in no facts about {display}." + + anchor = "" + if previous: + fresh = "\n".join(new_evidence or []) or "Nothing is new since then." + anchor = f""" +The page already exists. Keep the wording of its tagline, About, loves and own section wherever the sources still support it, and change only what new sources add to or contradict. Its [N] numbers already match the sources above. + +New since the page was written: +{fresh} + +THE PAGE AS IT IS NOW: +{previous} +""" + + lately_list = ", ".join(str(n) for n in lately) or "(none)" + return f"""You write the page about one person in a family's private wiki. Family members open it to see who {display} is right now, and they should enjoy reading it. + +This page is about: {display}. + +{facts_block} + +What the family has filed that involves {display}. Each source is numbered; cite it as [N] after the sentence it supports: + +{evidence} +{anchor} +Write in {LANGUAGE_NAMES[language(lang)]}, the way a family member would describe {first_name} to a relative who is visiting: warm, concrete, specific, with the small details and the humour the sources have. Short sentences. Present tense for who they are, past tense for what happened. + +Never sound like a report. Do not use words like "logistics", "dynamics", "engagement", "status", "is noted", "appears to", "records show", "sources", "documents indicate". Do not repeat {first_name}'s name at the start of every sentence. + +Answer with one JSON object and nothing else: +{{ + "tagline": "one short line that says who {first_name} is in the family, the way a relative would put it", + "about": "two to four sentences about {first_name} today: where they are in life, what fills their days, what makes them them. Cite [N].", + "loves": ["up to {LOVES_MAX} short lines, each a thing, person, food, place or pastime {first_name} loves, cited [N]. Not an event or an achievement."], + "lately": {{"": "for each of the sources {lately_list}: one line saying what {first_name} did or what happened to them, without the date, in the past tense, starting with the verb rather than the name or a pronoun. Cite nothing in it."}}, + "more": {{"heading": "a heading of your own", "text": "one theme about {first_name}, at most three sentences, cited [N]"}} +}} + +"more" is optional: use it only when the sources hold something about {first_name} worth its own section that fits nowhere else (a running joke, a milestone, a story the family keeps telling). It is about {first_name}, not about someone else. Otherwise leave it out. + +Rules: +- Use only what the sources and the facts say. Never invent. A field with nothing to say is left out or empty. +- The sources also involve other people (see "involves:"). A birth certificate is mostly about the parents; a school letter names a parent. Say something about {first_name} only when the source ties it to {first_name} by name. Never give {first_name} another person's role, job, costume, words or relationship. +- When a source says "the mother" or "her husband" without naming {first_name}, that is about someone else: leave it out. +- No dates, numbers of accounts or IDs, and no source titles in the prose. The page lists the documents itself. +- Several sources for one sentence are cited together: [1, 3]. +- Count people only from the sources: who the children, siblings and grandparents are comes from what the sources name. +""" diff --git a/stacklets/memory/bot/templates/person.de.md b/stacklets/memory/bot/templates/person.de.md new file mode 100644 index 00000000..ed7f2142 --- /dev/null +++ b/stacklets/memory/bot/templates/person.de.md @@ -0,0 +1,32 @@ + +# {{name}} +> {{tagline}} + +{{about}} + +## Was {{first_name}} liebt +{{loves}} + +{{more}} + +## Zuletzt +{{lately}} + +## Dokumente +{{documents}} diff --git a/stacklets/memory/bot/templates/person.en.md b/stacklets/memory/bot/templates/person.en.md new file mode 100644 index 00000000..e361ffd1 --- /dev/null +++ b/stacklets/memory/bot/templates/person.en.md @@ -0,0 +1,32 @@ + +# {{name}} +> {{tagline}} + +{{about}} + +## What {{first_name}} loves +{{loves}} + +{{more}} + +## Lately +{{lately}} + +## Documents +{{documents}} diff --git a/tests/unit/stacklets/test_memory_person_page.py b/tests/unit/stacklets/test_memory_person_page.py new file mode 100644 index 00000000..00168830 --- /dev/null +++ b/tests/unit/stacklets/test_memory_person_page.py @@ -0,0 +1,154 @@ +"""A person's page: the template holds the layout, the model writes the prose. + +Read from the outside, the way a family member reads the published page. +What these tests promise (docs/design/brain/wiki-page-anatomy.md): + +- Dates, the latest entries and the documents come from what was filed, + so a page never shows a made-up date or a diary entry as a document. +- A section with nothing in it is not on the page at all, heading + included. No "nothing recorded". +- A German household gets German headings and German dates. +- The model may add one section of its own; without it there is none. +- An answer the compiler cannot read leaves the page as it was. +""" + +from __future__ import annotations + +import asyncio +import json +import sys +from pathlib import Path + +import pytest + +_REPO_ROOT = Path(__file__).resolve().parents[3] +sys.path.insert(0, str(_REPO_ROOT / "stacklets")) +sys.path.insert(0, str(_REPO_ROOT / "stacklets" / "memory" / "bot" / "cli")) + +import wiki # noqa: E402 +from stack.people import Person # noqa: E402 + +MAGGIE = Person("maggie", "Maggie Simpson", ("Margaret",)) + +FIRST_WORD = {"rel": "family/diary/entries/2025/11/2025-11-27-aa11.md", "type": "diary", + "title": "Maggie's first word", "date": "2025-11-27", "persons": ["maggie"], + "summary": "At Thanksgiving Maggie said 'turkey'."} +SQUISHEE = {"rel": "family/diary/entries/2024/08/2024-08-24-bb22.md", "type": "diary", + "title": "Blue Squishee face", "date": "2024-08-24", "persons": ["maggie"], + "summary": "Apu gave Maggie a free Squishee; her face turned blue."} +BIRTH = {"rel": "family/documents/2025/01/2025-01-18-birth-certificate-p47.md", + "type": "document", "title": "Birth Certificate Margaret Simpson", + "date": "2025-01-18", "persons": ["maggie", "homer", "marge"], + "summary": "Birth certificate of Margaret Simpson."} + + +class _Model: + def __init__(self, answer: dict | str) -> None: + self.answer = answer + self.prompts: list[str] = [] + + async def complete(self, role, prompt, **kwargs) -> str: + self.prompts.append(prompt) + return self.answer if isinstance(self.answer, str) else json.dumps(self.answer) + + +ANSWER = { + "tagline": "The youngest Simpson, rarely without her pacifier.", + "about": "Maggie is the baby of the family and watches everything. [1]", + "loves": ["Squishees, even when they turn her blue. [2]"], + "lately": {"1": "Said her first word at Thanksgiving: turkey", + "2": "Got a free Squishee from Apu and turned blue"}, +} + + +@pytest.fixture +def brain(tmp_path, monkeypatch): + monkeypatch.setenv("BRAIN_REPO_DIR", str(tmp_path)) + (tmp_path / "maggie").mkdir() + return tmp_path + + +def _page(brain: Path, answer, entries, lang: str = "en") -> str: + rc = asyncio.run(wiki._generate_member( + _Model(answer), "maggie", entries, brain, person=MAGGIE, + shared_bucket="family", lang=lang, write=True)) + assert rc == 0 + return (brain / "maggie" / "about.md").read_text(encoding="utf-8") + + +class TestWhatTheCompilerFillsIn: + def test_latest_entries_carry_their_own_dates_newest_first(self, brain): + page = _page(brain, ANSWER, [FIRST_WORD, SQUISHEE]) + lately = page.split("## Lately\n", 1)[1].split("\n\n", 1)[0].splitlines() + assert lately == [ + "- **27 Nov 2025** · Said her first word at Thanksgiving: turkey. [1]", + "- **24 Aug 2024** · Got a free Squishee from Apu and turned blue. [2]", + ] + + def test_an_entry_the_model_wrote_no_line_for_keeps_its_title(self, brain): + answer = {**ANSWER, "lately": {"1": "Said 'turkey'"}} + page = _page(brain, answer, [FIRST_WORD, SQUISHEE]) + assert "- **24 Aug 2024** · Blue Squishee face. [2]" in page + + def test_documents_lists_only_what_was_filed_as_a_document(self, brain): + page = _page(brain, ANSWER, [FIRST_WORD, SQUISHEE, BIRTH]) + documents = page.split("## Documents\n", 1)[1].split("\n\n", 1)[0] + assert documents == ( + "- [Birth Certificate Margaret Simpson]" + "(/family/documents/2025/01/2025-01-18-birth-certificate-p47.md) · 18 Jan 2025") + + +class TestAnEmptySectionIsNotThere: + def test_no_documents_no_documents_heading(self, brain): + page = _page(brain, ANSWER, [FIRST_WORD, SQUISHEE]) + assert "## Documents" not in page + + def test_nothing_loved_no_loves_heading(self, brain): + page = _page(brain, {**ANSWER, "loves": []}, [FIRST_WORD, SQUISHEE]) + assert "loves" not in page.split("")[0].lower() + + def test_no_tagline_no_empty_quote(self, brain): + page = _page(brain, {**ANSWER, "tagline": ""}, [FIRST_WORD]) + assert "\n>" not in page.split("", 1)[1] + + def test_the_templates_own_notes_are_not_published(self, brain): + page = _page(brain, ANSWER, [FIRST_WORD]) + assert "Slots in double braces" not in page and "{{" not in page + + +class TestAGermanHousehold: + def test_headings_and_dates_are_german(self, brain): + page = _page(brain, ANSWER, [FIRST_WORD, BIRTH], lang="de") + assert "## Was Maggie liebt" in page + assert "## Zuletzt" in page and "## Dokumente" in page + assert "## Quellen" in page and "## References" not in page + assert "- **27. Nov. 2025** · " in page + + def test_the_model_is_asked_to_write_german(self, brain): + model = _Model(ANSWER) + asyncio.run(wiki._generate_member( + model, "maggie", [FIRST_WORD], brain, person=MAGGIE, + shared_bucket="family", lang="de", write=True)) + assert "Write in German" in model.prompts[0] + + +class TestASectionOfTheModelsOwn: + def test_it_appears_under_the_heading_the_model_chose(self, brain): + answer = {**ANSWER, "more": {"heading": "Firsts", "text": "Her first word was 'turkey'. [1]"}} + page = _page(brain, answer, [FIRST_WORD]) + assert "## Firsts\nHer first word was 'turkey'. [1]" in page + + def test_without_one_there_is_none(self, brain): + page = _page(brain, ANSWER, [FIRST_WORD]) + headings = [ln for ln in page.splitlines() if ln.startswith("## ")] + assert headings == ["## What Maggie loves", "## Lately", "## References"] + + +class TestAnAnswerThatCannotBeRead: + def test_the_page_stays_as_it_was(self, brain): + before = _page(brain, ANSWER, [FIRST_WORD]) + rc = asyncio.run(wiki._generate_member( + _Model("Here is Maggie's page: ..."), "maggie", [FIRST_WORD, SQUISHEE], brain, + person=MAGGIE, shared_bucket="family", lang="en", write=True)) + assert rc == 1 + assert (brain / "maggie" / "about.md").read_text(encoding="utf-8") == before diff --git a/tests/unit/stacklets/test_memory_wiki_citations.py b/tests/unit/stacklets/test_memory_wiki_citations.py index eb4b2886..d9d2a128 100644 --- a/tests/unit/stacklets/test_memory_wiki_citations.py +++ b/tests/unit/stacklets/test_memory_wiki_citations.py @@ -17,6 +17,7 @@ from __future__ import annotations import asyncio +import json import re import sys from pathlib import Path @@ -50,11 +51,11 @@ def _entry(rel: str, title: str, date: str) -> dict: class _Model: - """Writes one line per source the first time, then keeps the baseline. + """Writes one "loves" line per source the first time, then keeps the page. - Keeping the baseline untouched is what the prompt asks for when - nothing contradicts it, and what a model at temperature 0 mostly - does. Every number on the page is therefore the compiler's. + Keeping the page's wording is what the prompt asks for when nothing + contradicts it, and what a model at temperature 0 mostly does. + Every number on the page is therefore the compiler's. """ def __init__(self) -> None: @@ -62,13 +63,13 @@ def __init__(self) -> None: async def complete(self, role, prompt, **kwargs) -> str: self.prompts.append(prompt) - if "CURRENT PAGE (baseline):\n" in prompt: - baseline = prompt.split("CURRENT PAGE (baseline):\n", 1)[1] - return baseline.split("\nProduce a markdown page", 1)[0].strip() - lines = ["# Homer Simpson", "", "## Recent Activity"] - for n, title in re.findall(r"^\[(\d+)\] [\d-]+ · (.+?)(?: \(|$)", prompt, re.M): - lines.append(f"- {title}. [{n}]") - return "\n".join(lines) + if "THE PAGE AS IT IS NOW:\n" in prompt: + page = prompt.split("THE PAGE AS IT IS NOW:\n", 1)[1] + loves = re.findall(r"^- ([^*].*\[\d+\])$", page.split("## Lately")[0], re.M) + else: + loves = [f"{title}. [{n}]" for n, title in + re.findall(r"^\[(\d+)\] [\d-]+ · (.+?)(?: \(|$)", prompt, re.M)] + return json.dumps({"tagline": "Dad.", "about": "Homer is Homer.", "loves": loves}) @pytest.fixture @@ -87,11 +88,12 @@ def _build(model: _Model, brain: Path, entries: list[dict]) -> str: def _cited(page: str) -> dict[str, str]: - """Each sentence's subject, mapped to the title its reference names.""" + """Each line the model wrote, mapped to the title its reference names.""" refs = dict(re.findall(r"^- \[(\d+)\] \[([^\]]+)\]", page, re.M)) + loves = page.split("## What Homer loves", 1)[1].split("\n## ", 1)[0] return { sentence: refs.get(n, f"(no reference {n})") - for sentence, n in re.findall(r"^- (.+?)\. \[(\d+)\]$", page, re.M) + for sentence, n in re.findall(r"^- (.+?)\. \[(\d+)\]$", loves, re.M) } @@ -116,8 +118,8 @@ def test_only_the_new_entry_is_offered_as_new(self, brain): _build(model, brain, [HALLOWEEN, PANCAKES, SLEDDING, INSURANCE]) - new = model.prompts[-1].split("NEW since the baseline", 1)[1] - new = new.split("CURRENT PAGE", 1)[0] + new = model.prompts[-1].split("New since the page was written:", 1)[1] + new = new.split("THE PAGE AS IT IS NOW", 1)[0] assert re.findall(r"^\[\d+\] .+$", new, re.M) == ["[1] 2025-10-31 · Halloween costumes"] def test_a_rebuild_with_nothing_new_changes_no_number(self, brain): @@ -126,7 +128,7 @@ def test_a_rebuild_with_nothing_new_changes_no_number(self, brain): first = _build(model, brain, [PANCAKES, SLEDDING, INSURANCE]) assert _build(model, brain, [PANCAKES, SLEDDING, INSURANCE]) == first - assert "Nothing is new since the baseline" in model.prompts[-1] + assert "Nothing is new since then." in model.prompts[-1] class TestAPageFromBeforeTheFixIsRebuiltOnce: @@ -149,7 +151,7 @@ def test_the_old_text_is_not_offered_as_a_baseline(self, brain): page = _build(model, brain, [PANCAKES, SLEDDING]) - assert "CURRENT PAGE (baseline)" not in model.prompts[-1] + assert "THE PAGE AS IT IS NOW" not in model.prompts[-1] assert _cited(page) == { "Heart-shaped pancakes": "Heart-shaped pancakes", "Sledding on Mount Springfield": "Sledding on Mount Springfield", @@ -162,4 +164,4 @@ def test_the_rebuilt_page_is_a_baseline_again(self, brain): _build(model, brain, [PANCAKES, SLEDDING]) - assert "CURRENT PAGE (baseline)" in model.prompts[-1] + assert "THE PAGE AS IT IS NOW" in model.prompts[-1] From d868d76a8e248fc32d6a122ccc194af0f8ba30b4 Mon Sep 17 00:00:00 2001 From: Arthur Date: Sun, 11 Oct 2026 15:04:51 +0200 Subject: [PATCH 05/11] feat(wiki): write the home page from a template with the family up front The home page listed the family alphabetically, put Bart's friends and classmates under Broader Family, split Maggie into Maggie and a twin called Margaret, and filled empty sections with "No vehicle details are recorded". The home page is now filled from a Markdown template per language, `stacklets/memory/bot/templates/home..md`, in English and German: - The family: everyone the person files declare, oldest first, each linked to their page with one warm line. Nobody is dropped and nobody appears twice, whatever the model answers. - Relatives, friends and neighbours, and the people who help the family (doctor, school, teachers) each have a section of their own. - The home, vehicles, insurance, subscriptions and memberships hold plain facts with citations, and appear only when there are some. The model answers in JSON, one field per section, and is given every name a person goes by. The compiler formats the lists, so citations appear once at the end of a line. --- docs/design/brain/wiki-page-anatomy.md | 11 + stacklets/memory/bot/cli/wiki.py | 107 ++-------- stacklets/memory/bot/home_page.py | 202 ++++++++++++++++++ stacklets/memory/bot/page_template.py | 6 + stacklets/memory/bot/templates/home.de.md | 50 +++++ stacklets/memory/bot/templates/home.en.md | 50 +++++ tests/unit/stacklets/test_memory_home_page.py | 149 +++++++++++++ tests/unit/stacklets/test_memory_people.py | 15 +- 8 files changed, 491 insertions(+), 99 deletions(-) create mode 100644 stacklets/memory/bot/home_page.py create mode 100644 stacklets/memory/bot/templates/home.de.md create mode 100644 stacklets/memory/bot/templates/home.en.md create mode 100644 tests/unit/stacklets/test_memory_home_page.py diff --git a/docs/design/brain/wiki-page-anatomy.md b/docs/design/brain/wiki-page-anatomy.md index bbd11de7..2e70abc2 100644 --- a/docs/design/brain/wiki-page-anatomy.md +++ b/docs/design/brain/wiki-page-anatomy.md @@ -109,6 +109,17 @@ older stamp is regenerated from the evidence once instead of being kept as the baseline for the next rebuild, so a layout change reaches every page by the next nightly sweep. +### The home page template (built) + +The home page follows the same split, from its own template +(`home..md`, shared code in `page_template.py`). The compiler +lists everyone the person files declare, linked and never dropped, in +the order the model gives (oldest first). The model writes the welcome +and one line per person, and reads relatives, friends and neighbours, +the people who help the family, and the household facts (home, +vehicles, insurance, subscriptions, memberships) out of the sources. +The welcome is warm; the facts are plain. + ### Entity types and slot templates The ontology defines which pages exist; each entity type brings a diff --git a/stacklets/memory/bot/cli/wiki.py b/stacklets/memory/bot/cli/wiki.py index a68c3fcb..1ef1ddd4 100644 --- a/stacklets/memory/bot/cli/wiki.py +++ b/stacklets/memory/bot/cli/wiki.py @@ -80,10 +80,11 @@ # The diary compiler owns the diary's path and its reader-facing words; # the home page only places the pointer it renders. Imported by bare # name, the way `cli/diary.py` imports it, so the two commands share one -# module object and therefore one selected language. The person page's -# template and prose live beside it, in `person_page`. +# module object and therefore one selected language. The home and +# person pages' templates and prompts live beside it. sys.path.insert(0, str(Path(__file__).resolve().parents[1])) import diary # noqa: E402 +import home_page # noqa: E402 import person_page # noqa: E402 HELP = "Regenerate the family wiki's home and member pages" @@ -433,13 +434,23 @@ async def _generate_home( links into backlinks and graph edges for free, so the home page and the member pages become navigable in both directions. """ + # The template carries the layout; the model reads the sources and + # answers in JSON. Everyone on the roster gets a line, linked to their + # page, whatever the model wrote about them. names = {slug: p.name for slug, p in people.items()} - prompt = _build_home_prompt(index, roster=roster, names=names, lang=lang) + prompt = home_page.prompt( + evidence=_format_evidence(index), ids=roster, names=names, lang=lang, + aliases={slug: list(p.aliases) for slug, p in people.items()}) try: - page = (await llm.complete("overview", prompt, temperature=_TEMPERATURE)).strip() + raw = await llm.complete("overview", prompt, json_mode=True, temperature=_TEMPERATURE) + answer = home_page.read_answer(raw, ids=roster) except LLMUnavailableError as e: _err(f"LLM unavailable: {e}") return 1 + except home_page.AnswerError as e: + _err(f"home: the model's answer is unusable ({e}); the page stays as it was") + return 1 + page = home_page.render(answer, ids=roster, names=names, lang=lang) # Append References using the citations the LLM actually used. Built # deterministically from the index rather than asked of the model -- @@ -1673,94 +1684,6 @@ def _arg_values(argv: list[str], flag: str) -> list[str]: # ── Prompts ────────────────────────────────────────────────────────────── -def _build_home_prompt(entries: list[dict], *, roster: list[str], lang: str, - names: dict[str, str] | None = None) -> str: - """Single-shot prompt: feed all summaries, ask for one home page. - - The section layout is fixed -- we want the same headings every time - so re-runs produce comparable output and a future deriver can read - the page back into structured form. The model still decides what - facts land in each section. The roster is handed in as an explicit, - closed set of page paths so the Members section can link each name - to a member page that actually exists -- the model resolves identity - (the baby "Margaret" maps to the `maggie/` page), but it can only - pick from the paths we give it. With person files, `names` carries - each member's full name too, and the model fills in only the detail. - """ - names = names or {} - evidence = _format_evidence(entries) - if roster: - # Closed enumeration: the model copies this list instead of - # deriving the household from the evidence. Deriving is where - # small models split one person into two ("Maggie" and - # "Margaret") or drop the page links. - skeleton = "\n".join( - f"- **[{names[slug]}]({slug}/about)** — . [N]" if slug in names - else f"- **[]({slug}/about)** — . [N]" - for slug in roster - ) - member_links = ( - f"\nThe family has EXACTLY these {len(roster)} people, one bullet " - "each, in this order (it replaces oldest first) — fill in the full " - "name and detail, keep the link target as given:\n" - f"{skeleton}\n" - "A person can appear under different name variants in the sources " - "(the baby Margaret is the same person as Maggie) — never list " - "anyone twice, never add a person that is not in the list above.\n" - ) - else: - member_links = "" - return f"""You are composing the home page for a family's private, self-hosted memory wiki. It is the first page someone sees when they open the wiki, so this page is the family's "about" surface. The vault is private — never shared publicly — so identifying details (full names, addresses, vehicle plates, account numbers) are fine to include when documents reveal them. - -Source summaries (each is one document the archivist filed; cite as [N] inline where the fact came from a specific document): - -{evidence} - -Produce a markdown page with this EXACT structure and section order: - -# The -> - -## Members -One bullet per person living in the household, oldest first. Format each as exactly: -- **[]()** — . [N] -{member_links}Nothing else in the bullet: no birthdates, no nicknames, no placeholders. The member pages carry those details. - -## Broader Family -Relatives outside the household who appear in the documents: parents, in-laws, grandparents, siblings. One bullet per person: -- — [N] - -## Home -Two or three lines about the dwelling itself: ownership/rental, year acquired or moved in, anything structurally noted (mortgage, deed). Skip if nothing on record. - -## Real Estate -Other properties owned (rentals, second homes, plots). One bullet per property. - -## Vehicles -One bullet per vehicle: year, make/model, owner. - -## Insurance -One bullet per policy: name / provider — what it covers — owner. - -## Subscriptions -Recurring paid services (streaming, software, subscriptions to physical goods). One bullet each: -- — — - -## Memberships -Clubs, teams, associations, professional bodies. One bullet each: -- — — - -Rules: -- The H1 is the family surname prefixed with "The" (e.g. "# The Simpsons"). Pick the surname most frequently associated with household members; if multiple appear (blended family), use the dominant one. -- The blockquote line below the H1 is the current primary address. Omit the blockquote entirely if no address is on file. -- Respond in: {lang}. -- Use ONLY what the source summaries support. A section with no source material is omitted entirely — never invent, never write placeholder lines. -- Cite the document that established each fact as [N] inline. Multiple sources: [1, 3]. -- Keep entries dense and skimmable. One bullet per member; one bullet per item elsewhere. -- Do NOT add a "References" section — it is appended programmatically after your response. Don't list source paths or URLs in your output. -""" - - def _entry_kind(rel: str, slug: str) -> str: """The capture kind of an entry, read from its vault path. diff --git a/stacklets/memory/bot/home_page.py b/stacklets/memory/bot/home_page.py new file mode 100644 index 00000000..43971399 --- /dev/null +++ b/stacklets/memory/bot/home_page.py @@ -0,0 +1,202 @@ +"""The wiki's home page: a Markdown template, the model reads the sources. + +The home page is the family's front door: who lives here, who is around +them, and the household facts worth finding at a glance (the home, the +cars, the insurance). It is filled from `templates/home..md` +(see `page_template`). + +- **The compiler** lists everyone the person files declare, each linked + to their page, in the order the model gives (oldest first) and never + without someone: nobody is dropped, nobody appears twice. +- **The model** answers in JSON: the family's name and address, a short + welcome, one line per person, the relatives and the friends around + them, and the household facts it found, each with the sources it + cites as numbers. The compiler formats them, so the page has one + style whatever the model writes. + +Pure functions: no I/O beyond reading the template, no model calls. +The wiki compiler (`wiki._generate_home`) owns the evidence, the call +and the publish. +""" + +from __future__ import annotations + +import json +import re +from dataclasses import dataclass, field + +from page_template import LANGUAGE_NAMES, cites, fill, language, sentence, template, text + +FACT_SECTIONS = ("vehicles", "insurance", "subscriptions", "memberships") + +# The friends a reader takes in at a glance. Past that, the list stops +# being the people closest to the family. +FRIENDS_MAX = 8 + +_TRAILING_CITES = re.compile(r"\s*\[\d+(?:\s*,\s*\d+)*\]\s*\.?\s*$") + + +@dataclass +class Item: + """One line of a list section: a name, what it is, the sources.""" + + name: str + detail: str = "" + cites: str = "" + + def __post_init__(self) -> None: + # The model often cites inside the text as well as in `cite`. + # The page shows the citation once, at the end. + if found := _TRAILING_CITES.search(self.detail): + self.cites = self.cites or " " + found.group().strip().rstrip(".") + self.detail = self.detail[:found.start()].rstrip() + + def render(self) -> str: + detail = f" · {sentence(self.detail)}" if self.detail else "" + return f"- **{self.name}**{detail}{self.cites}" + + +@dataclass +class Answer: + """What the model read from the sources; every field may be empty.""" + + family: str = "" + address: str = "" + welcome: str = "" + people: dict[str, Item] = field(default_factory=dict) + order: list[str] = field(default_factory=list) + relatives: list[Item] = field(default_factory=list) + friends: list[Item] = field(default_factory=list) + helpers: list[Item] = field(default_factory=list) + home: str = "" + facts: dict[str, list[Item]] = field(default_factory=dict) + + +class AnswerError(ValueError): + """The model's answer is not the JSON object the prompt asked for.""" + + +def read_answer(raw: str, *, ids: list[str]) -> Answer: + """The model's JSON answer, or `AnswerError`. + + A field of the wrong shape is dropped rather than guessed at, and a + person the roster does not know is ignored. + """ + try: + data = json.loads(raw) + except json.JSONDecodeError as e: + raise AnswerError(f"not JSON: {e}") from e + if not isinstance(data, dict): + raise AnswerError("not a JSON object") + + people: dict[str, Item] = {} + raw_people = data.get("people") + for pid, value in (raw_people.items() if isinstance(raw_people, dict) else []): + if pid in ids and isinstance(value, dict): + # No citation: the line links to the person's own page, where + # every fact is cited. A model citing the whole family to one + # source was the most common wrong citation on this page. + people[pid] = Item(text(value.get("name")), text(value.get("line"))) + order = data.get("order") + return Answer( + family=text(data.get("family")), + address=text(data.get("address")), + welcome=text(data.get("welcome")), + people=people, + order=[p for p in order if p in ids] if isinstance(order, list) else [], + relatives=_items(data.get("relatives")), + friends=_items(data.get("friends"))[:FRIENDS_MAX], + helpers=_items(data.get("helpers")), + home=text(data.get("home")), + facts={s: _items(data.get(s)) for s in FACT_SECTIONS}, + ) + + +def _items(value) -> list[Item]: + if not isinstance(value, list): + return [] + items = [Item(text(v.get("name")), text(v.get("detail")), cites(v.get("cite"))) + for v in value if isinstance(v, dict)] + return [i for i in items if i.name] + + +# ── The page ───────────────────────────────────────────────────────────── + +def render(answer: Answer, *, ids: list[str], names: dict[str, str], lang: str) -> str: + """The home page body. `names` holds the declared full names by id.""" + order = answer.order + [p for p in ids if p not in answer.order] + people = [] + for pid in order: + item = answer.people.get(pid, Item("")) + name = names.get(pid) or item.name or pid.capitalize() + line = f" · {sentence(item.detail)}" if item.detail else "" + people.append(f"- **[{name}]({pid}/about)**{line}") + slots = { + "family": answer.family, + "address": answer.address, + "welcome": answer.welcome, + "people": "\n".join(people), + "relatives": "\n".join(i.render() for i in answer.relatives), + "friends": "\n".join(i.render() for i in answer.friends), + "helpers": "\n".join(i.render() for i in answer.helpers), + "home": answer.home, + **{s: "\n".join(i.render() for i in answer.facts.get(s, [])) for s in FACT_SECTIONS}, + } + return fill(template("home", lang), slots) + + +# ── The prompt ─────────────────────────────────────────────────────────── + +def prompt(*, evidence: str, ids: list[str], names: dict[str, str], lang: str, + aliases: dict[str, list[str]] | None = None) -> str: + """The instructions for the home page, asking for a JSON object. + + `aliases` are the other names the family uses for a person. Without + them a model reads "Margaret" on a birth certificate as a stranger, + and lists Maggie's twin sister among the relatives. + """ + aliases = aliases or {} + + def known_as(pid: str) -> str: + other = aliases.get(pid) or [] + return f" (also called {', '.join(other)})" if other else "" + + roster = "\n".join(f"- {pid}: {names.get(pid) or '(full name from the sources)'}{known_as(pid)}" + for pid in ids) + return f"""You write the home page of a family's private wiki. It is the first page the family sees when they open it: who they are, who is around them, and the household facts they want to find at a glance. The wiki is private and runs on the family's own server, so full names and addresses are fine. + +The family's people, by id. Each has their own page; write one line for each: +{roster} + +What the family has filed. Each source is numbered; cite sources by their numbers: + +{evidence} + +Write in {LANGUAGE_NAMES[language(lang)]}. The welcome and the lines about people are warm and personal, the way a family member would introduce them to a visitor. The household facts are plain and exact. + +The welcome is about the family, never about the wiki, the pages or the sources. + +Answer with one JSON object and nothing else: +{{ + "family": "the family's name as a heading: \\"The Simpsons\\" in English, \\"Familie Simpson\\" in German. Use the surname most of the people above share.", + "address": "where the family lives now: the street address if a source gives one, else the town", + "welcome": "two or three warm sentences about the family as it is now, with citations like [3]", + "order": ["every id above, oldest first: parents before their children, older children before younger ones"], + "people": {{"": {{"name": "the full name, only for a person listed without one", "line": "who they are, warmly and specifically, in under fifteen words: their place in the family and what they are like or busy with. No citation."}}}}, + "relatives": [{{"name": "full name", "detail": "how they are related to ONE of the family's people, in a few words, like \"Homer's father\"", "cite": [1]}}], + "friends": [{{"name": "full name", "detail": "who they are to the family, in a few words", "cite": [5]}}], + "helpers": [{{"name": "name", "detail": "what they do for the family: family doctor, Bart's teacher, the school, the corner shop", "cite": [9]}}], + "home": "plain facts about the home itself, with citations: owned or rented, since when, the mortgage, the rooms, repairs. Leave it out when no source gives such a fact; warmth belongs in the welcome.", + "vehicles": [{{"name": "make and model", "detail": "year, owner", "cite": [7]}}], + "insurance": [{{"name": "policy and insurer", "detail": "what it covers, for whom, cost if known", "cite": [7]}}], + "subscriptions": [{{"name": "service", "detail": "cadence and amount if known, for whom", "cite": [7]}}], + "memberships": [{{"name": "club or association", "detail": "what for, who is a member", "cite": [7]}}] +}} + +Rules: +- Use only what the sources say. Never invent. Leave out a field, or give an empty list, when the sources have nothing for it. No placeholder text. +- Never write about the sources themselves: no "the sources do not say", "not specified", "no records". What is unknown is simply not on the page. +- "relatives" are family by blood or marriage who are not among the people above: parents, grandparents, siblings and half-siblings, aunts, uncles, cousins, in-laws. Friends, neighbours, classmates and colleagues go in "friends", at most {FRIENDS_MAX}, the closest first. Doctors, teachers, schools, shops, tradespeople and others who serve the family go in "helpers". +- The family's people above go by every name listed for them. Nobody from that list appears in "relatives", "friends" or "helpers", under any of their names. +- No account numbers, policy numbers or IDs; the documents carry those. +""" diff --git a/stacklets/memory/bot/page_template.py b/stacklets/memory/bot/page_template.py index 45c9513e..64639d58 100644 --- a/stacklets/memory/bot/page_template.py +++ b/stacklets/memory/bot/page_template.py @@ -77,3 +77,9 @@ def sentence(line: str) -> str: line = line.strip() return line if not line or line[-1] in ".!?…" else line + "." + +def cites(value) -> str: + """` [1, 3]` for a model's list of citation numbers, "" for none.""" + nums = [n for n in value if isinstance(n, int) and not isinstance(n, bool)] \ + if isinstance(value, list) else [] + return f" [{', '.join(map(str, nums))}]" if nums else "" diff --git a/stacklets/memory/bot/templates/home.de.md b/stacklets/memory/bot/templates/home.de.md new file mode 100644 index 00000000..aef3ba9f --- /dev/null +++ b/stacklets/memory/bot/templates/home.de.md @@ -0,0 +1,50 @@ + +# {{family}} +> {{address}} + +{{welcome}} + +## Familie +{{people}} + +## Verwandte +{{relatives}} + +## Freunde und Nachbarn +{{friends}} + +## Wer uns hilft +{{helpers}} + +## Zuhause +{{home}} + +## Fahrzeuge +{{vehicles}} + +## Versicherungen +{{insurance}} + +## Abos +{{subscriptions}} + +## Mitgliedschaften +{{memberships}} diff --git a/stacklets/memory/bot/templates/home.en.md b/stacklets/memory/bot/templates/home.en.md new file mode 100644 index 00000000..5f8452ca --- /dev/null +++ b/stacklets/memory/bot/templates/home.en.md @@ -0,0 +1,50 @@ + +# {{family}} +> {{address}} + +{{welcome}} + +## Family +{{people}} + +## Relatives +{{relatives}} + +## Friends and neighbours +{{friends}} + +## The people who help us +{{helpers}} + +## Home +{{home}} + +## Vehicles +{{vehicles}} + +## Insurance +{{insurance}} + +## Subscriptions +{{subscriptions}} + +## Memberships +{{memberships}} diff --git a/tests/unit/stacklets/test_memory_home_page.py b/tests/unit/stacklets/test_memory_home_page.py new file mode 100644 index 00000000..a3b728ff --- /dev/null +++ b/tests/unit/stacklets/test_memory_home_page.py @@ -0,0 +1,149 @@ +"""The home page: the template holds the layout, the model reads the sources. + +Read from the outside, as the family reads the published page. What +these tests promise: + +- Everyone the person files declare is on the page, linked to their + page, once, in the order the model gives (oldest first); a person the + model forgot is still there, a person it made up is not. +- A section with nothing in it is not on the page, heading included. +- A German household gets German headings. +- An answer the compiler cannot read leaves the page as it was. +""" + +from __future__ import annotations + +import asyncio +import json +import re +import sys +from pathlib import Path + +import pytest + +_REPO_ROOT = Path(__file__).resolve().parents[3] +sys.path.insert(0, str(_REPO_ROOT / "stacklets")) +sys.path.insert(0, str(_REPO_ROOT / "stacklets" / "memory" / "bot" / "cli")) + +import wiki # noqa: E402 +from stack.people import Person # noqa: E402 + +PEOPLE = { + "bart": Person("bart", "Bart Simpson", account="bart"), + "homer": Person("homer", "Homer Simpson", account="homer"), + "maggie": Person("maggie", "Maggie Simpson"), +} +ROSTER = ["bart", "homer", "maggie"] +INDEX = [{"rel": "family/documents/2009/06/deed-p1.md", "title": "Deed", "date": "2009-06-15", + "summary": "742 Evergreen Terrace, bought by Homer and Marge."}] + +ANSWER = { + "family": "The Simpsons", + "address": "742 Evergreen Terrace", + "welcome": "A loud, loving family on Evergreen Terrace. [1]", + "order": ["homer", "bart", "maggie"], + "people": {"homer": {"line": "Dad, safety inspector", "cite": [1]}, + "bart": {"line": "The prankster"}, + "flanders": {"line": "Not ours"}}, + "relatives": [{"name": "Abraham Simpson", "detail": "Homer's father", "cite": [1]}], + "friends": [{"name": "Ned Flanders", "detail": "the neighbour", "cite": [1]}], + "home": "Owned since 2009. [1]", + "vehicles": [], "insurance": [], +} + + +class _Model: + def __init__(self, answer) -> None: + self.answer = answer + + async def complete(self, role, prompt, **kwargs) -> str: + return self.answer if isinstance(self.answer, str) else json.dumps(self.answer) + + +@pytest.fixture +def brain(tmp_path, monkeypatch): + monkeypatch.setenv("BRAIN_REPO_DIR", str(tmp_path)) + (tmp_path / "index.md").write_text( + "---\ntitle: Family Memory\ngenerated: true\n---\n", encoding="utf-8") + return tmp_path + + +def _home(brain: Path, answer, lang: str = "en") -> str: + rc = asyncio.run(wiki._generate_home( + _Model(answer), INDEX, roster=ROSTER, people=PEOPLE, + shared_bucket="family", lang=lang, write=True)) + assert rc == 0 + return (brain / "index.md").read_text(encoding="utf-8") + + +def _family(page: str) -> list[str]: + return re.findall(r"^- \*\*\[[^\]]+\]\((\w+)/about\)\*\*", page, re.M) + + +class TestEveryoneIsOnThePage: + def test_in_the_models_order_oldest_first(self, brain): + assert _family(_home(brain, ANSWER)) == ["homer", "bart", "maggie"] + + def test_a_person_the_model_left_out_is_still_listed(self, brain): + """Maggie has no line from the model and no place in its order.""" + page = _home(brain, ANSWER) + assert "- **[Maggie Simpson](maggie/about)**\n" in page + "\n" + + def test_a_person_the_model_made_up_is_not(self, brain): + assert "Not ours" not in _home(brain, ANSWER) + + def test_the_declared_name_wins_over_the_models(self, brain): + answer = {**ANSWER, "people": {"homer": {"name": "Homer J.", "line": "Dad"}}} + assert "- **[Homer Simpson](homer/about)** · Dad." in _home(brain, answer) + + +class TestTheSectionsAroundThem: + def test_relatives_and_friends_are_listed_apart(self, brain): + page = _home(brain, ANSWER) + assert "## Relatives\n- **Abraham Simpson** · Homer's father. [1]" in page + assert "## Friends and neighbours\n- **Ned Flanders** · the neighbour. [1]" in page + + def test_an_empty_section_is_not_on_the_page(self, brain): + page = _home(brain, ANSWER) + assert "## Vehicles" not in page and "## Insurance" not in page + + def test_a_german_household_reads_german_headings(self, brain): + page = _home(brain, ANSWER, lang="de") + assert "## Familie" in page and "## Verwandte" in page + assert "## Freunde und Nachbarn" in page and "## Zuhause" in page + + +class TestAnAnswerThatCannotBeRead: + def test_the_page_stays_as_it_was(self, brain): + before = _home(brain, ANSWER) + rc = asyncio.run(wiki._generate_home( + _Model("# The Simpsons\n..."), INDEX, roster=ROSTER, people=PEOPLE, + shared_bucket="family", lang="en", write=True)) + assert rc == 1 + assert (brain / "index.md").read_text(encoding="utf-8") == before + + +class TestTheModelIsToldWhoIsWho: + def test_every_name_a_person_goes_by(self): + """A birth certificate says "Margaret"; she is Maggie, not a twin.""" + prompt = wiki.home_page.prompt( + evidence="", ids=["maggie"], names={"maggie": "Maggie Simpson"}, lang="en", + aliases={"maggie": ["Margaret", "Mags"]}) + assert "- maggie: Maggie Simpson (also called Margaret, Mags)" in prompt + + +class TestOneStyleWhateverTheModelWrites: + def test_a_citation_inside_a_line_is_shown_once(self, brain): + answer = {**ANSWER, "relatives": [{"name": "Abraham Simpson", + "detail": "Homer's father [1].", "cite": [1]}]} + assert "- **Abraham Simpson** · Homer's father. [1]\n" in _home(brain, answer) + + def test_a_person_links_to_their_page_instead_of_citing(self, brain): + """Their own page cites every fact; one line here is not evidence.""" + answer = {**ANSWER, "people": {"homer": {"line": "Our dad [8].", "cite": [8]}}} + assert "- **[Homer Simpson](homer/about)** · Our dad.\n" in _home(brain, answer) + + def test_the_people_who_help_the_family_have_their_own_section(self, brain): + answer = {**ANSWER, "helpers": [{"name": "Dr. Hibbert", "detail": "family doctor", "cite": [1]}]} + page = _home(brain, answer) + assert "## The people who help us\n- **Dr. Hibbert** · family doctor. [1]" in page diff --git a/tests/unit/stacklets/test_memory_people.py b/tests/unit/stacklets/test_memory_people.py index f0ee8327..c027d418 100644 --- a/tests/unit/stacklets/test_memory_people.py +++ b/tests/unit/stacklets/test_memory_people.py @@ -284,23 +284,24 @@ def test_the_home_page_lists_everyone_declared_account_holders_first( class _Model: async def complete(self, role, prompt, **kwargs): prompts.append(prompt) - return "# The Simpsons\n" + return "{}" monkeypatch.setattr(wiki, "_index_vault", lambda vault: index) monkeypatch.setattr(wiki, "_brain_dir", lambda: brain) - (brain / "index.md").write_text("---\ntitle: Family Memory\n---\n") + (brain / "index.md").write_text("---\ntitle: Family Memory\ngenerated: true\n---\n") asyncio.run(wiki.run(_Model(), ["--home"])) - listed = re.findall(r"^- \*\*\[([^\]]+)\]\((\w+)/about\)\*\*", prompts[0], re.M) + page = (brain / "index.md").read_text() + listed = re.findall(r"^- \*\*\[([^\]]+)\]\((\w+)/about\)\*\*", page, re.M) assert listed == [("Homer Simpson", "homer"), ("Marge Simpson", "marge"), ("Maggie Simpson", "maggie")] def test_the_home_page_is_told_the_full_names(self): - prompt = wiki._build_home_prompt( - [], roster=["homer", "marge"], lang="en", + prompt = wiki.home_page.prompt( + evidence="", ids=["homer", "marge"], lang="en", names={"homer": "Homer Simpson", "marge": "Marge Simpson"}) - assert "- **[Marge Simpson](marge/about)**" in prompt - assert " Date: Sat, 10 Oct 2026 12:23:20 +0200 Subject: [PATCH 06/11] feat(wiki): write topic pages as facts and where things stand A topic page was one dense paragraph in the first person or in report voice, credited actions to the wrong person ("Homer has confirmed a lodge booking" that Marge made), and left the topic's open todos off the page. A topic page is now filled from a Markdown template per language, `stacklets/memory/bot/templates/topic..md`, in English and German: - At a glance: the key facts as label and value, with citations. - Where things stand: what has been booked, bought or decided, and by whom. The model is told who filed each source. - Still to do: the open items of the topic's todo list, as the family keeps it. - Notes, links, documents and what was filed elsewhere, listed by the compiler with who filed each and when, newest first. The model answers in JSON with the facts and the state only; plans and to-dos stay on the todo list. A section with nothing in it is left out. --- docs/design/brain/wiki-page-anatomy.md | 31 ++- stacklets/memory/bot/cli/wiki.py | 205 +++++------------- stacklets/memory/bot/templates/topic.de.md | 42 ++++ stacklets/memory/bot/templates/topic.en.md | 41 ++++ stacklets/memory/bot/topic_page.py | 162 ++++++++++++++ .../unit/stacklets/test_memory_topic_page.py | 136 ++++++++++++ tests/unit/stacklets/test_memory_wiki.py | 82 +------ 7 files changed, 460 insertions(+), 239 deletions(-) create mode 100644 stacklets/memory/bot/templates/topic.de.md create mode 100644 stacklets/memory/bot/templates/topic.en.md create mode 100644 stacklets/memory/bot/topic_page.py create mode 100644 tests/unit/stacklets/test_memory_topic_page.py diff --git a/docs/design/brain/wiki-page-anatomy.md b/docs/design/brain/wiki-page-anatomy.md index 2e70abc2..1f9b3eb3 100644 --- a/docs/design/brain/wiki-page-anatomy.md +++ b/docs/design/brain/wiki-page-anatomy.md @@ -109,16 +109,27 @@ older stamp is regenerated from the evidence once instead of being kept as the baseline for the next rebuild, so a layout change reaches every page by the next nightly sweep. -### The home page template (built) - -The home page follows the same split, from its own template -(`home..md`, shared code in `page_template.py`). The compiler -lists everyone the person files declare, linked and never dropped, in -the order the model gives (oldest first). The model writes the welcome -and one line per person, and reads relatives, friends and neighbours, -the people who help the family, and the household facts (home, -vehicles, insurance, subscriptions, memberships) out of the sources. -The welcome is warm; the facts are plain. +### The home and topic page templates (built) + +The home page and every topic page follow the same split, each from its +own template (`home..md`, `topic..md`, shared code in +`page_template.py`): + +- **Home.** The compiler lists everyone the person files declare, linked + and never dropped, in the order the model gives (oldest first). The + model writes the welcome and one line per person, and reads relatives, + friends and neighbours, the people who help the family, and the + household facts (home, vehicles, insurance, subscriptions, + memberships) out of the sources. The welcome is warm; the facts are + plain. +- **Topic.** Facts and state, no prose: the model gives the key facts as + label and value and what has happened (booked, bought, decided), with + who did it. The compiler lists the open items of the topic's todo + list as the family keeps it, and everything filed under the topic + with who filed it and when. + +Correspondent pages stay as they are: facts the compiler renders from +the documents, no model prose. ### Entity types and slot templates diff --git a/stacklets/memory/bot/cli/wiki.py b/stacklets/memory/bot/cli/wiki.py index 1ef1ddd4..b709b310 100644 --- a/stacklets/memory/bot/cli/wiki.py +++ b/stacklets/memory/bot/cli/wiki.py @@ -86,6 +86,12 @@ import diary # noqa: E402 import home_page # noqa: E402 import person_page # noqa: E402 +import topic_page # noqa: E402 + +# One module owns what a todo line is; `stack memory topic` reads +# through it the same way, by bare name from this folder. +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from todo_list import read_todos # noqa: E402 HELP = "Regenerate the family wiki's home and member pages" @@ -373,7 +379,7 @@ async def run(llm: LLM, argv: list[str]) -> int: topic_set = [t for t in topics if t[1] in wanted] for bucket_prefix, topic_slug in topic_set: rc = await _generate_topic( - llm, bucket_prefix, topic_slug, index, + llm, bucket_prefix, topic_slug, index, people=persons, shared_bucket=shared_bucket, lang=lang, write=not dry_run, ) if rc == 0: @@ -414,7 +420,7 @@ async def run(llm: LLM, argv: list[str]) -> int: for bucket_prefix, topic_slug in topics: await _generate_topic( - llm, bucket_prefix, topic_slug, index, + llm, bucket_prefix, topic_slug, index, people=persons, shared_bucket=shared_bucket, lang=lang, write=not dry_run, ) return 0 @@ -585,6 +591,7 @@ async def _generate_member( async def _generate_topic( llm: LLM, bucket_prefix: str, topic_slug: str, index: list[dict], *, + people: dict[str, Person] | None = None, shared_bucket: str, lang: str, write: bool, ) -> int: """Compose one topic's `about.md` from its slice plus cross-refs. @@ -612,19 +619,28 @@ async def _generate_topic( scope = "shared" if bucket_prefix == shared_bucket else "personal" display = topic_slug.replace("-", " ").title() - prompt = _build_topic_prompt( - display, topic_slug, scope, entries, cross_refs, lang=lang, + # The template carries the layout; the model reads the facts and the + # state out of the sources, as JSON. The todo items and the lists of + # what was filed come from disk and the index, as they are. + page_dir = f"{bucket_prefix}/{topic_slug}" + sources = _topic_sources(entries, cross_refs, topic_slug, people or {}) + todos = _open_todos(page_dir) + prompt = topic_page.prompt( + display=display, scope=scope, lang=lang, todos=todos, + evidence_text=topic_page.evidence( + sources, {n: e.get("summary") or "" for n, e in enumerate(entries + cross_refs, 1)}), ) try: - page = (await llm.complete("overview", prompt, temperature=_TEMPERATURE)).strip() + raw = await llm.complete("overview", prompt, json_mode=True, temperature=_TEMPERATURE) + answer = topic_page.read_answer(raw) except LLMUnavailableError as e: _err(f"LLM unavailable: {e}") return 1 + except topic_page.AnswerError as e: + _err(f"{page_dir}: the model's answer is unusable ({e}); the page stays as it was") + return 1 + page = topic_page.render(answer, display=display, sources=sources, todos=todos, lang=lang) - # Topic about.md lives at `//about.md`; citations climb - # two levels (out of /, out of /) to reach files in - # other buckets. - page_dir = f"{bucket_prefix}/{topic_slug}" page = _with_references(page, entries + cross_refs, page_dir=page_dir, lang=lang) if not write: @@ -645,6 +661,35 @@ async def _generate_topic( ) return rc +def _topic_sources(entries: list[dict], cross_refs: list[dict], topic_slug: str, + people: dict[str, Person]) -> list["topic_page.Source"]: + """The topic's captures as the page lists them, numbered as cited. + + The topic's own captures come first, then the ones filed elsewhere, + the same order the References block numbers them in. + """ + def filer(localpart: str) -> str: + person = people.get(localpart) + return person.first_name if person else localpart.capitalize() + + sources = [] + for n, e in enumerate(entries + cross_refs, start=1): + kind = _entry_kind(e.get("rel", ""), topic_slug) if n <= len(entries) else "elsewhere" + title, link = _reference(e) + sources.append(topic_page.Source( + n, kind, title, link, e.get("date") or "", filer(e.get("filed_by") or ""))) + return sources + + +def _open_todos(page_dir: str) -> list[str]: + """The open items of a topic's `todos.md`, as the family keeps them.""" + try: + doc = (_brain_dir() / page_dir / "todos.md").read_text(encoding="utf-8") + except (OSError, RuntimeError): + return [] + return read_todos(doc)[0] + + def _member_preamble(slug: str, display: str, synonyms: list[str]) -> str: """Frontmatter for a freshly-created member page. @@ -1703,148 +1748,6 @@ def _entry_kind(rel: str, slug: str) -> str: }.get(folder, "note") -def _format_topic_evidence(entries: list[dict], slug: str) -> str: - """Number entries `[N]` in list order, grouped under their kind. - - The `[N]` index matches each entry's position in `entries`, so the - deterministic References section (which maps `[N]` back to - `entries[N-1]`) stays aligned no matter how the LLM orders the page. - Grouping by kind tells the model which captures are saved links vs. - the family's own notes, so it can split them into the right sections. - """ - groups: dict[str, list[str]] = {} - for n, s in enumerate(entries, start=1): - kind = _entry_kind(s.get("rel", ""), slug) - parts = [s["date"]] if s.get("date") else [] - parts.append(s.get("title") or "(untitled)") - who = (s.get("filed_by") or "").strip() - if who: - parts.append(f"filed by {who}") - meta = " · ".join(parts) - block = f"[{n}] {meta}\n " + (s.get("summary") or "").replace("\n", "\n ") - groups.setdefault(kind, []).append(block) - out: list[str] = [] - for kind in _TOPIC_KIND_ORDER: - if groups.get(kind): - out.append(f"{_KIND_LABEL[kind]}:") - out.append("\n\n".join(groups[kind])) - out.append("") - return "\n".join(out).rstrip() - - -def _build_topic_prompt( - display: str, slug: str, scope: str, - entries: list[dict], cross_refs: list[dict], *, lang: str, -) -> str: - """Prompt for one topic's `about.md` page. - - Two surfaces feed the LLM: `entries` are captures filed under - the topic's own folder (the household's direct material on this - subject); `cross_refs` are captures elsewhere whose taxonomy - fields cited the slug (insurance docs that mention camping, - Marge's gear list that tagged camping, etc.). Both are numbered - `[N]` evidence; the LLM cites by number. - - Scope branches the tone: a shared topic reads as the family's - common interest; a personal topic frames it as one person's. - - Captures are grouped by kind so the page separates saved links - (Bookmarks) from the household's own notes (Notes) and filed - documents (Documents) instead of flattening everything into one - feed. About is a recency-weighted overview, not a changelog — the - latest developments are folded into the prose. - """ - - main_evidence = _format_topic_evidence(entries, slug) - if cross_refs: - # Numbering continues from main_evidence so a single citation - # space spans the whole prompt -- the LLM and the rendered - # references section stay aligned. - offset = len(entries) - cross_block_lines: list[str] = [] - for n, s in enumerate(cross_refs, start=offset + 1): - meta_bits = [s["date"]] if s.get("date") else [] - meta = " · ".join(meta_bits + [s.get("title") or "(untitled)"]) - cross_block_lines.append(f"[{n}] {meta}") - cross_block_lines.append( - " " + (s.get("summary") or "").replace("\n", "\n "), - ) - cross_block_lines.append("") - cross_block = "\n".join(cross_block_lines).rstrip() - cross_section = ( - "\nThe household also has material on this topic filed elsewhere " - "(insurance docs, personal notes, other members' captures). " - "Treat these as cross-references — surface them in a " - "`## Cross-references` section, one bullet each, citing as " - f"`[N]`:\n\n{cross_block}\n" - ) - else: - cross_section = ( - "\n(No cross-references on file yet — when other captures " - "tag this topic, they will appear in a `## Cross-references` " - "section. Omit the section entirely for now.)\n" - ) - - if scope == "shared": - framing = ( - f"This topic is a shared household interest. Frame the " - f"`## About` section as the family's collective material on " - f"{display.lower()} — what the household uses this topic for, " - f"what shows up in the captures." - ) - else: - framing = ( - f"This topic is a personal interest. Frame the `## About` " - f"section from the perspective of the one household member who " - f"keeps these notes — what they care about, what they're " - f"tracking on {display.lower()}." - ) - - # Only emit a typed section for a kind that actually has captures, so a - # topic with no bookmarks doesn't carry an empty Bookmarks heading. - present = [ - _KIND_LABEL[kind] - for kind in _TOPIC_KIND_ORDER - if any(_entry_kind(e.get("rel", ""), slug) == kind for e in entries) - ] - section_specs = "\n\n".join( - f"## {label}\nEvery {label[:-1].lower()} from above, newest first, one " - f"bullet each: ` — [N]`." - for label in present - ) - - return f"""You are composing the landing page for a topic folder in a family's private, self-hosted memory wiki. It is an OVERVIEW someone (or the family's assistant) reads to understand what this topic is about and what has happened in it lately — not an archive. Detail lives in the cited captures, one click away. The vault is private — identifying details (full names, places, prices) are fine to include when documents reveal them. - -This page is about the topic: {display} (vault slug: {slug}, scope: {scope}). - -{framing} - -Captures filed directly under this topic (cite as [N] inline where a fact came from a specific capture): - -{main_evidence} -{cross_section} -Produce a markdown page with this EXACT structure and section order: - -# {display} -> - -## About -One paragraph that reads as a CURRENT overview of the topic — what it is and where it stands right now — weighting recent captures more heavily than older ones. Fold the latest developments into the prose; do not list them as a separate changelog. [N] - -{section_specs} - -## Cross-references -{"Captures filed in other buckets that mention this topic. One bullet each: ` — [from ]`. [N]" if cross_refs else "(omit this section)"} - -Rules: -- Respond in: {lang}. -- Use ONLY what the sources above support. Never invent. Omit empty sections — no placeholder lines. -- Cite the source capture as [N] inline. Multiple sources: [1, 3]. -- Keep entries dense and skimmable. -- Do NOT add a "References" section — it is appended programmatically after your response. Don't list source paths or URLs in your output. -""" - - def _format_evidence(entries: list[dict], *, with_persons: bool = False) -> str: """Number the summaries as `[N]` evidence blocks for a prompt. diff --git a/stacklets/memory/bot/templates/topic.de.md b/stacklets/memory/bot/templates/topic.de.md new file mode 100644 index 00000000..869f04de --- /dev/null +++ b/stacklets/memory/bot/templates/topic.de.md @@ -0,0 +1,42 @@ + +# {{title}} +> {{summary}} + +## Auf einen Blick +{{facts}} + +## Stand der Dinge +{{state}} + +## Noch zu tun +{{todos}} + +## Notizen +{{notes}} + +## Links +{{bookmarks}} + +## Dokumente +{{documents}} + +## Anderswo im Wiki +{{elsewhere}} diff --git a/stacklets/memory/bot/templates/topic.en.md b/stacklets/memory/bot/templates/topic.en.md new file mode 100644 index 00000000..aa17e442 --- /dev/null +++ b/stacklets/memory/bot/templates/topic.en.md @@ -0,0 +1,41 @@ + +# {{title}} +> {{summary}} + +## At a glance +{{facts}} + +## Where things stand +{{state}} + +## Still to do +{{todos}} + +## Notes +{{notes}} + +## Links +{{bookmarks}} + +## Documents +{{documents}} + +## Elsewhere in the wiki +{{elsewhere}} diff --git a/stacklets/memory/bot/topic_page.py b/stacklets/memory/bot/topic_page.py new file mode 100644 index 00000000..fb271cec --- /dev/null +++ b/stacklets/memory/bot/topic_page.py @@ -0,0 +1,162 @@ +"""A topic's wiki page: the facts of the topic and where it stands. + +A topic is something the family works on or keeps track of: a trip, the +car, the kitchen renovation. Its page answers, in a minute: what are the +facts, what has been decided or done and by whom, what is still open. +It is filled from `templates/topic..md` (see `page_template`). + +- **The model** answers in JSON: the topic's name, one line on what it + is, the key facts as label and value, and what has been decided, done + or changed, each with the sources it cites. Plain and exact, the way a + well-kept status reads. +- **The compiler** lists the rest: the open items of the topic's todo + list as the family keeps it, and everything filed under the topic, + with who filed it and when, linked. + +Pure functions: no I/O beyond reading the template, no model calls. +The wiki compiler (`wiki._generate_topic`) owns the evidence, the call +and the publish. +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass, field + +from page_template import ( + LANGUAGE_NAMES, cites, fill, format_date, language, sentence, template, text, +) + +FACTS_MAX = 8 +STATE_MAX = 6 + + +@dataclass +class Answer: + """What the model read from the topic's sources; every field may be empty.""" + + title: str = "" + summary: str = "" + facts: list[tuple[str, str, str]] = field(default_factory=list) + state: list[tuple[str, str]] = field(default_factory=list) + + +class AnswerError(ValueError): + """The model's answer is not the JSON object the prompt asked for.""" + + +def read_answer(raw: str) -> Answer: + """The model's JSON answer, or `AnswerError`. Ill-shaped fields are dropped.""" + try: + data = json.loads(raw) + except json.JSONDecodeError as e: + raise AnswerError(f"not JSON: {e}") from e + if not isinstance(data, dict): + raise AnswerError("not a JSON object") + facts = data.get("facts") + state = data.get("state") + return Answer( + title=text(data.get("title")), + summary=text(data.get("summary")), + facts=[(text(f.get("label")), text(f.get("value")), cites(f.get("cite"))) + for f in (facts if isinstance(facts, list) else []) + if isinstance(f, dict) and text(f.get("label")) and text(f.get("value"))][:FACTS_MAX], + state=[(text(s.get("text")), cites(s.get("cite"))) + for s in (state if isinstance(state, list) else []) + if isinstance(s, dict) and text(s.get("text"))][:STATE_MAX], + ) + + +# ── The page ───────────────────────────────────────────────────────────── + +@dataclass +class Source: + """One capture as the page lists it.""" + + n: int + kind: str # "note", "bookmark", "document", or "elsewhere" + title: str + link: str + date: str = "" + filed_by: str = "" + + +def render(answer: Answer, *, display: str, sources: list[Source], + todos: list[str], lang: str) -> str: + """The topic page body. `todos` are the open items, in the list's order.""" + def listed(kind: str) -> str: + rows = sorted((s for s in sources if s.kind == kind), + key=lambda s: s.date, reverse=True) + return "\n".join(_source_line(s, lang) for s in rows) + + slots = { + "title": answer.title or display, + "summary": answer.summary, + "facts": "\n".join(f"- **{label}** · {value}{c}" for label, value, c in answer.facts), + "state": "\n".join(f"- {sentence(t)}{c}" for t, c in answer.state), + "todos": "\n".join(f"- [ ] {item}" for item in todos), + "notes": listed("note"), + "bookmarks": listed("bookmark"), + "documents": listed("document"), + "elsewhere": listed("elsewhere"), + } + return fill(template("topic", lang), slots) + + +def _source_line(s: Source, lang: str) -> str: + bits = [b for b in (s.filed_by, format_date(s.date, lang)) if b] + tail = f" · {' · '.join(bits)}" if bits else "" + return f"- [{s.title}]({s.link}){tail}" if s.link else f"- {s.title}{tail}" + + +# ── The prompt ─────────────────────────────────────────────────────────── + +def evidence(sources: list[Source], summaries: dict[int, str]) -> str: + """The numbered sources for the prompt, each with who filed it and when.""" + lines: list[str] = [] + for s in sources: + who = f", filed by {s.filed_by}" if s.filed_by else "" + where = " (filed elsewhere, mentions this topic)" if s.kind == "elsewhere" else "" + lines.append(f"[{s.n}] {s.date or 'undated'} · {s.kind} · {s.title}{who}{where}") + lines.append(" " + (summaries.get(s.n) or "").replace("\n", "\n ")) + lines.append("") + return "\n".join(lines).rstrip() + + +def prompt(*, display: str, scope: str, evidence_text: str, todos: list[str], + lang: str) -> str: + """The instructions for one topic's facts and state, asking for JSON.""" + whose = ("a shared topic of the whole family" if scope == "shared" + else "one family member's own topic") + todo_block = ("\n".join(f"- {t}" for t in todos) if todos + else "(no open items)") + return f"""You write the page of one topic in a family's private wiki: "{display}" (its folder name), {whose}. A family member opens it to know in a minute what the facts are and where things stand. The wiki is private, so names, places, dates and prices are fine. + +What has been filed on the topic. Each source is numbered; cite sources by their numbers: + +{evidence_text} + +The topic's open todo items, as the family keeps them (the page lists them itself): +{todo_block} + +Write in {LANGUAGE_NAMES[language(lang)]}, plain and exact, like a well-kept status. No opinions, no filler, no first person. + +Answer with one JSON object and nothing else: +{{ + "title": "the topic's proper name as the sources write it, with its punctuation (\"Itchy & Scratchy Land\" for the folder \"Itchy Scratchy Land\")", + "summary": "one line: what this topic is for the family", + "facts": [{{"label": "one or two words naming what the fact is, in {LANGUAGE_NAMES[language(lang)]}", "value": "the fact, exact", "cite": [4]}}], + "state": [{{"text": "one thing that has been decided, done, booked, bought or changed, saying who did it", "cite": [4]}}] +}} + +Rules: +- Use only what the sources say. Never invent a date, a price, a place or a name. A fact is only a fact when a source states it. +- Who did something is who filed the source or who the source names as doing it. Never credit one person with another's action. +- A fact joins only what one source says together: a place the sources name next to something else is not the same place. +- A label says what its value is: a date under a date label, a cost under a cost label. +- Plans, wishes and to-dos are neither facts nor state: the todo list above carries them. "state" is only what has happened: booked, bought, decided, paid, cancelled. +- Fewer is better: the three to five facts someone needs most, and only the state lines that happened. Never more than {FACTS_MAX} facts or {STATE_MAX} state lines. Leave a field empty rather than pad it. +- Background material the family saved for reference (articles, encyclopedia pages) is not news: use it for the summary at most. +- Never write about the sources themselves: no "the sources say", "not specified", "no records". +- No account numbers, booking codes or IDs. +""" diff --git a/tests/unit/stacklets/test_memory_topic_page.py b/tests/unit/stacklets/test_memory_topic_page.py new file mode 100644 index 00000000..9445651c --- /dev/null +++ b/tests/unit/stacklets/test_memory_topic_page.py @@ -0,0 +1,136 @@ +"""A topic's page: the facts of the topic and where it stands. + +Read from the outside, as the family reads the published page. What +these tests promise: + +- The open items are the family's own todo list, exactly, and done + items are not shown. +- Everything filed under the topic is listed under its kind with who + filed it and when, newest first; what was filed elsewhere has a + section of its own. +- The model's facts and state are shown with their citations; a section + with nothing in it is not on the page. +- The model is told who filed each source, so it can say who did what. +- A German household gets German headings; an unreadable answer leaves + the page as it was. +""" + +from __future__ import annotations + +import asyncio +import json +import sys +from pathlib import Path + +import pytest + +_REPO_ROOT = Path(__file__).resolve().parents[3] +sys.path.insert(0, str(_REPO_ROOT / "stacklets")) +sys.path.insert(0, str(_REPO_ROOT / "stacklets" / "memory" / "bot" / "cli")) + +import wiki # noqa: E402 +from stack.people import Person # noqa: E402 + +PEOPLE = {"marge": Person("marge", "Marge Simpson", account="marge"), + "homer": Person("homer", "Homer Simpson", account="homer")} + +PLAN = {"rel": "family/trip/notes/2026/10/plan-1.md", "title": "Road trip plan", + "date": "2026-10-09", "filed_by": "homer", "summary": "Leave Friday after work."} +LODGE = {"rel": "family/trip/notes/2026/10/lodge-2.md", "title": "Lodge booked", + "date": "2026-10-10", "filed_by": "marge", "summary": "Two rooms, 14 to 16 October."} +PARK = {"rel": "family/trip/bookmarks/2026/10/park-3.md", "title": "The park", + "date": "2026-10-08", "filed_by": "homer", "summary": "Encyclopedia page."} +POLICY = {"rel": "family/documents/2026/03/travel-insurance-p9.md", "title": "Travel insurance", + "date": "2026-03-15", "filed_by": "", "summary": "Covers trips abroad.", + "topics": ["trip"], "tags": []} + +TODOS = "# Trip\n\n- [ ] Pack sunscreen\n- [x] Book the lodge\n- [ ] Find a dog sitter\n" + +ANSWER = { + "title": "The Big Trip", + "summary": "The family's October road trip.", + "facts": [{"label": "Dates", "value": "14 to 16 October", "cite": [2]}], + "state": [{"text": "Marge booked the lodge, two rooms", "cite": [2]}], +} + + +class _Model: + def __init__(self, answer) -> None: + self.answer = answer + self.prompts: list[str] = [] + + async def complete(self, role, prompt, **kwargs) -> str: + self.prompts.append(prompt) + return self.answer if isinstance(self.answer, str) else json.dumps(self.answer) + + +@pytest.fixture +def brain(tmp_path, monkeypatch): + monkeypatch.setenv("BRAIN_REPO_DIR", str(tmp_path)) + (tmp_path / "family" / "trip").mkdir(parents=True) + (tmp_path / "family" / "trip" / "todos.md").write_text(TODOS, encoding="utf-8") + return tmp_path + + +def _topic(brain: Path, answer, lang: str = "en", model: _Model | None = None) -> str: + model = model or _Model(answer) + rc = asyncio.run(wiki._generate_topic( + model, "family", "trip", [PLAN, LODGE, PARK, POLICY], people=PEOPLE, + shared_bucket="family", lang=lang, write=True)) + assert rc == 0 + return (brain / "family" / "trip" / "about.md").read_text(encoding="utf-8") + + +def _section(page: str, heading: str) -> str: + return page.split(f"## {heading}\n", 1)[1].split("\n\n", 1)[0] + + +class TestWhatTheCompilerLists: + def test_the_open_todos_as_the_family_keeps_them(self, brain): + assert _section(_topic(brain, ANSWER), "Still to do") == ( + "- [ ] Pack sunscreen\n- [ ] Find a dog sitter") + + def test_notes_newest_first_with_who_and_when(self, brain): + assert _section(_topic(brain, ANSWER), "Notes") == ( + "- [Lodge booked](/family/trip/notes/2026/10/lodge-2.md) · Marge · 10 Oct 2026\n" + "- [Road trip plan](/family/trip/notes/2026/10/plan-1.md) · Homer · 9 Oct 2026") + + def test_links_and_what_was_filed_elsewhere_have_their_own_sections(self, brain): + page = _topic(brain, ANSWER) + assert _section(page, "Links").startswith("- [The park](") + assert _section(page, "Elsewhere in the wiki").startswith("- [Travel insurance](") + + +class TestWhatTheModelReads: + def test_facts_and_state_with_their_citations(self, brain): + page = _topic(brain, ANSWER) + assert page.split("", 1)[1].lstrip().startswith( + "# The Big Trip\n> The family's October road trip.") + assert _section(page, "At a glance") == "- **Dates** · 14 to 16 October [2]" + assert _section(page, "Where things stand") == "- Marge booked the lodge, two rooms. [2]" + + def test_nothing_to_say_no_section(self, brain): + page = _topic(brain, {"summary": "A trip."}) + assert "## At a glance" not in page and "## Where things stand" not in page + + def test_the_model_knows_who_filed_each_source(self, brain): + """Without it a booking Marge made is credited to Homer.""" + model = _Model(ANSWER) + _topic(brain, ANSWER, model=model) + assert "[2] 2026-10-10 · note · Lodge booked, filed by Marge" in model.prompts[0] + + +class TestLanguageAndFailure: + def test_a_german_household_reads_german_headings(self, brain): + page = _topic(brain, ANSWER, lang="de") + for heading in ("Auf einen Blick", "Stand der Dinge", "Noch zu tun", "Notizen", + "Anderswo im Wiki", "Quellen"): + assert f"## {heading}\n" in page + + def test_an_unreadable_answer_keeps_the_page(self, brain): + before = _topic(brain, ANSWER) + rc = asyncio.run(wiki._generate_topic( + _Model("The trip is going well."), "family", "trip", [PLAN, LODGE], people=PEOPLE, + shared_bucket="family", lang="en", write=True)) + assert rc == 1 + assert (brain / "family" / "trip" / "about.md").read_text(encoding="utf-8") == before diff --git a/tests/unit/stacklets/test_memory_wiki.py b/tests/unit/stacklets/test_memory_wiki.py index d6c15b8e..d0665ea6 100644 --- a/tests/unit/stacklets/test_memory_wiki.py +++ b/tests/unit/stacklets/test_memory_wiki.py @@ -6,9 +6,9 @@ other buckets via `topics:` or `tags:` frontmatter, and building the first-creation frontmatter for each topic's `about.md`. -The LLM-call paths (`_generate_topic`, `_build_topic_prompt`) stay -covered by the e2e rig — these tests pin the pure data shaping that -the deriver and future wiki rebuilds will keep working over. +The topic page itself is pinned in `test_memory_topic_page.py`; these +tests pin the pure data shaping that the deriver and future wiki +rebuilds will keep working over. """ from __future__ import annotations @@ -24,7 +24,6 @@ from wiki import ( # noqa: E402 _build_references_section, - _build_topic_prompt, _capture_index_pages, _clean_generated, _correspondent_body, @@ -32,7 +31,6 @@ _correspondent_preamble, _correspondent_roster, _entry_kind, - _format_topic_evidence, _frontmatter_error, _generated_pages_on_disk, _index_vault, @@ -608,13 +606,7 @@ def test_unmapped_numbers_pass_through(self) -> None: assert _renumber_citations("Kept [7].", {1: 2}) == "Kept [7]." -# ── Topic page: typed sections (bookmarks vs notes) + living About ────────── - -def _topic_entry(rel: str, title: str, *, date: str = "2026-06-10", - summary: str = "a summary", filed_by: str = "") -> dict: - return {"rel": rel, "title": title, "date": date, - "summary": summary, "filed_by": filed_by} - +# ── Topic page: the kind of each capture ──────────────────────────────────── class TestEntryKind: """A capture's kind is read from the folder after the topic slug.""" @@ -631,72 +623,6 @@ def test_slug_absent_defaults_to_note(self): assert _entry_kind("family/other/notes/x.md", "camping") == "note" -class TestFormatTopicEvidence: - """Evidence is grouped by kind, but [N] tracks list order so the - deterministic References mapping ([N] -> entries[N-1]) stays aligned.""" - - def test_grouped_by_kind_with_list_order_citations(self): - # note first in the list, bookmark second -> bookmark keeps [2] - entries = [ - _topic_entry("family/camping/notes/2026/06/n.md", "Checkliste"), - _topic_entry("family/camping/bookmarks/2026/06/b.md", "Fenstertasche"), - ] - ev = _format_topic_evidence(entries, "camping") - assert "Bookmarks:" in ev and "Notes:" in ev - assert ev.index("Bookmarks:") < ev.index("Notes:") # section order by kind - assert "[2] 2026-06-10 · Fenstertasche" in ev # bookmark kept its index - assert "[1] 2026-06-10 · Checkliste" in ev - - def test_filer_surfaced_in_evidence(self): - entries = [_topic_entry("family/camping/bookmarks/2026/06/b.md", - "Fenstertasche", filed_by="marge")] - ev = _format_topic_evidence(entries, "camping") - assert "filed by marge" in ev - - -class TestBuildTopicPrompt: - """The page separates bookmarks from notes and frames About as a - living, recency-weighted overview rather than a flat activity feed.""" - - BOOKMARK = _topic_entry("family/camping/bookmarks/2026/06/b.md", "Fenstertasche") - NOTE = _topic_entry("family/camping/notes/2026/06/n.md", "Checkliste") - - def test_typed_sections_for_present_kinds_only(self): - prompt = _build_topic_prompt( - "Camping", "camping", "shared", [self.BOOKMARK, self.NOTE], [], lang="de", - ) - assert "## Bookmarks" in prompt - assert "## Notes" in prompt - assert "## Documents" not in prompt # no document capture present - - def test_no_flat_recent_activity_section(self): - prompt = _build_topic_prompt( - "Camping", "camping", "shared", [self.BOOKMARK], [], lang="de", - ) - assert "Recent Activity" not in prompt - - def test_about_is_recency_weighted_overview(self): - prompt = _build_topic_prompt( - "Camping", "camping", "shared", [self.NOTE], [], lang="de", - ) - lower = prompt.lower() - assert "weighting recent captures" in lower - assert "current overview" in lower - - def test_cross_references_section_when_present(self): - cross = [_topic_entry("family/insurance/documents/2026/06/d.md", "Police")] - prompt = _build_topic_prompt( - "Camping", "camping", "shared", [self.NOTE], cross, lang="de", - ) - assert "## Cross-references" in prompt - - def test_sections_request_attribution(self): - prompt = _build_topic_prompt( - "Camping", "camping", "shared", [self.BOOKMARK], [], lang="de", - ) - assert "who filed it" in prompt.lower() - - class TestIndexFiledBy: """The vault index carries filed_by so attribution reaches the page.""" From 67019a1822aa3f14d6a3245ec1b1a397306fd355 Mon Sep 17 00:00:00 2001 From: Arthur Date: Sun, 11 Oct 2026 15:59:52 +0200 Subject: [PATCH 07/11] fix(wiki): credit the person who filed a source on person pages A person page credited one person with what another did ("Homer confirmed the booking for two rooms" that Marge made) and read a family member's other name as someone new ("the birth of his daughter, Margaret" for Maggie's birth certificate). The model now sees who filed each source, as the topic page already does, and the whole family with every name each person goes by. It is told to credit only who filed a source or who the source names as doing it, and that another name of a family member is the same person. --- stacklets/memory/bot/cli/wiki.py | 40 ++++++++++++++----- stacklets/memory/bot/person_page.py | 11 ++++- .../unit/stacklets/test_memory_person_page.py | 23 +++++++++++ 3 files changed, 61 insertions(+), 13 deletions(-) diff --git a/stacklets/memory/bot/cli/wiki.py b/stacklets/memory/bot/cli/wiki.py index b709b310..f70b3dea 100644 --- a/stacklets/memory/bot/cli/wiki.py +++ b/stacklets/memory/bot/cli/wiki.py @@ -341,7 +341,7 @@ async def run(llm: LLM, argv: list[str]) -> int: _err(f"no member bucket for '{slug}' — skipping") continue rc = await _generate_member( - llm, slug, index, vault, person=persons.get(slug), + llm, slug, index, vault, person=persons.get(slug), people=persons, shared_bucket=shared_bucket, lang=lang, write=not dry_run, ) if rc == 0: @@ -407,7 +407,7 @@ async def run(llm: LLM, argv: list[str]) -> int: # here -- the overall run still succeeds. for member_slug in roster: await _generate_member( - llm, member_slug, index, vault, person=persons.get(member_slug), + llm, member_slug, index, vault, person=persons.get(member_slug), people=persons, shared_bucket=shared_bucket, lang=lang, write=not dry_run, ) @@ -504,13 +504,16 @@ async def _generate_home( async def _generate_member( llm: LLM, slug: str, index: list[dict], vault: Path, *, - person: Person | None = None, + person: Person | None = None, people: dict[str, Person] | None = None, shared_bucket: str, lang: str, write: bool, ) -> int: """Compose one person's page from their slice of the vault. `person` is their declared file; without person files the name comes - from the slug, as before. + from the slug, as before. `people` is every declared person: the + model is told who filed each source and every name each of them + goes by, so it credits the right person and never takes an alias + for someone new. """ entries = _member_entries(index, slug) if not entries and person is not None: @@ -543,7 +546,8 @@ async def _generate_member( lately = person_page.latest(entries) prompt = person_page.prompt( display=display, first_name=first_name, - evidence=_format_evidence(entries, with_persons=True), facts=facts, + evidence=_format_evidence(entries, with_persons=True, people=people or {}), + facts=facts, family=_family_names(people or {}), lately=lately, lang=lang, previous=previous, new_evidence=new_evidence, ) try: @@ -668,19 +672,27 @@ def _topic_sources(entries: list[dict], cross_refs: list[dict], topic_slug: str, The topic's own captures come first, then the ones filed elsewhere, the same order the References block numbers them in. """ - def filer(localpart: str) -> str: - person = people.get(localpart) - return person.first_name if person else localpart.capitalize() - sources = [] for n, e in enumerate(entries + cross_refs, start=1): kind = _entry_kind(e.get("rel", ""), topic_slug) if n <= len(entries) else "elsewhere" title, link = _reference(e) sources.append(topic_page.Source( - n, kind, title, link, e.get("date") or "", filer(e.get("filed_by") or ""))) + n, kind, title, link, e.get("date") or "", _filer(e.get("filed_by") or "", people))) return sources +def _filer(localpart: str, people: dict[str, Person]) -> str: + """Who filed an entry, by the first name the family uses.""" + person = people.get(localpart) + return person.first_name if person else localpart.capitalize() + + +def _family_names(people: dict[str, Person]) -> list[str]: + """Each declared person with every other name they go by.""" + return [p.name + (f" (also called {', '.join(p.aliases)})" if p.aliases else "") + for p in people.values()] + + def _open_todos(page_dir: str) -> list[str]: """The open items of a topic's `todos.md`, as the family keeps them.""" try: @@ -1748,7 +1760,8 @@ def _entry_kind(rel: str, slug: str) -> str: }.get(folder, "note") -def _format_evidence(entries: list[dict], *, with_persons: bool = False) -> str: +def _format_evidence(entries: list[dict], *, with_persons: bool = False, + people: dict[str, Person] | None = None) -> str: """Number the summaries as `[N]` evidence blocks for a prompt. ``with_persons`` appends each entry's `persons:` list to its header @@ -1756,11 +1769,16 @@ def _format_evidence(entries: list[dict], *, with_persons: bool = False) -> str: husband" has no referent on its own, and a small model will happily pin those roles on whoever the page is about. Naming who a document involves gives the model an anchor to attribute against. + + ``people`` names who filed each entry, so a booking Marge filed is + not credited to whoever else it mentions. """ lines: list[str] = [] for n, s in enumerate(entries, start=1): meta_bits = [s["date"]] if s["date"] else [] meta = " · ".join(meta_bits + [s["title"]]) + if people is not None and s.get("filed_by"): + meta += f", filed by {_filer(s['filed_by'], people)}" if with_persons and s.get("persons"): meta += " (involves: " + ", ".join(s["persons"]) + ")" lines.append(f"[{n}] {meta}") diff --git a/stacklets/memory/bot/person_page.py b/stacklets/memory/bot/person_page.py index 7c4eabbe..b362694a 100644 --- a/stacklets/memory/bot/person_page.py +++ b/stacklets/memory/bot/person_page.py @@ -156,7 +156,7 @@ def render(parts: Parts, *, name: str, first_name: str, entries: list[dict], # ── The prompt ─────────────────────────────────────────────────────────── def prompt(*, display: str, first_name: str, evidence: str, facts: list[tuple[str, str]], - lately: list[int], lang: str, previous: str = "", + lately: list[int], lang: str, family: list[str] | None = None, previous: str = "", new_evidence: list[str] | None = None) -> str: """The instructions for one person's prose, asking for a JSON object. @@ -184,11 +184,16 @@ def prompt(*, display: str, first_name: str, evidence: str, facts: list[tuple[st {previous} """ + family_block = "" + if family: + family_block = ("\nThe family, with every name each one goes by (a source may use any of them):\n" + + "\n".join(f"- {line}" for line in family) + "\n") + lately_list = ", ".join(str(n) for n in lately) or "(none)" return f"""You write the page about one person in a family's private wiki. Family members open it to see who {display} is right now, and they should enjoy reading it. This page is about: {display}. - +{family_block} {facts_block} What the family has filed that involves {display}. Each source is numbered; cite it as [N] after the sentence it supports: @@ -214,6 +219,8 @@ def prompt(*, display: str, first_name: str, evidence: str, facts: list[tuple[st - Use only what the sources and the facts say. Never invent. A field with nothing to say is left out or empty. - The sources also involve other people (see "involves:"). A birth certificate is mostly about the parents; a school letter names a parent. Say something about {first_name} only when the source ties it to {first_name} by name. Never give {first_name} another person's role, job, costume, words or relationship. - When a source says "the mother" or "her husband" without naming {first_name}, that is about someone else: leave it out. +- Who did something is who filed the source or who the source names as doing it. Never credit {first_name} with what another person did. +- A family member under another of their names is the same person, never a new child, sibling or twin. - No dates, numbers of accounts or IDs, and no source titles in the prose. The page lists the documents itself. - Several sources for one sentence are cited together: [1, 3]. - Count people only from the sources: who the children, siblings and grandparents are comes from what the sources name. diff --git a/tests/unit/stacklets/test_memory_person_page.py b/tests/unit/stacklets/test_memory_person_page.py index 00168830..21fdcadc 100644 --- a/tests/unit/stacklets/test_memory_person_page.py +++ b/tests/unit/stacklets/test_memory_person_page.py @@ -144,6 +144,29 @@ def test_without_one_there_is_none(self, brain): assert headings == ["## What Maggie loves", "## Lately", "## References"] +class TestWhatTheModelIsTold: + """What the prompt carries so the prose credits the right person.""" + + MARGE = Person("marge", "Marge Simpson", account="marge") + + def _prompt(self, brain, entries) -> str: + model = _Model(ANSWER) + asyncio.run(wiki._generate_member( + model, "maggie", entries, brain, person=MAGGIE, + people={"maggie": MAGGIE, "marge": self.MARGE}, + shared_bucket="family", lang="en", write=True)) + return model.prompts[0] + + def test_who_filed_each_source(self, brain): + """Without it Homer's page says he booked the lodge Marge booked.""" + entry = {**FIRST_WORD, "filed_by": "marge"} + assert "[1] 2025-11-27 · Maggie's first word, filed by Marge" in self._prompt(brain, [entry]) + + def test_every_name_a_family_member_goes_by(self, brain): + """Without it a birth certificate for Margaret reads as a new daughter.""" + assert "- Maggie Simpson (also called Margaret)" in self._prompt(brain, [BIRTH]) + + class TestAnAnswerThatCannotBeRead: def test_the_page_stays_as_it_was(self, brain): before = _page(brain, ANSWER, [FIRST_WORD]) From ebf1b5eef123a9c15f430b18d6030a93792f5849 Mon Sep 17 00:00:00 2001 From: Arthur Date: Sun, 11 Oct 2026 16:16:24 +0200 Subject: [PATCH 08/11] feat(wiki): add the facts at a glance to person pages A person page told who someone is but not what a relative looks up: when they were born, their school or job, their doctor, the car they drive. Under the About text, a person page now lists up to five such facts as label and value, each with its source, read only from sources that name the person. With none, the section is left out. Every person page is regenerated once in the new layout, by the next rebuild that touches it or the nightly sweep at the latest. --- stacklets/memory/bot/cli/wiki.py | 2 +- stacklets/memory/bot/page_template.py | 5 +++++ stacklets/memory/bot/person_page.py | 12 +++++++++++- stacklets/memory/bot/templates/person.de.md | 4 ++++ stacklets/memory/bot/templates/person.en.md | 4 ++++ stacklets/memory/bot/topic_page.py | 4 ++-- tests/unit/stacklets/test_memory_person_page.py | 16 ++++++++++++++++ 7 files changed, 43 insertions(+), 4 deletions(-) diff --git a/stacklets/memory/bot/cli/wiki.py b/stacklets/memory/bot/cli/wiki.py index f70b3dea..3a9426b8 100644 --- a/stacklets/memory/bot/cli/wiki.py +++ b/stacklets/memory/bot/cli/wiki.py @@ -499,7 +499,7 @@ async def _generate_home( # once instead of kept as the baseline: it has the previous layout, or # predates the fix that renumbers diary citations by the source the # References block shows, so its text may cite the wrong entry. -_PERSON_PAGE_STAMP = "" +_PERSON_PAGE_STAMP = "" async def _generate_member( diff --git a/stacklets/memory/bot/page_template.py b/stacklets/memory/bot/page_template.py index 64639d58..c9c40c0e 100644 --- a/stacklets/memory/bot/page_template.py +++ b/stacklets/memory/bot/page_template.py @@ -78,6 +78,11 @@ def sentence(line: str) -> str: return line if not line or line[-1] in ".!?…" else line + "." +def fact(label: str, value: str, cited: str = "") -> str: + """One "at a glance" line: `- **Born** · 18 Jan 2025 [3]`. A fact, not a sentence.""" + return f"- **{label}** · {value}{cited}" + + def cites(value) -> str: """` [1, 3]` for a model's list of citation numbers, "" for none.""" nums = [n for n in value if isinstance(n, int) and not isinstance(n, bool)] \ diff --git a/stacklets/memory/bot/person_page.py b/stacklets/memory/bot/person_page.py index b362694a..bd7a7318 100644 --- a/stacklets/memory/bot/person_page.py +++ b/stacklets/memory/bot/person_page.py @@ -35,7 +35,7 @@ from dataclasses import dataclass, field from page_template import ( - LANGUAGE_NAMES, fill, format_date, language, sentence, template, text, + LANGUAGE_NAMES, cites, fact, fill, format_date, language, sentence, template, text, ) # A card, not an archive: what fits on a phone screen above the fold @@ -43,6 +43,7 @@ LATELY_MAX = 6 DOCUMENTS_MAX = 6 LOVES_MAX = 5 +FACTS_MAX = 5 # ── What the compiler knows ────────────────────────────────────────────── @@ -77,6 +78,7 @@ class Parts: tagline: str = "" about: str = "" + facts: list[tuple[str, str, str]] = field(default_factory=list) loves: list[str] = field(default_factory=list) more_heading: str = "" more_text: str = "" @@ -107,10 +109,15 @@ def read_answer(raw: str, *, lately: list[int]) -> Parts: lines = lines if isinstance(lines, dict) else {} loves = data.get("loves") loves = loves if isinstance(loves, list) else [] + facts = data.get("facts") + facts = facts if isinstance(facts, list) else [] more_text = more.get("text") return Parts( tagline=text(data.get("tagline")), about=text(data.get("about")), + facts=[(text(f.get("label")), text(f.get("value")), cites(f.get("cite"))) + for f in facts + if isinstance(f, dict) and text(f.get("label")) and text(f.get("value"))][:FACTS_MAX], loves=[t for t in map(text, loves) if t][:LOVES_MAX], more_heading=text(more.get("heading")), more_text=more_text.strip() if isinstance(more_text, str) else "", @@ -145,6 +152,7 @@ def render(parts: Parts, *, name: str, first_name: str, entries: list[dict], "first_name": first_name, "tagline": parts.tagline, "about": parts.about, + "facts": "\n".join(fact(label, value, c) for label, value, c in parts.facts), "loves": "\n".join(f"- {line}" for line in parts.loves), "more": more, "lately": "\n".join(lately_lines), @@ -208,6 +216,7 @@ def prompt(*, display: str, first_name: str, evidence: str, facts: list[tuple[st {{ "tagline": "one short line that says who {first_name} is in the family, the way a relative would put it", "about": "two to four sentences about {first_name} today: where they are in life, what fills their days, what makes them them. Cite [N].", + "facts": [{{"label": "one or two words in {LANGUAGE_NAMES[language(lang)]}: Born, School, Job, Doctor, Car", "value": "short and exact", "cite": [2]}}], "loves": ["up to {LOVES_MAX} short lines, each a thing, person, food, place or pastime {first_name} loves, cited [N]. Not an event or an achievement."], "lately": {{"": "for each of the sources {lately_list}: one line saying what {first_name} did or what happened to them, without the date, in the past tense, starting with the verb rather than the name or a pronoun. Cite nothing in it."}}, "more": {{"heading": "a heading of your own", "text": "one theme about {first_name}, at most three sentences, cited [N]"}} @@ -221,6 +230,7 @@ def prompt(*, display: str, first_name: str, evidence: str, facts: list[tuple[st - When a source says "the mother" or "her husband" without naming {first_name}, that is about someone else: leave it out. - Who did something is who filed the source or who the source names as doing it. Never credit {first_name} with what another person did. - A family member under another of their names is the same person, never a new child, sibling or twin. +- "facts" are what a relative would look up, at most {FACTS_MAX}: when {first_name} was born, their school and class or their job and employer, their doctor, the car they drive. Only from a source that names {first_name}, plain and exact, never a guess. No account, policy or ID numbers. - No dates, numbers of accounts or IDs, and no source titles in the prose. The page lists the documents itself. - Several sources for one sentence are cited together: [1, 3]. - Count people only from the sources: who the children, siblings and grandparents are comes from what the sources name. diff --git a/stacklets/memory/bot/templates/person.de.md b/stacklets/memory/bot/templates/person.de.md index ed7f2142..1ec76256 100644 --- a/stacklets/memory/bot/templates/person.de.md +++ b/stacklets/memory/bot/templates/person.de.md @@ -8,6 +8,7 @@ keine Dokumente hat, bekommt keine Überschrift „Dokumente“. name, first_name aus der Datei der Person tagline eine warme Zeile, vom Modell geschrieben about ein kurzer Absatz, vom Modell geschrieben + facts geboren, Schule oder Beruf, Arzt, Auto: Bezeichnung und Wert, vom Modell loves Aufzählung, vom Modell geschrieben more ein Abschnitt, den das Modell mit eigener Überschrift ergänzen kann lately die neuesten Einträge über die Person, vom Compiler datiert @@ -20,6 +21,9 @@ Dieser Kommentar wird nicht veröffentlicht. {{about}} +## Auf einen Blick +{{facts}} + ## Was {{first_name}} liebt {{loves}} diff --git a/stacklets/memory/bot/templates/person.en.md b/stacklets/memory/bot/templates/person.en.md index e361ffd1..acecb34f 100644 --- a/stacklets/memory/bot/templates/person.en.md +++ b/stacklets/memory/bot/templates/person.en.md @@ -8,6 +8,7 @@ documents has no Documents heading. name, first_name from the person's file tagline one warm line, written by the model about a short paragraph, written by the model + facts born, school or job, doctor, car: label and value, from the model loves bullets, written by the model more a section the model may add, with its own heading lately the latest entries naming them, dated by the compiler @@ -20,6 +21,9 @@ This comment is not published. {{about}} +## At a glance +{{facts}} + ## What {{first_name}} loves {{loves}} diff --git a/stacklets/memory/bot/topic_page.py b/stacklets/memory/bot/topic_page.py index fb271cec..ca6de777 100644 --- a/stacklets/memory/bot/topic_page.py +++ b/stacklets/memory/bot/topic_page.py @@ -24,7 +24,7 @@ from dataclasses import dataclass, field from page_template import ( - LANGUAGE_NAMES, cites, fill, format_date, language, sentence, template, text, + LANGUAGE_NAMES, cites, fact, fill, format_date, language, sentence, template, text, ) FACTS_MAX = 8 @@ -92,7 +92,7 @@ def listed(kind: str) -> str: slots = { "title": answer.title or display, "summary": answer.summary, - "facts": "\n".join(f"- **{label}** · {value}{c}" for label, value, c in answer.facts), + "facts": "\n".join(fact(label, value, c) for label, value, c in answer.facts), "state": "\n".join(f"- {sentence(t)}{c}" for t, c in answer.state), "todos": "\n".join(f"- [ ] {item}" for item in todos), "notes": listed("note"), diff --git a/tests/unit/stacklets/test_memory_person_page.py b/tests/unit/stacklets/test_memory_person_page.py index 21fdcadc..f131deb7 100644 --- a/tests/unit/stacklets/test_memory_person_page.py +++ b/tests/unit/stacklets/test_memory_person_page.py @@ -5,6 +5,8 @@ - Dates, the latest entries and the documents come from what was filed, so a page never shows a made-up date or a diary entry as a document. +- The facts a relative looks up (born, school or job, doctor) sit right + under the About text, each with its source. - A section with nothing in it is not on the page at all, heading included. No "nothing recorded". - A German household gets German headings and German dates. @@ -116,6 +118,20 @@ def test_the_templates_own_notes_are_not_published(self, brain): assert "Slots in double braces" not in page and "{{" not in page +class TestTheFactsAtAGlance: + FACTS = {**ANSWER, "facts": [{"label": "Born", "value": "18 January 2025", "cite": [2]}, + {"label": "Doctor", "value": "Dr. Hibbert"}]} + + def test_sit_under_the_about_text_with_their_sources(self, brain): + page = _page(brain, self.FACTS, [FIRST_WORD, BIRTH]) + assert ("watches everything. [1]\n\n## At a glance\n" + "- **Born** · 18 January 2025 [2]\n- **Doctor** · Dr. Hibbert\n\n" + "## What Maggie loves") in page + + def test_a_german_household_reads_a_german_heading(self, brain): + assert "## Auf einen Blick\n- **Born**" in _page(brain, self.FACTS, [FIRST_WORD, BIRTH], lang="de") + + class TestAGermanHousehold: def test_headings_and_dates_are_german(self, brain): page = _page(brain, ANSWER, [FIRST_WORD, BIRTH], lang="de") From 168ea547e7b8566aa35ee7f6fbce97cb3cf60668 Mon Sep 17 00:00:00 2001 From: Arthur Date: Sun, 11 Oct 2026 16:17:25 +0200 Subject: [PATCH 09/11] feat(wiki): put the household facts at the top of the home page The home page hid the household facts at the bottom, after every friend and teacher, left the pets off, and wrote policy numbers into the insurance list. The home page now has: - **At a glance**, right after the welcome: the home (owned or rented, the mortgage lender), each car, the family doctor and the children's school, at most six lines with citations. It replaces the Home and Vehicles sections. - **Pets**, listed with the family. - Insurance named by kind and insurer, and each person or place in one section only. --- stacklets/memory/bot/home_page.py | 40 ++++++++++++------- stacklets/memory/bot/templates/home.de.md | 15 ++++--- stacklets/memory/bot/templates/home.en.md | 15 ++++--- tests/unit/stacklets/test_memory_home_page.py | 39 +++++++++++++----- 4 files changed, 68 insertions(+), 41 deletions(-) diff --git a/stacklets/memory/bot/home_page.py b/stacklets/memory/bot/home_page.py index 43971399..e910ef69 100644 --- a/stacklets/memory/bot/home_page.py +++ b/stacklets/memory/bot/home_page.py @@ -1,15 +1,16 @@ """The wiki's home page: a Markdown template, the model reads the sources. -The home page is the family's front door: who lives here, who is around -them, and the household facts worth finding at a glance (the home, the -cars, the insurance). It is filled from `templates/home..md` -(see `page_template`). +The home page is the family's front door: who lives here, who is +around them, and the household facts worth +finding at a glance (the home, the cars, the insurance). It is filled +from `templates/home..md` (see `page_template`). - **The compiler** lists everyone the person files declare, each linked to their page, in the order the model gives (oldest first) and never without someone: nobody is dropped, nobody appears twice. - **The model** answers in JSON: the family's name and address, a short - welcome, one line per person, the relatives and the friends around + welcome, the key household facts, one line per person, the pets, the + relatives and the friends around them, and the household facts it found, each with the sources it cites as numbers. The compiler formats them, so the page has one style whatever the model writes. @@ -25,9 +26,13 @@ import re from dataclasses import dataclass, field -from page_template import LANGUAGE_NAMES, cites, fill, language, sentence, template, text +from page_template import LANGUAGE_NAMES, cites, fact, fill, language, sentence, template, text -FACT_SECTIONS = ("vehicles", "insurance", "subscriptions", "memberships") +FACT_SECTIONS = ("insurance", "subscriptions", "memberships") + +# What a newcomer takes in before scrolling: the home, the cars, the +# doctor, the school. Past that it is a list, not a glance. +GLANCE_MAX = 6 # The friends a reader takes in at a glance. Past that, the list stops # being the people closest to the family. @@ -68,7 +73,8 @@ class Answer: relatives: list[Item] = field(default_factory=list) friends: list[Item] = field(default_factory=list) helpers: list[Item] = field(default_factory=list) - home: str = "" + glance: list[Item] = field(default_factory=list) + pets: list[Item] = field(default_factory=list) facts: dict[str, list[Item]] = field(default_factory=dict) @@ -107,15 +113,16 @@ def read_answer(raw: str, *, ids: list[str]) -> Answer: relatives=_items(data.get("relatives")), friends=_items(data.get("friends"))[:FRIENDS_MAX], helpers=_items(data.get("helpers")), - home=text(data.get("home")), + glance=_items(data.get("glance"), name="label", detail="value")[:GLANCE_MAX], + pets=_items(data.get("pets")), facts={s: _items(data.get(s)) for s in FACT_SECTIONS}, ) -def _items(value) -> list[Item]: +def _items(value, *, name: str = "name", detail: str = "detail") -> list[Item]: if not isinstance(value, list): return [] - items = [Item(text(v.get("name")), text(v.get("detail")), cites(v.get("cite"))) + items = [Item(text(v.get(name)), text(v.get(detail)), cites(v.get("cite"))) for v in value if isinstance(v, dict)] return [i for i in items if i.name] @@ -131,15 +138,16 @@ def render(answer: Answer, *, ids: list[str], names: dict[str, str], lang: str) name = names.get(pid) or item.name or pid.capitalize() line = f" · {sentence(item.detail)}" if item.detail else "" people.append(f"- **[{name}]({pid}/about)**{line}") + people += [pet.render() for pet in answer.pets] slots = { "family": answer.family, "address": answer.address, "welcome": answer.welcome, + "glance": "\n".join(fact(i.name, i.detail, i.cites) for i in answer.glance), "people": "\n".join(people), "relatives": "\n".join(i.render() for i in answer.relatives), "friends": "\n".join(i.render() for i in answer.friends), "helpers": "\n".join(i.render() for i in answer.helpers), - "home": answer.home, **{s: "\n".join(i.render() for i in answer.facts.get(s, [])) for s in FACT_SECTIONS}, } return fill(template("home", lang), slots) @@ -181,14 +189,14 @@ def known_as(pid: str) -> str: "family": "the family's name as a heading: \\"The Simpsons\\" in English, \\"Familie Simpson\\" in German. Use the surname most of the people above share.", "address": "where the family lives now: the street address if a source gives one, else the town", "welcome": "two or three warm sentences about the family as it is now, with citations like [3]", + "glance": [{{"label": "one or two words in {LANGUAGE_NAMES[language(lang)]}: Home, Car, Doctor, School", "value": "the fact, short and exact: owned or rented and the lender, make and model, name", "cite": [2]}}], "order": ["every id above, oldest first: parents before their children, older children before younger ones"], "people": {{"": {{"name": "the full name, only for a person listed without one", "line": "who they are, warmly and specifically, in under fifteen words: their place in the family and what they are like or busy with. No citation."}}}}, + "pets": [{{"name": "the pet's name", "detail": "what animal, and whose", "cite": [4]}}], "relatives": [{{"name": "full name", "detail": "how they are related to ONE of the family's people, in a few words, like \"Homer's father\"", "cite": [1]}}], "friends": [{{"name": "full name", "detail": "who they are to the family, in a few words", "cite": [5]}}], "helpers": [{{"name": "name", "detail": "what they do for the family: family doctor, Bart's teacher, the school, the corner shop", "cite": [9]}}], - "home": "plain facts about the home itself, with citations: owned or rented, since when, the mortgage, the rooms, repairs. Leave it out when no source gives such a fact; warmth belongs in the welcome.", - "vehicles": [{{"name": "make and model", "detail": "year, owner", "cite": [7]}}], - "insurance": [{{"name": "policy and insurer", "detail": "what it covers, for whom, cost if known", "cite": [7]}}], + "insurance": [{{"name": "the kind of insurance and the insurer, like \"Car insurance, Springfield Mutual\"", "detail": "what it covers, for whom, cost if known", "cite": [7]}}], "subscriptions": [{{"name": "service", "detail": "cadence and amount if known, for whom", "cite": [7]}}], "memberships": [{{"name": "club or association", "detail": "what for, who is a member", "cite": [7]}}] }} @@ -198,5 +206,7 @@ def known_as(pid: str) -> str: - Never write about the sources themselves: no "the sources do not say", "not specified", "no records". What is unknown is simply not on the page. - "relatives" are family by blood or marriage who are not among the people above: parents, grandparents, siblings and half-siblings, aunts, uncles, cousins, in-laws. Friends, neighbours, classmates and colleagues go in "friends", at most {FRIENDS_MAX}, the closest first. Doctors, teachers, schools, shops, tradespeople and others who serve the family go in "helpers". - The family's people above go by every name listed for them. Nobody from that list appears in "relatives", "friends" or "helpers", under any of their names. +- "glance" holds the household facts someone new to the family wants first, at most {GLANCE_MAX}: the home (owned or rented, the mortgage lender), each car, the family doctor, the children's school. Pets go in "pets", not here. +- Each person or place appears in one section only: a shop owner who is a friend is a friend, not also a helper. - No account numbers, policy numbers or IDs; the documents carry those. """ diff --git a/stacklets/memory/bot/templates/home.de.md b/stacklets/memory/bot/templates/home.de.md index aef3ba9f..7dc91cf6 100644 --- a/stacklets/memory/bot/templates/home.de.md +++ b/stacklets/memory/bot/templates/home.de.md @@ -7,12 +7,14 @@ Absatz mit leerem Platzhalter fällt ganz weg, samt Überschrift. family der Name der Familie („Familie Simpson“), vom Modell address wo die Familie wohnt, vom Modell welcome zwei oder drei warme Sätze, vom Modell + glance was jemand Neues über den Haushalt zuerst wissen will + (das Zuhause, die Autos, der Arzt, die Schule), vom Modell people alle Personen aus den Personendateien, verlinkt, - mit je einer Zeile vom Modell + mit je einer Zeile vom Modell, danach die Haustiere relatives Verwandte außerhalb des Haushalts, vom Modell friends Freunde, Nachbarn und Klassenkameraden, die engsten zuerst helpers Arzt, Schule, Lehrer und andere, die für die Familie da sind - home, vehicles, insurance, subscriptions, memberships + insurance, subscriptions, memberships Fakten, die das Modell aus den Dokumenten gelesen hat Dieser Kommentar wird nicht veröffentlicht. @@ -22,6 +24,9 @@ Dieser Kommentar wird nicht veröffentlicht. {{welcome}} +## Auf einen Blick +{{glance}} + ## Familie {{people}} @@ -34,12 +39,6 @@ Dieser Kommentar wird nicht veröffentlicht. ## Wer uns hilft {{helpers}} -## Zuhause -{{home}} - -## Fahrzeuge -{{vehicles}} - ## Versicherungen {{insurance}} diff --git a/stacklets/memory/bot/templates/home.en.md b/stacklets/memory/bot/templates/home.en.md index 5f8452ca..d05ece5e 100644 --- a/stacklets/memory/bot/templates/home.en.md +++ b/stacklets/memory/bot/templates/home.en.md @@ -7,12 +7,14 @@ slot is empty is left out whole, heading included. family the family's name ("The Simpsons"), from the model address where the family lives, from the model welcome two or three warm sentences, from the model + glance the household facts a newcomer wants first (the home, + the cars, the doctor, the school), from the model people everyone the person files declare, linked to their page, - with one line each from the model + with one line each from the model, then the pets relatives relatives outside the household, from the model friends friends, neighbours and classmates, the closest first helpers the doctor, the school, teachers and others who serve the family - home, vehicles, insurance, subscriptions, memberships + insurance, subscriptions, memberships facts the model read from the family's documents This comment is not published. @@ -22,6 +24,9 @@ This comment is not published. {{welcome}} +## At a glance +{{glance}} + ## Family {{people}} @@ -34,12 +39,6 @@ This comment is not published. ## The people who help us {{helpers}} -## Home -{{home}} - -## Vehicles -{{vehicles}} - ## Insurance {{insurance}} diff --git a/tests/unit/stacklets/test_memory_home_page.py b/tests/unit/stacklets/test_memory_home_page.py index a3b728ff..2102a371 100644 --- a/tests/unit/stacklets/test_memory_home_page.py +++ b/tests/unit/stacklets/test_memory_home_page.py @@ -6,6 +6,8 @@ - Everyone the person files declare is on the page, linked to their page, once, in the order the model gives (oldest first); a person the model forgot is still there, a person it made up is not. +- The household facts a newcomer wants first sit at the top, and the + pets are listed with the family. - A section with nothing in it is not on the page, heading included. - A German household gets German headings. - An answer the compiler cannot read leaves the page as it was. @@ -34,8 +36,9 @@ "maggie": Person("maggie", "Maggie Simpson"), } ROSTER = ["bart", "homer", "maggie"] -INDEX = [{"rel": "family/documents/2009/06/deed-p1.md", "title": "Deed", "date": "2009-06-15", - "summary": "742 Evergreen Terrace, bought by Homer and Marge."}] +DEED = {"rel": "family/documents/2009/06/deed-p1.md", "type": "document", "title": "Deed", + "date": "2009-06-15", "summary": "742 Evergreen Terrace, bought by Homer and Marge."} +INDEX = [DEED] ANSWER = { "family": "The Simpsons", @@ -47,16 +50,19 @@ "flanders": {"line": "Not ours"}}, "relatives": [{"name": "Abraham Simpson", "detail": "Homer's father", "cite": [1]}], "friends": [{"name": "Ned Flanders", "detail": "the neighbour", "cite": [1]}], - "home": "Owned since 2009. [1]", - "vehicles": [], "insurance": [], + "glance": [{"label": "Home", "value": "Owned since 2009", "cite": [1]}], + "pets": [{"name": "Santa's Little Helper", "detail": "our greyhound", "cite": [1]}], + "insurance": [], } class _Model: def __init__(self, answer) -> None: self.answer = answer + self.prompts: list[str] = [] async def complete(self, role, prompt, **kwargs) -> str: + self.prompts.append(prompt) return self.answer if isinstance(self.answer, str) else json.dumps(self.answer) @@ -68,9 +74,10 @@ def brain(tmp_path, monkeypatch): return tmp_path -def _home(brain: Path, answer, lang: str = "en") -> str: +def _home(brain: Path, answer, lang: str = "en", index: list[dict] = INDEX, + model: _Model | None = None) -> str: rc = asyncio.run(wiki._generate_home( - _Model(answer), INDEX, roster=ROSTER, people=PEOPLE, + model or _Model(answer), index, roster=ROSTER, people=PEOPLE, shared_bucket="family", lang=lang, write=True)) assert rc == 0 return (brain / "index.md").read_text(encoding="utf-8") @@ -104,13 +111,25 @@ def test_relatives_and_friends_are_listed_apart(self, brain): assert "## Friends and neighbours\n- **Ned Flanders** · the neighbour. [1]" in page def test_an_empty_section_is_not_on_the_page(self, brain): - page = _home(brain, ANSWER) - assert "## Vehicles" not in page and "## Insurance" not in page + page = _home(brain, {**ANSWER, "glance": []}) + assert "## At a glance" not in page and "## Insurance" not in page def test_a_german_household_reads_german_headings(self, brain): page = _home(brain, ANSWER, lang="de") - assert "## Familie" in page and "## Verwandte" in page - assert "## Freunde und Nachbarn" in page and "## Zuhause" in page + assert "## Auf einen Blick" in page and "## Familie" in page and "## Verwandte" in page + assert "## Freunde und Nachbarn" in page + + +class TestTheHouseholdAtAGlance: + def test_the_key_facts_come_right_after_the_welcome(self, brain): + page = _home(brain, ANSWER) + assert "## At a glance\n- **Home** · Owned since 2009 [1]" in page + assert page.index("## At a glance") < page.index("## Family") + + def test_the_pets_are_listed_with_the_family(self, brain): + page = _home(brain, ANSWER) + assert ("- **[Maggie Simpson](maggie/about)**\n" + "- **Santa's Little Helper** · our greyhound. [1]\n") in page class TestAnAnswerThatCannotBeRead: From 9ae2e47646d78e4a41541324f113e5bde4e050c9 Mon Sep 17 00:00:00 2001 From: Arthur Date: Sun, 11 Oct 2026 16:18:08 +0200 Subject: [PATCH 10/11] feat(wiki): tell the family's last month on the home page The home page said who the family is but nothing about what they have been up to. Right after the welcome, a Lately section now tells the last month in three or four warm sentences, with citations. The compiler picks the sources, the diary entries and notes of the last 31 days, and the model writes from those only, so an old story never reads as news and a bill that arrived is not an event. A month with nothing filed has no such section. --- stacklets/memory/bot/cli/wiki.py | 8 +-- stacklets/memory/bot/home_page.py | 51 ++++++++++++++----- stacklets/memory/bot/templates/home.de.md | 5 ++ stacklets/memory/bot/templates/home.en.md | 5 ++ tests/unit/stacklets/test_memory_home_page.py | 35 +++++++++++-- tests/unit/stacklets/test_memory_people.py | 2 +- 6 files changed, 86 insertions(+), 20 deletions(-) diff --git a/stacklets/memory/bot/cli/wiki.py b/stacklets/memory/bot/cli/wiki.py index 3a9426b8..3f078cb9 100644 --- a/stacklets/memory/bot/cli/wiki.py +++ b/stacklets/memory/bot/cli/wiki.py @@ -61,6 +61,7 @@ import sys import tomllib from collections.abc import Callable +from datetime import date from pathlib import Path import yaml @@ -430,7 +431,7 @@ async def run(llm: LLM, argv: list[str]) -> int: async def _generate_home( llm: LLM, index: list[dict], *, roster: list[str], people: dict[str, Person], - shared_bucket: str, lang: str, write: bool, + shared_bucket: str, lang: str, write: bool, today: date | None = None, ) -> int: """Compose the household home page from every filed summary. @@ -444,12 +445,13 @@ async def _generate_home( # answers in JSON. Everyone on the roster gets a line, linked to their # page, whatever the model wrote about them. names = {slug: p.name for slug, p in people.items()} + recent = home_page.recent(index, today or date.today()) prompt = home_page.prompt( - evidence=_format_evidence(index), ids=roster, names=names, lang=lang, + evidence=_format_evidence(index), ids=roster, names=names, lang=lang, recent=recent, aliases={slug: list(p.aliases) for slug, p in people.items()}) try: raw = await llm.complete("overview", prompt, json_mode=True, temperature=_TEMPERATURE) - answer = home_page.read_answer(raw, ids=roster) + answer = home_page.read_answer(raw, ids=roster, recent=recent) except LLMUnavailableError as e: _err(f"LLM unavailable: {e}") return 1 diff --git a/stacklets/memory/bot/home_page.py b/stacklets/memory/bot/home_page.py index e910ef69..c8200400 100644 --- a/stacklets/memory/bot/home_page.py +++ b/stacklets/memory/bot/home_page.py @@ -1,16 +1,18 @@ """The wiki's home page: a Markdown template, the model reads the sources. -The home page is the family's front door: who lives here, who is -around them, and the household facts worth +The home page is the family's front door: who lives here, what they +have been up to, who is around them, and the household facts worth finding at a glance (the home, the cars, the insurance). It is filled from `templates/home..md` (see `page_template`). - **The compiler** lists everyone the person files declare, each linked to their page, in the order the model gives (oldest first) and never - without someone: nobody is dropped, nobody appears twice. + without someone: nobody is dropped, nobody appears twice. It also + picks the filings of the last month the "lately" paragraph is written + from, so the model cannot retell an old story as news. - **The model** answers in JSON: the family's name and address, a short - welcome, the key household facts, one line per person, the pets, the - relatives and the friends around + welcome, the key household facts, one line per person, the pets, a + paragraph on the last month, the relatives and the friends around them, and the household facts it found, each with the sources it cites as numbers. The compiler formats them, so the page has one style whatever the model writes. @@ -25,6 +27,7 @@ import json import re from dataclasses import dataclass, field +from datetime import date, timedelta from page_template import LANGUAGE_NAMES, cites, fact, fill, language, sentence, template, text @@ -34,6 +37,11 @@ # doctor, the school. Past that it is a list, not a glance. GLANCE_MAX = 6 +# "Lately" is the last month, and only what the family did or wrote +# down: a diary entry or a note, not a bill that arrived. +LATELY_DAYS = 31 +LATELY_MAX = 8 + # The friends a reader takes in at a glance. Past that, the list stops # being the people closest to the family. FRIENDS_MAX = 8 @@ -75,6 +83,7 @@ class Answer: helpers: list[Item] = field(default_factory=list) glance: list[Item] = field(default_factory=list) pets: list[Item] = field(default_factory=list) + lately: str = "" facts: dict[str, list[Item]] = field(default_factory=dict) @@ -82,11 +91,12 @@ class AnswerError(ValueError): """The model's answer is not the JSON object the prompt asked for.""" -def read_answer(raw: str, *, ids: list[str]) -> Answer: +def read_answer(raw: str, *, ids: list[str], recent: list[int]) -> Answer: """The model's JSON answer, or `AnswerError`. A field of the wrong shape is dropped rather than guessed at, and a - person the roster does not know is ignored. + person the roster does not know is ignored. Without `recent` sources + there is no "lately", whatever the model wrote. """ try: data = json.loads(raw) @@ -115,6 +125,7 @@ def read_answer(raw: str, *, ids: list[str]) -> Answer: helpers=_items(data.get("helpers")), glance=_items(data.get("glance"), name="label", detail="value")[:GLANCE_MAX], pets=_items(data.get("pets")), + lately=text(data.get("lately")) if recent else "", facts={s: _items(data.get(s)) for s in FACT_SECTIONS}, ) @@ -127,6 +138,14 @@ def _items(value, *, name: str = "name", detail: str = "detail") -> list[Item]: return [i for i in items if i.name] +def recent(entries: list[dict], today: date) -> list[int]: + """The evidence numbers of the last month's diary entries and notes, newest first.""" + since = (today - timedelta(days=LATELY_DAYS)).isoformat() + found = [(e.get("date") or "", n) for n, e in enumerate(entries, start=1) + if e.get("type") != "document" and since <= (e.get("date") or "") <= today.isoformat()] + return [n for _, n in sorted(found, reverse=True)][:LATELY_MAX] + + # ── The page ───────────────────────────────────────────────────────────── def render(answer: Answer, *, ids: list[str], names: dict[str, str], lang: str) -> str: @@ -145,6 +164,7 @@ def render(answer: Answer, *, ids: list[str], names: dict[str, str], lang: str) "welcome": answer.welcome, "glance": "\n".join(fact(i.name, i.detail, i.cites) for i in answer.glance), "people": "\n".join(people), + "lately": answer.lately, "relatives": "\n".join(i.render() for i in answer.relatives), "friends": "\n".join(i.render() for i in answer.friends), "helpers": "\n".join(i.render() for i in answer.helpers), @@ -156,12 +176,14 @@ def render(answer: Answer, *, ids: list[str], names: dict[str, str], lang: str) # ── The prompt ─────────────────────────────────────────────────────────── def prompt(*, evidence: str, ids: list[str], names: dict[str, str], lang: str, - aliases: dict[str, list[str]] | None = None) -> str: + recent: list[int], aliases: dict[str, list[str]] | None = None) -> str: """The instructions for the home page, asking for a JSON object. - `aliases` are the other names the family uses for a person. Without - them a model reads "Margaret" on a birth certificate as a stranger, - and lists Maggie's twin sister among the relatives. + `recent` are the sources of the last month (see `recent`); without + them the model is not asked for a "lately" paragraph. `aliases` are + the other names the family uses for a person. Without them a model + reads "Margaret" on a birth certificate as a stranger, and lists + Maggie's twin sister among the relatives. """ aliases = aliases or {} @@ -169,6 +191,10 @@ def known_as(pid: str) -> str: other = aliases.get(pid) or [] return f" (also called {', '.join(other)})" if other else "" + lately = "" + if recent: + lately = (f'\n "lately": "three or four warm sentences on what the family did in the last month: what happened, ' + f'was decided or booked, in the past tense, from only the sources {", ".join(map(str, recent))}, with citations like [3]",') roster = "\n".join(f"- {pid}: {names.get(pid) or '(full name from the sources)'}{known_as(pid)}" for pid in ids) return f"""You write the home page of a family's private wiki. It is the first page the family sees when they open it: who they are, who is around them, and the household facts they want to find at a glance. The wiki is private and runs on the family's own server, so full names and addresses are fine. @@ -192,7 +218,7 @@ def known_as(pid: str) -> str: "glance": [{{"label": "one or two words in {LANGUAGE_NAMES[language(lang)]}: Home, Car, Doctor, School", "value": "the fact, short and exact: owned or rented and the lender, make and model, name", "cite": [2]}}], "order": ["every id above, oldest first: parents before their children, older children before younger ones"], "people": {{"": {{"name": "the full name, only for a person listed without one", "line": "who they are, warmly and specifically, in under fifteen words: their place in the family and what they are like or busy with. No citation."}}}}, - "pets": [{{"name": "the pet's name", "detail": "what animal, and whose", "cite": [4]}}], + "pets": [{{"name": "the pet's name", "detail": "what animal, and whose", "cite": [4]}}],{lately} "relatives": [{{"name": "full name", "detail": "how they are related to ONE of the family's people, in a few words, like \"Homer's father\"", "cite": [1]}}], "friends": [{{"name": "full name", "detail": "who they are to the family, in a few words", "cite": [5]}}], "helpers": [{{"name": "name", "detail": "what they do for the family: family doctor, Bart's teacher, the school, the corner shop", "cite": [9]}}], @@ -207,6 +233,7 @@ def known_as(pid: str) -> str: - "relatives" are family by blood or marriage who are not among the people above: parents, grandparents, siblings and half-siblings, aunts, uncles, cousins, in-laws. Friends, neighbours, classmates and colleagues go in "friends", at most {FRIENDS_MAX}, the closest first. Doctors, teachers, schools, shops, tradespeople and others who serve the family go in "helpers". - The family's people above go by every name listed for them. Nobody from that list appears in "relatives", "friends" or "helpers", under any of their names. - "glance" holds the household facts someone new to the family wants first, at most {GLANCE_MAX}: the home (owned or rented, the mortgage lender), each car, the family doctor, the children's school. Pets go in "pets", not here. +- "lately" tells a story, not a list: no packing lists or to-dos, at most one open question. - Each person or place appears in one section only: a shop owner who is a friend is a friend, not also a helper. - No account numbers, policy numbers or IDs; the documents carry those. """ diff --git a/stacklets/memory/bot/templates/home.de.md b/stacklets/memory/bot/templates/home.de.md index 7dc91cf6..eb318c90 100644 --- a/stacklets/memory/bot/templates/home.de.md +++ b/stacklets/memory/bot/templates/home.de.md @@ -7,6 +7,8 @@ Absatz mit leerem Platzhalter fällt ganz weg, samt Überschrift. family der Name der Familie („Familie Simpson“), vom Modell address wo die Familie wohnt, vom Modell welcome zwei oder drei warme Sätze, vom Modell + lately ein kurzer Absatz über den letzten Monat, vom Modell, + nur aus dem Tagebuch und den Notizen dieses Monats glance was jemand Neues über den Haushalt zuerst wissen will (das Zuhause, die Autos, der Arzt, die Schule), vom Modell people alle Personen aus den Personendateien, verlinkt, @@ -24,6 +26,9 @@ Dieser Kommentar wird nicht veröffentlicht. {{welcome}} +## Zuletzt +{{lately}} + ## Auf einen Blick {{glance}} diff --git a/stacklets/memory/bot/templates/home.en.md b/stacklets/memory/bot/templates/home.en.md index d05ece5e..9ebda4bb 100644 --- a/stacklets/memory/bot/templates/home.en.md +++ b/stacklets/memory/bot/templates/home.en.md @@ -7,6 +7,8 @@ slot is empty is left out whole, heading included. family the family's name ("The Simpsons"), from the model address where the family lives, from the model welcome two or three warm sentences, from the model + lately a short paragraph on the last month, from the model, + written from that month's diary and notes only glance the household facts a newcomer wants first (the home, the cars, the doctor, the school), from the model people everyone the person files declare, linked to their page, @@ -24,6 +26,9 @@ This comment is not published. {{welcome}} +## Lately +{{lately}} + ## At a glance {{glance}} diff --git a/tests/unit/stacklets/test_memory_home_page.py b/tests/unit/stacklets/test_memory_home_page.py index 2102a371..aff5de1e 100644 --- a/tests/unit/stacklets/test_memory_home_page.py +++ b/tests/unit/stacklets/test_memory_home_page.py @@ -8,6 +8,8 @@ model forgot is still there, a person it made up is not. - The household facts a newcomer wants first sit at the top, and the pets are listed with the family. +- What the family did in the last month is told as a short paragraph, + from that month's filings only; a quiet month has no such section. - A section with nothing in it is not on the page, heading included. - A German household gets German headings. - An answer the compiler cannot read leaves the page as it was. @@ -19,6 +21,7 @@ import json import re import sys +from datetime import date from pathlib import Path import pytest @@ -38,7 +41,13 @@ ROSTER = ["bart", "homer", "maggie"] DEED = {"rel": "family/documents/2009/06/deed-p1.md", "type": "document", "title": "Deed", "date": "2009-06-15", "summary": "742 Evergreen Terrace, bought by Homer and Marge."} +CAMPING = {"rel": "family/diary/entries/2026/10/2026-10-04-aa11.md", "type": "diary", + "title": "Camping at the lake", "date": "2026-10-04", + "summary": "Homer's tent collapsed in the rain; Lisa counted owls."} +INVOICE = {"rel": "family/documents/2026/10/vet-p2.md", "type": "document", "title": "Vet invoice", + "date": "2026-10-06", "summary": "Santa's Little Helper, vaccination."} INDEX = [DEED] +TODAY = date(2026, 10, 11) ANSWER = { "family": "The Simpsons", @@ -52,6 +61,7 @@ "friends": [{"name": "Ned Flanders", "detail": "the neighbour", "cite": [1]}], "glance": [{"label": "Home", "value": "Owned since 2009", "cite": [1]}], "pets": [{"name": "Santa's Little Helper", "detail": "our greyhound", "cite": [1]}], + "lately": "The family went camping at the lake. [2]", "insurance": [], } @@ -78,7 +88,7 @@ def _home(brain: Path, answer, lang: str = "en", index: list[dict] = INDEX, model: _Model | None = None) -> str: rc = asyncio.run(wiki._generate_home( model or _Model(answer), index, roster=ROSTER, people=PEOPLE, - shared_bucket="family", lang=lang, write=True)) + shared_bucket="family", lang=lang, write=True, today=TODAY)) assert rc == 0 return (brain / "index.md").read_text(encoding="utf-8") @@ -115,9 +125,9 @@ def test_an_empty_section_is_not_on_the_page(self, brain): assert "## At a glance" not in page and "## Insurance" not in page def test_a_german_household_reads_german_headings(self, brain): - page = _home(brain, ANSWER, lang="de") + page = _home(brain, ANSWER, lang="de", index=[DEED, CAMPING]) assert "## Auf einen Blick" in page and "## Familie" in page and "## Verwandte" in page - assert "## Freunde und Nachbarn" in page + assert "## Freunde und Nachbarn" in page and "## Zuletzt" in page class TestTheHouseholdAtAGlance: @@ -132,6 +142,23 @@ def test_the_pets_are_listed_with_the_family(self, brain): "- **Santa's Little Helper** · our greyhound. [1]\n") in page +class TestTheLastMonth: + def test_is_told_right_after_the_welcome(self, brain): + page = _home(brain, ANSWER, index=[DEED, CAMPING]) + assert "## Lately\nThe family went camping at the lake. [2]" in page + assert page.index("## Lately") < page.index("## At a glance") < page.index("## Family") + + def test_the_model_reads_only_this_months_diary_and_notes_for_it(self, brain): + """A deed from 2009 or a vet invoice is not what the family did lately.""" + model = _Model(ANSWER) + _home(brain, ANSWER, index=[DEED, CAMPING, INVOICE], model=model) + assert "only the sources 2" in model.prompts[0] + + def test_a_quiet_month_has_no_such_section(self, brain): + page = _home(brain, ANSWER, index=[DEED]) + assert "## Lately" not in page and "camping" not in page + + class TestAnAnswerThatCannotBeRead: def test_the_page_stays_as_it_was(self, brain): before = _home(brain, ANSWER) @@ -146,7 +173,7 @@ class TestTheModelIsToldWhoIsWho: def test_every_name_a_person_goes_by(self): """A birth certificate says "Margaret"; she is Maggie, not a twin.""" prompt = wiki.home_page.prompt( - evidence="", ids=["maggie"], names={"maggie": "Maggie Simpson"}, lang="en", + evidence="", ids=["maggie"], names={"maggie": "Maggie Simpson"}, lang="en", recent=[], aliases={"maggie": ["Margaret", "Mags"]}) assert "- maggie: Maggie Simpson (also called Margaret, Mags)" in prompt diff --git a/tests/unit/stacklets/test_memory_people.py b/tests/unit/stacklets/test_memory_people.py index c027d418..72390e10 100644 --- a/tests/unit/stacklets/test_memory_people.py +++ b/tests/unit/stacklets/test_memory_people.py @@ -298,7 +298,7 @@ async def complete(self, role, prompt, **kwargs): def test_the_home_page_is_told_the_full_names(self): prompt = wiki.home_page.prompt( - evidence="", ids=["homer", "marge"], lang="en", + evidence="", ids=["homer", "marge"], lang="en", recent=[], names={"homer": "Homer Simpson", "marge": "Marge Simpson"}) assert "- marge: Marge Simpson" in prompt assert "(full name from the sources)" not in prompt From dba9ec1f05db9808f6dc955d4e1d445b0068463a Mon Sep 17 00:00:00 2001 From: Arthur Date: Sun, 11 Oct 2026 18:35:33 +0200 Subject: [PATCH 11/11] feat(wiki): add a schedule to the home page MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The home page told what the family did, but not what is ahead or what comes round every week or year. After Lately, a Schedule section now lists: - What is ahead in the next 90 days, by date, soonest first: trips, bookings, appointments, school events, payments due. The model reads the dates out of the sources; the compiler keeps only real dates from today on, drops what is over or too far off, and formats them in the household language (`14–16 Oct 2026`, `14.–16. Okt. 2026`). - What recurs, after the dates: practices and clubs, monthly payments, yearly renewals and birthdays, as a source states them. Each line cites its source. With nothing ahead and nothing recurring, the section is left out. --- stacklets/memory/bot/cli/wiki.py | 8 ++- stacklets/memory/bot/home_page.py | 58 +++++++++++++++++-- stacklets/memory/bot/page_template.py | 13 +++++ stacklets/memory/bot/templates/home.de.md | 6 ++ stacklets/memory/bot/templates/home.en.md | 6 ++ tests/unit/stacklets/test_memory_home_page.py | 54 ++++++++++++++++- tests/unit/stacklets/test_memory_people.py | 3 +- 7 files changed, 139 insertions(+), 9 deletions(-) diff --git a/stacklets/memory/bot/cli/wiki.py b/stacklets/memory/bot/cli/wiki.py index 3f078cb9..339de613 100644 --- a/stacklets/memory/bot/cli/wiki.py +++ b/stacklets/memory/bot/cli/wiki.py @@ -445,13 +445,15 @@ async def _generate_home( # answers in JSON. Everyone on the roster gets a line, linked to their # page, whatever the model wrote about them. names = {slug: p.name for slug, p in people.items()} - recent = home_page.recent(index, today or date.today()) + today = today or date.today() + recent = home_page.recent(index, today) prompt = home_page.prompt( - evidence=_format_evidence(index), ids=roster, names=names, lang=lang, recent=recent, + evidence=_format_evidence(index), ids=roster, names=names, lang=lang, + recent=recent, today=today, aliases={slug: list(p.aliases) for slug, p in people.items()}) try: raw = await llm.complete("overview", prompt, json_mode=True, temperature=_TEMPERATURE) - answer = home_page.read_answer(raw, ids=roster, recent=recent) + answer = home_page.read_answer(raw, ids=roster, recent=recent, today=today) except LLMUnavailableError as e: _err(f"LLM unavailable: {e}") return 1 diff --git a/stacklets/memory/bot/home_page.py b/stacklets/memory/bot/home_page.py index c8200400..4479abe1 100644 --- a/stacklets/memory/bot/home_page.py +++ b/stacklets/memory/bot/home_page.py @@ -29,7 +29,9 @@ from dataclasses import dataclass, field from datetime import date, timedelta -from page_template import LANGUAGE_NAMES, cites, fact, fill, language, sentence, template, text +from page_template import ( + LANGUAGE_NAMES, cites, fact, fill, format_span, language, sentence, template, text, +) FACT_SECTIONS = ("insurance", "subscriptions", "memberships") @@ -42,6 +44,14 @@ LATELY_DAYS = 31 LATELY_MAX = 8 +# The schedule looks one season ahead: far enough to plan, near enough +# that every line still matters this week. +SCHEDULE_DAYS = 90 +SCHEDULE_MAX = 8 +RECURRING_MAX = 6 + +_ISO_DAY = re.compile(r"^\d{4}-\d{2}-\d{2}$") + # The friends a reader takes in at a glance. Past that, the list stops # being the people closest to the family. FRIENDS_MAX = 8 @@ -69,6 +79,19 @@ def render(self) -> str: return f"- **{self.name}**{detail}{self.cites}" +@dataclass +class Event: + """One date on the schedule: a day, or a span of days.""" + + start: str + end: str + what: str + cites: str = "" + + def render(self, lang: str) -> str: + return fact(format_span(self.start, self.end, lang), self.what, self.cites) + + @dataclass class Answer: """What the model read from the sources; every field may be empty.""" @@ -84,6 +107,8 @@ class Answer: glance: list[Item] = field(default_factory=list) pets: list[Item] = field(default_factory=list) lately: str = "" + schedule: list[Event] = field(default_factory=list) + recurring: list[Item] = field(default_factory=list) facts: dict[str, list[Item]] = field(default_factory=dict) @@ -91,12 +116,13 @@ class AnswerError(ValueError): """The model's answer is not the JSON object the prompt asked for.""" -def read_answer(raw: str, *, ids: list[str], recent: list[int]) -> Answer: +def read_answer(raw: str, *, ids: list[str], recent: list[int], today: date) -> Answer: """The model's JSON answer, or `AnswerError`. A field of the wrong shape is dropped rather than guessed at, and a person the roster does not know is ignored. Without `recent` sources - there is no "lately", whatever the model wrote. + there is no "lately", whatever the model wrote. The schedule keeps + only real dates from `today` to `SCHEDULE_DAYS` ahead, soonest first. """ try: data = json.loads(raw) @@ -126,6 +152,8 @@ def read_answer(raw: str, *, ids: list[str], recent: list[int]) -> Answer: glance=_items(data.get("glance"), name="label", detail="value")[:GLANCE_MAX], pets=_items(data.get("pets")), lately=text(data.get("lately")) if recent else "", + schedule=_schedule(data.get("schedule"), today), + recurring=_items(data.get("recurring"), name="when", detail="what")[:RECURRING_MAX], facts={s: _items(data.get(s)) for s in FACT_SECTIONS}, ) @@ -138,6 +166,19 @@ def _items(value, *, name: str = "name", detail: str = "detail") -> list[Item]: return [i for i in items if i.name] +def _schedule(value, today: date) -> list[Event]: + first, last = today.isoformat(), (today + timedelta(days=SCHEDULE_DAYS)).isoformat() + events = [] + for v in value if isinstance(value, list) else []: + if not isinstance(v, dict): + continue + start, end, what = text(v.get("date")), text(v.get("end")), text(v.get("what")) + end = end if _ISO_DAY.match(end) and end > start else "" + if _ISO_DAY.match(start) and what and first <= (end or start) and start <= last: + events.append(Event(start, end, what, cites(v.get("cite")))) + return sorted(events, key=lambda e: e.start)[:SCHEDULE_MAX] + + def recent(entries: list[dict], today: date) -> list[int]: """The evidence numbers of the last month's diary entries and notes, newest first.""" since = (today - timedelta(days=LATELY_DAYS)).isoformat() @@ -165,6 +206,8 @@ def render(answer: Answer, *, ids: list[str], names: dict[str, str], lang: str) "glance": "\n".join(fact(i.name, i.detail, i.cites) for i in answer.glance), "people": "\n".join(people), "lately": answer.lately, + "schedule": "\n".join([e.render(lang) for e in answer.schedule] + + [fact(i.name, i.detail, i.cites) for i in answer.recurring]), "relatives": "\n".join(i.render() for i in answer.relatives), "friends": "\n".join(i.render() for i in answer.friends), "helpers": "\n".join(i.render() for i in answer.helpers), @@ -176,9 +219,10 @@ def render(answer: Answer, *, ids: list[str], names: dict[str, str], lang: str) # ── The prompt ─────────────────────────────────────────────────────────── def prompt(*, evidence: str, ids: list[str], names: dict[str, str], lang: str, - recent: list[int], aliases: dict[str, list[str]] | None = None) -> str: + recent: list[int], today: date, aliases: dict[str, list[str]] | None = None) -> str: """The instructions for the home page, asking for a JSON object. + `today` lets the model tell what is still ahead for the schedule. `recent` are the sources of the last month (see `recent`); without them the model is not asked for a "lately" paragraph. `aliases` are the other names the family uses for a person. Without them a model @@ -199,6 +243,8 @@ def known_as(pid: str) -> str: for pid in ids) return f"""You write the home page of a family's private wiki. It is the first page the family sees when they open it: who they are, who is around them, and the household facts they want to find at a glance. The wiki is private and runs on the family's own server, so full names and addresses are fine. +Today is {today.isoformat()}. + The family's people, by id. Each has their own page; write one line for each: {roster} @@ -219,6 +265,8 @@ def known_as(pid: str) -> str: "order": ["every id above, oldest first: parents before their children, older children before younger ones"], "people": {{"": {{"name": "the full name, only for a person listed without one", "line": "who they are, warmly and specifically, in under fifteen words: their place in the family and what they are like or busy with. No citation."}}}}, "pets": [{{"name": "the pet's name", "detail": "what animal, and whose", "cite": [4]}}],{lately} + "schedule": [{{"date": "YYYY-MM-DD, the day it happens, after {today.isoformat()}", "end": "YYYY-MM-DD, only for something lasting several days", "what": "what happens and for whom, in a few words", "cite": [6]}}], + "recurring": [{{"when": "how often, in a few words: Every Tuesday, Monthly, Every 12 May", "what": "what recurs and for whom, in a few words", "cite": [6]}}], "relatives": [{{"name": "full name", "detail": "how they are related to ONE of the family's people, in a few words, like \"Homer's father\"", "cite": [1]}}], "friends": [{{"name": "full name", "detail": "who they are to the family, in a few words", "cite": [5]}}], "helpers": [{{"name": "name", "detail": "what they do for the family: family doctor, Bart's teacher, the school, the corner shop", "cite": [9]}}], @@ -233,6 +281,8 @@ def known_as(pid: str) -> str: - "relatives" are family by blood or marriage who are not among the people above: parents, grandparents, siblings and half-siblings, aunts, uncles, cousins, in-laws. Friends, neighbours, classmates and colleagues go in "friends", at most {FRIENDS_MAX}, the closest first. Doctors, teachers, schools, shops, tradespeople and others who serve the family go in "helpers". - The family's people above go by every name listed for them. Nobody from that list appears in "relatives", "friends" or "helpers", under any of their names. - "glance" holds the household facts someone new to the family wants first, at most {GLANCE_MAX}: the home (owned or rented, the mortgage lender), each car, the family doctor, the children's school. Pets go in "pets", not here. +- "schedule" holds only what is still ahead: today is {today.isoformat()}, so anything dated before it is over and not on the schedule. Appointments, trips, bookings, school events, renewals and payments due, with the date a source gives. A day and month without a year ("October 14th") take the year the source was filed in. Never a date you work out from a weekday or guess; leave out anything without one. In {LANGUAGE_NAMES[language(lang)]}. +- "recurring" holds what repeats, as a source states it, at most {RECURRING_MAX}: weekly practices, lessons and clubs, monthly payments, yearly renewals, birthdays. What happened once is not recurring. - "lately" tells a story, not a list: no packing lists or to-dos, at most one open question. - Each person or place appears in one section only: a shop owner who is a friend is a friend, not also a helper. - No account numbers, policy numbers or IDs; the documents carry those. diff --git a/stacklets/memory/bot/page_template.py b/stacklets/memory/bot/page_template.py index c9c40c0e..7bb4726a 100644 --- a/stacklets/memory/bot/page_template.py +++ b/stacklets/memory/bot/page_template.py @@ -50,6 +50,19 @@ def format_date(iso: str, lang: str) -> str: return f"{day}. {name} {year}" if language(lang) == "de" else f"{day} {name} {year}" +def format_span(start: str, end: str, lang: str) -> str: + """A span of days, short: `14–16 Oct 2026`, `30 Oct – 2 Nov 2026`, `14.–16. Okt. 2026`.""" + if not end or end <= start: + return format_date(start, lang) + first, last = format_date(start, lang), format_date(end, lang) + if start[:7] == end[:7]: + day = first.split(" ", 1)[0] + return f"{day}–{last}" + if start[:4] == end[:4]: + return f"{first.rsplit(' ', 1)[0]} – {last}" + return f"{first} – {last}" + + def template(kind: str, lang: str) -> str: """The template text for a page kind and language, without its comment.""" text = (TEMPLATES / f"{kind}.{language(lang)}.md").read_text(encoding="utf-8") diff --git a/stacklets/memory/bot/templates/home.de.md b/stacklets/memory/bot/templates/home.de.md index eb318c90..7082f433 100644 --- a/stacklets/memory/bot/templates/home.de.md +++ b/stacklets/memory/bot/templates/home.de.md @@ -9,6 +9,9 @@ Absatz mit leerem Platzhalter fällt ganz weg, samt Überschrift. welcome zwei oder drei warme Sätze, vom Modell lately ein kurzer Absatz über den letzten Monat, vom Modell, nur aus dem Tagebuch und den Notizen dieses Monats + schedule was in den nächsten drei Monaten ansteht, nach Datum, vom + Modell, dann was sich wiederholt; der Compiler streicht + Vergangenes, Fernes und Geratenes glance was jemand Neues über den Haushalt zuerst wissen will (das Zuhause, die Autos, der Arzt, die Schule), vom Modell people alle Personen aus den Personendateien, verlinkt, @@ -29,6 +32,9 @@ Dieser Kommentar wird nicht veröffentlicht. ## Zuletzt {{lately}} +## Termine +{{schedule}} + ## Auf einen Blick {{glance}} diff --git a/stacklets/memory/bot/templates/home.en.md b/stacklets/memory/bot/templates/home.en.md index 9ebda4bb..f1b534f5 100644 --- a/stacklets/memory/bot/templates/home.en.md +++ b/stacklets/memory/bot/templates/home.en.md @@ -9,6 +9,9 @@ slot is empty is left out whole, heading included. welcome two or three warm sentences, from the model lately a short paragraph on the last month, from the model, written from that month's diary and notes only + schedule what is ahead in the next three months, by date, from the + model, then what recurs; the compiler drops past, far-off + and guessed dates glance the household facts a newcomer wants first (the home, the cars, the doctor, the school), from the model people everyone the person files declare, linked to their page, @@ -29,6 +32,9 @@ This comment is not published. ## Lately {{lately}} +## Schedule +{{schedule}} + ## At a glance {{glance}} diff --git a/tests/unit/stacklets/test_memory_home_page.py b/tests/unit/stacklets/test_memory_home_page.py index aff5de1e..969d4cdf 100644 --- a/tests/unit/stacklets/test_memory_home_page.py +++ b/tests/unit/stacklets/test_memory_home_page.py @@ -10,6 +10,10 @@ pets are listed with the family. - What the family did in the last month is told as a short paragraph, from that month's filings only; a quiet month has no such section. +- The schedule lists what is coming up in the next three months by + date, soonest first, then what recurs (practice every Tuesday, the + yearly renewal), each with its source. A date already past, too far + ahead or not a date at all never reaches the page. - A section with nothing in it is not on the page, heading included. - A German household gets German headings. - An answer the compiler cannot read leaves the page as it was. @@ -173,7 +177,7 @@ class TestTheModelIsToldWhoIsWho: def test_every_name_a_person_goes_by(self): """A birth certificate says "Margaret"; she is Maggie, not a twin.""" prompt = wiki.home_page.prompt( - evidence="", ids=["maggie"], names={"maggie": "Maggie Simpson"}, lang="en", recent=[], + evidence="", ids=["maggie"], names={"maggie": "Maggie Simpson"}, lang="en", recent=[], today=date(2026, 10, 11), aliases={"maggie": ["Margaret", "Mags"]}) assert "- maggie: Maggie Simpson (also called Margaret, Mags)" in prompt @@ -193,3 +197,51 @@ def test_the_people_who_help_the_family_have_their_own_section(self, brain): answer = {**ANSWER, "helpers": [{"name": "Dr. Hibbert", "detail": "family doctor", "cite": [1]}]} page = _home(brain, answer) assert "## The people who help us\n- **Dr. Hibbert** · family doctor. [1]" in page + + +class TestTheSchedule: + LODGE = {"date": "2026-10-14", "end": "2026-10-16", "what": "Lodge at the park, two rooms", "cite": [1]} + DENTIST = {"date": "2026-10-12", "what": "Bart at the dentist", "cite": [1]} + + def _schedule(self, brain, *events, lang="en") -> str: + return _home(brain, {**ANSWER, "schedule": list(events)}, lang=lang) + + def test_lists_what_is_coming_soonest_first(self, brain): + page = self._schedule(brain, self.LODGE, self.DENTIST) + assert ("## Schedule\n- **12 Oct 2026** · Bart at the dentist [1]\n" + "- **14–16 Oct 2026** · Lodge at the park, two rooms [1]\n") in page + + def test_sits_between_the_last_month_and_the_facts(self, brain): + page = _home(brain, {**ANSWER, "schedule": [self.DENTIST]}, index=[DEED, CAMPING]) + assert page.index("## Lately") < page.index("## Schedule") < page.index("## At a glance") + + def test_what_recurs_follows_the_dates(self, brain): + band = {"when": "Every Tuesday", "what": "Lisa's band practice", "cite": [1]} + page = _home(brain, {**ANSWER, "schedule": [self.DENTIST], "recurring": [band]}) + assert ("- **12 Oct 2026** · Bart at the dentist [1]\n" + "- **Every Tuesday** · Lisa's band practice [1]\n") in page + + def test_what_recurs_is_a_schedule_on_its_own(self, brain): + rent = {"when": "Monthly", "what": "Mortgage payment", "cite": [1]} + assert "## Schedule\n- **Monthly** · Mortgage payment [1]" in _home(brain, {**ANSWER, "recurring": [rent]}) + + def test_something_still_running_stays(self, brain): + trip = {"date": "2026-10-09", "end": "2026-10-12", "what": "Camping", "cite": [1]} + assert "- **9–12 Oct 2026** · Camping [1]" in self._schedule(brain, trip) + + def test_past_far_off_or_guessed_dates_are_left_out(self, brain): + page = self._schedule( + brain, + {"date": "2026-10-01", "what": "Already happened"}, + {"date": "2027-06-01", "what": "Next summer"}, + {"date": "next Friday", "what": "A guess"}) + assert "## Schedule" not in page + + def test_a_german_household_reads_german_dates(self, brain): + page = self._schedule(brain, self.LODGE, lang="de") + assert "## Termine\n- **14.–16. Okt. 2026** · Lodge at the park, two rooms [1]" in page + + def test_the_model_knows_what_day_it_is(self, brain): + model = _Model(ANSWER) + _home(brain, ANSWER, model=model) + assert "Today is 2026-10-11." in model.prompts[0] diff --git a/tests/unit/stacklets/test_memory_people.py b/tests/unit/stacklets/test_memory_people.py index 72390e10..d23a18bf 100644 --- a/tests/unit/stacklets/test_memory_people.py +++ b/tests/unit/stacklets/test_memory_people.py @@ -25,6 +25,7 @@ import json import re import sys +from datetime import date from pathlib import Path import pytest @@ -298,7 +299,7 @@ async def complete(self, role, prompt, **kwargs): def test_the_home_page_is_told_the_full_names(self): prompt = wiki.home_page.prompt( - evidence="", ids=["homer", "marge"], lang="en", recent=[], + evidence="", ids=["homer", "marge"], lang="en", recent=[], today=date(2026, 10, 11), names={"homer": "Homer Simpson", "marge": "Marge Simpson"}) assert "- marge: Marge Simpson" in prompt assert "(full name from the sources)" not in prompt