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 docs/admin-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -625,7 +625,7 @@ Key things to know:
- **dns_provider**: with a domain, `"hetzner"` or `"cloudflare"` serves everything over HTTPS, with certificates Caddy obtains through the provider's API. Empty serves plain HTTP. The API token is stored with `./stack infra dns-token`, never in `stack.toml`.
- **host**: the hostname used in port-mode URLs and hints. Empty = auto-detect the LAN IP (right for households: phones need it). Set to `localhost` for a single-machine setup, or to a fixed name like `mac-mini.local` if the LAN IP keeps shifting.
- **data_dir**: where all persistent data lives. Back this up. Outside the git repo.
- **language**: asked during install, proposed from your timezone. `[core] language` is the family's language: the bots answer in it, document categories are seeded in it, voice messages are transcribed in it. Changing it later seeds the other set of tags alongside the first with `./stack restart docs`; the first set stays. `[ai] language` is the language the stack speaks to you, the voice that reads answers aloud. The installer sets it to the family language; set it to `en` for an English voice in a German household. `stack doctor` warns when `[core] language` is missing, which only happens on installs from before the installer wrote it.
- **language**: asked during install, proposed from your timezone. `[core] language` is the family's language: the bots answer in it, document categories are seeded in it, voice messages are transcribed in it, and the archivist writes every filing's title, summary and facts in it, whatever language the document or capture is in (the original stays untouched). A room can choose another language, or keep the source's, with `!config language <code>|source|default`. Changing it later seeds the other set of tags alongside the first with `./stack restart docs`; the first set stays. `[ai] language` is the language the stack speaks to you, the voice that reads answers aloud. The installer sets it to the family language; set it to `en` for an English voice in a German household. `stack doctor` warns when `[core] language` is missing, which only happens on installs from before the installer wrote it.
- **AI model**: installer picks one for your RAM tier. Alternatives are listed as comments in `stack.toml`. Switch by uncommenting a different line and running `./stack setup ai`.

### users.toml
Expand Down
10 changes: 10 additions & 0 deletions docs/user-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,16 @@ What you can drop there:

Everything you capture is filed under your own name in the family archive: Homer's pastes land in Homer's notes and bookmarks, Marge's in hers. The bot replies with what it filed and where.

**In your family's language.** The archivist writes titles, summaries and facts in your family's language, whatever language the link, photo or document is in: a Spanish hotel bill from the holiday gets a German summary in a German family. Names, amounts and technical terms stay as they are. The original stays untouched. A room can choose differently with `!config language`:

| Command | What the archivist writes in |
|---|---|
| `!config language default` | your family's language (also when nothing is set) |
| `!config language en` | always English (also `de`, `es`, `fr`, `it`, `nl`, `pt`) |
| `!config language source` | the language the link or text is written in |

`!config` on its own shows the room's settings.

---

## Topic rooms
Expand Down
30 changes: 30 additions & 0 deletions lib/stack/ai/language.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
"""Language names for prompts.

The stack's prompts are written in English, and a model answers an
English prompt in English unless the prompt names the language to write
in. Prompts that write for the family name it with `language_name`.
"""

# The codes a room may choose with `!config language`, and the English
# name a prompt uses for each.
LANGUAGES = {
"de": "German",
"en": "English",
"es": "Spanish",
"fr": "French",
"it": "Italian",
"nl": "Dutch",
"pt": "Portuguese",
}


def language_code(value: str) -> str:
"""`DE` and `pt-BR` as the two-letter code the table uses."""
return (value or "").strip().lower()[:2]


def language_name(value: str) -> str:
"""The English name of a language code, or the code itself when the
table has no name for it, so a prompt always states a target."""
code = language_code(value)
return LANGUAGES.get(code, code)
32 changes: 32 additions & 0 deletions stacklets/docs/bot/archivist.py
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@
from pipeline import (
Classifier,
DEFAULT_CLASSIFY_MAX_CHARS,
DEFAULT_LANGUAGE,
SOURCE_LANGUAGE,
PaperlessAPI,
PaperlessDuplicateError,
)
Expand All @@ -70,6 +72,7 @@
from stack.ai.client import (
ModelCapabilities,
)
from stack.ai.language import LANGUAGES, language_name

# Make sibling stacklets importable. In the bot-runner container,
# `/stacklets/` is mounted read-only and holds all stacklets; locally
Expand Down Expand Up @@ -137,6 +140,17 @@ def _timed(operation: str):
elapsed = time.monotonic() - t0
logger.info("[archivist] {} completed in {:.1f}s", operation, elapsed)

def _language_option(household: str) -> dict:
"""The `!config language` entry: the household language by default,
any listed language, or the language of the source."""
describe = {
DEFAULT_LANGUAGE: f"the household language ({language_name(household)})",
SOURCE_LANGUAGE: "the language the content is written in",
}
describe.update(LANGUAGES)
return {"default": DEFAULT_LANGUAGE, "describe": describe}


