diff --git a/docs/design/brain/wiki-page-anatomy.md b/docs/design/brain/wiki-page-anatomy.md index 23540e18..1f9b3eb3 100644 --- a/docs/design/brain/wiki-page-anatomy.md +++ b/docs/design/brain/wiki-page-anatomy.md @@ -79,6 +79,58 @@ 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. + +### 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 The ontology defines which pages exist; each entity type brings a @@ -154,7 +206,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 b6b1e178..339de613 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 @@ -73,16 +74,25 @@ 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 # 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 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 +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" @@ -275,8 +285,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 +295,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 +326,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: @@ -330,7 +342,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: @@ -368,7 +380,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: @@ -382,7 +394,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: @@ -396,7 +408,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, ) @@ -409,7 +421,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 @@ -419,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. @@ -429,13 +441,26 @@ 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) + 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, today=today, + 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, recent=recent, today=today) 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 -- @@ -446,7 +471,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 @@ -473,15 +498,26 @@ async def _generate_home( return rc +# 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( 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: @@ -500,42 +536,41 @@ 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}") - - prompt = _build_member_prompt( - display, slug, entries, facts, lang=lang, - previous=previous, new_evidence=new_evidence, + 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) + + # 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, people=people or {}), + facts=facts, family=_family_names(people or {}), + 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 = _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}") @@ -564,6 +599,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. @@ -591,20 +627,29 @@ 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) + page = _with_references(page, entries + cross_refs, page_dir=page_dir, lang=lang) if not write: print(f"\n\n{page}") @@ -624,6 +669,43 @@ 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. + """ + 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 "", 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: + 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. @@ -685,6 +767,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, @@ -1202,16 +1285,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. @@ -1224,15 +1316,14 @@ 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 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 +1331,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 +1484,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,14 +1507,14 @@ 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") - if refs != -1: - body = body[:refs].rstrip() + refs = _REFERENCES_HEADING.search(body) + if refs: + body = body[:refs.start()].rstrip() return body, refs_map @@ -1618,200 +1745,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 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"{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 _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. @@ -1831,149 +1764,8 @@ 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: +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 @@ -1981,11 +1773,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/home_page.py b/stacklets/memory/bot/home_page.py new file mode 100644 index 00000000..4479abe1 --- /dev/null +++ b/stacklets/memory/bot/home_page.py @@ -0,0 +1,289 @@ +"""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, 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. 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, 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. + +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 datetime import date, timedelta + +from page_template import ( + LANGUAGE_NAMES, cites, fact, fill, format_span, language, sentence, template, text, +) + +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 + +# "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 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 + +_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 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.""" + + 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) + 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) + + +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], 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. The schedule keeps + only real dates from `today` to `SCHEDULE_DAYS` ahead, soonest first. + """ + 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")), + 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}, + ) + + +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"))) + for v in value if isinstance(v, dict)] + 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() + 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: + """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}") + 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), + "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), + **{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, + 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 + 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 "" + + 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. + +Today is {today.isoformat()}. + +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]", + "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]}}],{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]}}], + "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]}}] +}} + +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. +- "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 new file mode 100644 index 00000000..7bb4726a --- /dev/null +++ b/stacklets/memory/bot/page_template.py @@ -0,0 +1,103 @@ +"""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 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") + 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 + "." + + +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)] \ + if isinstance(value, list) else [] + return f" [{', '.join(map(str, nums))}]" if nums else "" diff --git a/stacklets/memory/bot/person_page.py b/stacklets/memory/bot/person_page.py new file mode 100644 index 00000000..bd7a7318 --- /dev/null +++ b/stacklets/memory/bot/person_page.py @@ -0,0 +1,237 @@ +"""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, cites, fact, 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 +FACTS_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 = "" + facts: list[tuple[str, str, str]] = field(default_factory=list) + 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 [] + 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 "", + 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, + "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), + "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, 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. + + `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} +""" + + 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: + +{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].", + "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]"}} +}} + +"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. +- 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/home.de.md b/stacklets/memory/bot/templates/home.de.md new file mode 100644 index 00000000..7082f433 --- /dev/null +++ b/stacklets/memory/bot/templates/home.de.md @@ -0,0 +1,60 @@ + +# {{family}} +> {{address}} + +{{welcome}} + +## Zuletzt +{{lately}} + +## Termine +{{schedule}} + +## Auf einen Blick +{{glance}} + +## Familie +{{people}} + +## Verwandte +{{relatives}} + +## Freunde und Nachbarn +{{friends}} + +## Wer uns hilft +{{helpers}} + +## 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..f1b534f5 --- /dev/null +++ b/stacklets/memory/bot/templates/home.en.md @@ -0,0 +1,60 @@ + +# {{family}} +> {{address}} + +{{welcome}} + +## Lately +{{lately}} + +## Schedule +{{schedule}} + +## At a glance +{{glance}} + +## Family +{{people}} + +## Relatives +{{relatives}} + +## Friends and neighbours +{{friends}} + +## The people who help us +{{helpers}} + +## Insurance +{{insurance}} + +## Subscriptions +{{subscriptions}} + +## Memberships +{{memberships}} diff --git a/stacklets/memory/bot/templates/person.de.md b/stacklets/memory/bot/templates/person.de.md new file mode 100644 index 00000000..1ec76256 --- /dev/null +++ b/stacklets/memory/bot/templates/person.de.md @@ -0,0 +1,36 @@ + +# {{name}} +> {{tagline}} + +{{about}} + +## Auf einen Blick +{{facts}} + +## 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..acecb34f --- /dev/null +++ b/stacklets/memory/bot/templates/person.en.md @@ -0,0 +1,36 @@ + +# {{name}} +> {{tagline}} + +{{about}} + +## At a glance +{{facts}} + +## What {{first_name}} loves +{{loves}} + +{{more}} + +## Lately +{{lately}} + +## Documents +{{documents}} 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..ca6de777 --- /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, fact, 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(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"), + "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_home_page.py b/tests/unit/stacklets/test_memory_home_page.py new file mode 100644 index 00000000..969d4cdf --- /dev/null +++ b/tests/unit/stacklets/test_memory_home_page.py @@ -0,0 +1,247 @@ +"""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. +- 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. +- 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. +""" + +from __future__ import annotations + +import asyncio +import json +import re +import sys +from datetime import date +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"] +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", + "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]}], + "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": [], +} + + +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 / "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", 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, today=TODAY)) + 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, "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", index=[DEED, CAMPING]) + assert "## Auf einen Blick" in page and "## Familie" in page and "## Verwandte" in page + assert "## Freunde und Nachbarn" in page and "## Zuletzt" 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 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) + 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", recent=[], today=date(2026, 10, 11), + 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 + + +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 c7bbb44e..d23a18bf 100644 --- a/tests/unit/stacklets/test_memory_people.py +++ b/tests/unit/stacklets/test_memory_people.py @@ -23,7 +23,9 @@ import asyncio import base64 import json +import re import sys +from datetime import date from pathlib import Path import pytest @@ -269,12 +271,38 @@ 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 "{}" + monkeypatch.setattr(wiki, "_index_vault", lambda vault: index) + monkeypatch.setattr(wiki, "_brain_dir", lambda: brain) + (brain / "index.md").write_text("---\ntitle: Family Memory\ngenerated: true\n---\n") + + asyncio.run(wiki.run(_Model(), ["--home"])) + + 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", recent=[], today=date(2026, 10, 11), names={"homer": "Homer Simpson", "marge": "Marge Simpson"}) - assert "- **[Marge Simpson](marge/about)**" in prompt - assert " 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 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") + 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 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]) + 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_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 e0ec9799..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, @@ -591,7 +589,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: @@ -607,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.""" @@ -630,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.""" @@ -1062,3 +989,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} 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..d9d2a128 --- /dev/null +++ b/tests/unit/stacklets/test_memory_wiki_citations.py @@ -0,0 +1,167 @@ +"""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 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 + +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 "loves" line per source the first time, then keeps the page. + + 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: + self.prompts: list[str] = [] + + async def complete(self, role, prompt, **kwargs) -> str: + self.prompts.append(prompt) + 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 +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 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+)\]$", loves, 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 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): + """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 then." 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 "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", + } + + 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 "THE PAGE AS IT IS NOW" in model.prompts[-1]