Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion lib/stack/installer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
21 changes: 12 additions & 9 deletions stacklets/ai/cli/switch.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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


Expand Down
7 changes: 5 additions & 2 deletions stacklets/docs/bot/archivist.py
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -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(
Expand Down
34 changes: 18 additions & 16 deletions stacklets/docs/bot/messages/archivist.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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}"
Expand Down Expand Up @@ -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}"
Expand Down
2 changes: 1 addition & 1 deletion stacklets/docs/bot/nl_query.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down
3 changes: 3 additions & 0 deletions stacklets/docs/bot/pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
10 changes: 8 additions & 2 deletions stacklets/memory/bot/cli/diary.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
10 changes: 8 additions & 2 deletions tests/driver/instance.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
10 changes: 10 additions & 0 deletions tests/unit/stacklets/test_ai_switch.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
48 changes: 48 additions & 0 deletions tests/unit/stacklets/test_archivist_routing.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading