From 225034fec81a60d86668f275650349658d6eb5df Mon Sep 17 00:00:00 2001 From: Arthur Date: Thu, 24 Sep 2026 22:50:19 +0200 Subject: [PATCH] feat(diary): let a correction say who a memory was for The reading of a memo sometimes takes a nickname for the person spoken to, and a reply in the card's thread could fix the date, the people and the facts but not that. A correction such as "This was for Marge, not Homie" now sets whom the entry is for; a household member's name takes its canonical spelling, anyone else keeps the family's words. A correction that says nothing about it leaves it as it was. Closes: #133 --- docs/design/brain/vault-format.md | 2 +- docs/user-guide.md | 2 +- stacklets/memory/bot/cli/diary.py | 7 ++++-- stacklets/memory/bot/diary_card.py | 22 ++++++++++++++++++- .../unit/stacklets/test_memory_diary_card.py | 19 ++++++++++++++++ 5 files changed, 47 insertions(+), 5 deletions(-) diff --git a/docs/design/brain/vault-format.md b/docs/design/brain/vault-format.md index 6c622e9e..37bbc5e0 100644 --- a/docs/design/brain/vault-format.md +++ b/docs/design/brain/vault-format.md @@ -162,7 +162,7 @@ card; its event id joins `event_ids`. | `persons` | O | list. | | `tags` | O | list. | | `filed_by` | O | Matrix localpart of the sender. | -| `addressee` | O | who the message is spoken to, as it names them. | +| `addressee` | O | who the message is spoken to, as it names them; a correction can change it. | | `medium` | O | `voice` \| `image` \| `video` \| `file` \| `text`. | | `mode` | O | `monologue` \| `dialogue` \| `note`. | | `duration_ms` | O | integer. | diff --git a/docs/user-guide.md b/docs/user-guide.md index 056d5af8..d06c79dd 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -231,7 +231,7 @@ The AI mishears names and gets things wrong. Correct it in the memory's thread, > **archivist**: ๐Ÿ“” **Sandcastle at the lake** ยท Sunday, 20 September 2026 ... > **you** (in the thread): It was Lisa who built the tower, not Bart. And it was the 19th. -The archivist answers with ๐Ÿ‘€, puts the corrected card in the thread, and confirms with โœ…. You can correct the title, the date, who it is about, the summary and the facts. The recorded words themselves stay as they were. +The archivist answers with ๐Ÿ‘€, puts the corrected card in the thread, and confirms with โœ…. You can correct the title, the date, who it is about, who it was for, the summary and the facts. The recorded words themselves stay as they were. famstack saves your correction in the family's archive under your name, and the card is yours from then on: the nightly run never changes it back. To correct it again, reply in the same thread. diff --git a/stacklets/memory/bot/cli/diary.py b/stacklets/memory/bot/cli/diary.py index 6b37f861..5183358f 100644 --- a/stacklets/memory/bot/cli/diary.py +++ b/stacklets/memory/bot/cli/diary.py @@ -1463,13 +1463,16 @@ def _report(cards, plan, tree, failed, *, bucket: str, room_id: str, family list only), "topics" (topics from the vocabulary only), "quotes" (passages copied word for word from the entry's words; keep the card's quotes unless the correction touches -them), and "date": the day the entry happened as YYYY-MM-DD when the -correction states it, otherwise null. +them), "date": the day the entry happened as YYYY-MM-DD when the +correction states it, otherwise null, and "addressee": whom the entry is +spoken to when the correction says so (a name from the family list, or +the correction's own words such as "the kids"), otherwise null. """ def _card_prompt(card) -> str: lines = [f"Title: {card.title}", f"Date: {card.on.isoformat()}", + f"Spoken to: {card.addressee or 'no one in particular'}", f"Description: {card.description}", f"People: {', '.join(card.persons)}", f"Topics: {', '.join(t for t in card.tags if not t.startswith('Person: '))}", diff --git a/stacklets/memory/bot/diary_card.py b/stacklets/memory/bot/diary_card.py index a3cecb7a..c889b0cf 100644 --- a/stacklets/memory/bot/diary_card.py +++ b/stacklets/memory/bot/diary_card.py @@ -66,6 +66,9 @@ class Extraction: # A day a family member's correction states, as YYYY-MM-DD. Honoured # only for an entry that has corrections (see `to_card`). date: str = "" + # Whom the entry is spoken to, as a correction states it. Only + # `correct` uses it; a first reading takes the addressee from the room. + addressee: str = "" _MAX_QUOTES = 3 @@ -93,6 +96,21 @@ def _kind_only(fact: str) -> bool: return bool(sep) and label.strip().lower() in _KIND_LABELS +def _addressee(value, people: "dict[str, str]") -> str: + """Whom a correction says the entry was for. + + A household member's name resolves to its canonical form; anyone else + ("the kids", "Grandpa") is kept as the family wrote it. Nothing, or a + model's null, is "". + """ + if not isinstance(value, str): + return "" + words = " ".join(value.split()) + if not words or words.lower() in ("null", "none"): + return "" + return people.get(words.lower(), words) + + def _iso_day(value) -> str: """`value` if it is a YYYY-MM-DD day, else "".""" if not isinstance(value, str) or not re.fullmatch(r"\d{4}-\d{2}-\d{2}", value.strip()): @@ -150,6 +168,7 @@ def text(key: str) -> str: quotes=_strings(raw.get("quotes"))[:_MAX_QUOTES], model=model, date=_iso_day(raw.get("date")), + addressee=_addressee(raw.get("addressee"), people), ) @@ -239,7 +258,7 @@ def correct(card: Card, extraction: Extraction, *, event_id: str) -> Card: `extraction` is the model's reading of the current card with the correction applied. It replaces everything the model reads out of an entry (title, description, people, tags, summary, facts, quotes) and, - when it names a day, the date. The family's words, replies and files + when the correction states them, the date and whom the entry was for. The family's words, replies and files stay as they are. The correction's event id joins the card's, so the card leads back to the message that changed it; the words of the correction are kept by the vault commit, not on the card. @@ -249,6 +268,7 @@ def correct(card: Card, extraction: Extraction, *, event_id: str) -> Card: on, confidence = date.fromisoformat(extraction.date), "corrected" return replace( card, on=on, confidence=confidence, + addressee=extraction.addressee or card.addressee, title=extraction.title.strip() or card.title, description=extraction.description.strip(), persons=list(extraction.persons), diff --git a/tests/unit/stacklets/test_memory_diary_card.py b/tests/unit/stacklets/test_memory_diary_card.py index 2fd28b5c..61d9e83e 100644 --- a/tests/unit/stacklets/test_memory_diary_card.py +++ b/tests/unit/stacklets/test_memory_diary_card.py @@ -416,6 +416,25 @@ def test_a_correction_can_move_the_date(self): assert fixed.confidence == "corrected" assert diary_card.to_entry(fixed).basis == diary.basis_text("corrected") + def test_a_correction_can_say_who_the_memory_was_for(self): + """"This was for Marge, not Homie": the reading took a nickname for + the person spoken to, and the family puts it right.""" + card = replace(_card(), addressee="Homie") + fixed = diary_card.correct(card, _read(addressee="marge"), event_id="$fix") + + assert fixed.addressee == "Marge" + + def test_a_correction_that_names_no_one_keeps_who_it_was_for(self): + card = replace(_card(), addressee="Bart") + assert diary_card.correct(card, _read(), event_id="$fix").addressee == "Bart" + + def test_someone_outside_the_household_is_kept_as_the_family_named_them(self): + """A memo can be for "the kids" or for Grandpa: the family's words + stand when they name no household member.""" + card = replace(_card(), addressee="Bart") + fixed = diary_card.correct(card, _read(addressee=" the kids "), event_id="$fix") + assert fixed.addressee == "the kids" + def test_a_correction_without_a_date_keeps_the_date(self): fixed = diary_card.correct(_card(), _extraction(), event_id="$fix")