def _llm_error_for_chat(
pipeline_error: tuple[str, str] | None,
*, name: str, openai_url: str, link: str,
Expand Down Expand Up @@ -333,6 +347,12 @@ def __init__(
self.openai_url = os.environ.get("OPENAI_URL", "")
self.openai_key = os.environ.get("OPENAI_KEY", "")
self.language = os.environ.get("LANGUAGE", "en")
# `!config language`: the language a room's filings are written in.
# Built here because its description names the household language.
self.CONFIG_OPTIONS = {
**MicroBot.CONFIG_OPTIONS,
"language": _language_option(self.language),
}
# Per-bot settings from stacklet.toml [bots.archivist.settings]
self.classify_enabled = settings.get("classify", True)
self.reformat_enabled = settings.get("reformat", True)
Expand Down Expand Up @@ -838,6 +858,12 @@ def _scope_bucket(self, room) -> str:
return humans[0].split(":")[0].lstrip("@").lower()
return self.shared_bucket

async def _write_in(self, room_id: str) -> str | None:
"""The language the room chose for its filings (`!config language`),
or None for the household language."""
choice = (await self.get_room_config(room_id)).get("language")
return None if choice in (None, "", DEFAULT_LANGUAGE) else choice

async def _topic_binding(self, room, sender_mxid: str) -> TopicBinding | None:
"""Read existing topic state, or bootstrap if the room name
matches the prefix pattern.
Expand Down Expand Up @@ -1367,6 +1393,7 @@ async def _handle_reply_reprocess(
o = await self._services.pipeline.reprocess(
doc_id=doc_id, user_hint=user_hint, date_filed=date_filed,
initial_classification=initial_classification,
write_in=await self._write_in(room_id),
)
if o.status == "doc_missing":
await self._answer(
Expand Down Expand Up @@ -1410,6 +1437,7 @@ async def _handle_reply_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),
)
# `_reply_for_capture` already knows how to render the
# reclassified branch + attach the envelope, so we just defer.
Expand Down Expand Up @@ -1440,6 +1468,7 @@ async def _process_document(
filename=filename, display_name=display_name, file_data=file_data,
date_filed=date_filed, submitter_mxid=submitter_mxid,
user_hint=user_hint,
write_in=await self._write_in(room_id),
)
if outcome.doc_id:
for tag in extra_tags or []:
Expand Down Expand Up @@ -2302,6 +2331,7 @@ async def _handle_capture(
seed_topics=binding.seed_topics if binding else None,
bucket=binding.bucket if binding else None,
user_hint=user_hint,
write_in=await self._write_in(room_id),
)
await self._reply_for_capture(room_id, outcome, reply_to)

Expand All @@ -2327,6 +2357,7 @@ async def _handle_text_capture(
seed_topics=binding.seed_topics if binding else None,
bucket=binding.bucket if binding else None,
transcribed=transcribed,
write_in=await self._write_in(room_id),
)
await self._reply_for_capture(room_id, outcome, reply_to)

Expand Down Expand Up @@ -2433,6 +2464,7 @@ async def _handle_binary_capture(
event_id=capture_id, room_id=room_id,
sender_mxid=sender_mxid, ts_ms=captured_ts,
),
write_in=await self._write_in(room_id),
)
await self._reply_for_capture(room_id, outcome, reply_to)

Expand Down
19 changes: 16 additions & 3 deletions stacklets/docs/bot/capture_pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,7 @@ async def capture_url(
seed_topics: list[str] | None = None,
bucket: str | None = None,
user_hint: str | None = None,
write_in: str | None = None,
) -> CaptureOutcome:
"""Fetch a URL and file it as a bookmark.

Expand All @@ -211,6 +212,10 @@ async def capture_url(
the generated title and summary reflect the framing the user
actually wrote, not just whatever the article extractor pulled
out. Empty/None leaves the prompt unchanged.

``write_in`` is the language the room chose for its captures
(`!config language`); None is the household language. The same
holds for every capture entry point below.
"""
await notifier.acknowledge()
outcome = await self._url_extractor.fetch(url)
Expand All @@ -231,7 +236,7 @@ async def capture_url(
source=source, kind="bookmark", sender_mxid=sender_mxid,
display_link=url, actor=sender_mxid,
capture_id=capture_id, seed_topics=seed_topics,
bucket=bucket, user_hint=user_hint,
bucket=bucket, user_hint=user_hint, write_in=write_in,
)
return replace(result, blocked_reason=blocked_reason) if blocked_reason else result

Expand All @@ -241,6 +246,7 @@ async def capture_text(
seed_topics: list[str] | None = None,
bucket: str | None = None,
transcribed: bool = False,
write_in: str | None = None,
) -> CaptureOutcome:
"""File a message body as a note. Nothing is fetched — the text is
the source; TextExtractor surfaces any embedded URL as the link.
Expand All @@ -265,7 +271,7 @@ async def capture_text(
display_link=source.source_uri or origin,
actor=sender_mxid,
capture_id=capture_id, seed_topics=seed_topics,
bucket=bucket, transcribed=transcribed,
bucket=bucket, transcribed=transcribed, write_in=write_in,
)

async def capture_email(
Expand Down Expand Up @@ -324,6 +330,7 @@ async def capture_binary(
bucket: str | None = None,
default_person: bool = True,
kept_media: dict | None = None,
write_in: str | None = None,
) -> CaptureOutcome:
"""File a PDF or image as a bookmark.

Expand Down Expand Up @@ -358,7 +365,7 @@ async def capture_binary(
images=images, actor=sender_mxid,
capture_id=capture_id, seed_topics=seed_topics,
bucket=bucket, default_person=default_person,
kept_media=kept_media,
kept_media=kept_media, write_in=write_in,
)

def _cap_pdf_body(self, source: SourceContent) -> SourceContent:
Expand Down Expand Up @@ -536,6 +543,7 @@ def _source_from_binary(
async def reprocess(
self, *, vault_path: str, user_hint: str, sender_mxid: str,
initial_classification: dict | None = None,
write_in: str | None = None,
) -> CaptureOutcome:
"""Re-classify an already-filed capture using a human note.

Expand Down Expand Up @@ -599,6 +607,7 @@ async def reprocess(
capture_id=str(capture_id) if capture_id else None,
initial_classification=initial_classification,
kept_media=_kept_media_from(raw),
write_in=write_in,
)

async def _publish(
Expand All @@ -617,6 +626,7 @@ async def _publish(
default_person: bool = True,
transcribed: bool = False,
kept_media: dict | None = None,
write_in: str | None = None,
) -> CaptureOutcome:
"""Shared tail: classify, mirror, record tags, return the outcome.

Expand Down Expand Up @@ -653,6 +663,7 @@ async def _publish(
initial_classification=initial_classification,
default_person=default_person,
current_list=current_list,
write_in=write_in,
# Todo extraction is opt-in per source kind: human-typed notes
# only. Bookmarks (saved URLs/snippets) stay out — that's the
# guard against a pasted thread manufacturing a household todo.
Expand Down Expand Up @@ -769,6 +780,7 @@ async def _classify(
default_person: bool = True,
extract_action_items: bool = False,
current_list: str = "",
write_in: str | None = None,
) -> dict:
"""Capture-specific classify. Degrades to a minimal classification
(sender as the only person, the extractor's title hint) on LLM
Expand Down Expand Up @@ -802,6 +814,7 @@ async def _classify(
initial_classification=initial_classification,
extract_action_items=extract_action_items,
current_list=current_list,
write_in=write_in,
)
except (LLMUnavailableError, LLMModelNotFoundError, LLMTimeoutError) as e:
logger.warning("[archivist] capture classify failed: {}", e)
Expand Down
11 changes: 9 additions & 2 deletions stacklets/docs/bot/document_pipeline.py
Original file line number Diff line number Diff line change
Expand Up @@ -169,9 +169,13 @@ async def process(
date_filed: str | None = None,
submitter_mxid: str | None = None,
user_hint: str | None = None,
write_in: str | None = None,
) -> FilingOutcome:
"""Run the full pipeline and return a FilingOutcome.

``write_in`` is the language the room chose for its filings
(`!config language`); None is the household language.

Each step can fail independently; classification and reformat are
*enrichment*, not gates — a filed doc is always mirrored before we
return, so a flaky LLM reduces the richness of the entry rather
Expand Down Expand Up @@ -243,7 +247,7 @@ async def process(
if self.classify_enabled and has_text:
result = await self._enrich(
doc, ext, is_image, is_pdf_with_text, is_pdf_ocr_layer,
file_data, date_filed, submitter_mxid, user_hint,
file_data, date_filed, submitter_mxid, user_hint, write_in,
)
else:
result = EnrichResult()
Expand Down Expand Up @@ -336,7 +340,7 @@ async def _unfinished_twin(self, dup: PaperlessDuplicateError) -> int | None:

async def _enrich(
self, doc, ext, is_image, is_pdf_with_text, is_pdf_ocr_layer,
file_data, date_filed, submitter_mxid, user_hint=None,
file_data, date_filed, submitter_mxid, user_hint=None, write_in=None,
) -> EnrichResult:
"""Classify the doc, attaching images when vision helps.

Expand Down Expand Up @@ -379,6 +383,7 @@ async def _enrich(
date_filed=date_filed,
submitter_mxid=submitter_mxid,
user_hint=user_hint,
write_in=write_in,
)

def _mirror_body(self, is_text, file_data, formatted, ocr_text, *, reformatted):
Expand Down Expand Up @@ -431,6 +436,7 @@ def _filed_envelope(self, doc_id: int, classification: dict, **resolved) -> dict
async def reprocess(
self, *, doc_id: int, user_hint: str, date_filed: str | None = None,
initial_classification: dict | None = None,
write_in: str | None = None,
) -> ReprocessOutcome:
"""Re-enrich an already-filed doc with the user's reply as a hint.

Expand Down Expand Up @@ -465,6 +471,7 @@ async def reprocess(
date_filed=date_filed,
user_hint=user_hint,
initial_classification=initial_classification,
write_in=write_in,
)
if result.llm_error:
return ReprocessOutcome(
Expand Down
Loading
Loading