From 3fd506a15f58d8b7b2a989695733e0a58e4d43dd Mon Sep 17 00:00:00 2001 From: Test Date: Tue, 13 Oct 2026 22:37:54 +0200 Subject: [PATCH 1/8] fix(archivist): keep an upload's caption on its capture A photo or file posted with a caption outside the documents room was captured without it: the model summarised the picture alone and the entry had no trace of what the sender wrote. - The caption now reaches the model with the upload, as it already did for a filed document, so the summary says what the sender said. - The entry keeps the caption word for word under `## Caption`. - A correction reads the caption off the upload it was filed from, falling back to the entry's, so replying to the filing restores a caption an earlier filing dropped. --- stacklets/docs/bot/archivist.py | 17 +++++ stacklets/docs/bot/capture_pipeline.py | 32 ++++++++++ stacklets/docs/bot/git_mirror.py | 8 ++- stacklets/docs/bot/vault_entry.py | 8 +++ .../stacklets/test_archivist_corrections.py | 51 ++++++++++++++- tests/unit/stacklets/test_archivist_source.py | 30 +++++++++ tests/unit/stacklets/test_capture_pipeline.py | 62 +++++++++++++++++++ tests/unit/stacklets/test_vault_entry.py | 38 ++++++++++++ 8 files changed, 244 insertions(+), 2 deletions(-) diff --git a/stacklets/docs/bot/archivist.py b/stacklets/docs/bot/archivist.py index 64b98f76..7384331f 100644 --- a/stacklets/docs/bot/archivist.py +++ b/stacklets/docs/bot/archivist.py @@ -1268,6 +1268,7 @@ async def _handle_correction( await self._handle_reply_capture_reprocess( room_id, capture_path, hint, event.sender, reply_to, initial_classification=initial, + capture_id=envelope.get("data", {}).get("capture_id"), ) return True return False @@ -1428,6 +1429,7 @@ async def _handle_reply_capture_reprocess( self, room_id: str, vault_path: str, user_hint: str, sender_mxid: str, reply_to: str, *, initial_classification: dict | None = None, + capture_id: str | None = None, ) -> None: """Re-classify a capture with the user's reply as a hint. @@ -1435,12 +1437,22 @@ async def _handle_reply_capture_reprocess( the pipeline, render via the shared reprocessed presenter, attach the fresh `capture.reclassified` envelope so the user can chain another correction by replying to THIS message. + + ``capture_id`` is the event the capture was filed from. Its + caption is read off that event again, so a correction restores + a caption an earlier filing dropped. """ + upload = await self._fetch_event(room_id, capture_id) + caption = ( + _attachment_caption(upload.source.get("content", {})) + if upload is not None else "" + ) outcome = await self._services.capture.reprocess( vault_path=vault_path, user_hint=user_hint, sender_mxid=sender_mxid, initial_classification=initial_classification, write_in=await self._write_in(room_id), + caption=caption, ) # `_reply_for_capture` already knows how to render the # reclassified branch + attach the envelope, so we just defer. @@ -1616,6 +1628,7 @@ async def _on_file(self, room, event) -> None: # prompt as user_hint -- "neue Personalausweise für Marge und # Bart" steers the LLM the same way a reply-to-correct does, # without the user having to wait for a bad classification first. + # A capture also keeps the caption on its entry, as written. raw_filename = content.get("filename") or content.get("body") or "document" caption = _attachment_caption(content) display_name = _clean_filename(raw_filename, msgtype) @@ -1686,6 +1699,7 @@ async def _on_file(self, room, event) -> None: default_person=not bot_attachment, extra_seed_topics=extra_seed_topics, captured_ts=getattr(event, "server_timestamp", 0) or 0, + caption=caption, ) async def _on_text(self, room, event: RoomMessageText) -> None: @@ -2422,6 +2436,7 @@ async def _handle_binary_capture( default_person: bool = True, extra_seed_topics: list[str] | None = None, captured_ts: int = 0, + caption: str = "", ) -> None: """Capture a PDF or image as a visual bookmark. @@ -2439,6 +2454,7 @@ async def _handle_binary_capture( attachment): the sender is a bot, not the owner, so don't fall it in as the person. ``extra_seed_topics`` prepend provenance tags (Sender, ``email``) on top of any topic-room seeds. + ``caption`` is what the sender wrote with the upload. """ room = self._room_by_id(room_id) binding = await self._topic_binding(room, sender_mxid) @@ -2468,6 +2484,7 @@ async def _handle_binary_capture( sender_mxid=sender_mxid, ts_ms=captured_ts, ), write_in=await self._write_in(room_id), + caption=caption, ) await self._reply_for_capture(room_id, outcome, reply_to) diff --git a/stacklets/docs/bot/capture_pipeline.py b/stacklets/docs/bot/capture_pipeline.py index f1aee053..8fa0883c 100644 --- a/stacklets/docs/bot/capture_pipeline.py +++ b/stacklets/docs/bot/capture_pipeline.py @@ -339,6 +339,7 @@ async def capture_binary( default_person: bool = True, kept_media: dict | None = None, write_in: str | None = None, + caption: str = "", ) -> CaptureOutcome: """File a PDF or image as a bookmark. @@ -356,6 +357,10 @@ async def capture_binary( is the copy the wiki serves itself. The caller keeps it because it is the one that has the room, the sender and the event this upload arrived on, which is what the archive records. + + ``caption`` is what the sender wrote with the upload. The model + reads it alongside the file, as it reads a note on a filed + document, and the entry keeps it as written. """ source, images, kind = self._source_from_binary( file_data=file_data, mime=mime, filename=filename, @@ -371,6 +376,7 @@ async def capture_binary( source=source, kind=kind, sender_mxid=sender_mxid, display_link=display_link or source_uri or filename, images=images, actor=sender_mxid, + user_hint=caption or None, caption=caption, capture_id=capture_id, seed_topics=seed_topics, bucket=bucket, default_person=default_person, kept_media=kept_media, write_in=write_in, @@ -552,6 +558,7 @@ async def reprocess( self, *, vault_path: str, user_hint: str, sender_mxid: str, initial_classification: dict | None = None, write_in: str | None = None, + caption: str = "", ) -> CaptureOutcome: """Re-classify an already-filed capture using a human note. @@ -570,6 +577,11 @@ async def reprocess( The archived file is the one thing that cannot be recovered that way: the bytes are long gone from this path, so the reference is read back off the entry being replaced. + + ``caption`` is the caption on the upload the capture was filed + from, when the caller could read it there. It wins over the + entry's, which an older filing may have left out; without it, + the entry's caption is kept. """ if self._mirror is None: return CaptureOutcome(status="no_mirror") @@ -577,6 +589,7 @@ async def reprocess( if raw is None: return CaptureOutcome(status="capture_missing") meta, summary_text = _parse_capture_markdown(raw) + caption = caption or _caption_from(raw) # The summary callout doesn't carry the frontmatter tag list, # so the classifier would otherwise have to re-derive tags # from prose alone -- they drift on each pass. Append them as @@ -591,6 +604,8 @@ async def reprocess( body_parts: list[str] = [] if summary_text: body_parts.append(summary_text) + if caption: + body_parts.append("The sender's caption: " + caption) if existing_tags: body_parts.append( "Existing tags on this entry: " + ", ".join(existing_tags) @@ -615,6 +630,7 @@ async def reprocess( capture_id=str(capture_id) if capture_id else None, initial_classification=initial_classification, kept_media=_kept_media_from(raw), + caption=caption, write_in=write_in, ) @@ -636,6 +652,7 @@ async def _publish( kept_media: dict | None = None, write_in: str | None = None, extract_action_items: bool = False, + caption: str = "", ) -> CaptureOutcome: """Shared tail: classify, mirror, record tags, return the outcome. @@ -727,6 +744,7 @@ async def _publish( capture_id=capture_id, submitter=sender_mxid, kept_media=kept_media, + caption=caption, ) # Feed topic tags (not the derived Person: X) back into the @@ -975,6 +993,20 @@ def _kept_media_from(raw: str) -> dict | None: } +# The caption section `vault_entry.render_capture` closes an entry with. +_CAPTION_SECTION = re.compile(r"^## Caption\n\n(.*)\Z", re.M | re.S) + + +def _caption_from(raw: str) -> str: + """The sender's caption an entry keeps, read back off the page. + + A correction re-renders the entry from the prior one, so the caption + has to be read back, or the first reply to fix a tag would drop it. + """ + found = _CAPTION_SECTION.search(raw) + return found.group(1).strip() if found else "" + + def _parse_yaml_frontmatter(text: str) -> dict: """Same stdlib subset memory/lib's `_parse_frontmatter` uses -- keeps the bot stdlib-only for this path so we don't pull diff --git a/stacklets/docs/bot/git_mirror.py b/stacklets/docs/bot/git_mirror.py index fc79555c..0fedd382 100644 --- a/stacklets/docs/bot/git_mirror.py +++ b/stacklets/docs/bot/git_mirror.py @@ -505,6 +505,7 @@ def _render_capture( facts: list | None = None, action_items: list | None = None, kept_media: dict | None = None, + caption: str = "", ) -> str: """Capture mirror markdown. Delegates to ``vault_entry.render_capture``.""" return render_capture( @@ -512,7 +513,7 @@ def _render_capture( captured_at=captured_at, source_uri=source_uri, persons=persons, from_path=from_path, shared_bucket=self.shared_bucket, summary=summary, facts=facts, action_items=action_items, - kept_media=kept_media, + kept_media=kept_media, caption=caption, ) def _commit_message( @@ -1008,6 +1009,7 @@ async def publish_capture( capture_id: str | None = None, submitter: str | None = None, kept_media: dict | None = None, + caption: str = "", ) -> str | None: """Create or update a capture entry in the mirror. @@ -1031,6 +1033,9 @@ async def publish_capture( (a URL, a pasted note) and on a reprocess, which re-renders from the prior entry and never sees the original again. + ``caption`` is what the sender wrote with an upload; the entry + keeps it word for word. + ``existing_path`` is the reprocess hook: when supplied, the write deletes the old file if the new title-derived path differs (a rename) and re-uses the same identity otherwise. @@ -1125,6 +1130,7 @@ async def publish_capture( facts=briefing_facts, action_items=briefing_actions, kept_media=kept_media, + caption=caption, ) verb = "update" if existing else "capture" diff --git a/stacklets/docs/bot/vault_entry.py b/stacklets/docs/bot/vault_entry.py index 00f10b35..c88bf7cb 100644 --- a/stacklets/docs/bot/vault_entry.py +++ b/stacklets/docs/bot/vault_entry.py @@ -444,6 +444,7 @@ def render_capture( facts: list | None = None, action_items: list | None = None, kept_media: dict | None = None, + caption: str = "", ) -> str: """Assemble the mirror file for a capture entry (kind=note|bookmark). @@ -478,6 +479,8 @@ def render_capture( bytes live in the site; ``embed`` is the version worth putting on the page and is empty for anything that should be downloaded rather than displayed. + caption: What the sender wrote with an upload, kept as written. + The summary is the model's reading of it; this is the record. Returns: Complete markdown string for the mirror file. @@ -541,6 +544,11 @@ def render_capture( parts.append(f"> {ln}" if ln else ">") parts.append("") + # Last on the page, so a correction can read it back as everything + # after its heading (`capture_pipeline._caption_from`). + if caption.strip(): + parts.extend(["## Caption", "", caption.strip(), ""]) + return "\n".join(parts) diff --git a/tests/unit/stacklets/test_archivist_corrections.py b/tests/unit/stacklets/test_archivist_corrections.py index c19abe2e..56c20a66 100644 --- a/tests/unit/stacklets/test_archivist_corrections.py +++ b/tests/unit/stacklets/test_archivist_corrections.py @@ -186,7 +186,8 @@ async def _reprocess(room_id, doc_id, user_hint, reply_to, *, bot.routed.append(("reprocess", doc_id, user_hint, initial_classification)) async def _capture_reprocess(room_id, vault_path, user_hint, sender_mxid, - reply_to, *, initial_classification=None): + reply_to, *, initial_classification=None, + capture_id=None): bot.routed.append(("capture_reprocess", vault_path, user_hint)) async def _text_capture(room_id, text, sender, reply_to=None, *, @@ -401,6 +402,54 @@ async def test_capture_thread_reaches_the_capture_pipeline(self, bot): ] +class TestACorrectionRereadsTheCaption: + """A photo filed with a caption, before captions reached the entry, + lost the caption's words: the technique, the lessons learned. A + correction re-reads the upload the capture was filed from, so its + caption comes back, and the family has a way to repair an entry + without posting the photo again.""" + + @pytest.mark.asyncio + async def test_the_uploads_caption_reaches_the_reprocess(self, bot): + caption = "Raku-Brand. Gelernt: Glasur dünner auftragen." + client = bot._client + client.add(_message( + "$photo", HOMER, caption, + content={"msgtype": "m.image", "filename": "IMG_1.jpg", + "url": "mxc://server/img"}, + )) + client.add( + _message("$capfiled", BOT_ID, "Saved", + content=_thread_relation("$photo"), + envelope={"source": "docs", "type": "capture.filed", + "data": {"vault_path": "family/hobby/x.md", + "capture_id": "$photo"}}), + thread_root="$photo", + ) + seen: dict = {} + + async def reprocess(**kwargs): + seen.update(kwargs) + return SimpleNamespace() + + async def _none(*_a, **_k): + return None + + bot._services = SimpleNamespace(capture=SimpleNamespace(reprocess=reprocess)) + bot._write_in = _none + bot._reply_for_capture = _none + bot._handle_reply_capture_reprocess = ( + ArchivistBot._handle_reply_capture_reprocess.__get__(bot) + ) + + await bot._on_text(_docs_room(), _message( + "$correction", HOMER, "das ist Keramik", + content=_thread_relation("$photo"), + )) + + assert seen["caption"] == caption + + # ── What must keep working ─────────────────────────────────────────────── diff --git a/tests/unit/stacklets/test_archivist_source.py b/tests/unit/stacklets/test_archivist_source.py index 79d22571..8dcbc06d 100644 --- a/tests/unit/stacklets/test_archivist_source.py +++ b/tests/unit/stacklets/test_archivist_source.py @@ -381,3 +381,33 @@ async def test_unmarked_file_keeps_sender_attribution(tmp_path): await bot._on_file(_room(), _file_event(content, sender="@homer:server")) assert captured["default_person"] is True assert captured["extra_seed_topics"] is None + + +@pytest.mark.asyncio +async def test_a_photos_caption_reaches_the_capture(tmp_path): + """Element X sends a photo and its caption as one message: the file's + name in `filename`, the words in `body`. Outside the documents room + the photo is a capture, and the caption is what the sender said about + it, so it travels with the photo.""" + bot = _bot(tmp_path) + captured = _wire_file(bot) + photo = { + "msgtype": "m.image", "url": "mxc://server/img", + "filename": "IMG_1.jpg", "body": "Bart, first time without training wheels", + "info": {"mimetype": "image/jpeg"}, + } + await bot._on_file(_room(), _file_event(photo, sender="@marge:server")) + assert captured["caption"] == "Bart, first time without training wheels" + + +@pytest.mark.asyncio +async def test_a_photo_without_a_caption_has_none(tmp_path): + # Without a caption, clients put the file's name in `body`. + bot = _bot(tmp_path) + captured = _wire_file(bot) + photo = { + "msgtype": "m.image", "url": "mxc://server/img", "body": "IMG_1.jpg", + "info": {"mimetype": "image/jpeg"}, + } + await bot._on_file(_room(), _file_event(photo, sender="@marge:server")) + assert captured["caption"] == "" diff --git a/tests/unit/stacklets/test_capture_pipeline.py b/tests/unit/stacklets/test_capture_pipeline.py index da37d9bd..436d1880 100644 --- a/tests/unit/stacklets/test_capture_pipeline.py +++ b/tests/unit/stacklets/test_capture_pipeline.py @@ -72,6 +72,8 @@ async def classify_capture(self, *, text, person_names, existing_tags, # is about to add to; extracting blind is what grew one list from # thirteen items to twenty-seven. self.saw_current_list = current_list + self.saw_text = text + self.saw_user_hint = user_hint self.saw_write_in = write_in self.saw_extract_action_items = extract_action_items if self._raises: @@ -1150,6 +1152,66 @@ async def test_an_entry_that_never_had_a_file_does_not_grow_one(self): assert published["kept_media"] is None +class TestAPhotosCaption: + """A photo posted with a caption ("Bart, first time without training + wheels") carries the only words about it the family wrote. The model + reads the caption with the picture, so the summary says what the + sender said; the entry keeps the caption word for word; and a later + correction, which re-reads the entry rather than the picture, still + has it.""" + + CAPTION = "Bart fährt zum ersten Mal ohne Stützräder" + + async def _post(self, caption: str): + mirror, classifier = FakeMirror(), FakeClassifier() + await _pipeline(mirror=mirror, classifier=classifier).capture_binary( + file_data=b"\x89PNG\r\n", mime="image/png", filename="IMG_1.png", + source_uri="mxc://home.test/x", sender_mxid="@marge:server", + caption=caption, + ) + return mirror.captures[0], classifier + + @pytest.mark.asyncio + async def test_the_model_reads_the_caption_with_the_picture(self): + _, classifier = await self._post(self.CAPTION) + assert classifier.saw_user_hint == self.CAPTION + + @pytest.mark.asyncio + async def test_the_entry_keeps_the_caption(self): + published, _ = await self._post(self.CAPTION) + assert published["caption"] == self.CAPTION + + @pytest.mark.asyncio + async def test_a_photo_without_a_caption_is_read_on_its_own(self): + published, classifier = await self._post("") + assert classifier.saw_user_hint is None + assert not published["caption"] + + @pytest.mark.asyncio + async def test_a_correction_keeps_the_caption(self): + path = "marge/bookmarks/test-capture.md" + entry = render_capture( + frontmatter=capture_frontmatter( + title="Fahrrad", captured_at="2026-03-14", kind="bookmark", + source_uri="mxc://home.test/x", persons=["Marge"], + tags=["familie"], model=None, capture_id="$img:home.test", + ), + body="", kind="bookmark", captured_at="2026-03-14", + source_uri="mxc://home.test/x", persons=["Marge"], + from_path=path, shared_bucket="family", + summary="Ein Kind auf einem Fahrrad.", facts=[], + caption=self.CAPTION, + ) + mirror, classifier = FakeMirror(), FakeClassifier() + mirror._stored = {path: entry} + await _pipeline(mirror=mirror, classifier=classifier).reprocess( + vault_path=path, user_hint="das ist Bart", sender_mxid="@marge:s", + ) + + assert mirror.captures[-1]["caption"] == self.CAPTION + assert self.CAPTION in classifier.saw_text + + class TestTheRoomsLanguageReachesTheClassifier: """A room can choose the language its captures are written in (`!config language`). Every capture entry point hands that choice to diff --git a/tests/unit/stacklets/test_vault_entry.py b/tests/unit/stacklets/test_vault_entry.py index d26430f9..f8bddef4 100644 --- a/tests/unit/stacklets/test_vault_entry.py +++ b/tests/unit/stacklets/test_vault_entry.py @@ -730,6 +730,44 @@ def test_an_entry_whose_file_was_not_kept_reads_as_it_always_did(self): vault with no brain, a full disk: same page as before.""" assert self._render(None) == self._render({}) + +class TestTheCaptionStaysOnTheEntry: + """A photo posted with a caption carries the sender's own words. The + summary is the model's reading of both; the caption itself is kept + as it was written, under the picture, as the diary's cards keep it.""" + + def _render(self, **over) -> str: + args = dict( + from_path="marge/bookmarks/2026/03/entry.md", + shared_bucket="family", + frontmatter=capture_frontmatter( + title="Bart's first bike", captured_at="2026-03-14", + kind="bookmark", source_uri="mxc://home.local/abcdef", + persons=[], tags=[], model=None, + ), + body="", kind="bookmark", captured_at="2026-03-14", + source_uri="mxc://home.local/abcdef", persons=[], + summary="Bart rides his first bike.", facts=[], + kept_media={"name": "IMG_1.jpg", "original": "/media/2026/03/evt.jpg", + "embed": "/media/2026/03/evt.webp"}, + ) + args.update(over) + return render_capture(**args) + + def test_the_caption_is_kept_word_for_word_under_the_picture(self): + caption = "Bart fährt zum ersten Mal ohne Stützräder!\nOpa hat geholfen." + content = self._render(caption=caption) + + picture = content.index("![[/media/2026/03/evt.webp]]") + heading = content.index("## Caption") + assert picture < heading + assert content[heading:].split("\n", 2)[2].strip() == caption + + def test_an_upload_without_a_caption_has_no_caption_section(self): + assert "## Caption" not in self._render(caption="") + assert self._render(caption="") == self._render() + + class TestRenderEmailMessageSection: def test_marker_heading_briefing_and_body(self): From c434a9c0e5dd8b4c178ac137272729c38baf2065 Mon Sep 17 00:00:00 2001 From: Test Date: Tue, 13 Oct 2026 22:39:59 +0200 Subject: [PATCH 2/8] fix(archivist): keep a recording's caption on its note An audio file posted with a caption is transcribed into a text message before the archivist sees it, and the transcript replaced the caption, so the note kept only the spoken words. The transcript's provenance now keeps the upload's own `body` beside its `filename`, and the archivist reads the caption off them by the same rule as for a photo or a file: the model reads it with the transcript and the note keeps it under `## Caption`. --- stacklets/core/bot-runner/voice.py | 5 ++++ stacklets/docs/bot/archivist.py | 6 ++++- stacklets/docs/bot/capture_pipeline.py | 5 ++++ .../stacklets/test_archivist_corrections.py | 2 +- .../unit/stacklets/test_archivist_routing.py | 26 +++++++++++++++++-- tests/unit/stacklets/test_capture_pipeline.py | 10 +++++++ tests/unit/stacklets/test_microbot_voice.py | 14 ++++++++++ 7 files changed, 64 insertions(+), 4 deletions(-) diff --git a/stacklets/core/bot-runner/voice.py b/stacklets/core/bot-runner/voice.py index 3515f657..d3f94f8e 100644 --- a/stacklets/core/bot-runner/voice.py +++ b/stacklets/core/bot-runner/voice.py @@ -77,6 +77,11 @@ def transcribed_source(source: dict, transcript: str) -> dict: provenance["url"] = url if filename := (content.get("filename") or content.get("body")): provenance["filename"] = filename + # The upload's own `body` beside its `filename`, as sent: when they + # differ, `body` is the sender's caption, which the transcript is + # about to replace. + if body := content.get("body"): + provenance["body"] = body info = content.get("info") if isinstance(info, dict): if (duration := info.get("duration")) is not None: diff --git a/stacklets/docs/bot/archivist.py b/stacklets/docs/bot/archivist.py index 7384331f..ef9a38a3 100644 --- a/stacklets/docs/bot/archivist.py +++ b/stacklets/docs/bot/archivist.py @@ -1926,6 +1926,9 @@ async def _on_text(self, room, event: RoomMessageText) -> None: await self._handle_text_capture( room.room_id, query, event.sender, reply_to, capture_id=event.event_id, transcribed=transcribed, + caption=_attachment_caption( + event.source.get("content", {}).get(voice.TRANSCRIPT_KEY) or {} + ), ) else: @@ -2355,7 +2358,7 @@ async def _handle_capture( async def _handle_text_capture( self, room_id: str, text: str, sender_mxid: str, reply_to: str | None = None, *, capture_id: str | None = None, - transcribed: bool = False, + transcribed: bool = False, caption: str = "", ) -> None: # 👀 before the work, like every other capture shape: a link gets # it from the pipeline's notifier, an upload from the handler. A @@ -2375,6 +2378,7 @@ async def _handle_text_capture( bucket=binding.bucket if binding else None, transcribed=transcribed, write_in=await self._write_in(room_id), + caption=caption, ) await self._reply_for_capture(room_id, outcome, reply_to) diff --git a/stacklets/docs/bot/capture_pipeline.py b/stacklets/docs/bot/capture_pipeline.py index 8fa0883c..e3036ac7 100644 --- a/stacklets/docs/bot/capture_pipeline.py +++ b/stacklets/docs/bot/capture_pipeline.py @@ -253,6 +253,7 @@ async def capture_text( bucket: str | None = None, transcribed: bool = False, write_in: str | None = None, + caption: str = "", ) -> CaptureOutcome: """File a message body as a note. Nothing is fetched — the text is the source; TextExtractor surfaces any embedded URL as the link. @@ -266,6 +267,9 @@ async def capture_text( sender's one chance to catch a mishearing, and counts the note as the speaker's own words, the only notes that become todos (see `own_words`). + + ``caption`` is what the sender wrote with a recording, handled as + an upload's caption is (`capture_binary`). """ source = await self._text_extractor.extract(text) if source is None: @@ -280,6 +284,7 @@ async def capture_text( capture_id=capture_id, seed_topics=seed_topics, bucket=bucket, transcribed=transcribed, write_in=write_in, extract_action_items=in_own_words(text, spoken=transcribed), + user_hint=caption or None, caption=caption, ) async def capture_email( diff --git a/tests/unit/stacklets/test_archivist_corrections.py b/tests/unit/stacklets/test_archivist_corrections.py index 56c20a66..0ce043f6 100644 --- a/tests/unit/stacklets/test_archivist_corrections.py +++ b/tests/unit/stacklets/test_archivist_corrections.py @@ -191,7 +191,7 @@ async def _capture_reprocess(room_id, vault_path, user_hint, sender_mxid, bot.routed.append(("capture_reprocess", vault_path, user_hint)) async def _text_capture(room_id, text, sender, reply_to=None, *, - capture_id=None, transcribed=False): + capture_id=None, transcribed=False, caption=""): bot.routed.append(("capture_text", text)) async def _send(room_id, text, *a, **kw): diff --git a/tests/unit/stacklets/test_archivist_routing.py b/tests/unit/stacklets/test_archivist_routing.py index fd56b750..866c46a7 100644 --- a/tests/unit/stacklets/test_archivist_routing.py +++ b/tests/unit/stacklets/test_archivist_routing.py @@ -799,7 +799,8 @@ async def _record_capture(room_id, url, sender, reply_to=None, calls.append(("capture_url", url)) async def _record_text_capture(room_id, text, sender, reply_to=None, - *, capture_id=None, transcribed=False): + *, capture_id=None, transcribed=False, + caption=""): calls.append(("capture_text", text)) async def _record_url(room_id, url, reply_to=None, **kw): @@ -1056,7 +1057,7 @@ async def test_the_capture_is_told_the_words_were_transcribed( flags: list[bool] = [] async def _record(room_id, text, sender, reply_to=None, *, - capture_id=None, transcribed=False): + capture_id=None, transcribed=False, caption=""): flags.append(transcribed) bot._handle_text_capture = _record @@ -1065,6 +1066,27 @@ async def _record(room_id, text, sender, reply_to=None, *, ) assert flags == [True] + @pytest.mark.asyncio + async def test_an_audio_files_caption_reaches_the_capture( + self, bot_with_recorder, + ): + """A recording posted with a caption is captured like a photo + posted with one: the caption travels with it.""" + bot, _ = bot_with_recorder + captions: list[str] = [] + + async def _record(room_id, text, sender, reply_to=None, *, + capture_id=None, transcribed=False, caption=""): + captions.append(caption) + + bot._handle_text_capture = _record + event = self._event(self.SHORT, transcribed=True) + event.source["content"][voice.TRANSCRIPT_KEY].update( + filename="kurs.m4a", body="Notizen vom Töpferkurs", + ) + await bot._on_text(self._shared_room(), event) + assert captions == ["Notizen vom Töpferkurs"] + @pytest.mark.asyncio async def test_a_mention_still_asks_rather_than_files( self, bot_with_recorder, diff --git a/tests/unit/stacklets/test_capture_pipeline.py b/tests/unit/stacklets/test_capture_pipeline.py index 436d1880..d5fa7ae2 100644 --- a/tests/unit/stacklets/test_capture_pipeline.py +++ b/tests/unit/stacklets/test_capture_pipeline.py @@ -1187,6 +1187,16 @@ async def test_a_photo_without_a_caption_is_read_on_its_own(self): assert classifier.saw_user_hint is None assert not published["caption"] + @pytest.mark.asyncio + async def test_a_recordings_caption_is_kept_like_a_photos(self): + mirror, classifier = FakeMirror(), FakeClassifier() + await _pipeline(mirror=mirror, classifier=classifier).capture_text( + text="Die Glasur dünn auftragen.", sender_mxid="@marge:server", + transcribed=True, caption=self.CAPTION, + ) + assert classifier.saw_user_hint == self.CAPTION + assert mirror.captures[0]["caption"] == self.CAPTION + @pytest.mark.asyncio async def test_a_correction_keeps_the_caption(self): path = "marge/bookmarks/test-capture.md" diff --git a/tests/unit/stacklets/test_microbot_voice.py b/tests/unit/stacklets/test_microbot_voice.py index b10f8160..086ed7ee 100644 --- a/tests/unit/stacklets/test_microbot_voice.py +++ b/tests/unit/stacklets/test_microbot_voice.py @@ -376,6 +376,20 @@ def test_does_not_mutate_the_original(self): assert VOICE_SOURCE["content"]["msgtype"] == before assert "url" in VOICE_SOURCE["content"] + def test_the_uploads_own_words_survive_the_rewrite(self): + """An audio file can carry a caption: the file's name in + `filename`, the sender's words in `body`. The transcript takes + over `body`, so the provenance keeps both as the upload had them, + and a consumer reads the caption off it as off any upload.""" + captioned = {**VOICE_SOURCE, "content": { + **VOICE_SOURCE["content"], + "filename": "kurs.m4a", "body": "Notizen vom Töpferkurs", + }} + out = voice.transcribed_source(captioned, "Die Glasur dünn auftragen.") + provenance = out["content"][voice.TRANSCRIPT_KEY] + assert provenance["filename"] == "kurs.m4a" + assert provenance["body"] == "Notizen vom Töpferkurs" + class TestIsVoice: def test_audio_with_a_payload(self): From d91159f28b6e04f2714ad096b864f7076bd6c89d Mon Sep 17 00:00:00 2001 From: Test Date: Tue, 13 Oct 2026 23:14:34 +0200 Subject: [PATCH 3/8] fix(archivist): keep a capture where it is when a reply corrects it A reply in a capture's thread refiles it, and the refile wrote the entry to a new path: its file name follows the title, which the model rewords, and its folder followed whoever replied, so a capture from a topic room moved into the replier's own folder. The old file was deleted, and every link to it broke: the wiki page, the topic page and list items. A capture now keeps the path it was first filed under. A refile changes what the entry says, never where it is. --- stacklets/docs/bot/capture_pipeline.py | 7 ++- stacklets/docs/bot/git_mirror.py | 59 +++++++--------------- tests/unit/stacklets/test_git_mirror.py | 67 +++++++++++++++++++++++++ 3 files changed, 89 insertions(+), 44 deletions(-) diff --git a/stacklets/docs/bot/capture_pipeline.py b/stacklets/docs/bot/capture_pipeline.py index e3036ac7..9ea48c9f 100644 --- a/stacklets/docs/bot/capture_pipeline.py +++ b/stacklets/docs/bot/capture_pipeline.py @@ -570,8 +570,7 @@ async def reprocess( Mirrors ``DocumentPipeline.reprocess`` -- read the prior entry, rebuild a SourceContent from its persisted summary, run the classifier again with the reply chain as ``user_hint``, and - rewrite the mirror in place (renaming the slug when the new - title shifts). Captures don't have a separate Paperless + rewrite the entry where it is, whatever its new title. Captures don't have a separate Paperless backing store, so the prior summary IS the source the LLM re-reads; this stays cheap (no vision round-trip, no re-fetch of the original binary) at the cost of corrections having to @@ -668,8 +667,8 @@ async def _publish( ``existing_path`` + ``captured_at`` are the reprocess inputs -- when re-classifying an already-filed capture we keep its - original capture date and hand the prior path to publish_capture - so the rewrite either updates in place or renames the slug. + original capture date and hand the prior path to publish_capture, + which rewrites the entry where it is. ``reclassified`` flips the envelope type to ``capture.reclassified`` so the reply-chain walker treats it as a chain-link, not a boundary. diff --git a/stacklets/docs/bot/git_mirror.py b/stacklets/docs/bot/git_mirror.py index 0fedd382..73ab4b7a 100644 --- a/stacklets/docs/bot/git_mirror.py +++ b/stacklets/docs/bot/git_mirror.py @@ -405,9 +405,9 @@ async def _lookup_capture_path( per URL/body, the slug is not. So the same link re-pasted with different framing, or content re-classified to a different title, would otherwise fork a near-duplicate. Match on the `/s/` folder + the - `-.md` suffix instead: return the exact path when it exists - (in-place update), else a same-hash file under a different slug (so the - caller renames it), else None. + `-.md` suffix instead: return the exact path when it exists, + else a same-hash file under a different slug (which the caller then + updates where it is), else None. """ parts = target_path.split("/") if len(parts) < 2: @@ -428,7 +428,7 @@ async def _lookup_capture_path( if path.startswith(prefix) and path.endswith(suffix): if path == target_path: return path # exact identity + title → update in place - match = path # same identity, different title → rename + match = path # same identity, different title return match # ── Filename, frontmatter, body ────────────────────────────────────── @@ -1036,11 +1036,8 @@ async def publish_capture( ``caption`` is what the sender wrote with an upload; the entry keeps it word for word. - ``existing_path`` is the reprocess hook: when supplied, the - write deletes the old file if the new title-derived path - differs (a rename) and re-uses the same identity otherwise. - The doc-mirror's `publish` already does this for renamed - Paperless docs; captures pick up the same shape. + ``existing_path`` is the reprocess hook: the entry is rewritten + where it is, whatever its new title. Returns the path where the capture landed (relative to the repo root) on success, None when Forgejo is unreachable or @@ -1071,30 +1068,21 @@ async def publish_capture( # Dedup on identity, not filename: if this URL/body already lives under # a different title (a re-paste with new framing, an older differently- - # titled capture), find it by its hash so we update/rename that entry - # instead of forking a duplicate. The reprocess caller already knows the - # path, so only look when it didn't pass one. + # titled capture), find it by its hash so we update that entry instead + # of forking a duplicate. The reprocess caller already knows the path, + # so only look when it didn't pass one. if existing_path is None: existing_path = await self._lookup_capture_path(client, target_path) - # Reprocess path: read the previous file at its old path. When - # the title changes the slug changes too, so we'll delete the - # old entry after writing the new one (same as the doc - # mirror's rename handling). - existing = None - if existing_path and existing_path != target_path: - existing = await asyncio.to_thread( - client.get_file, self.repo_owner, REPO_NAME, existing_path, - ) - existing_at_new = await asyncio.to_thread( - client.get_file, self.repo_owner, REPO_NAME, target_path, - ) - else: - lookup_path = existing_path or target_path - existing = await asyncio.to_thread( - client.get_file, self.repo_owner, REPO_NAME, lookup_path, - ) - existing_at_new = existing if lookup_path == target_path else None + # A capture keeps the path it was first filed under. Topic pages, + # list items and the link in the chat point at it, so a new title + # or a correction from someone else in the room changes what the + # entry says, never where it is. + if existing_path: + target_path = existing_path + existing = await asyncio.to_thread( + client.get_file, self.repo_owner, REPO_NAME, target_path, + ) # Attribute the capture to whoever filed it: the localpart goes in # `filed_by` frontmatter, and the same identity becomes the git commit @@ -1151,18 +1139,9 @@ async def publish_capture( client.put_file, self.repo_owner, REPO_NAME, target_path, content=content, message=message, - sha=existing_at_new["sha"] if existing_at_new else None, + sha=existing["sha"] if existing else None, author_name=author_name, author_email=author_email, ) - # Title rename on reprocess: remove the prior file after - # the new one is in place so the vault never has both. - if existing_path and existing_path != target_path and existing: - await asyncio.to_thread( - client.delete_file, - self.repo_owner, REPO_NAME, existing_path, - sha=existing["sha"], - message=f"rename: {existing_path} → {target_path}", - ) except ForgejoError as e: logger.warning( "[git-mirror] Capture publish failed for {}: {}", diff --git a/tests/unit/stacklets/test_git_mirror.py b/tests/unit/stacklets/test_git_mirror.py index 91b73afc..11f46676 100644 --- a/tests/unit/stacklets/test_git_mirror.py +++ b/tests/unit/stacklets/test_git_mirror.py @@ -11,6 +11,7 @@ import sys import yaml from pathlib import Path +from types import SimpleNamespace import pytest @@ -968,3 +969,69 @@ async def test_scoped_to_entity_and_kind(self, mirror): ]) assert await mirror._lookup_capture_path( client, "homer/bookmarks/2026/06/y-449437.md") is None + + +class _FakeVaultClient(_FakeTodoClient): + """The Forgejo file API a capture write uses, over an in-memory tree.""" + + def __init__(self, files=None): + super().__init__(files) + self.deleted: list[str] = [] + + def list_tree(self, owner, repo): + return [{"path": p, "type": "blob"} for p in self.files] + + def delete_file(self, owner, repo, path, *, sha, message): + self.deleted.append(path) + self.files.pop(path, None) + + +class TestACaptureKeepsItsPath: + """A capture's path is its address: the topic page, a list item and + the link in the chat all point at it. A correction that gives it a new + title, or comes from someone else in the room, changes what the page + says, never where it is.""" + + PATH = "family/painting/bookmarks/2026/10/bart-s-drawing-454811.md" + + async def _refile(self, mirror, monkeypatch, *, entity, title): + client = _FakeVaultClient({self.PATH: {"content": "old", "sha": "s1"}}) + monkeypatch.setattr("git_mirror.ForgejoClient", lambda **_: client) + mirror._setup_done = True + mirror._creds = SimpleNamespace(token="t") + path = await mirror.publish_capture( + entity=entity, kind="bookmark", source_uri="mxc://home.test/x", + title_hint=None, body_text="", + classification={"title": title, "summary": "s"}, + captured_at="2026-10-13", model=None, + existing_path=self.PATH, capture_id="$x", + ) + return path, client + + async def test_a_new_title_keeps_the_file(self, mirror, monkeypatch): + path, client = await self._refile( + mirror, monkeypatch, entity="family/painting", + title="Bart's watercolour, wet on wet") + assert path == self.PATH + assert client.deleted == [] + assert "# Bart's watercolour, wet on wet" in client.files[self.PATH]["content"] + + async def test_a_reply_from_someone_else_keeps_it_in_its_topic( + self, mirror, monkeypatch, + ): + path, client = await self._refile( + mirror, monkeypatch, entity="marge", title="Bart's drawing") + assert path == self.PATH + assert list(client.files) == [self.PATH] + + async def test_a_new_capture_is_named_after_its_title(self, mirror, monkeypatch): + client = _FakeVaultClient() + monkeypatch.setattr("git_mirror.ForgejoClient", lambda **_: client) + mirror._setup_done = True + mirror._creds = SimpleNamespace(token="t") + path = await mirror.publish_capture( + entity="family/painting", kind="bookmark", source_uri="mxc://home.test/y", + title_hint=None, body_text="", classification={"title": "Tent", "summary": "s"}, + captured_at="2026-10-13", model=None, + ) + assert path.startswith("family/painting/bookmarks/2026/10/tent-") From 4db0d911bea5122aeb15453698c0499399832059 Mon Sep 17 00:00:00 2001 From: Test Date: Tue, 13 Oct 2026 23:14:39 +0200 Subject: [PATCH 4/8] feat(messages): post an uploaded file with a caption `stack messages upload --caption ` posts the file with a caption, the way a phone does when the sender types something under a picture: the words in `body`, the file's name in `filename`. The test driver passes it on as `driver as send --caption `. --- stacklets/messages/cli/_matrix.py | 29 ++++++++++++++----- stacklets/messages/cli/upload.py | 25 ++++++++++------ tests/driver/README.md | 1 + tests/driver/chat.py | 5 ++-- tests/driver/commands.py | 4 ++- .../stacklets/test_messages_file_content.py | 27 +++++++++++++++++ 6 files changed, 72 insertions(+), 19 deletions(-) create mode 100644 tests/unit/stacklets/test_messages_file_content.py diff --git a/stacklets/messages/cli/_matrix.py b/stacklets/messages/cli/_matrix.py index e92164ee..c755d518 100644 --- a/stacklets/messages/cli/_matrix.py +++ b/stacklets/messages/cli/_matrix.py @@ -121,6 +121,24 @@ def _save_session(repo_root, username, token, device_id, user_id): # ── Matrix client ──────────────────────────────────────────────────────────── +def file_content(mxc_uri, filename, mime, *, msgtype="m.file", size=0, caption=""): + """The content of a file message, with its caption when there is one. + + A caption goes in `body` and the file's name moves to `filename`, as + the spec has it since v1.10 and as Element X sends it. Without a + caption, `body` is the name, which is what every client sends then. + """ + content = { + "msgtype": msgtype, + "body": caption or filename, + "url": mxc_uri, + "info": {"mimetype": mime, "size": size}, + } + if caption: + content["filename"] = filename + return content + + class MatrixClient: """Minimal Matrix client for the famstack CLI. @@ -550,7 +568,8 @@ def upload_media(self, data, content_type, filename): except (urllib.error.HTTPError, urllib.error.URLError, TimeoutError): return None - def send_file(self, room, mxc_uri, filename, mime, *, msgtype="m.file", size=0): + def send_file(self, room, mxc_uri, filename, mime, *, msgtype="m.file", size=0, + caption=""): """Post a file/image message referencing an already-uploaded mxc URI. `msgtype` is "m.file" for documents or "m.image" for pictures — @@ -561,12 +580,8 @@ def send_file(self, room, mxc_uri, filename, mime, *, msgtype="m.file", size=0): return False, f"Room '{room}' not found" txn = f"{int(time.time() * 1000)}_{random.randint(0, 0xFFFFFFFF):08x}" - body = { - "msgtype": msgtype, - "body": filename, - "url": mxc_uri, - "info": {"mimetype": mime, "size": size}, - } + body = file_content(mxc_uri, filename, mime, msgtype=msgtype, size=size, + caption=caption) status, resp = _put( self._url(f"/_matrix/client/v3/rooms/{room_id}/send/m.room.message/{txn}"), body, diff --git a/stacklets/messages/cli/upload.py b/stacklets/messages/cli/upload.py index 8e5128b5..4cd609ad 100644 --- a/stacklets/messages/cli/upload.py +++ b/stacklets/messages/cli/upload.py @@ -1,5 +1,5 @@ """ -stack messages upload [--as ] — post a file to a room +stack messages upload [--as ] [--caption ] — post a file to a room Uploads a local file (PDF, image, anything) to a Matrix room as a file or image message. This is the file counterpart to `stack messages send`, and @@ -11,6 +11,9 @@ stack messages upload documents payslip.pdf --as homer +`--caption ` posts the file with a caption, as a phone does when +the sender types something under the picture. + The user's password is read from the secrets store (the same place the account was created), so the sender shows up correctly in the timeline and the archivist's "received from " reply. Images (.png/.jpg) are sent @@ -48,23 +51,26 @@ } +_USAGE = "Usage: stack messages upload [--as ] [--caption ]" + + def _parse_argv(argv): - """Pull (room, path, sender) out of argv. sender is None unless --as.""" - sender = None + """Pull (room, path, sender, caption) out of argv, then an error or None.""" + options = {"--as": None, "--caption": ""} rest = [] i = 0 while i < len(argv): - if argv[i] == "--as": + if argv[i] in options: if i + 1 >= len(argv): - return None, None, None, "--as needs a username" - sender = argv[i + 1] + return None, None, None, "", f"{argv[i]} needs a value" + options[argv[i]] = argv[i + 1] i += 2 continue rest.append(argv[i]) i += 1 if len(rest) < 2: - return None, None, None, 'Usage: stack messages upload [--as ]' - return rest[0], rest[1], sender, None + return None, None, None, "", _USAGE + return rest[0], rest[1], options["--as"], options["--caption"], None def run(args, stacklet, config): @@ -72,7 +78,7 @@ def run(args, stacklet, config): return {"error": "Messages is not running — start it with 'stack up messages'"} # sys.argv: ['stack', 'messages', 'upload', , , ...] - room, path_str, sender, err = _parse_argv(sys.argv[3:]) + room, path_str, sender, caption, err = _parse_argv(sys.argv[3:]) if err: return {"error": err} @@ -110,6 +116,7 @@ def run(args, stacklet, config): ok, detail = client.send_file( room, mxc, path.name, mime, msgtype=msgtype, size=len(data), + caption=caption, ) if ok: return {"ok": True, "room": room, "as": username, diff --git a/tests/driver/README.md b/tests/driver/README.md index af0a197c..adf2bb82 100644 --- a/tests/driver/README.md +++ b/tests/driver/README.md @@ -45,6 +45,7 @@ driver as marge say picnic "and the grill?" --thread driver as marge voice picnic "Stacky, add sunscreen to the list" driver as marge voice picnic --file memo.ogg driver as lisa send documents report-card.pdf +driver as marge send camping tent.jpg --caption "the new tent, pitched in 10 minutes" driver as marge react picnic 📌 driver read picnic [--limit 20] # recent messages, with ids diff --git a/tests/driver/chat.py b/tests/driver/chat.py index 0d5dd195..d483b606 100644 --- a/tests/driver/chat.py +++ b/tests/driver/chat.py @@ -27,8 +27,9 @@ def speak(instance: Instance, who: str, room: str, text: str, recording: str | N return send(instance, who, room, audio) -def send(instance: Instance, who: str, room: str, path: Path) -> dict: - return instance.stack("messages", "upload", room, instance.put(path), "--as", who) +def send(instance: Instance, who: str, room: str, path: Path, caption: str = "") -> dict: + extra = ("--caption", caption) if caption else () + return instance.stack("messages", "upload", room, instance.put(path), "--as", who, *extra) def react(instance: Instance, who: str, room: str, event: str, emoji: str) -> dict: diff --git a/tests/driver/commands.py b/tests/driver/commands.py index f88019e6..6aa2cf57 100644 --- a/tests/driver/commands.py +++ b/tests/driver/commands.py @@ -32,7 +32,8 @@ def as_member(args, instance: Instance) -> None: "say": lambda: chat.say(instance, args.who, args.room, args.text, thread=args.thread, reply_to=args.reply_to), "voice": lambda: chat.speak(instance, args.who, args.room, args.text, args.file), - "send": lambda: chat.send(instance, args.who, args.room, Path(args.file)), + "send": lambda: chat.send(instance, args.who, args.room, Path(args.file), + args.caption), "react": lambda: chat.react(instance, args.who, args.room, args.event, args.emoji), }[args.action] answer = action() @@ -180,6 +181,7 @@ def _member_actions(sub) -> list: send = acts.add_parser("send", help="send a file: photo, PDF, audio") send.add_argument("room") send.add_argument("file") + send.add_argument("--caption", default="", help="words posted with the file") react = acts.add_parser("react", help="react to a message") react.add_argument("room") react.add_argument("event") diff --git a/tests/unit/stacklets/test_messages_file_content.py b/tests/unit/stacklets/test_messages_file_content.py new file mode 100644 index 00000000..a7af73f9 --- /dev/null +++ b/tests/unit/stacklets/test_messages_file_content.py @@ -0,0 +1,27 @@ +"""A file posted from the CLI looks like one a phone posts. + +The archivist reads a caption the way the spec defines it (v1.10, media +captions): with a caption, `body` carries the words and `filename` the +name; without one, `body` is the name and there is no `filename`. The +CLI's uploads drive the rig, so they must have the same shape. +""" + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[3] / "stacklets" / "messages" / "cli")) + +from _matrix import file_content # noqa: E402 + + +def test_a_caption_is_the_body_and_the_name_moves_to_filename(): + content = file_content("mxc://s/a", "IMG_1.jpg", "image/jpeg", + msgtype="m.image", caption="the new tent") + assert content["body"] == "the new tent" + assert content["filename"] == "IMG_1.jpg" + + +def test_without_a_caption_the_body_is_the_name(): + content = file_content("mxc://s/a", "IMG_1.jpg", "image/jpeg", msgtype="m.image") + assert content["body"] == "IMG_1.jpg" + assert "filename" not in content From 115bb514718b8f29c2cdca3f9fc1a629dba2eba3 Mon Sep 17 00:00:00 2001 From: Test Date: Tue, 13 Oct 2026 23:20:24 +0200 Subject: [PATCH 5/8] feat(archivist): keep the replies to a capture on its entry A reply in a capture's thread refiles it, and the model works the reply into the summary in its own words. What the family wrote was otherwise kept only in the chat, so a detail the paraphrase left out was gone from the entry. The entry now keeps every reply in its thread under `## Replies`, with its author and in the order they came, as a diary card does. A refile reads the earlier replies back and adds the new one. --- stacklets/docs/bot/archivist.py | 6 +- stacklets/docs/bot/capture_pipeline.py | 40 +++++++++++++- stacklets/docs/bot/git_mirror.py | 10 +++- stacklets/docs/bot/vault_entry.py | 14 ++++- .../stacklets/test_archivist_corrections.py | 3 +- tests/unit/stacklets/test_capture_pipeline.py | 55 +++++++++++++++++++ tests/unit/stacklets/test_vault_entry.py | 15 +++++ 7 files changed, 133 insertions(+), 10 deletions(-) diff --git a/stacklets/docs/bot/archivist.py b/stacklets/docs/bot/archivist.py index ef9a38a3..1fd19e14 100644 --- a/stacklets/docs/bot/archivist.py +++ b/stacklets/docs/bot/archivist.py @@ -1269,6 +1269,7 @@ async def _handle_correction( room_id, capture_path, hint, event.sender, reply_to, initial_classification=initial, capture_id=envelope.get("data", {}).get("capture_id"), + reply_text=_strip_reply_fallback(event.body or ""), ) return True return False @@ -1430,6 +1431,7 @@ async def _handle_reply_capture_reprocess( sender_mxid: str, reply_to: str, *, initial_classification: dict | None = None, capture_id: str | None = None, + reply_text: str = "", ) -> None: """Re-classify a capture with the user's reply as a hint. @@ -1440,7 +1442,8 @@ async def _handle_reply_capture_reprocess( ``capture_id`` is the event the capture was filed from. Its caption is read off that event again, so a correction restores - a caption an earlier filing dropped. + a caption an earlier filing dropped. ``reply_text`` is the words + of this reply, which the entry keeps as written. """ upload = await self._fetch_event(room_id, capture_id) caption = ( @@ -1453,6 +1456,7 @@ async def _handle_reply_capture_reprocess( initial_classification=initial_classification, write_in=await self._write_in(room_id), caption=caption, + reply_text=reply_text, ) # `_reply_for_capture` already knows how to render the # reclassified branch + attach the envelope, so we just defer. diff --git a/stacklets/docs/bot/capture_pipeline.py b/stacklets/docs/bot/capture_pipeline.py index 9ea48c9f..b4f516a1 100644 --- a/stacklets/docs/bot/capture_pipeline.py +++ b/stacklets/docs/bot/capture_pipeline.py @@ -564,6 +564,7 @@ async def reprocess( initial_classification: dict | None = None, write_in: str | None = None, caption: str = "", + reply_text: str = "", ) -> CaptureOutcome: """Re-classify an already-filed capture using a human note. @@ -586,6 +587,10 @@ async def reprocess( from, when the caller could read it there. It wins over the entry's, which an older filing may have left out; without it, the entry's caption is kept. + + ``reply_text`` is the words of the reply that asked for this + refile. The entry keeps them under the replier's name, after the + replies it already had. """ if self._mirror is None: return CaptureOutcome(status="no_mirror") @@ -594,6 +599,7 @@ async def reprocess( return CaptureOutcome(status="capture_missing") meta, summary_text = _parse_capture_markdown(raw) caption = caption or _caption_from(raw) + replies = _with_reply(_replies_from(raw), sender_mxid, reply_text) # The summary callout doesn't carry the frontmatter tag list, # so the classifier would otherwise have to re-derive tags # from prose alone -- they drift on each pass. Append them as @@ -634,7 +640,7 @@ async def reprocess( capture_id=str(capture_id) if capture_id else None, initial_classification=initial_classification, kept_media=_kept_media_from(raw), - caption=caption, + caption=caption, replies=replies, write_in=write_in, ) @@ -657,6 +663,7 @@ async def _publish( write_in: str | None = None, extract_action_items: bool = False, caption: str = "", + replies: "list[tuple[str, str]] | tuple" = (), ) -> CaptureOutcome: """Shared tail: classify, mirror, record tags, return the outcome. @@ -749,6 +756,7 @@ async def _publish( submitter=sender_mxid, kept_media=kept_media, caption=caption, + replies=replies, ) # Feed topic tags (not the derived Person: X) back into the @@ -997,8 +1005,12 @@ def _kept_media_from(raw: str) -> dict | None: } -# The caption section `vault_entry.render_capture` closes an entry with. -_CAPTION_SECTION = re.compile(r"^## Caption\n\n(.*)\Z", re.M | re.S) +# The sections `vault_entry.render_capture` closes an entry with: the +# caption, then the replies, each reply an author line and its words +# quoted below it. +_CAPTION_SECTION = re.compile(r"^## Caption\n\n(.*?)(?=^## Replies\n|\Z)", re.M | re.S) +_REPLIES_SECTION = re.compile(r"^## Replies\n(.*)\Z", re.M | re.S) +_REPLY_AUTHOR = re.compile(r"^\*\*(.+)\*\*$") def _caption_from(raw: str) -> str: @@ -1011,6 +1023,28 @@ def _caption_from(raw: str) -> str: return found.group(1).strip() if found else "" +def _replies_from(raw: str) -> list[tuple[str, str]]: + """The ``(author, words)`` of each reply an entry keeps, in order.""" + found = _REPLIES_SECTION.search(raw) + replies: list[tuple[str, list[str]]] = [] + for line in (found.group(1) if found else "").splitlines(): + if author := _REPLY_AUTHOR.match(line.strip()): + replies.append((author.group(1), [])) + elif line.startswith(">") and replies: + replies[-1][1].append(line[2:] if line.startswith("> ") else line[1:]) + return [(who, "\n".join(words).strip()) for who, words in replies] + + +def _with_reply(replies, sender_mxid: str, words: str) -> list[tuple[str, str]]: + """``replies`` with this one added, unless the entry already has it, + so a correction handled twice is kept once.""" + words = words.strip() + reply = (sender_mxid.split(":")[0].lstrip("@").capitalize(), words) + if not words or reply in replies: + return list(replies) + return [*replies, reply] + + def _parse_yaml_frontmatter(text: str) -> dict: """Same stdlib subset memory/lib's `_parse_frontmatter` uses -- keeps the bot stdlib-only for this path so we don't pull diff --git a/stacklets/docs/bot/git_mirror.py b/stacklets/docs/bot/git_mirror.py index 73ab4b7a..071a7b61 100644 --- a/stacklets/docs/bot/git_mirror.py +++ b/stacklets/docs/bot/git_mirror.py @@ -506,6 +506,7 @@ def _render_capture( action_items: list | None = None, kept_media: dict | None = None, caption: str = "", + replies: "list[tuple[str, str]] | tuple" = (), ) -> str: """Capture mirror markdown. Delegates to ``vault_entry.render_capture``.""" return render_capture( @@ -513,7 +514,7 @@ def _render_capture( captured_at=captured_at, source_uri=source_uri, persons=persons, from_path=from_path, shared_bucket=self.shared_bucket, summary=summary, facts=facts, action_items=action_items, - kept_media=kept_media, caption=caption, + kept_media=kept_media, caption=caption, replies=replies, ) def _commit_message( @@ -1010,6 +1011,7 @@ async def publish_capture( submitter: str | None = None, kept_media: dict | None = None, caption: str = "", + replies: "list[tuple[str, str]] | tuple" = (), ) -> str | None: """Create or update a capture entry in the mirror. @@ -1033,8 +1035,9 @@ async def publish_capture( (a URL, a pasted note) and on a reprocess, which re-renders from the prior entry and never sees the original again. - ``caption`` is what the sender wrote with an upload; the entry - keeps it word for word. + ``caption`` is what the sender wrote with an upload, and + ``replies`` the ``(author, words)`` written in its thread; the + entry keeps both word for word. ``existing_path`` is the reprocess hook: the entry is rewritten where it is, whatever its new title. @@ -1119,6 +1122,7 @@ async def publish_capture( action_items=briefing_actions, kept_media=kept_media, caption=caption, + replies=replies, ) verb = "update" if existing else "capture" diff --git a/stacklets/docs/bot/vault_entry.py b/stacklets/docs/bot/vault_entry.py index c88bf7cb..4da64e51 100644 --- a/stacklets/docs/bot/vault_entry.py +++ b/stacklets/docs/bot/vault_entry.py @@ -445,6 +445,7 @@ def render_capture( action_items: list | None = None, kept_media: dict | None = None, caption: str = "", + replies: "list[tuple[str, str]] | tuple" = (), ) -> str: """Assemble the mirror file for a capture entry (kind=note|bookmark). @@ -481,6 +482,8 @@ def render_capture( be downloaded rather than displayed. caption: What the sender wrote with an upload, kept as written. The summary is the model's reading of it; this is the record. + replies: ``(author, words)`` for each reply in the capture's thread, + in the order they came, kept as written for the same reason. Returns: Complete markdown string for the mirror file. @@ -544,10 +547,17 @@ def render_capture( parts.append(f"> {ln}" if ln else ">") parts.append("") - # Last on the page, so a correction can read it back as everything - # after its heading (`capture_pipeline._caption_from`). + # The family's own words close the page, so a correction can read + # them back by their headings (`capture_pipeline._caption_from`, + # `_replies_from`). Replies look as they do on a diary card. if caption.strip(): parts.extend(["## Caption", "", caption.strip(), ""]) + if replies: + parts.extend(["## Replies", ""]) + for who, words in replies: + parts.append(f"**{who}**") + parts.extend(f"> {ln}" if ln else ">" for ln in words.strip().splitlines()) + parts.append("") return "\n".join(parts) diff --git a/tests/unit/stacklets/test_archivist_corrections.py b/tests/unit/stacklets/test_archivist_corrections.py index 0ce043f6..f6963e6e 100644 --- a/tests/unit/stacklets/test_archivist_corrections.py +++ b/tests/unit/stacklets/test_archivist_corrections.py @@ -187,7 +187,7 @@ async def _reprocess(room_id, doc_id, user_hint, reply_to, *, async def _capture_reprocess(room_id, vault_path, user_hint, sender_mxid, reply_to, *, initial_classification=None, - capture_id=None): + capture_id=None, reply_text=""): bot.routed.append(("capture_reprocess", vault_path, user_hint)) async def _text_capture(room_id, text, sender, reply_to=None, *, @@ -448,6 +448,7 @@ async def _none(*_a, **_k): )) assert seen["caption"] == caption + assert seen["reply_text"] == "das ist Keramik" # ── What must keep working ─────────────────────────────────────────────── diff --git a/tests/unit/stacklets/test_capture_pipeline.py b/tests/unit/stacklets/test_capture_pipeline.py index d5fa7ae2..4be76ba9 100644 --- a/tests/unit/stacklets/test_capture_pipeline.py +++ b/tests/unit/stacklets/test_capture_pipeline.py @@ -1222,6 +1222,61 @@ async def test_a_correction_keeps_the_caption(self): assert self.CAPTION in classifier.saw_text +class TestARepliesWordsStayOnTheEntry: + """A reply in a capture's thread refiles it with the reply as the + sender's note. The model works the reply into the summary in its own + words; the entry also keeps the reply as it was written, with whoever + wrote it, so nothing the family said about the item is lost to a + paraphrase. Each reply adds to the ones before it.""" + + PATH = "family/painting/bookmarks/test-capture.md" + + def _entry(self, **over) -> str: + args = dict( + frontmatter=capture_frontmatter( + title="Raku-Schale", captured_at="2026-10-13", kind="bookmark", + source_uri="mxc://home.test/x", persons=["Marge"], + tags=["keramik"], model=None, capture_id="$img:home.test", + ), + body="", kind="bookmark", captured_at="2026-10-13", + source_uri="mxc://home.test/x", persons=["Marge"], + from_path=self.PATH, shared_bucket="family", + summary="Eine Raku-Schale.", facts=[], + ) + args.update(over) + return render_capture(**args) + + async def _reply(self, entry: str, text: str, sender: str = "@marge:s"): + mirror = FakeMirror() + mirror._stored = {self.PATH: entry} + await _pipeline(mirror=mirror).reprocess( + vault_path=self.PATH, user_hint=text, sender_mxid=sender, + reply_text=text, + ) + return mirror.captures[-1] + + @pytest.mark.asyncio + async def test_a_reply_is_kept_with_its_author(self): + published = await self._reply(self._entry(), "950 Grad war zu heiß, nächstes Mal 900.") + assert published["replies"] == [("Marge", "950 Grad war zu heiß, nächstes Mal 900.")] + + @pytest.mark.asyncio + async def test_a_second_reply_joins_the_first_and_the_caption_stays(self): + entry = self._entry(caption="Raku im Garten", + replies=[("Marge", "Glasur dünner auftragen.")]) + published = await self._reply(entry, "Sofort ins Sägemehl.", sender="@homer:s") + assert published["replies"] == [ + ("Marge", "Glasur dünner auftragen."), ("Homer", "Sofort ins Sägemehl."), + ] + assert published["caption"] == "Raku im Garten" + + @pytest.mark.asyncio + async def test_the_same_reply_twice_is_kept_once(self): + entry = self._entry(replies=[("Marge", "Glasur dünner auftragen.")]) + published = await self._reply(entry, "Glasur dünner auftragen.") + assert published["replies"] == [("Marge", "Glasur dünner auftragen.")] + + class TestTheRoomsLanguageReachesTheClassifier: """A room can choose the language its captures are written in (`!config language`). Every capture entry point hands that choice to diff --git a/tests/unit/stacklets/test_vault_entry.py b/tests/unit/stacklets/test_vault_entry.py index f8bddef4..8074d8c8 100644 --- a/tests/unit/stacklets/test_vault_entry.py +++ b/tests/unit/stacklets/test_vault_entry.py @@ -767,6 +767,21 @@ def test_an_upload_without_a_caption_has_no_caption_section(self): assert "## Caption" not in self._render(caption="") assert self._render(caption="") == self._render() + def test_replies_are_kept_word_for_word_with_their_author(self): + content = self._render( + caption="Wet on wet.", + replies=[("Marge", "The red bled into the sky.\nNext time: hairdryer."), + ("Bart", "Ay caramba")], + ) + + replies = content[content.index("## Replies"):] + assert content.index("## Caption") < content.index("## Replies") + assert "**Marge**\n> The red bled into the sky.\n> Next time: hairdryer." in replies + assert "**Bart**\n> Ay caramba" in replies + + def test_an_entry_nobody_replied_to_has_no_replies_section(self): + assert "## Replies" not in self._render(replies=[]) + class TestRenderEmailMessageSection: From d49847a4f45ccf05586328a725271e680fe4786e Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 14 Oct 2026 08:47:33 +0200 Subject: [PATCH 6/8] fix(archivist): file a posted photo as a note, not a bookmark A photo posted outside the documents room was filed under `bookmarks/`, next to saved links. It is the family's own record, with their caption and replies, like a note they type. It is now filed under `notes/`. A PDF stays a bookmark. Photos filed before this move in the next change. --- stacklets/docs/bot/archivist.py | 8 ++++---- stacklets/docs/bot/capture_pipeline.py | 9 ++++++--- tests/unit/stacklets/test_capture_pipeline.py | 12 ++++++++++-- 3 files changed, 20 insertions(+), 9 deletions(-) diff --git a/stacklets/docs/bot/archivist.py b/stacklets/docs/bot/archivist.py index 1fd19e14..25cddbb7 100644 --- a/stacklets/docs/bot/archivist.py +++ b/stacklets/docs/bot/archivist.py @@ -1667,9 +1667,9 @@ async def _on_file(self, room, event) -> None: # Documents room → full archivist pipeline (Paperless + classify # + entity reconciliation). Every other reacting room is a - # capture room: PDFs and images become visual bookmarks in the - # sender's bucket; Matrix already stores the binary, so we link - # to the mxc URL and don't re-archive the bytes. + # capture room: a PDF becomes a bookmark and a photo a note, in + # the room's topic or the sender's bucket, linked to the mxc URL + # and with a copy of the bytes in the media archive. if self._is_documents_room(ctx): await self._process_document( room.room_id, raw_filename, display_name, file_data, reply_to, @@ -2446,7 +2446,7 @@ async def _handle_binary_capture( captured_ts: int = 0, caption: str = "", ) -> None: - """Capture a PDF or image as a visual bookmark. + """Capture a PDF as a bookmark, or a photo as a note. Vision-driven by default (the classifier sees the rendered page(s) + any extractable text layer). The entry points back at diff --git a/stacklets/docs/bot/capture_pipeline.py b/stacklets/docs/bot/capture_pipeline.py index b4f516a1..ef3c1630 100644 --- a/stacklets/docs/bot/capture_pipeline.py +++ b/stacklets/docs/bot/capture_pipeline.py @@ -346,7 +346,7 @@ async def capture_binary( write_in: str | None = None, caption: str = "", ) -> CaptureOutcome: - """File a PDF or image as a bookmark. + """File a PDF as a bookmark, or a photo as a note. Vision-driven by default: render the page(s) and let the LLM do extraction + summary + tags in one prompt. Long text-layer @@ -463,10 +463,13 @@ def _source_from_binary( ``kind`` ("bookmark" or "note") drives whether the body is kept in the mirror: notes preserve the bytes verbatim, since a Markdown export or text file IS the artifact; bookmarks - keep only the summary, since the binary stays in Matrix. + keep only the summary, since the binary stays in Matrix. A + photo is a note with no body: the picture is the artifact. """ title_hint = filename or None is_image = mime in IMAGE_MIMES + # A photo the family posts is their own record, like a note they + # type, not a pointer to something kept elsewhere. if is_image: return ( SourceContent( @@ -474,7 +477,7 @@ def _source_from_binary( title_hint=title_hint, source_uri=source_uri, ), [ImageAttachment(data=file_data, mime=mime)], - "bookmark", + "note", ) # md / txt uploads land as notes: the bytes themselves are the diff --git a/tests/unit/stacklets/test_capture_pipeline.py b/tests/unit/stacklets/test_capture_pipeline.py index 4be76ba9..0c989a95 100644 --- a/tests/unit/stacklets/test_capture_pipeline.py +++ b/tests/unit/stacklets/test_capture_pipeline.py @@ -341,7 +341,8 @@ def _pipe(self, *, vision_max_pdf_pages=5): return p def test_image_returns_single_image_no_text(self): - # Plain image -> one ImageAttachment, no extracted text, bookmark kind. + # Plain image -> one ImageAttachment, no extracted text, a note: a + # photo the family posts is their own record, not a pointer. pipe = self._pipe() source, images, kind = pipe._source_from_binary( file_data=b"\xff\xd8fake-jpeg-bytes", @@ -355,7 +356,7 @@ def test_image_returns_single_image_no_text(self): assert source.text == "" assert len(images) == 1 assert images[0].mime == "image/jpeg" - assert kind == "bookmark" + assert kind == "note" def test_unsupported_mime_returns_none(self): # Video and archives land here and fall through to None. (Audio @@ -1181,6 +1182,13 @@ async def test_the_entry_keeps_the_caption(self): published, _ = await self._post(self.CAPTION) assert published["caption"] == self.CAPTION + @pytest.mark.asyncio + async def test_a_photo_is_filed_as_a_note(self): + """A photo the family posts is their own record, like a note they + type, not a pointer to something elsewhere like a saved link.""" + published, _ = await self._post(self.CAPTION) + assert published["kind"] == "note" + @pytest.mark.asyncio async def test_a_photo_without_a_caption_is_read_on_its_own(self): published, classifier = await self._post("") From 489b2c00a3a690f4472bbcbe0565e2000f2ef202 Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 14 Oct 2026 08:52:07 +0200 Subject: [PATCH 7/8] fix(archivist): find a capture by its id when a reply corrects it A reply to a filing found the capture at the path the filing message recorded. When the capture had moved since, the correction answered that it was missing. A correction now looks the capture up by its `capture_id`, the event id of the message it was filed from, in the local copy of the family memory. The recorded path is the fallback while that copy has not caught up with a write. --- stacklets/docs/bot/archivist.py | 1 + stacklets/docs/bot/capture_pipeline.py | 27 ++++++++- stacklets/docs/bot/vault_context.py | 26 ++++++++ .../stacklets/test_archivist_corrections.py | 1 + tests/unit/stacklets/test_capture_pipeline.py | 59 ++++++++++++++++++- 5 files changed, 111 insertions(+), 3 deletions(-) diff --git a/stacklets/docs/bot/archivist.py b/stacklets/docs/bot/archivist.py index 25cddbb7..e1f95cbf 100644 --- a/stacklets/docs/bot/archivist.py +++ b/stacklets/docs/bot/archivist.py @@ -1457,6 +1457,7 @@ async def _handle_reply_capture_reprocess( write_in=await self._write_in(room_id), caption=caption, reply_text=reply_text, + capture_id=capture_id, ) # `_reply_for_capture` already knows how to render the # reclassified branch + attach the envelope, so we just defer. diff --git a/stacklets/docs/bot/capture_pipeline.py b/stacklets/docs/bot/capture_pipeline.py index ef3c1630..46192b1c 100644 --- a/stacklets/docs/bot/capture_pipeline.py +++ b/stacklets/docs/bot/capture_pipeline.py @@ -568,6 +568,7 @@ async def reprocess( write_in: str | None = None, caption: str = "", reply_text: str = "", + capture_id: str | None = None, ) -> CaptureOutcome: """Re-classify an already-filed capture using a human note. @@ -594,12 +595,17 @@ async def reprocess( ``reply_text`` is the words of the reply that asked for this refile. The entry keeps them under the replier's name, after the replies it already had. + + ``capture_id`` names the capture for good; ``vault_path`` is where + the filing message saw it, which a move makes stale. The capture + is looked up by its id first (`_find_capture`). """ if self._mirror is None: return CaptureOutcome(status="no_mirror") - raw = await self._mirror.read_capture(vault_path) - if raw is None: + found = await self._find_capture(capture_id, vault_path) + if found is None: return CaptureOutcome(status="capture_missing") + vault_path, raw = found meta, summary_text = _parse_capture_markdown(raw) caption = caption or _caption_from(raw) replies = _with_reply(_replies_from(raw), sender_mxid, reply_text) @@ -809,6 +815,23 @@ async def _publish( scope=entity_slug, ) + async def _find_capture( + self, capture_id: str | None, recorded_path: str, + ) -> tuple[str, str] | None: + """The path and text of a filed capture, by its id where it can be. + + The local copy of the family memory says where the id lives now. + It can lag a write by minutes, so the path the filing message + recorded is tried after it. + """ + by_id = (self._vault.capture_path(capture_id) + if self._vault is not None and capture_id else None) + for path in dict.fromkeys(p for p in (by_id, recorded_path) if p): + raw = await self._mirror.read_capture(path) + if raw is not None: + return path, raw + return None + async def _current_list(self, entity_slug: str) -> str: """The list a topic room's note adds to, for the classifier to see. diff --git a/stacklets/docs/bot/vault_context.py b/stacklets/docs/bot/vault_context.py index 64cd73e2..5dbd9dba 100644 --- a/stacklets/docs/bot/vault_context.py +++ b/stacklets/docs/bot/vault_context.py @@ -25,6 +25,8 @@ people_prompt_section, persons_prompt_section, ) +from stack.frontmatter import FrontmatterError +from stack.frontmatter import parse as parse_frontmatter from stack.lists import CALENDAR, LISTS_DIR, ListError, read_list from stack.people import Person, load_people @@ -112,3 +114,27 @@ def persons_section(self) -> str: return people_prompt_section(people) persons = load_persons_from_vault(path, self.shared_bucket) return persons_prompt_section(persons) + + def capture_path(self, capture_id: str) -> str | None: + """Where the capture filed with `capture_id` lives now, or None. + + The id is the event id of the message it was filed from and never + changes; the path does, when a capture moves. A file whose + frontmatter the parser rejects (a hand edit gone wrong) is skipped + rather than guessed at. `core/tools-server/capture_index.py` + resolves `/go/capture/` over the wiki's copy. + """ + root = self._vault_path() + wanted = (capture_id or "").strip() + if root is None or not wanted or not root.is_dir(): + return None + for path in sorted(root.rglob("*.md")): + if ".git" in path.parts: + continue + try: + fm = parse_frontmatter(path.read_text(encoding="utf-8")) + except (OSError, UnicodeDecodeError, FrontmatterError): + continue + if str(fm.get("capture_id") or "") == wanted: + return path.relative_to(root).as_posix() + return None diff --git a/tests/unit/stacklets/test_archivist_corrections.py b/tests/unit/stacklets/test_archivist_corrections.py index f6963e6e..21d761ab 100644 --- a/tests/unit/stacklets/test_archivist_corrections.py +++ b/tests/unit/stacklets/test_archivist_corrections.py @@ -449,6 +449,7 @@ async def _none(*_a, **_k): assert seen["caption"] == caption assert seen["reply_text"] == "das ist Keramik" + assert seen["capture_id"] == "$photo" # ── What must keep working ─────────────────────────────────────────────── diff --git a/tests/unit/stacklets/test_capture_pipeline.py b/tests/unit/stacklets/test_capture_pipeline.py index 0c989a95..eddfd435 100644 --- a/tests/unit/stacklets/test_capture_pipeline.py +++ b/tests/unit/stacklets/test_capture_pipeline.py @@ -19,6 +19,7 @@ sys.path.insert(0, str(_REPO_ROOT / "stacklets" / "docs" / "bot")) from capture_pipeline import CapturePipeline # noqa: E402 +from vault_context import VaultContext # noqa: E402 from vault_entry import capture_frontmatter, render_capture # noqa: E402 from stack.web import FetchOutcome # noqa: E402 from stack.web.quality import Verdict # noqa: E402 @@ -151,7 +152,7 @@ async def acknowledge(self): def _pipeline(*, mirror=_UNSET, classifier=None, capture_keep_body=False, - llm=None, text_extractor=None, url_extractor=None): + llm=None, text_extractor=None, url_extractor=None, vault=None): return CapturePipeline( url_extractor=url_extractor or FakeExtractor(_source(source_uri="http://src")), text_extractor=text_extractor @@ -165,6 +166,7 @@ def _pipeline(*, mirror=_UNSET, classifier=None, capture_keep_body=False, capture_keep_body=capture_keep_body, capture_tag_prompt_size=50, llm=llm, + vault=vault, ) @@ -1367,3 +1369,58 @@ async def test_a_topic_rooms_note_is_shown_the_topics_list(self): text="Kühlbox mitnehmen", sender_mxid="@marge:s", bucket="family/camping") assert classifier.saw_current_list == "- [ ] Kühlbox\n" + + +class TestACorrectionFindsTheCaptureWhereItIsNow: + """A correction names the capture it is about by the id it was filed + with, the message's own event id, which never changes. Its path does: + a refile used to rename it, and photos filed as bookmarks have moved + to notes. So the capture is looked up by its id first, and the path + the filing message recorded is only the fallback while the local + copy of the family memory has not caught up.""" + + OLD = "family/painting/bookmarks/2026/10/raku-454811.md" + NEW = "family/painting/notes/2026/10/raku-454811.md" + ID = "$img:home.test" + + def _entry(self, path: str) -> str: + return render_capture( + frontmatter=capture_frontmatter( + title="Raku", captured_at="2026-10-13", kind="note", + source_uri="mxc://home.test/x", persons=[], tags=[], + model=None, capture_id=self.ID, + ), + body="", kind="note", captured_at="2026-10-13", + source_uri="mxc://home.test/x", persons=[], from_path=path, + shared_bucket="family", summary="Eine Schale.", facts=[], + ) + + async def _correct(self, tmp_path, monkeypatch, *, local: str | None): + if local: + (tmp_path / local).parent.mkdir(parents=True) + (tmp_path / local).write_text(self._entry(local), encoding="utf-8") + monkeypatch.setenv("MEMORY_VAULT_DIR", str(tmp_path)) + mirror = FakeMirror() + mirror._stored = {self.NEW: self._entry(self.NEW)} + out = await _pipeline( + mirror=mirror, vault=VaultContext(language="de", shared_bucket="family"), + ).reprocess( + vault_path=self.OLD, capture_id=self.ID, + user_hint="das ist Keramik", sender_mxid="@marge:s", + ) + return out, mirror + + @pytest.mark.asyncio + async def test_a_moved_capture_is_found_by_its_id(self, tmp_path, monkeypatch): + out, mirror = await self._correct(tmp_path, monkeypatch, local=self.NEW) + assert out.status == "reclassified" + assert mirror.captures[-1]["existing_path"] == self.NEW + + @pytest.mark.asyncio + async def test_without_the_id_on_disk_the_recorded_path_is_tried( + self, tmp_path, monkeypatch, + ): + """Nothing local carries the id, and the recorded path is gone: + the correction says the capture is missing rather than guessing.""" + out, _ = await self._correct(tmp_path, monkeypatch, local=None) + assert out.status == "capture_missing" From a59416e49980506f3cccc6511d89cb0f5bb11bd1 Mon Sep 17 00:00:00 2001 From: Test Date: Wed, 14 Oct 2026 08:56:45 +0200 Subject: [PATCH 8/8] fix(archivist): move the photos filed as bookmarks to notes Photos posted outside the documents room are filed as notes now. The ones filed before still sat under `bookmarks/`. The archivist moves them once, a minute after it starts, in one commit: each photo is written under `notes/` with `type: note`, and saved links and PDFs stay where they are. An entry filed before 0.4.0-rc.1 does not show its picture, so the archivist downloads its upload to tell a photo from a PDF. Chat links and corrections find a capture by its id and follow the move. The migration is its own module, to be removed once the framework runs stacklet migrations. --- stacklets/docs/bot/archivist.py | 37 ++++- stacklets/docs/bot/git_mirror.py | 6 + .../docs/bot/migration_photos_to_notes.py | 124 +++++++++++++++++ stacklets/docs/bot/vault_context.py | 4 + .../test_migration_photos_to_notes.py | 126 ++++++++++++++++++ 5 files changed, 296 insertions(+), 1 deletion(-) create mode 100644 stacklets/docs/bot/migration_photos_to_notes.py create mode 100644 tests/unit/stacklets/test_migration_photos_to_notes.py diff --git a/stacklets/docs/bot/archivist.py b/stacklets/docs/bot/archivist.py index e1f95cbf..5f33c721 100644 --- a/stacklets/docs/bot/archivist.py +++ b/stacklets/docs/bot/archivist.py @@ -51,7 +51,9 @@ from capture_tags import CaptureTagCache from extractors import TextExtractor, UrlExtractor -from git_mirror import GitMirror +from git_mirror import BOT_EMAIL, BOT_USERNAME, GitMirror +from git_mirror import REPO_NAME as MEMORY_REPO +from migration_photos_to_notes import is_picture_bytes, migrate_photos_to_notes import voice from microbot import CHECK, CROSS, EYES, MicroBot from pdf_analysis import ( @@ -465,10 +467,43 @@ async def start(self) -> None: # first-sync hook runs once per install, not once per start. if self._diary_task is None: self._diary_task = asyncio.create_task(self._diary.loop()) + asyncio.create_task(self._migrate_photos_to_notes()) # The framework's start() owns the session loop and closes the # http session (via _aclose) on shutdown. await super().start() + async def _migrate_photos_to_notes(self) -> None: + """Run `migration_photos_to_notes` once per instance; remove with it. + + Waits a minute like the diary loop: an old entry is placed by + downloading its upload, which needs the first sync done. A marker + in the session directory keeps it from running again; a failure + leaves no marker, so the next start tries again. + """ + marker = Path(self._session_dir) / "migration-photos-to-notes.done" + mirror, root = self._services.mirror, self._services.vault.root() + if marker.exists() or mirror is None or root is None: + return + await asyncio.sleep(60) + + async def is_picture(mxc: str) -> bool: + data = await self._download_media(mxc) + return bool(data) and is_picture_bytes(data) + + try: + client = await mirror.vault_client() + if client is None: + return + await migrate_photos_to_notes( + client, mirror.repo_owner, MEMORY_REPO, root=root, + is_picture=is_picture, + author_name=BOT_USERNAME, author_email=BOT_EMAIL, + ) + except Exception as e: # noqa: BLE001 - a migration never stops the bot + logger.warning("[archivist] moving photos to notes failed: {}", e) + return + marker.touch() + def _build_services(self) -> ArchivistServices: """Wire every service from the bot's config. diff --git a/stacklets/docs/bot/git_mirror.py b/stacklets/docs/bot/git_mirror.py index 071a7b61..dc95e126 100644 --- a/stacklets/docs/bot/git_mirror.py +++ b/stacklets/docs/bot/git_mirror.py @@ -326,6 +326,12 @@ def _load_or_create_creds(self) -> MirrorCreds: self._save_creds(creds) return creds + async def vault_client(self) -> ForgejoClient | None: + """A client for the vault repo, or None while Forgejo is not set up.""" + if not await self.ensure_setup(): + return None + return ForgejoClient(url=self.code_url, token=self._setup_creds.token) + @property def _setup_creds(self) -> MirrorCreds: """The credentials `ensure_setup()` loaded. diff --git a/stacklets/docs/bot/migration_photos_to_notes.py b/stacklets/docs/bot/migration_photos_to_notes.py new file mode 100644 index 00000000..54e51be1 --- /dev/null +++ b/stacklets/docs/bot/migration_photos_to_notes.py @@ -0,0 +1,124 @@ +"""Migration: move the photos filed as bookmarks to notes, once. + +A photo posted outside the documents room used to be filed under +`bookmarks/`, next to saved links. It is the family's own record and is +filed under `notes/` now (`capture_pipeline`). This moves the ones filed +before, in one commit. Nothing else needs to follow: chat links and +corrections find a capture by its `capture_id`, the wiki's pages are +rebuilt from the new paths, and a bookmark never put items on a list. + +Remove this module once the framework runs stacklet migrations, which +can carry it, and in any case before 1.0. + +Which bookmarks are photos: since 0.4.0-rc.1 an entry shows its picture +(`![[/media/...]]`), and a PDF is only named (`**File**`). Entries filed +before that carry only the `mxc://` address of the upload, so the caller +answers for those by looking at the file itself (`is_picture_bytes`). +""" + +from __future__ import annotations + +import asyncio +import re +from pathlib import Path +from typing import Awaitable, Callable + +from loguru import logger + +from stack.forgejo import FileChange, ForgejoClient +from stack.frontmatter import FrontmatterError +from stack.frontmatter import dump as dump_frontmatter +from stack.frontmatter import parse as parse_frontmatter + +# As `vault_entry.render_capture` writes them. +_SHOWN = re.compile(r"^!\[\[/media/[^\]]+\]\]", re.M) +_NAMED = re.compile(r"^> \*\*File\*\* ", re.M) +_KIND = re.compile(r"^(> .*\*\*Kind\*\* )bookmark", re.M) + +PICTURE = "picture" +UNKNOWN = "unknown" + +# The first bytes of the picture formats a phone or a camera sends. +_PICTURE_MAGIC = (b"\xff\xd8\xff", b"\x89PNG", b"GIF8") +_HEIF_BRANDS = (b"heic", b"heix", b"hevc", b"mif1", b"msf1", b"avif") + + +def is_picture_bytes(data: bytes) -> bool: + """Whether `data` is a JPEG, PNG, GIF, WebP or HEIC/AVIF picture.""" + if data.startswith(_PICTURE_MAGIC): + return True + if data[:4] == b"RIFF" and data[8:12] == b"WEBP": + return True + return data[4:8] == b"ftyp" and data[8:12] in _HEIF_BRANDS + + +def verdict(text: str) -> str | None: + """`PICTURE`, `UNKNOWN` when only the file can tell, or None.""" + try: + fm = parse_frontmatter(text) + except FrontmatterError: + return None + if fm.get("type") != "bookmark" or not str(fm.get("resource") or "").startswith("mxc://"): + return None + if _SHOWN.search(text): + return PICTURE + if _NAMED.search(text): + return None + return UNKNOWN + + +def as_note(text: str) -> str: + """The entry with `type: note` and its Kind line saying so.""" + fm = parse_frontmatter(text) + fm["type"] = "note" + body = text[text.index("\n---\n", 4) + len("\n---\n"):] + return "---\n" + dump_frontmatter(fm) + "\n---\n" + _KIND.sub(r"\1note", body, count=1) + + +def note_path(path: str) -> str: + """`/.../bookmarks///` under `notes/` instead.""" + return path.replace("/bookmarks/", "/notes/", 1) + + +async def migrate_photos_to_notes( + client: ForgejoClient, owner: str, repo: str, *, root: Path, + is_picture: Callable[[str], Awaitable[bool]], + author_name: str, author_email: str, +) -> list[tuple[str, str]]: + """Move every photo bookmark to notes; return the `(old, new)` paths. + + `root` is the local copy of the vault, which says what to look at; + each file is read from Forgejo before it is changed, so the commit + is refused rather than overwriting an edit made since. `is_picture` + answers for an `mxc://` address an entry alone cannot place. + """ + async def read(path: str) -> dict | None: + return await asyncio.to_thread(client.get_file, owner, repo, path) + + changes: list[FileChange] = [] + moved: list[tuple[str, str]] = [] + for path in sorted(root.rglob("*.md")): + rel = path.relative_to(root).as_posix() + if "/bookmarks/" not in rel or ".git" in path.parts: + continue + local = path.read_text(encoding="utf-8", errors="replace") + found = verdict(local) + if found is None: + continue + if found == UNKNOWN and not await is_picture(str(parse_frontmatter(local)["resource"])): + continue + current, new = await read(rel), note_path(rel) + if not current or await read(new): + continue + changes.append(FileChange("create", new, as_note(current.get("content") or ""))) + changes.append(FileChange("delete", rel, sha=current.get("sha") or "")) + moved.append((rel, new)) + + if changes: + await asyncio.to_thread( + client.change_files, owner, repo, changes, + message="notes: file the photos filed as bookmarks as notes", + author_name=author_name, author_email=author_email, + ) + logger.info("[migration] moved {} photo(s) to notes", len(moved)) + return moved diff --git a/stacklets/docs/bot/vault_context.py b/stacklets/docs/bot/vault_context.py index 5dbd9dba..4bb9977a 100644 --- a/stacklets/docs/bot/vault_context.py +++ b/stacklets/docs/bot/vault_context.py @@ -38,6 +38,10 @@ def __init__(self, *, language: str, shared_bucket: str): self.language = language self.shared_bucket = shared_bucket + def root(self) -> Path | None: + """The local copy of the vault, or None when there is none.""" + return self._vault_path() + def _vault_path(self) -> Path | None: """Current `MEMORY_VAULT_DIR`, read fresh; None when unset. diff --git a/tests/unit/stacklets/test_migration_photos_to_notes.py b/tests/unit/stacklets/test_migration_photos_to_notes.py new file mode 100644 index 00000000..c173a752 --- /dev/null +++ b/tests/unit/stacklets/test_migration_photos_to_notes.py @@ -0,0 +1,126 @@ +"""Migration: photos filed as bookmarks move to notes, once. + +Photos were filed under `bookmarks/` until they became notes. The move +finds them in the local copy of the family memory, writes each under +`notes/` with its type saying so, and leaves everything that is not a +photo where it is: a saved link, a PDF. The entries are written by the +real renderer, so the move recognises them as the archivist writes them. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +import pytest + +_REPO_ROOT = Path(__file__).resolve().parents[3] +sys.path.insert(0, str(_REPO_ROOT / "lib")) +sys.path.insert(0, str(_REPO_ROOT / "stacklets" / "docs" / "bot")) + +from migration_photos_to_notes import is_picture_bytes, migrate_photos_to_notes # noqa: E402 +from stack.frontmatter import parse as parse_frontmatter # noqa: E402 +from vault_entry import capture_frontmatter, render_capture # noqa: E402 + +DIR = "family/painting/bookmarks/2026/10" + + +def _entry(path: str, *, source: str, kept: dict | None = None) -> str: + return render_capture( + frontmatter=capture_frontmatter( + title="X", captured_at="2026-10-13", kind="bookmark", source_uri=source, + persons=[], tags=[], model=None, capture_id=f"${Path(path).stem}", + ), + body="", kind="bookmark", captured_at="2026-10-13", source_uri=source, + persons=[], from_path=path, shared_bucket="family", summary="s", facts=[], + kept_media=kept, + ) + + +ENTRIES = { + # Filed since 0.4.0-rc.1: the picture is on the page. + f"{DIR}/raku-aaaaaa.md": _entry(f"{DIR}/raku-aaaaaa.md", source="mxc://h/1", kept={ + "name": "IMG_1.jpg", "original": "/media/2026/10/a.jpg", "embed": "/media/2026/10/a.webp"}), + # A PDF: named, never shown. + f"{DIR}/form-bbbbbb.md": _entry(f"{DIR}/form-bbbbbb.md", source="mxc://h/2", kept={ + "name": "form.pdf", "original": "/media/2026/10/b.pdf", "embed": ""}), + # A saved link. + f"{DIR}/article-cccccc.md": _entry(f"{DIR}/article-cccccc.md", source="https://example.org/a"), + # Filed before the media archive: only the upload's address. + f"{DIR}/glaze-dddddd.md": _entry(f"{DIR}/glaze-dddddd.md", source="mxc://h/old-photo"), + f"{DIR}/scan-eeeeee.md": _entry(f"{DIR}/scan-eeeeee.md", source="mxc://h/old-pdf"), +} +UPLOADS = {"mxc://h/old-photo": b"\xff\xd8\xff\xe0 jpeg", "mxc://h/old-pdf": b"%PDF-1.7"} + + +class _Forgejo: + def __init__(self, files: dict[str, str]): + self.files = {p: {"content": c, "sha": f"sha-{p}"} for p, c in files.items()} + self.commits: list[list] = [] + + def get_file(self, owner, repo, path): + return self.files.get(path) + + def change_files(self, owner, repo, changes, *, message, author_name, author_email): + self.commits.append(changes) + for c in changes: + if c.operation == "delete": + self.files.pop(c.path) + else: + self.files[c.path] = {"content": c.content, "sha": "new"} + + +async def _move(tmp_path: Path, client: _Forgejo): + for rel, info in client.files.items(): + (tmp_path / rel).parent.mkdir(parents=True, exist_ok=True) + (tmp_path / rel).write_text(info["content"], encoding="utf-8") + + async def is_picture(mxc: str) -> bool: + return is_picture_bytes(UPLOADS[mxc]) + + return await migrate_photos_to_notes( + client, "family", "memory", root=tmp_path, is_picture=is_picture, + author_name="archivist-bot", author_email="archivist-bot@local", + ) + + +@pytest.mark.asyncio +async def test_photos_move_to_notes_and_nothing_else_does(tmp_path): + client = _Forgejo(ENTRIES) + moved = await _move(tmp_path, client) + + assert sorted(new for _, new in moved) == [ + "family/painting/notes/2026/10/glaze-dddddd.md", + "family/painting/notes/2026/10/raku-aaaaaa.md", + ] + assert sorted(p for p in client.files if "/bookmarks/" in p) == [ + f"{DIR}/article-cccccc.md", f"{DIR}/form-bbbbbb.md", f"{DIR}/scan-eeeeee.md", + ] + assert len(client.commits) == 1 + + +@pytest.mark.asyncio +async def test_a_moved_photo_says_it_is_a_note(tmp_path): + client = _Forgejo(ENTRIES) + await _move(tmp_path, client) + + note = client.files["family/painting/notes/2026/10/raku-aaaaaa.md"]["content"] + assert parse_frontmatter(note)["type"] == "note" + assert parse_frontmatter(note)["capture_id"] == "$raku-aaaaaa" + assert "**Kind** note" in note + assert "![[/media/2026/10/a.webp]]" in note + + +@pytest.mark.asyncio +async def test_with_nothing_left_to_move_nothing_is_written(tmp_path): + client = _Forgejo({k: v for k, v in ENTRIES.items() if "raku" not in k and "glaze" not in k}) + assert await _move(tmp_path, client) == [] + assert client.commits == [] + + +def test_pictures_are_told_from_other_files_by_their_first_bytes(): + assert is_picture_bytes(b"\x89PNG\r\n\x1a\n") + assert is_picture_bytes(b"RIFF\x00\x00\x00\x00WEBPVP8 ") + assert is_picture_bytes(b"\x00\x00\x00\x18ftypheic") + assert not is_picture_bytes(b"%PDF-1.7") + assert not is_picture_bytes(b"")