From 5454e9e2a138295355fcee05db4bf92d1f4bbdc9 Mon Sep 17 00:00:00 2001 From: Arthur Date: Sun, 27 Sep 2026 17:16:05 +0200 Subject: [PATCH 1/6] fix(archivist): let the family retry a filing the AI could not classify When the AI server failed, the filing reply told the family to set up AI with `./stack up ai`, which is wrong on a stack that uses a server elsewhere. It also carried no filing envelope, so a reply in its thread was taken as a search, and the error itself was never logged. The reply now says the AI could not read the document and that a reply in the thread tries again. It carries the filing's envelope, so that reply reclassifies the document. The classifier's error is logged with the document id. --- stacklets/docs/bot/archivist.py | 7 ++- stacklets/docs/bot/messages/archivist.yml | 34 ++++++------- stacklets/docs/bot/pipeline.py | 3 ++ .../unit/stacklets/test_archivist_routing.py | 48 +++++++++++++++++++ 4 files changed, 74 insertions(+), 18 deletions(-) diff --git a/stacklets/docs/bot/archivist.py b/stacklets/docs/bot/archivist.py index 71c550b0..c17e0170 100644 --- a/stacklets/docs/bot/archivist.py +++ b/stacklets/docs/bot/archivist.py @@ -1495,12 +1495,15 @@ async def _reply_for_outcome( return # status == "enriched": the document is filed; pick the reply. + # A failed classification carries the filing's envelope too, so + # a reply in its thread classifies the document again. + filing = {"dev.famstack.event": o.envelope} if o.envelope else None llm_error = _llm_error_for_chat( o.llm_error, name=o.display_name, openai_url=self.openai_url, link=o.link, ) if llm_error: key, kwargs = llm_error - await self._answer(room_id, self.t(key, **kwargs), reply_to) + await self._answer(room_id, self.t(key, **kwargs), reply_to, metadata=filing) elif not o.has_text: await self._answer( room_id, self.t("filed_no_text", name=o.display_name, link=o.link), @@ -1514,7 +1517,7 @@ async def _reply_for_outcome( elif not o.classification: await self._answer( room_id, self.t("classify_failed", name=o.display_name, link=o.link), - reply_to, + reply_to, metadata=filing, ) else: reply_text = render_filing_reply( diff --git a/stacklets/docs/bot/messages/archivist.yml b/stacklets/docs/bot/messages/archivist.yml index b620da0b..c3bafd66 100644 --- a/stacklets/docs/bot/messages/archivist.yml +++ b/stacklets/docs/bot/messages/archivist.yml @@ -25,29 +25,30 @@ en: reprocess_doc_missing: "\u274c Cannot reprocess #{doc_id} \u2014 not found in Paperless." reprocess_llm_error: "\u274c Reclassify #{doc_id} failed ({kind}): {detail}" - # Ollama errors + # The document is filed either way. Each reply carries the filing's + # envelope, so a reply in its thread classifies it again. llm_unavailable: | 📁 Filed: {name} - *AI classification skipped — set up AI with `stack up ai` for automatic tagging.* + ⚠️ The AI could not read it just now, so it has no tags yet. + Reply here to try again, with a few words about it if you like. {link} llm_model_missing: | 📁 Filed: {name} - ⚠️ Model "{model}" is not available. - Check your AI backend (oMLX, Ollama, etc.) - Please tag manually in Paperless. + ⚠️ The AI model "{model}" is not available, so it has no tags yet. + Reply here to try again once the admin has fixed the model. {link} llm_timeout: | 📁 Filed: {name} - ⚠️ AI timed out — the model may still be loading. - Try uploading again in a minute, or tag manually in Paperless. + ⚠️ The AI took too long; the model may still be loading. + Reply here in a minute to try again. {link} - classify_failed: "\U0001F4C1 Filed: {name}\n\n Could not classify automatically — please tag in Paperless.\n\n {link}" + classify_failed: "\U0001F4C1 Filed: {name}\n\n Could not classify it automatically. Reply here to try again, with a few words about it if you like.\n\n {link}" # Classification summary category: "Category: {value}" @@ -248,29 +249,30 @@ de: reprocess_doc_missing: "\u274c Kann #{doc_id} nicht erneut verarbeiten \u2014 nicht in Paperless gefunden." reprocess_llm_error: "\u274c Neuklassifizierung von #{doc_id} fehlgeschlagen ({kind}): {detail}" - # Ollama errors + # Das Dokument ist in jedem Fall abgelegt. Jede Antwort trägt den + # Umschlag der Ablage, eine Antwort im Thread klassifiziert es neu. llm_unavailable: | 📁 Abgelegt: {name} - *KI-Klassifizierung übersprungen — mit `stack up ai` einrichten für automatisches Tagging.* + ⚠️ Die KI konnte es gerade nicht lesen, es hat noch keine Tags. + Antworte hier, um es noch einmal zu versuchen, gern mit ein paar Worten dazu. {link} llm_model_missing: | 📁 Abgelegt: {name} - ⚠️ Modell "{model}" ist nicht verfügbar. - Prüfe dein KI-Backend (oMLX, Ollama, etc.) - Bitte manuell in Paperless taggen. + ⚠️ Das KI-Modell "{model}" ist nicht verfügbar, es hat noch keine Tags. + Antworte hier, um es noch einmal zu versuchen, sobald das Modell wieder da ist. {link} llm_timeout: | 📁 Abgelegt: {name} - ⚠️ KI-Zeitlimit erreicht — Modell lädt möglicherweise noch. - Versuche es in einer Minute erneut, oder tagge manuell in Paperless. + ⚠️ Die KI hat zu lange gebraucht, das Modell lädt vielleicht noch. + Antworte hier in einer Minute, um es noch einmal zu versuchen. {link} - classify_failed: "\U0001F4C1 Abgelegt: {name}\n\n Automatische Klassifizierung fehlgeschlagen — bitte in Paperless taggen.\n\n {link}" + classify_failed: "\U0001F4C1 Abgelegt: {name}\n\n Automatisch einordnen hat nicht geklappt. Antworte hier, um es noch einmal zu versuchen, gern mit ein paar Worten dazu.\n\n {link}" # Classification summary category: "Kategorie: {value}" diff --git a/stacklets/docs/bot/pipeline.py b/stacklets/docs/bot/pipeline.py index b6c69313..8ef4416a 100644 --- a/stacklets/docs/bot/pipeline.py +++ b/stacklets/docs/bot/pipeline.py @@ -1602,10 +1602,13 @@ async def enrich_document( initial_classification=initial_classification, ) except LLMUnavailableError as e: + logger.warning("[pipeline] doc #{} classify: AI unavailable: {}", doc.get("id"), e) return EnrichResult(llm_error=("unavailable", str(e))) except LLMModelNotFoundError as e: + logger.warning("[pipeline] doc #{} classify: model missing: {}", doc.get("id"), e) return EnrichResult(llm_error=("model_missing", str(e))) except LLMTimeoutError as e: + logger.warning("[pipeline] doc #{} classify: timed out: {}", doc.get("id"), e) return EnrichResult(llm_error=("timeout", str(e))) if not classification: diff --git a/tests/unit/stacklets/test_archivist_routing.py b/tests/unit/stacklets/test_archivist_routing.py index 3242d6fe..fd56b750 100644 --- a/tests/unit/stacklets/test_archivist_routing.py +++ b/tests/unit/stacklets/test_archivist_routing.py @@ -652,6 +652,54 @@ async def test_filing_ocr_failed_crosses(self, tmp_path): assert reacts == [self.CROSS] +class TestAFailedClassificationCanBeRetried: + """The document is filed even when the AI server fails. The reply used + to tell the family to run `stack up ai`, which is wrong on a stack + that uses a server elsewhere, and carried no filing envelope, so a + reply in its thread went to search. It says to reply to try again, + and that reply finds the document.""" + + def _answered(self, tmp_path, **outcome): + from matching import build_document_event + bot = _build_bot(tmp_path) + sent = [] + + async def _answer(room_id, text, reply_to, *, metadata=None, **_): + sent.append((text, metadata)) + + async def _noop(*a, **k): + return None + + bot._answer, bot._react = _answer, _noop + o = SimpleNamespace( + status="enriched", display_name="policy.pdf", doc_id=2, link="http://x/2", + has_text=True, classify_enabled=True, classification={}, + envelope=build_document_event(2, {}), **outcome) + return bot, o, sent + + @pytest.mark.parametrize("error", [("unavailable", "HTTP 400"), + ("timeout", "60s"), + ("model_missing", "qwen")]) + async def test_the_reply_says_how_to_retry_and_carries_the_filing(self, tmp_path, error): + bot, o, sent = self._answered(tmp_path, llm_error=error) + + await bot._reply_for_outcome("!r:server", o, "$upload") + + (text, metadata), = sent + assert "Reply here" in text + assert "stack up ai" not in text + assert bot._correction_target_doc_id(metadata["dev.famstack.event"]) == 2 + + async def test_an_empty_classification_can_be_retried_too(self, tmp_path): + bot, o, sent = self._answered(tmp_path, llm_error=None) + + await bot._reply_for_outcome("!r:server", o, "$upload") + + (text, metadata), = sent + assert "Reply here" in text + assert bot._correction_target_doc_id(metadata["dev.famstack.event"]) == 2 + + class TestPastePredicate: """`_looks_like_paste` is the gate between "chat in a capture room" and "this is content to summarize and file." The heuristic is From d5f8b01c2a3b2b962f34f582ea76e077491cb761 Mon Sep 17 00:00:00 2001 From: Arthur Date: Sun, 27 Sep 2026 17:16:51 +0200 Subject: [PATCH 2/6] fix(diary): report an empty memories room instead of failing With nothing recorded in the memories room yet, the diary published its empty page and returned without the `--json` report. The archivist posts cards from that report and took its absence for a failed compile, so every fresh install logged `diary job: compile failed rc=0` until the family recorded something. An empty room now reports no cards. --- stacklets/memory/bot/cli/diary.py | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/stacklets/memory/bot/cli/diary.py b/stacklets/memory/bot/cli/diary.py index 0a8f3c4c..83ef5ed2 100644 --- a/stacklets/memory/bot/cli/diary.py +++ b/stacklets/memory/bot/cli/diary.py @@ -1193,8 +1193,14 @@ async def run(llm, argv: list[str]) -> int: # explains how to record something. _err(f"nothing recorded in {room_arg} yet, publishing an empty diary") await transcriber.aclose() - return _publish_pages(diary.pages_for([], bucket=bucket), - bucket=bucket, dry_run=dry_run) + rc = _publish_pages(diary.pages_for([], bucket=bucket), + bucket=bucket, dry_run=dry_run) + if as_json: + # The archivist posts from this report, and a run with no + # report reads to it as a failed compile. + print(json.dumps({"room_id": room_id, "cards": [], "new": 0, + "kept_as_edited": []})) + return rc _err(f"{len(messages)} message(s) in {room_arg}") # Before anything is read or interpreted: the files themselves. From ad7843914e6dd87cd669dfb2101d13a674d8605f Mon Sep 17 00:00:00 2001 From: Arthur Date: Sun, 27 Sep 2026 17:17:23 +0200 Subject: [PATCH 3/6] fix(ai): install the model chosen with stack ai switch managed `./stack ai switch managed --model ` wrote the model only after `./stack up ai` had installed the engine, and the install downloads the model `[ai] default` names at that moment. After a switch from another server that was the other server's model id, which the install could not download. The model is now written before the engine comes up, and put back with the rest of `[ai]` when that fails. The command also prints its report without a terminal, so an admin over ssh sees what it did. --- stacklets/ai/cli/switch.py | 21 ++++++++++++--------- tests/unit/stacklets/test_ai_switch.py | 10 ++++++++++ 2 files changed, 22 insertions(+), 9 deletions(-) diff --git a/stacklets/ai/cli/switch.py b/stacklets/ai/cli/switch.py index 197830c8..0d26fe06 100644 --- a/stacklets/ai/cli/switch.py +++ b/stacklets/ai/cli/switch.py @@ -138,17 +138,19 @@ def _running() -> set[str]: if state in ("running", "starting", "failing")} -def _run_the_engine(config: dict, ai: dict) -> dict | None: +def _run_the_engine(config: dict, ai: dict, model: str) -> dict | None: """Hand `[ai]` to the engine the stack manages and bring it up. `stack up ai` installs whatever is missing (the stacklet, or oMLX on - a Mac that ran only the voice services) and starts it. When it fails, - `[ai]` goes back to what it was, so the stack keeps a server that - answers. Returns an error, or None when the engine is up. + a Mac that ran only the voice services), downloads the default model + and starts it, so a `--model` is written before it runs. When it + fails, `[ai]` goes back to what it was, so the stack keeps a server + that answers. Returns an error, or None when the engine is up. """ set_cfg = config["set_cfg"] - before = {k: ai.get(k, "") for k in MANAGED_ENGINE} - for key, value in MANAGED_ENGINE.items(): + settings = {**MANAGED_ENGINE, **({"default": model} if model else {})} + before = {k: ai.get(k, "") for k in settings} + for key, value in settings.items(): set_cfg("ai", key, value) if _stack_up_ai(config) == 0: return None @@ -176,7 +178,7 @@ def run(args, stacklet, config): ai = config.get("stack", {}).get("ai", {}) managed = opts.target == MANAGED - if managed and (failed := _run_the_engine(config, ai)): + if managed and (failed := _run_the_engine(config, ai, opts.model)): return failed url = MANAGED_ENGINE["openai_url"] if managed else normalize_url(opts.target) key = _NO_KEY if managed else opts.key @@ -263,8 +265,9 @@ def run(args, stacklet, config): if notes: result["notes"] = notes - if sys.stderr.isatty(): - _report(result, dedicated=bool(whisper)) + # Also without a terminal: over ssh or from a script the admin would + # otherwise see nothing. Under --json this goes to stderr. + _report(result, dedicated=bool(whisper)) return result diff --git a/tests/unit/stacklets/test_ai_switch.py b/tests/unit/stacklets/test_ai_switch.py index 654fe17b..849174a3 100644 --- a/tests/unit/stacklets/test_ai_switch.py +++ b/tests/unit/stacklets/test_ai_switch.py @@ -228,6 +228,16 @@ def test_when_it_cannot_come_up_the_stack_keeps_its_server(self, instance, ai_se assert "still uses " + ai_server.url_for("/v1") in result["error"] assert _ai(instance) == before + def test_a_chosen_model_is_put_back_with_the_rest(self, instance, ai_server): + """The model goes into `[ai]` before the engine comes up, since + the install downloads it, and back out when that fails.""" + _switch(instance, ai_server.url_for("/v1")) + before = dict(_ai(instance)) + + _switch(instance, "managed", "--model", "mlx-community/Qwen3.5-9B-MLX-4bit") + + assert _ai(instance) == before + class TestVoiceMessages: """A speech server, once set, gets the voice messages whatever the AI From 45ac675e7bf36c4e7017bb96bcee96aa580bb466 Mon Sep 17 00:00:00 2001 From: Arthur Date: Sun, 27 Sep 2026 17:19:14 +0200 Subject: [PATCH 4/6] fix(install): write the sign-in hint without an em dash The installer's closing screen is product text and follows the project's writing rule. --- lib/stack/installer.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/stack/installer.py b/lib/stack/installer.py index b0db6ec4..97d3883d 100644 --- a/lib/stack/installer.py +++ b/lib/stack/installer.py @@ -676,7 +676,7 @@ def wizard(): nl() out(f" Username {BOLD}{TEAL}{admin_id}{RESET}") out(f" Password {BOLD}{TEAL}{admin_id}{RESET}") - dim(" (your first name, lowercase — change it after login)") + dim(" (your first name, lowercase; change it after login)") nl() bold("3. Phones and other computers") From 59bbf2c0417ff7a88e8a6d16c31847ecac73f89a Mon Sep 17 00:00:00 2001 From: Arthur Date: Sun, 27 Sep 2026 17:19:15 +0200 Subject: [PATCH 5/6] fix(archivist): separate the evidence details without an em dash An answer's evidence lines joined the title and its details with an em dash. They use the middle dot the details already use. --- stacklets/docs/bot/nl_query.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/stacklets/docs/bot/nl_query.py b/stacklets/docs/bot/nl_query.py index eca5a16b..bacface4 100644 --- a/stacklets/docs/bot/nl_query.py +++ b/stacklets/docs/bot/nl_query.py @@ -160,7 +160,7 @@ def format_evidence_item(ev: dict, n: int) -> str: if doc_id := ev.get("doc_id"): meta_bits.append(f"#{doc_id}") if meta_bits: - head += " — " + " · ".join(meta_bits) + head += " · " + " · ".join(meta_bits) return head From 4fdb9076f095835e5e4126f17849956d7205654a Mon Sep 17 00:00:00 2001 From: Arthur Date: Sun, 27 Sep 2026 17:19:24 +0200 Subject: [PATCH 6/6] test(driver): post a file under its own name Files were copied to the instance's Mac as `driver-