diff --git a/docs/design/agent/plan.md b/docs/design/agent/plan.md index 76437931..7f012a0a 100644 --- a/docs/design/agent/plan.md +++ b/docs/design/agent/plan.md @@ -33,7 +33,7 @@ The famstack-product ships with self-contained defaults. Individual instances cu - **Grocery backend.** Default backend stores the list in `family/memory/lists/groceries.md`; the "phone access" path is "ask the bot in Matrix." Optional Trello backend (Homer's personal stack) bridges to an existing Trello board via REST API; family workflow unchanged, agent gains read/write through the same `GroceryStore` interface. - **Pattern.** Same shape as `taxonomy.toml` (default seed, instance overrides), `ontology.toml` (seed, evolved per instance), and the brain invariant "memory is instance data, not product policy." The agent defines the protocol; instances pick the backend. -The v1 build ships the vault backend. The Trello backend ships in the same repo but disabled by default. Other instances (deskstack, future families) get to pick. +The v1 build ships the vault backend. The Trello backend ships in the same repo but disabled by default. Other instances (future families, non-family deployments) get to pick. ### Long-term (not in v1, captured for direction) diff --git a/docs/design/brain/family-ontology.md b/docs/design/brain/family-ontology.md index 5aa1f95a..699e4eba 100644 --- a/docs/design/brain/family-ontology.md +++ b/docs/design/brain/family-ontology.md @@ -222,16 +222,12 @@ Write-up: whitepaper round 6 + likely the strongest standalone post Decision 2026-06-12: no authored per-topic ontologies (insurance terms, medical specialties, ...) for famstack. Maintenance treadmill, prompt token bloat on the small models we optimize for, serves completeness the -corpus doesn't need. Two variants stay alive: +corpus doesn't need. One variant stays alive: - **Emergent topic depth** — the living loop accumulates confirmed correspondents, aliases, and doctype frequencies UNDER topics from the household's actual mail. A query over the entity pages + usage, zero authoring. Revisit after the extraction-proposal phase. -- **Authored vertical packs for deskstack** — law firm / tax advisor - domain ontologies (court names, Mandant/Akte structures, deadline - doctypes) are authored once, shared by every firm, and a paid - differentiator. Product idea, parked under deskstack. Round 6 error analysis can reopen this: if 9B failures cluster in one domain, that's the data-driven case for depth in that topic. diff --git a/docs/design/brain/knowledge-architecture.md b/docs/design/brain/knowledge-architecture.md index 944765c2..66e100f8 100644 --- a/docs/design/brain/knowledge-architecture.md +++ b/docs/design/brain/knowledge-architecture.md @@ -410,7 +410,7 @@ class FamstackEvent: summary: str # one-line human-readable summary data: dict # structured payload (varies by type) timestamp: datetime # UTC - actor: str | None # who/what caused it: "@homer:merles.eu", "archivist-bot" + actor: str | None # who/what caused it: "@homer:home.local", "archivist-bot" class EventSink(ABC): """Where events go. First implementation: Matrix.""" diff --git a/docs/design/brain/knowledge-structure.md b/docs/design/brain/knowledge-structure.md index 595e35c6..f6b1cea6 100644 --- a/docs/design/brain/knowledge-structure.md +++ b/docs/design/brain/knowledge-structure.md @@ -198,7 +198,7 @@ facts: - "Coverage period: 2026-01-01 to 2026-12-31" action_items: - { action: "Compare prices before renewal", due: "2026-11-30" } -source_link: https://paperless.merles.eu/documents/247/details +source_link: https://paperless.home.local/documents/247/details processing: ai_formatted model: qwen3-32b --- @@ -251,7 +251,7 @@ topics = ["medical"] **Machine-appended example** (`facts.jsonl`): ```json -{"id":"f_2026-04-20T14:22Z_a7b3","kind":"fact","text":"Car insurance premium is EUR 340/year","persons":["Homer"],"topics":["insurance","vehicle"],"source":{"kind":"paperless","doc_id":247},"actor":"@homer:merles.eu","extracted_at":"2026-04-20T14:22:00Z","expires_at":"2026-07-19T00:00Z","confidence":0.85,"superseded_by":null} +{"id":"f_2026-04-20T14:22Z_a7b3","kind":"fact","text":"Car insurance premium is EUR 340/year","persons":["Homer"],"topics":["insurance","vehicle"],"source":{"kind":"paperless","doc_id":247},"actor":"@homer:home.local","extracted_at":"2026-04-20T14:22:00Z","expires_at":"2026-07-19T00:00Z","confidence":0.85,"superseded_by":null} ``` **Invariants:** diff --git a/docs/design/brain/ontology-design.md b/docs/design/brain/ontology-design.md index 34cb8e2b..ed8ca992 100644 --- a/docs/design/brain/ontology-design.md +++ b/docs/design/brain/ontology-design.md @@ -84,19 +84,19 @@ persons: homer: aliases: [Homer, Papa] services: - matrix: "@homer:merles.eu" + matrix: "@homer:home.local" paperless: "Person: Homer" immich: face-abc123 forgejo: homer - calendar: homer@merles.eu + calendar: homer@home.local marge: aliases: [Marge, Mama] services: - matrix: "@marge:merles.eu" + matrix: "@marge:home.local" paperless: "Person: Marge" immich: face-def456 - calendar: marge@merles.eu + calendar: marge@home.local organizations: duff-insurance: @@ -275,10 +275,10 @@ The person entity in the ontology is the join key: ```markdown ## Homer -- matrix: @homer:merles.eu +- matrix: @homer:home.local - paperless: "Person: Homer" - immich: face-id-abc123 -- calendar: homer@merles.eu +- calendar: homer@home.local ``` When Kit Bot serves Homer, it can query "everything associated with Homer" across all services by resolving through the ontology. When Marge asks Kit something, the person filter scopes results to what's relevant to her. diff --git a/docs/design/brain/ontology-v1.md b/docs/design/brain/ontology-v1.md index c16f53b3..d0748e36 100644 --- a/docs/design/brain/ontology-v1.md +++ b/docs/design/brain/ontology-v1.md @@ -224,14 +224,14 @@ persons: aliases: [Papa] services: paperless_tag: "Person: Homer" - matrix: "@homer:merles.eu" + matrix: "@homer:home.local" marge: label: Marge aliases: [Mama] services: paperless_tag: "Person: Marge" - matrix: "@marge:merles.eu" + matrix: "@marge:home.local" # ── Organizations ────────────────────────────────────────────── # Open set. Bootstrap with a small list of universally-useful orgs @@ -586,7 +586,7 @@ Same handler, parsed by the LLM, written directly to ontology.yaml as a commit: ``` update: organisation Duff Insurance categories +finance - Confirmed-By: @homer:merles.eu + Confirmed-By: @homer:home.local ``` A correction always sets `confirmed: true` on the affected entries — that locks them against future auto-overrides from low-confidence learning. @@ -624,7 +624,7 @@ Net: the system errs on the side of writing, but it doesn't err on the side of * - `ontology-seed-self-employed.yaml` — adds `Mandant`, `Rechnungsnummer`, `Honorar`, `USt-Voranmeldung` - `ontology-seed-landlord.yaml` — adds `Mieter`, `Nebenkostenabrechnung`, `Mietvertrag` -- `ontology-seed-deskstack.yaml` — office-oriented vocabulary for the deskstack product (clients, matters, billable hours) +- `ontology-seed-office.yaml` — office-oriented vocabulary (clients, matters, billable hours) Starter packs are how we monetize tier specialization without bloating the default install. Each pack ships a curated baseline; auto-extension takes over from there. diff --git a/docs/design/brain/plan.md b/docs/design/brain/plan.md index 7fe0fd86..0df42e34 100644 --- a/docs/design/brain/plan.md +++ b/docs/design/brain/plan.md @@ -28,7 +28,7 @@ Non-goals for 0.3.0: - Active decay / supersede / promotion logic (wait for dream cycle in 0.4.0+). - Matrix conversation extraction beyond what Archivist already emits (Deriver bot — 0.4.0+). - Vector / semantic retrieval. Keyword + ontology expansion is enough at family scale. -- Cross-product ontology sharing (famstack vs deskstack via published artifact). Same code path, different seeds. +- Cross-product ontology sharing (famstack vs another deployment via published artifact). Same code path, different seeds. ## Invariants diff --git a/docs/design/brain/topic-rooms.md b/docs/design/brain/topic-rooms.md index 0cfc9fd0..4cfec838 100644 --- a/docs/design/brain/topic-rooms.md +++ b/docs/design/brain/topic-rooms.md @@ -25,7 +25,7 @@ A Matrix room whose name starts with `Thema:` or `Topic:` is a topic room. The a Topic rooms compose with the existing capture pipeline. The classifier, mirror, search, deriver, ontology-canonicalizer — none of them special-case topic folders. They see a bucket like any other. -**Topics always nest inside the bucket that owns them.** Shared topics live under the household's configured shared bucket (`family//`, or `office//` for deskstack); personal topics live under the originating person's bucket (`homer//`). The top level of the vault stays pure access-scope: one folder per privacy boundary, never a topic folder. This makes a default sender-scoped search (`["family/", "/"]`) automatically include shared-topic content — a family member asking "what did we note about camping?" in #documents finds it without knowing the topic room exists. +**Topics always nest inside the bucket that owns them.** Shared topics live under the household's configured shared bucket (`family//`, or `office//` in a non-family deployment); personal topics live under the originating person's bucket (`homer//`). The top level of the vault stays pure access-scope: one folder per privacy boundary, never a topic folder. This makes a default sender-scoped search (`["family/", "/"]`) automatically include shared-topic content — a family member asking "what did we note about camping?" in #documents finds it without knowing the topic room exists. ## Naming convention @@ -37,7 +37,7 @@ Topic rooms compose with the existing capture pipeline. The classifier, mirror, | `Topic: 3D printing` | `3d-printing` | `family/3d-printing/` | `homer/3d-printing/` | `3d-printing` | | `Thema: Café Hopping` | `cafe-hopping` | `family/cafe-hopping/` | `homer/cafe-hopping/` | `cafe-hopping` | -(Shared-bucket paths show `family/` because that is the default `[core] shared_bucket`. A deskstack household with `shared_bucket = "office"` gets `office//`.) +(Shared-bucket paths show `family/` because that is the default `[core] shared_bucket`. A deployment with `shared_bucket = "office"` gets `office//`.) ### Parser rules diff --git a/docs/stack-reference.md b/docs/stack-reference.md index 6e1a00ca..0d4e1f04 100644 --- a/docs/stack-reference.md +++ b/docs/stack-reference.md @@ -991,8 +991,8 @@ shared_bucket = "family" # slug for the shared/institutional # bucket inside the memory vault. # Personal entities (homer, marge, …) # live at //. Default - # "family" fits famstack; deskstack or - # non-family deployments override. + # "family" fits famstack; non-family + # deployments override. [updates] schedule = "0 0 3 * * *" # Watchtower cron (3am nightly) diff --git a/lib/stack/bot_runner.py b/lib/stack/bot_runner.py index 7b9f74cc..03c0f0c9 100644 --- a/lib/stack/bot_runner.py +++ b/lib/stack/bot_runner.py @@ -1,7 +1,7 @@ """The bot-runner container as the stack's tools runtime. The host-side `./stack` is stdlib-only by design: it starts fast and -needs no pip install before a family can use it. But some commands are +needs no pip install before it can be run. But some commands are thin wrappers over pipelines that want aiohttp, loguru, yaml and a rendered service env. Rather than break the stdlib invariant on the host or clone those pipelines in urllib, those commands `docker exec` into diff --git a/lib/stack/installer_v2.py b/lib/stack/installer_v2.py index 3dbb1261..f058a0fb 100644 --- a/lib/stack/installer_v2.py +++ b/lib/stack/installer_v2.py @@ -93,7 +93,7 @@ def detect_timezone(): def detect_language(timezone: str) -> str: - """Guess the household language from timezone. Defaults to English.""" + """Guess the instance language from timezone. Defaults to English.""" return _TZ_LANGUAGE.get(timezone, "en") @@ -268,7 +268,7 @@ def write_stack_toml(family_name, server_name, timezone, language="en"): # Slug for the bucket inside the memory vault that holds institutional # artifacts (documents, correspondents). Personal entities live at # //; the shared bucket lives at //. -# Defaults to "family"; rename for deskstack ("office"), surname-based +# Defaults to "family"; rename for a non-family deployment ("office"), surname-based # households, etc. shared_bucket = "family" diff --git a/lib/stack/links.py b/lib/stack/links.py index 133a60d1..d94dcaf9 100644 --- a/lib/stack/links.py +++ b/lib/stack/links.py @@ -1,8 +1,8 @@ """Logical link construction — the emitter half of the `/go` namespace. A link a bot posts into a Matrix room is there forever: the timeline is -append-only, so whatever URL was in the message is the URL a family -member clicks two years later. Point it straight at a service and it +append-only, so whatever URL was in the message is the URL someone +clicks two years later. Point it straight at a service and it dies the day the domain changes, the stack flips between port mode and domain mode, or Paperless moves. Point it at `home./go/docs/247` and it re-resolves at click time. @@ -50,7 +50,7 @@ # # A link posted into chat is permanent, so the cost of putting a kind in # the wrong family is paid forever. When in doubt, ask what changes when -# a family renames a topic or corrects a title. +# someone renames a topic or corrects a title. def go_docs(doc_id: int | str) -> str: """`/docs/` — a document, wherever it is filed right now. @@ -98,7 +98,7 @@ def go_capture(capture_id: str) -> str: def go_person(slug: str, leaf: str | None = None) -> str: - """`/person/` — a household member's page. + """`/person/` — a person's page. >>> go_person("homer") '/person/homer' diff --git a/lib/stack/list_doc.py b/lib/stack/list_doc.py index 06297483..744974ea 100644 --- a/lib/stack/list_doc.py +++ b/lib/stack/list_doc.py @@ -1,6 +1,6 @@ """A list page, and what changed between two versions of one. -A family's list lives in `todos.md`, and more than one thing writes it: the +A list lives in `todos.md`, and more than one thing writes it: the curator merging extracted action items, a person editing it in Forgejo's editor, and an agent asked to tidy it up. The interesting failure is not a malformed document. It is a quiet one: six of twenty-five items gone and a @@ -17,7 +17,7 @@ so intent comes out of what changed rather than a sentence the caller invents. WHY REWORDING IS ITS OWN CATEGORY - A real family list went from thirteen items to twenty-seven because each + A real list went from thirteen items to twenty-seven because each pass through the classifier renamed things: "Alternative Dachbox" came back as "suchen", then "recherchieren", then "prüfen", then "besorgen". Nothing was lost and nothing was really added, but a report that called that four @@ -95,7 +95,7 @@ def destructive(self) -> bool: def summary(self) -> str: """One line naming what happened, losses first and named in full. - Counts are not checkable by a family member -- "8 items became 7" tells + Counts are not checkable by a reader -- "8 items became 7" tells nobody which one went. So a removal always names every item, while the ordinary categories stay short. """ diff --git a/lib/stack/ontology.py b/lib/stack/ontology.py index 8aa26093..2f709e34 100644 --- a/lib/stack/ontology.py +++ b/lib/stack/ontology.py @@ -9,7 +9,7 @@ and the loader; the *content* — the actual list of topics and types — lives outside the framework, in a seed file shipped by whichever stacklet owns the vocabulary (in famstack: `stacklets/memory/`). -A different product (deskstack, studio, freelance) supplies different +A different deployment (studio, freelance, an office) supplies different seeds against the same machinery. Two readers care about an ontology: @@ -189,7 +189,7 @@ def resolve_doctype(self, text: str, lang: str = "en") -> Optional[DocType]: def languages(self) -> List[str]: """All language codes the ontology carries names in. - Derived dynamically from the entries so a household that adds a + Derived dynamically from the entries so an instance that adds a third language ('fr', 'es', …) on a topic doesn't need any code change to be cross-language-canonicalized. """ @@ -206,7 +206,7 @@ def languages(self) -> List[str]: # may still emit a name in a different language (training-data # bias) or drop a doctype-shaped name into the topic field. # `canonicalize_topic` and `canonicalize_doctype` normalize the - # LLM's output back to a single canonical in the household language + # LLM's output back to a single canonical in the configured language # — by trying every language the ontology knows — and flag # cross-field hallucinations so the matcher can reject them # instead of silently growing the tag set with garbage. @@ -215,9 +215,9 @@ def canonicalize_topic(self, text: str, lang: str) -> "Resolution": """Resolve an LLM topic string to a canonical topic name. Tries `lang` first, then every other language the ontology - knows — a German household with `topic.travel.names.de = "Reise"` + knows — a German instance with `topic.travel.names.de = "Reise"` still recognizes the LLM's "Travel" as the same concept and - returns "Reise" (the household-language canonical). + returns "Reise" (the canonical in the configured language). When `text` resolves to a doctype instead of a topic, returns `Resolution(canonical=None, cross_field=True)` so the caller diff --git a/lib/stack/page_patch.py b/lib/stack/page_patch.py index 06421f38..6ecb45fc 100644 --- a/lib/stack/page_patch.py +++ b/lib/stack/page_patch.py @@ -3,9 +3,9 @@ `apply_patch` is the tool nanobot advertises to the model as the default way to change a file, and the model reaches for it accordingly. Its edits are ordinary text substitutions -- find this exact string, put that one there -- -which it normally performs against a file on disk. A family memory page is -not on disk: the agent sees a read-only projection, and the real document -lives in the family's git store behind `stack memory write`. +which it normally performs against a file on disk. A memory page is not on +disk: the agent sees a read-only projection, and the real document lives +in the git-backed store behind `stack memory write`. So this is the same operation with the filesystem taken out: text in, edits in, text out. Pure, so the write path can run it wherever the *current* diff --git a/lib/stack/stack.py b/lib/stack/stack.py index 84914e5b..f4811b81 100644 --- a/lib/stack/stack.py +++ b/lib/stack/stack.py @@ -208,11 +208,10 @@ def _build_template_vars(self) -> dict: "domain": self._cfg("core", "domain"), "language": self._cfg("core", "language", self._cfg("ai", "language", "en")), "timezone": self._cfg("core", "timezone", "UTC"), - # Name of the shared/family-level bucket inside the memory - # vault. Personal entities (homer, marge, …) live at - # //, the shared bucket at //. - # Default "family" fits famstack; deskstack or non-family - # deployments override to "office", "household", a surname, etc. + # Name of the shared bucket inside the memory vault. Personal + # entities live at //, the shared bucket at + # //. Defaults to "family"; deployments + # override to "office", "household", a surname, etc. "shared_bucket": self._cfg("core", "shared_bucket", "family"), # Wiki freshness (the memory stacklet's curator sidecar). # Bool/int from stack.toml render as env-friendly strings. @@ -285,9 +284,9 @@ def _build_template_vars(self) -> dict: ) template_vars["mail_poll_interval"] = str(mail_cfg.get("poll_interval", 120)) - # The family agent's identity, one knob: `[agent] name` (default + # The agent's identity, one knob: `[agent] name` (default # "Stacky") drives its persona, Matrix display name, handle, and home - # room. A family renames it (e.g. "Merlin" -> @merlin-bot, #merlin) + # room. An instance renames it (e.g. "Merlin" -> @merlin-bot, #merlin) # with one stack.toml line and no code edits. agent_name = self._cfg("agent", "name", "Stacky") agent_slug = agent_name.strip().lower().replace(" ", "-") diff --git a/lib/stack/vault.py b/lib/stack/vault.py index 1868a3be..6c880929 100644 --- a/lib/stack/vault.py +++ b/lib/stack/vault.py @@ -59,7 +59,7 @@ def slug(text: str) -> str: def slugify_person(name: str) -> str: """Map a person name to its vault bucket slug. - Buckets are the Matrix localpart lowercased; for the default family + Buckets are the Matrix localpart lowercased; with stock user seeding that is the first name lowercased ("Homer Simpson" -> "homer"). We take the first whitespace token so a full name still resolves to the bucket the captures landed in. The person's container entity page diff --git a/stacklets/core/stacklet.toml b/stacklets/core/stacklet.toml index 244d3fbe..0db4ffcb 100644 --- a/stacklets/core/stacklet.toml +++ b/stacklets/core/stacklet.toml @@ -84,7 +84,7 @@ BRAIN_REPO_DIR = "/data/memory/brain" # Shared-bucket slug — the entity that owns institutional artifacts # (documents, correspondents) inside the memory vault. Personal # entities (homer, marge, …) live at //. Defaults to -# "family"; deskstack or non-family deployments override via +# "family"; non-family deployments override via # stack.toml [core] shared_bucket. SHARED_BUCKET = "{shared_bucket}" diff --git a/stacklets/docs/bot/git_mirror.py b/stacklets/docs/bot/git_mirror.py index 76db4ea9..dd6a36f9 100644 --- a/stacklets/docs/bot/git_mirror.py +++ b/stacklets/docs/bot/git_mirror.py @@ -108,7 +108,7 @@ def _commit_author(submitter: str | None) -> tuple[str, str]: """`(author_name, author_email)` for a capture commit. Attributes the commit to the family member who filed it, derived from - their Matrix id (`@marge:merles.eu` -> `marge`, `marge@merles.eu`), so + their Matrix id (`@marge:home.local` -> `marge`, `marge@home.local`), so `git log --author` answers "who added this". Falls back to the bot when there is no submitter or the id is malformed — the commit still lands. """ @@ -155,9 +155,9 @@ class GitMirror: data_dir: Path org_name: str = "family" # Slug for the shared/institutional bucket inside the vault. Default - # "family" matches famstack's stock layout; deskstack or non-family - # deployments override via stack.toml [core] shared_bucket → env - # var SHARED_BUCKET → archivist → here. + # "family" matches famstack's stock layout; non-family deployments + # override via stack.toml [core] shared_bucket → env var + # SHARED_BUCKET → archivist → here. shared_bucket: str = "family" paperless_version: str = "" diff --git a/stacklets/docs/bot/topic_rooms.py b/stacklets/docs/bot/topic_rooms.py index 3617fc11..5461b3c4 100644 --- a/stacklets/docs/bot/topic_rooms.py +++ b/stacklets/docs/bot/topic_rooms.py @@ -263,7 +263,7 @@ def make_room_state( Same answer regardless of trigger: who put this topic on the map. ``shared_bucket`` is the household's configured shared-bucket slug - (`family` by default; deskstack uses `office`). The bucket field + (`family` by default; `office` in a non-family deployment). The bucket field becomes ``/`` for shared topics and ``/`` for personal ones. """ diff --git a/tests/stacklets/test_git_mirror.py b/tests/stacklets/test_git_mirror.py index d2ccccfa..4de5bc81 100644 --- a/tests/stacklets/test_git_mirror.py +++ b/tests/stacklets/test_git_mirror.py @@ -32,7 +32,7 @@ class TestCommitAuthor: from their Matrix id; a missing/malformed id falls back to the bot.""" def test_derives_name_and_email_from_mxid(self): - assert _commit_author("@marge:merles.eu") == ("marge", "marge@merles.eu") + assert _commit_author("@marge:home.local") == ("marge", "marge@home.local") def test_localpart_only_id_falls_back_to_local_domain(self): assert _commit_author("@homer") == ("homer", "homer@local") @@ -41,7 +41,7 @@ def test_no_submitter_is_the_bot(self): assert _commit_author(None) == (BOT_USERNAME, BOT_EMAIL) def test_filer_localpart(self): - assert _filer_localpart("@Marge:merles.eu") == "marge" + assert _filer_localpart("@Marge:home.local") == "marge" assert _filer_localpart(None) is None diff --git a/tests/stacklets/test_memory_wiki.py b/tests/stacklets/test_memory_wiki.py index 929d4d7f..03f08a03 100644 --- a/tests/stacklets/test_memory_wiki.py +++ b/tests/stacklets/test_memory_wiki.py @@ -191,8 +191,8 @@ def test_multiple_topics_per_bucket(self, tmp_path): assert ("family", "camping") in locs assert ("family", "photography") in locs - def test_deskstack_shared_bucket(self, tmp_path): - """A deskstack household's shared bucket is `office`. The + def test_office_shared_bucket(self, tmp_path): + """A non-family deployment's shared bucket is `office`. The discovery reads the configured slug, not a hard-coded `family`.""" _make_topic_folder(tmp_path, "office", "client-x") locs = _topic_locations( diff --git a/tests/stacklets/test_vault_entry.py b/tests/stacklets/test_vault_entry.py index 5e16e357..427e8970 100644 --- a/tests/stacklets/test_vault_entry.py +++ b/tests/stacklets/test_vault_entry.py @@ -100,9 +100,9 @@ def test_no_date_no_title(self): def test_custom_bucket(self): path = document_filepath( - "deskstack", "2025-03-27", 42, "Invoice", True, + "office", "2025-03-27", 42, "Invoice", True, ) - assert path == "deskstack/documents/2025/03/2025-03-27-invoice-p42.md" + assert path == "office/documents/2025/03/2025-03-27-invoice-p42.md" def test_umlauts_in_slug(self): path = document_filepath(