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") 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/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/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 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/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. diff --git a/tests/driver/instance.py b/tests/driver/instance.py index 8de2c8be..b1b8d4b6 100644 --- a/tests/driver/instance.py +++ b/tests/driver/instance.py @@ -58,10 +58,16 @@ def stack(self, *args: str) -> dict: return answer def put(self, local: Path) -> str: - """Copy a file to the instance's Mac; the path it has there.""" + """Copy a file to the instance's Mac; the path it has there. + + The file keeps its name, in a directory of its own: the upload is + posted under that name, as a phone posts a file under its own. + """ if not self.host: return str(local.resolve()) - remote = f"/tmp/driver-{time.time_ns()}-{local.name}" + remote = f"/tmp/driver-{time.time_ns()}/{local.name}" + subprocess.run(["ssh", self.host, f"mkdir -p {shlex.quote(str(Path(remote).parent))}"], + check=True) subprocess.run(["scp", "-q", str(local), f"{self.host}:{remote}"], check=True) return remote 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 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