From 1c1a3cfc8b60ee77702ed39b1a4b2f2cf4a2915b Mon Sep 17 00:00:00 2001 From: Arthur Date: Thu, 11 Jun 2026 16:53:57 +0200 Subject: [PATCH 01/21] =?UTF-8?q?docs(admin):=20complete=20the=20port=20ta?= =?UTF-8?q?ble=20=E2=80=94=20LM=20Studio,=20Piper=20TTS,=20memory=20wiki?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/admin-guide.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/admin-guide.md b/docs/admin-guide.md index ce8271d..cc20d34 100644 --- a/docs/admin-guide.md +++ b/docs/admin-guide.md @@ -134,7 +134,10 @@ famstack uses the 420xx range to avoid conflicts with anything you might already | 42040 | code (Forgejo) | | 42050 | chatai (Open WebUI) | | 42060 | ai (oMLX) | +| 42061 | ai (LM Studio, optional GGUF backend) | | 42062 | ai (Whisper) | +| 42063 | ai (Piper TTS) | +| 42070 | memory (family wiki) | If a port is taken, stop the offender or switch to `domain` mode (see [stack-reference.md](stack-reference.md)). From dabea714184a49731183aba39fb9742cab6418ae Mon Sep 17 00:00:00 2001 From: Arthur Date: Tue, 16 Jun 2026 15:06:10 +0200 Subject: [PATCH 02/21] docs: add Open Knowledge Format conformance design New design/brain/open-knowledge-format.md: how the famstack vault maps to Google's OKF spec, the cheap beta-window renames to become conformant, and the higher-leverage borrows from the reference repo (eval harness, two-pass enrichment, augmentation-strict regeneration). knowledge-architecture.md: record the "second brain night shift" article as a research source, with the briefing-as-product takeaway and the contradiction-queue divergence decision. --- docs/design/brain/knowledge-architecture.md | 13 +- docs/design/brain/open-knowledge-format.md | 342 ++++++++++++++++++++ 2 files changed, 354 insertions(+), 1 deletion(-) create mode 100644 docs/design/brain/open-knowledge-format.md diff --git a/docs/design/brain/knowledge-architecture.md b/docs/design/brain/knowledge-architecture.md index 5d4a1ea..f7ea448 100644 --- a/docs/design/brain/knowledge-architecture.md +++ b/docs/design/brain/knowledge-architecture.md @@ -13,7 +13,7 @@ The Family Brain connects the silos. It extracts knowledge from every service, o ## Research Sources -This design synthesizes six external sources and our existing infrastructure. +This design synthesizes seven external sources and our existing infrastructure. ### Karpathy's LLM Wiki Pattern Source: https://gist.github.com/karpathy/442a6bf555914893e9891c11519de94f @@ -186,6 +186,17 @@ Source: r/LocalLLaMA (636K+ members), various articles and benchmarks - Hindsight (MIT): single Docker command, embedded PostgreSQL + pgvector - OpenMemory: temporal knowledge graph with composite scoring (salience, recency, coactivation) +### "Second Brain Night Shift" (Khairallah, 2026) +Source: viral how-to for a 300-agent overnight PKM swarm on Kimi Work + Obsidian + +A solo-knowledge-worker build: dump raw captures into one folder, and overnight a swarm of role-specialized agents (scouts fetch, catalogers atomize, cartographers link, critics flag contradictions, editors synthesize + write a briefing) refines them into an interlinked vault. Most of it is vendor marketing (the "300 agents" fan-out, Kimi K2.6) and most of the *architecture* we already arrived at independently: it is Karpathy's wiki + Total Recall's dream cycle restated. Validating, not novel: refinery-by-stage folders (`0-raw` -> `2-atoms` -> `3-threads`) match our L0->L4 layers; "sources sacred, no source no note" matches our immutable-mirror + provenance rule; atomic notes match L2 facts; role decomposition matches our Archivist/Deriver/Scribe split; the same 7 decay types appear (lifted from Total Recall, same TTLs). + +**The one genuinely fresh contribution: the morning briefing is the product.** The article's strongest instinct is that the overnight refinement is invisible and worthless until something *lands on your desk* -- "last night surfaced one contradiction worth your attention, here's the link." We do all the refinement (classify, mirror, derive) and emit nothing proactive: briefings are parked in three separate docs (engram stats, wiki-improvements brief, dream-cycle summary). This reframes the briefing from polish to centerpiece, and it is our `smart recall` paid wedge: "famstack noticed the car insurance renews in 9 days and this letter contradicts last year's premium" is the payoff that makes the background work feel alive. For a family it is *more* compelling than for a solo nerd. + +**The trap to reject: the human friction queue.** The article's climax -- "spend 20 minutes a week as editor-in-chief resolving the contradiction list" -- is exactly the pending/review queue our design forbids (see the auto-extend principle). It assumes one disciplined knowledge worker; a family has no editor-in-chief, and "whose belief wins" is incoherent across personal buckets. **Decision: adopt contradiction *detection*, reject contradiction *queues*.** When a new doc contradicts an old fact, resolve by newest-wins-with-changelog or surface-in-context-when-asked, never a triage list someone must clear. + +**Assessment for famstack:** Steal the briefing, steal the "your server worked while you slept" narrative for marketing, skip the swarm (a family sees a handful of captures a day, not 50 articles -- our few purpose-built bots are correct), keep realtime classify-on-arrival (better chat UX than overnight batch; the night shift is the *consolidation/briefing* layer, not the capture path). Net: this article mostly de-risks the existing design and isolates the briefing as the highest-leverage unbuilt piece. + --- ## Architecture diff --git a/docs/design/brain/open-knowledge-format.md b/docs/design/brain/open-knowledge-format.md new file mode 100644 index 0000000..57d052b --- /dev/null +++ b/docs/design/brain/open-knowledge-format.md @@ -0,0 +1,342 @@ +# Open Knowledge Format (OKF) support + +> Status: design draft +> Created: 2026-06-16 +> Author: Arthur + Claude +> Depends on: [knowledge-structure.md](knowledge-structure.md) (vault shape), +> [family-ontology.md](family-ontology.md) (entity pages), +> [wiki-engine.md](wiki-engine.md) (derived wiki) +> Source: https://cloud.google.com/blog/products/data-analytics/how-the-open-knowledge-format-can-improve-data-sharing +> Spec: https://github.com/GoogleCloudPlatform/knowledge-catalog/tree/main/okf + +## TL;DR + +OKF is an open spec (Google Cloud Data Cloud team, published 2026-06-12) +that formalizes the LLM-wiki pattern -- markdown files + YAML frontmatter, +file path as identity, markdown links as the relationship graph, +`index.md` for navigation, `log.md` for chronology. That is, almost +exactly, the famstack memory vault we already ship. + +We are *structurally* about 90% conformant by accident, because both we +and OKF descend from the same Karpathy pattern. The gap is small and +mostly naming. Since we are pre-1.0 on a beta branch, this is the cheap +window to close it: add the one mandatory field, align a couple of field +names, and add an export path. **Recommendation: adopt OKF as a +conformance + export target, not as our internal model.** Keep our richer +vault (facts with TTL, privacy buckets, ontology, contradiction handling); +make it *emit* clean OKF so a family's knowledge is portable to any +OKF-aware tool. Do not bet the architecture on a four-day-old v0.1 spec. + +## What OKF actually is + +A bundle is a directory of markdown files. Each file is one "concept" +(a table, a metric, a runbook -- for us: a document, a person, a topic). +Rules, in full: + +- **Identity is the file path.** `/family/people/homer.md` *is* the + identifier for Homer. No UUIDs. +- **One mandatory frontmatter field: `type`.** Everything else is + optional. Standard optional fields: `title`, `description`, + `resource` (URL to the original), `tags`, `timestamp`. +- **Relationships are plain markdown links** between files: + `[customers](/tables/customers.md)`. The link graph is the knowledge + graph. No RDF, no triples, no database. +- **`index.md`** in any folder = progressive-disclosure navigation as an + agent walks the tree. **`log.md`** = optional append-only change log. + Both are reserved filenames. +- **Producers may add any custom fields; consumers use what they + understand and ignore the rest.** The spec is the interoperability + surface, deliberately not a content model. + +It ships two reference implementations worth knowing: an *enrichment +agent* (walks a data source, drafts OKF docs -- our Deriver analog) and a +*single-file static HTML visualizer* (renders a bundle as an interactive +graph, no backend, no data leaves the page). + +What OKF is **not**, and therefore what it cannot replace for us: it has +no notion of fact decay/TTL, privacy scoping, contradiction handling, or +entities as first-class. It is a *serialization surface*, not a memory +system. It is also aimed at enterprise data catalogs (BigQuery tables, +metrics), not family memory -- the structural overlap is real, the +semantic overlap is partial. We export to it; we do not model in it. + +## Why bother + +One reason, and it is a good one: **portability with a name to point at.** +Our pitch has always been "plain markdown on your own disk, no lock-in, +take it anywhere." OKF lets us upgrade that from a vibe to a standard: +*your family's knowledge is stored in an open, vendor-neutral format -- +render it on GitHub, open it in any editor, hand it to any OKF-aware +agent, with zero export step.* That is a credibility asset for a +privacy-first, own-your-data product, and it costs us very little because +we are already shaped like OKF. + +Secondary: the OKF static visualizer is a free, offline, no-install vault +browser we could adopt or fork for families who do not run Obsidian. + +## Gap analysis (grounded in current code) + +All field names below are verified against `stacklets/docs/bot/mirror_format.py` +and `docs/design/brain/family-ontology.md` as of this branch. + +### Document mirror frontmatter + +Current (`document_frontmatter`): `title`, `date?`, `correspondent?`, +`document_type?`, `category?`, `persons?`, `tags?`, `paperless_id`, +`paperless_url?`, `processing`, `model?`, `paperless_version?`, +`source`, `added`. + +| OKF field | famstack today | Gap / action | +|---|---|---| +| `type` (required) | absent | **Add** `type: document`. This is the only hard conformance break. | +| `title` | `title` | aligned | +| `description` | none (prose lives in the `> [!summary]` body callout) | optional; could mirror the summary's first line into `description` | +| `resource` | `paperless_url` (base URL, not page-specific) | align: set `resource` to the per-document Paperless page URL | +| `tags` | `tags` | aligned | +| `timestamp` | `added` | **Rename** `added` -> `timestamp` | +| (custom) | `correspondent`, `document_type`, `category`, `persons`, `paperless_id`, `processing`, `model`, `paperless_version`, `source` | keep as custom fields -- OKF ignores what it does not know | + +Note the naming subtlety: OKF `type` is the *concept kind* ("document"), +while our existing `document_type` is the Paperless *subtype* ("invoice", +"contract"). They are different axes and can coexist: `type: document` + +`document_type: invoice`. No need to rename `document_type`. + +### Capture frontmatter + +Current (`capture_frontmatter`): `title`, `kind`, `date?`, `persons?`, +`tags?`, `source_uri?`, `model?`, `added`. + +| OKF field | famstack today | Gap / action | +|---|---|---| +| `type` (required) | `kind` (note/bookmark) | **Add** `type: note` / `type: bookmark`, or promote `kind` to `type`. Recommend promoting `kind` -> `type` (one field, OKF-native). | +| `resource` | `source_uri` | **Rename** `source_uri` -> `resource` (exact semantic match: URL to the original) | +| `timestamp` | `added` | **Rename** `added` -> `timestamp` | +| `title`, `tags` | same | aligned | + +### Entity pages + +Current (`family/people/homer.md`): `canonical`, `aliases`, `role`, +`member`, `birthday?`, `employer?`, `owners?`, `relation?`. Critically, +`kind` is **derived from the folder, never stored** (people -> person). + +| OKF field | famstack today | Gap / action | +|---|---|---| +| `type` (required) | absent (folder-derived) | **Add** `type: person` / `pet` / `vehicle` / `place`. We keep deriving from the folder for our own use; we also write it so the file is self-describing and OKF-valid standalone. | +| `title` | `canonical` | either rename `canonical` -> `title`, or write both. Recommend writing `title: ` and keeping `canonical` for our H1/canonicalization logic. | +| (custom) | `aliases`, `role`, `member`, `birthday`, `owners`, ... | keep as custom | + +The doc already promises "unknown frontmatter keys are preserved (forward +compat)", so adding `type`/`title` is consistent with the existing schema +contract, not a break. + +### Structural items + +| OKF concept | famstack today | Gap / action | +|---|---|---| +| identity = file path | date-bucketed paths + `entity/about.md` hubs | compatible. `about.md` is the entity concept file; folder is the entity. | +| `index.md` (per-folder nav) | one L4 master `index.md`; entity hubs are `about.md` | add lightweight per-folder `index.md` listings (the wiki rebuild can emit these). `about.md` stays the human hub. | +| `log.md` | `stack memory log` over git history; no materialized file | optional. Could materialize a `log.md` on export, or leave it (git IS the log). | +| reserved filenames | `_unfiled`, `about` | check `index.md`/`log.md` do not collide with any generated slug; the archivist's reserved-slug set already guards `about`. | +| relationship links | Obsidian `[[wikilinks]]` (`[[Homer]]`, `[[ADAC]]`) | **The one real divergence.** OKF wants relative markdown links `[Homer](/family/people/homer.md)`. See below. | + +## The wikilink decision + +This is the only non-trivial fork. OKF's graph is built from standard +relative markdown links. We author Obsidian `[[wikilinks]]` (name-based, +resolved by Obsidian's index), which OKF parsers do not understand as +edges. + +Two options: + +1. **Translate on export.** Keep `[[wikilinks]]` as the internal authoring + convention (Obsidian-native, matches `wiki-engine`, matches olw, what + families browsing the vault actually use). The OKF exporter rewrites + `[[Name]]` -> `[Name](relative/path.md)` by resolving against the entity + roster. Internal UX unchanged; OKF surface is clean. +2. **Switch natively to relative markdown links.** Conformant by + construction, no exporter. But it degrades the Obsidian experience + (wikilinks are why backlinks and the graph view "just work") and is a + wider rename touching every renderer in `mirror_format.py`. + +**Recommendation: option 1 (translate on export).** The vault's primary +consumer is a family in Obsidian/Forgejo, not an OKF agent. Optimize the +internal surface for them; treat OKF as the portable export. Resolving +`[[Name]]` to a path requires the entity roster, which the wiki engine +already maintains -- the exporter is small. + +(If we ever find the export translation is lossy or annoying to maintain, +the beta window is the time to reconsider option 2. Flag it, do not +silently switch.) + +## What we change vs what we build + +### Changes (the beta-window renames -- cheap now, expensive after 1.0) + +1. Add `type` to all three frontmatter builders (`document`, capture + note/bookmark, entity person/pet/vehicle/place). For captures, promote + `kind` -> `type`. +2. Rename `added` -> `timestamp` everywhere it is written (and update any + reader: Dataview queries in docs, `search_service`, CLI). +3. Rename capture `source_uri` -> `resource`; for documents set a + page-specific `resource` URL. +4. Write `title` on entity pages (mirror of `canonical`). + +All four are mechanical and test-covered surfaces. Each is a coherent +standalone commit (per repo working-notes: invariant changes stay +reviewable). Migration of *existing* vault files is a one-shot script +(rename keys in frontmatter, add `type` from folder); pre-1.0 we can also +just let old entries age out, but a script is safer and trivial. + +### Build (additive, no rewrite) + +5. **OKF exporter / conformance mode.** A `stack memory export --okf + ` (or a vault flag) that: emits per-folder `index.md` nav files, + translates `[[wikilinks]]` to relative links, and writes a top-level + bundle `index.md`. Reuses the wiki engine's roster + the existing + `mirror_format` renderers. +6. **Conformance test.** A small validator (the OKF spec ships conformance + criteria) run in `stacktests` against a fabricated Simpsons vault, so we + do not silently drift out of conformance. +7. **(Optional, later) Visualizer.** Adopt or fork the OKF static HTML + visualizer as an offline vault browser for non-Obsidian families. + +## What we explicitly do NOT do + +- **Do not replace the internal model with OKF.** OKF has no TTL/decay, no + privacy scoping, no contradiction handling, no first-class entities. Our + L0-L4 model and ontology stay the source of truth; OKF is a projection. +- **Do not contort the family ontology to fit a data-catalog spec.** OKF's + worked examples are BigQuery tables and metrics. Where family semantics + (persons, member/relative, owners) have no OKF standard field, they stay + as custom fields. We bend the export, not the model. +- **Do not deep-couple to v0.1.** The spec is four days old. We track it, + we conform at the surface, we keep the exporter thin enough to drop or + rev cheaply if the spec changes or stalls. +- **Do not block any current brain work on this.** This is a portability + layer on top of the existing vault, sequenced after the core knowledge + pipeline, not ahead of it. + +## Suggested sequencing + +- **Phase 1 (now, ~half a day):** the four frontmatter changes (items 1-4) + + migration script + update readers. Lands us conformant-at-rest except + for links. +- **Phase 2 (~half a day):** exporter + per-folder `index.md` + wikilink + translation (items 5) + conformance test (item 6). +- **Phase 3 (optional, later):** visualizer (item 7). + +Phases 1-2 are the whole conformance story and total roughly a day of +work, almost all of it mechanical, because we were already this shape. + +## Inspiration from the reference repo (beyond conformance) + +The reference repo (`github.com/GoogleCloudPlatform/knowledge-catalog`, +checked out locally) is **Apache 2.0**, which matters: unlike Honcho +(AGPL, flagged as unusable for us in `knowledge-architecture.md`), this is +license-compatible with our AGPLv3, so we can lift *code*, not just +patterns. It contains three subsystems worth mining. Ranked by leverage +for famstack, not by what OKF conformance strictly needs. + +### Tier 1 -- genuinely new, high leverage + +**1. The evaluation framework (the standout borrow).** +`agents/enrichment/eval/` is a ready-made blueprint for measuring +extraction quality, which is exactly the untested claim in +`family-ontology.md` ("priming a weak local model with household context +closes most of the accuracy gap" -- experiment round 6, never run). The +shape: +- A golden JSON per corpus (`goldens/TEMPLATE.json`): `expected_concepts` + with `canonical` + `flavor_hints` (aliases) + `golden_facts`, plus + `acceptable_extra_concepts` (extras that don't count as errors) and + `non_entries` (things that must NOT be merged or invented). +- Deterministic checks first (`metrics.py` `check_structural`): YAML + parses, required fields present, no stray frontmatter -- no LLM needed. +- LLM-as-judge metrics: `concept_recall` / `concept_precision`, + `fact_recall`, `hallucination_free`, `business_terms_presence`. +- `MetricResult{score, passed, detail, insights}` -- every score carries + *why* and *how to improve*. + +We have the Simpsons corpus already. A `goldens/simpsons_family.json` plus +the recall/precision/hallucination metrics turns "we believe priming +helps" into a number we can regress against on every classifier change. +This probably deserves to graduate into its own doc or fold into the +family-ontology experiment plan; it is broader than OKF. + +**2. Two-pass enrichment: topic-neutral summarize, then topic-shaped reduce.** +`agents/enrichment/src/modes/doc_mode.py` summarizes each source once into +a neutral "doc card" (cached, reused), then applies topic lenses +downstream. Maps directly onto the unbuilt Deriver: classify/summarize a +document once into a stable intermediate, synthesize many wiki/topic pages +from the cards without re-reading sources. This is the structural defense +against derived-note drift (sources read once; cards are the fixed +middle layer), and it reinforces our L1-mirror-as-stable-intermediate. + +**3. Augmentation-strict regeneration (a concrete prompt contract).** +`okf/src/enrichment_agent/prompts/web_ingestion_instruction.md` is the +discipline our wiki regeneration needs and `wiki-page-anatomy.md` only +gestures at ("primed notes survive regeneration"). Verbatim-worthy rules: +copy `type`/`title`/`resource` verbatim, refine `description` only if +improved, merge `tags` as a union, keep every existing heading in order, +*extend not rewrite*, never shrink `# Schema` or `# Citations`. And +`bundle_tools.py` enforces the shrink-guard in *code*, not just the prompt +-- a regeneration that drops a section is rejected. We should make +"regeneration never destroys" a verifiable invariant the same way. + +### Tier 2 -- useful, accelerates the conformance plan + +**4. A clean, tested OKF document parser (liftable code).** +`okf/src/enrichment_agent/bundle/document.py` is a ~small `OKFDocument` +with `parse` / `serialize` / `validate` and roundtrip + error tests +(`okf/tests/test_document.py`). This is the Phase 2 conformance validator +nearly for free; Apache 2.0 means we can port it directly. + +**5. `index.md` autogeneration algorithm.** +`okf/src/enrichment_agent/bundle/index.py` `regenerate_indexes()`: walk +depth-first, group by frontmatter `type`, emit `* [title](link) - +description`, bubble child descriptions up to parent indexes; +`synthesizer.py` LLM-writes a one-line description when none exists. This +is the concrete recipe for the per-folder `index.md` I scoped in Phase 2. + +**6. Retrieval without embeddings (confirms our direction).** +`samples/enrichment/src/tools/fileskb/main.py` exposes exactly three +operations -- `list`, `read`, `search` (regex grep over `.md`) -- behind a +`SKILL.md` that tells the agent how to traverse. Plus the +`index.md`-first progressive-disclosure walk. This validates our +`stack memory` + CLI-as-agent-interface direction rather than adding new +work; the borrow is the three-tool minimalism and the SKILL.md packaging. + +### Tier 3 -- patterns to note, mostly not applicable + +**7. Static HTML graph viewer.** `okf/src/enrichment_agent/viewer/` +renders a bundle as a single self-contained cytoscape.js page: node per +concept, color by `type`, size by body length (a cheap "maturity" +signal), edges from parsed links. Concrete recipe for the optional +Phase 3 visualizer. + +**8. mdcode's identity model (one nugget, skip the rest).** `mdcode` +(`toolbox/mdcode/`) is a heavyweight bidirectional sync engine +(source-of-truth <-> markdown, with snapshots, manifests, layouts, +push-back). Most of it is **not applicable**: we never write vault edits +back to Paperless, so the whole push/conflict half is dead weight for us. +The one nugget worth keeping: **identity lives in frontmatter, the +filesystem path is derived; reindex by globbing and reading the stored +id, so files can be renamed/moved without breaking identity.** That +directly informs our rename work -- if we stamp a stable concept id, the +"filename is stable after first AI pass" constraint in `mirror_format.py` +relaxes. Their separate `.state` checksum file (detect human edits vs +machine writes, fail-fast on drift) is also a clean pattern for the +Deriver to avoid clobbering hand-edits. + +## Open questions + +1. `resource` for documents: link to the Paperless page, the OCR PDF, or + both (one in `resource`, one custom)? Leaning Paperless page. +2. Do we materialize `log.md` per bundle on export, or declare git the + log and skip it? Leaning skip (git IS the log) unless a consumer needs + the file. +3. `type` vocabulary: fix a closed set (`document`, `note`, `bookmark`, + `person`, `pet`, `vehicle`, `place`, `topic`, `correspondent`) in + `ontology.toml` so producers and the conformance test agree. +4. Per-folder `index.md` vs our `about.md` hubs: keep both (different + jobs -- `index.md` is machine nav, `about.md` is the human entity page) + or unify? Leaning keep both. From 60c24aef6fffaca1f05cd34395a43a5769d16a13 Mon Sep 17 00:00:00 2001 From: Arthur Date: Fri, 19 Jun 2026 17:34:27 +0200 Subject: [PATCH 03/21] refactor: single source for mirror frontmatter and rendering git_mirror duplicated all of mirror_format's path/frontmatter/render logic and the two copies had drifted (capture_id existed in one only). Make git_mirror delegate to mirror_format and fold capture_id back in, so OKF field changes are a single-site edit. --- stacklets/docs/bot/git_mirror.py | 380 ++++------------------------ stacklets/docs/bot/mirror_format.py | 9 + 2 files changed, 53 insertions(+), 336 deletions(-) diff --git a/stacklets/docs/bot/git_mirror.py b/stacklets/docs/bot/git_mirror.py index 119c8b7..37c48c3 100644 --- a/stacklets/docs/bot/git_mirror.py +++ b/stacklets/docs/bot/git_mirror.py @@ -43,20 +43,26 @@ from __future__ import annotations import asyncio -import hashlib import json import os -import re import secrets -import unicodedata from dataclasses import dataclass, field from pathlib import Path -import yaml from loguru import logger from stack.forgejo import ForgejoClient, ForgejoError +from mirror_format import ( + slug, + document_filepath, + capture_filepath, + document_frontmatter, + capture_frontmatter, + render_document, + render_capture, +) + # The shared family knowledge vault. The memory stacklet creates and # seeds the org + repo (description, README, ontology, facts); the @@ -288,45 +294,13 @@ async def _lookup_path(self, client: ForgejoClient, paperless_id: int) -> str | # ── Filename, frontmatter, body ────────────────────────────────────── def _slug(self, text: str) -> str: - """Filesystem-safe slug: ASCII-ish, lowercase, hyphen-separated. - - The cap is a defensive ceiling, not a primary length control — - the classifier title prompt asks for short identifying titles - (no dates, no amounts), so well-shaped inputs land far under - this cap. The slice is hard at 60 chars; the title prompt is - responsible for keeping titles human-scannable, not the slug. - """ - normalized = unicodedata.normalize("NFKD", text) - ascii_ = normalized.encode("ascii", "ignore").decode() - slug = re.sub(r"[^a-zA-Z0-9]+", "-", ascii_).strip("-").lower() - return slug[:60] or "document" + """Filesystem-safe slug. Delegates to ``mirror_format.slug``.""" + return slug(text) def _filepath(self, date: str | None, paperless_id: int, title: str | None, has_title: bool) -> str: - """Build /documents/YYYY/MM/YYYY-MM-DD--p.md. - - `has_title` is True when we have a slug-worthy title (from AI - classification or the caller's fallback filename) — as opposed - to the generic `Paperless #N`. The `-p` suffix always appears - so the Paperless ID is recoverable from the filename alone, - surviving cache loss without needing to scan frontmatter. - - Documents live in the shared bucket because they are institutional - artifacts — a marriage certificate or a family insurance bill - has no single personal owner. Per-person indexing happens via - the frontmatter `persons:` field, not the path. - """ - documents_root = f"{self.shared_bucket}/documents" - if date and re.match(r"^\d{4}-\d{2}-\d{2}$", date): - y, m, _ = date.split("-") - prefix = f"{documents_root}/{y}/{m}/{date}" - else: - prefix = f"{documents_root}/_unfiled" - - unfiled = f"{documents_root}/_unfiled" - if has_title and title: - slug = self._slug(title) - return f"{prefix}-{slug}-p{paperless_id}.md" if prefix != unfiled else f"{unfiled}/{slug}-p{paperless_id}.md" - return f"{prefix}-p{paperless_id}.md" if prefix != unfiled else f"{unfiled}/p{paperless_id}.md" + """Document mirror path. Delegates to ``mirror_format.document_filepath``, + injecting this mirror's configured shared bucket.""" + return document_filepath(self.shared_bucket, date, paperless_id, title, has_title) def _frontmatter( self, @@ -343,33 +317,16 @@ def _frontmatter( processing: str, model: str | None, ) -> dict: - """Assemble the frontmatter dict in a stable key order.""" - import datetime as dt - now = dt.datetime.now(dt.timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") - fm: dict = {"title": title} - if date: - fm["date"] = date - if correspondent: - fm["correspondent"] = correspondent - if document_type: - fm["document_type"] = document_type - if category: - fm["category"] = category - if persons: - fm["persons"] = persons - if tags: - fm["tags"] = tags - fm["paperless_id"] = paperless_id - if paperless_url: - fm["paperless_url"] = paperless_url - fm["processing"] = processing - if model: - fm["model"] = model - if self.paperless_version: - fm["paperless_version"] = self.paperless_version - fm["source"] = "paperless" - fm["added"] = now - return fm + """Document frontmatter. Delegates to ``mirror_format.document_frontmatter``, + injecting this mirror's known Paperless server version.""" + return document_frontmatter( + title=title, date=date, + correspondent=correspondent, document_type=document_type, + category=category, persons=persons, tags=tags, + paperless_id=paperless_id, paperless_url=paperless_url, + processing=processing, model=model, + paperless_version=self.paperless_version, + ) def _render( self, @@ -382,72 +339,14 @@ def _render( facts: list | None = None, action_items: list | None = None, source_link: tuple[str, str] | None = None, - wiki_header: bool = True, ) -> str: - """Assemble the mirror file. - - Layout, from top to bottom: - - - YAML frontmatter (machine view: structured metadata) - - H1 title - - wiki-link header (`**From:** [[ADAC]] · **About:** [[Homer]]`) - - **briefing callout** — `> [!summary]` with prose, optional - source link, facts, and action items. Wrapped in a callout - so the briefing reads as a distinct block from the OCR body - (Obsidian renders a tinted box; Forgejo falls back to a - labeled blockquote). - - the OCR-cleaned document body - """ - fm_yaml = yaml.safe_dump( - frontmatter, sort_keys=False, allow_unicode=True, default_flow_style=False, - ).strip() - parts = ["---", fm_yaml, "---", ""] - - parts.append(f"# {frontmatter.get('title', 'Untitled')}") - parts.append("") - - if wiki_header and (correspondent or persons): - bits = [] - if correspondent: - bits.append(f"**From:** [[{correspondent}]]") - if persons: - bits.append("**About:** " + ", ".join(f"[[{p}]]" for p in persons)) - parts.append("> " + " · ".join(bits)) - parts.append("") - - briefing = self._briefing_block( + """Document mirror markdown. Delegates to ``mirror_format.render_document``.""" + return render_document( + frontmatter=frontmatter, body=body, + correspondent=correspondent, persons=persons, summary=summary, facts=facts, action_items=action_items, source_link=source_link, ) - if briefing: - parts.append(briefing) - parts.append("") - - # Empty body — bookmark captures stop at the briefing block. - # The LLM summary is the content; the URL is the source. - body_stripped = body.strip() if body else "" - if body_stripped: - parts.append(body_stripped) - parts.append("") - return "\n".join(parts) - - # ── Capture render ─────────────────────────────────────────────────── - # - # Captures diverge from documents in three ways and so render - # through their own path rather than overloading `_render`: - # - # 1. The meta block uses Captured/Kind/Source instead of the - # document's From/About/Date/Type/Category. Frontmatter is - # hidden in viewers; the meta block puts the same facts in - # reading view. - # 2. No `## Action items` block. A bookmark to a Reddit thread - # is not a todo. We don't want the LLM manufacturing chores - # out of every paste. - # 3. `kind: note` keeps the user's pasted text but tucks it inside - # an Obsidian collapsible callout. The summary is what the eye - # lands on; verifying the original is one click away. - # `kind: bookmark` has no body at all — the URL plus the - # summary IS the entry. def _render_capture( self, *, @@ -460,146 +359,12 @@ def _render_capture( summary: str | None = None, facts: list | None = None, ) -> str: - """Assemble a capture mirror file (kind=note|bookmark).""" - fm_yaml = yaml.safe_dump( - frontmatter, sort_keys=False, allow_unicode=True, default_flow_style=False, - ).strip() - parts = ["---", fm_yaml, "---", ""] - - parts.append(f"# {frontmatter.get('title', 'Untitled')}") - parts.append("") - - meta_lines: list[str] = [] - if persons: - meta_lines.append( - "**About** " + ", ".join(f"[[{p}]]" for p in persons) - ) - line2_bits = [] - if captured_at: - line2_bits.append(f"**Captured** {captured_at}") - line2_bits.append(f"**Kind** {kind}") - meta_lines.append(" · ".join(line2_bits)) - if source_uri: - meta_lines.append(f"**Source** <{source_uri}>") - parts.extend(f"> {ln}" for ln in meta_lines) - parts.append("") - - # Briefing — summary + facts only. Action items are intentionally - # omitted for captures (see header comment). - briefing = self._briefing_block( - summary=summary, facts=facts, action_items=None, + """Capture mirror markdown. Delegates to ``mirror_format.render_capture``.""" + return render_capture( + frontmatter=frontmatter, body=body, kind=kind, + captured_at=captured_at, source_uri=source_uri, persons=persons, + summary=summary, facts=facts, ) - if briefing: - parts.append(briefing) - parts.append("") - - # Notes: collapsible callout around the verbatim paste. The `-` - # after [!quote] tells Obsidian to default-collapse the section. - # Forgejo's renderer falls back to a labeled blockquote. - if kind == "note": - body_stripped = body.strip() if body else "" - if body_stripped: - parts.append("> [!quote]- Original paste") - for ln in body_stripped.split("\n"): - parts.append(f"> {ln}" if ln else ">") - parts.append("") - - return "\n".join(parts) - - # ── Briefing block ─────────────────────────────────────────────────── - # - # The briefing is the classifier's per-document take, rendered as an - # Obsidian `> [!summary]` callout so it reads as a distinct block — - # not "yet another H2 section that looks identical to the body". The - # callout's tinted styling in Obsidian (and labeled blockquote in - # Forgejo) keeps the LLM-extracted view visually separate from the - # OCR-cleaned content that follows. - # - # `source_link`, when present, surfaces a direct link inside the - # callout — for documents that's the Paperless web URL, so the user - # is one click from the original PDF without scrolling the YAML - # frontmatter or opening the file menu. - # - # Action items stay as standard task checkboxes (work inside callouts - # in Obsidian and remain Tasks-plugin-queryable). - - def _briefing_block( - self, - *, - summary: str | None, - facts: list | None, - action_items: list | None, - source_link: tuple[str, str] | None = None, - ) -> str: - """Render the briefing as a `> [!summary]` callout. - - Sections are conditional: an empty prose summary, empty facts, - or empty action items all drop out. When everything is empty the - callout itself is suppressed — no stale `> [!summary]` shell. - - `source_link` is `(label, url)`; when both are non-empty it - renders as `[label](url)` directly under the prose. - """ - sections: list[str] = [] - - if summary and isinstance(summary, str) and summary.strip(): - sections.append(summary.strip()) - - if source_link: - label, url = source_link - if label and url: - sections.append(f"[{label}]({url})") - - fact_lines = self._fact_lines(facts or []) - if fact_lines: - sections.append("**Facts**\n" + "\n".join(fact_lines)) - - task_lines = self._action_item_lines(action_items or []) - if task_lines: - sections.append("**Action items**\n" + "\n".join(task_lines)) - - if not sections: - return "" - - inner = "\n\n".join(sections) - lines = ["> [!summary]"] - for ln in inner.split("\n"): - lines.append(f"> {ln}" if ln else ">") - return "\n".join(lines) - - @staticmethod - def _fact_lines(facts: list) -> list[str]: - out = [] - for f in facts: - if isinstance(f, str) and f.strip(): - out.append(f"- {f.strip()}") - return out - - @staticmethod - def _action_item_lines(items: list) -> list[str]: - out: list[str] = [] - for ai in items: - line = GitMirror._format_action_item(ai) - if line: - out.append(line) - return out - - @staticmethod - def _format_action_item(ai) -> str | None: - """`{action, due}` → `- [ ] action — YYYY-MM-DD` or `- [ ] action`.""" - if isinstance(ai, str): - return f"- [ ] {ai.strip()}" if ai.strip() else None - if not isinstance(ai, dict): - return None - action = (ai.get("action") or "").strip() - if not action: - return None - due = ai.get("due") - if isinstance(due, str): - due_clean = due.strip() - if due_clean and due_clean.lower() not in ("null", "none", "n/a"): - return f"- [ ] {action} — {due_clean}" - return f"- [ ] {action}" def _commit_message( self, @@ -813,32 +578,11 @@ def _capture_filepath( title: str | None, hash_key: str, ) -> str: - """Build /s/YYYY/MM/-.md. - - `entity` is the sender's slug (Matrix localpart, lowercased). - `kind` is "note" or "bookmark"; the folder is the plural. - - `hash_key` is whatever stable string the caller wants to identify - this capture by: typically the source URL for fetched/pasted - captures with a link, or a content hash when the paste has no - embedded source URL. The same key yields the same path on - re-publish — idempotent update vs. duplicate. - - Invalid `captured_at` falls back to - `/s/_unfiled/-.md` — same convention - the documents path uses for entries without a usable date. - """ - digest = hashlib.sha256( - hash_key.encode("utf-8") if hash_key else b"", - ).hexdigest()[: self._CAPTURE_HASH_LEN] - - slug = self._slug(title) if title else "capture" - kind_dir = f"{kind}s" - - if captured_at and re.match(r"^\d{4}-\d{2}-\d{2}$", captured_at): - y, m, _ = captured_at.split("-") - return f"{entity}/{kind_dir}/{y}/{m}/{slug}-{digest}.md" - return f"{entity}/{kind_dir}/_unfiled/{slug}-{digest}.md" + """Capture mirror path. Delegates to ``mirror_format.capture_filepath``, + injecting this mirror's capture-hash length.""" + return capture_filepath( + entity, kind, captured_at, title, hash_key, self._CAPTURE_HASH_LEN, + ) def _capture_frontmatter( self, *, @@ -851,48 +595,12 @@ def _capture_frontmatter( model: str | None, capture_id: str | None = None, ) -> dict: - """Frontmatter for a capture entry. - - `kind` is "bookmark" (URL pointer + LLM summary) or "note" - (pasted body the user typed). Document-shaped fields - (correspondent, document_type, category, paperless_id, - paperless_url) are intentionally absent — captures aren't part - of the Paperless ontology. - - `source_uri` is optional — a pure text note with no embedded - link omits the field entirely, so a Dataview `where source_uri` - cleanly filters to "captures that point at a source." - - `date` carries the capture date — the article's own publish - date (if any) lives in the briefing block. The capture log is - a record of *when we captured*, not when the source published. - """ - import datetime as dt - now = ( - dt.datetime.now(dt.timezone.utc) - .replace(microsecond=0) - .isoformat() - .replace("+00:00", "Z") + """Capture frontmatter. Delegates to ``mirror_format.capture_frontmatter``.""" + return capture_frontmatter( + title=title, captured_at=captured_at, kind=kind, + source_uri=source_uri, persons=persons, tags=tags, + model=model, capture_id=capture_id, ) - fm: dict = {"title": title, "kind": kind} - if captured_at: - fm["date"] = captured_at - if persons: - fm["persons"] = persons - if tags: - fm["tags"] = tags - if source_uri: - fm["source_uri"] = source_uri - if capture_id: - # Same identifier the `dev.famstack.event` envelope carries - # under `data.capture_id`. Stored on the file too so a - # later grep (or the future deriver) can find this entry - # without depending on the mutable `vault_path`. - fm["capture_id"] = capture_id - if model: - fm["model"] = model - fm["added"] = now - return fm async def read_capture(self, path: str) -> str | None: """Return the raw markdown of a capture entry, or None if missing. diff --git a/stacklets/docs/bot/mirror_format.py b/stacklets/docs/bot/mirror_format.py index cf992f1..759a37c 100644 --- a/stacklets/docs/bot/mirror_format.py +++ b/stacklets/docs/bot/mirror_format.py @@ -245,6 +245,7 @@ def capture_frontmatter( persons: list[str], tags: list[str], model: str | None, + capture_id: str | None = None, ) -> dict: """Frontmatter for a capture entry. @@ -270,6 +271,8 @@ def capture_frontmatter( persons: List of person names. tags: List of topic tags. model: Model name used for classification, or None. + capture_id: Stable capture identifier carried on the + ``dev.famstack.event`` envelope, or None. Returns: Frontmatter dict ready for YAML serialization. @@ -290,6 +293,12 @@ def capture_frontmatter( fm["tags"] = tags if source_uri: fm["source_uri"] = source_uri + if capture_id: + # Same identifier the `dev.famstack.event` envelope carries under + # `data.capture_id`. Stored on the file too so a later grep (or + # the deriver) can find this entry without depending on the + # mutable vault path. + fm["capture_id"] = capture_id if model: fm["model"] = model fm["added"] = now From 64b6ee2d6c2a8ba94cec4cf2afe75f57a59e3246 Mon Sep 17 00:00:00 2001 From: Arthur Date: Fri, 19 Jun 2026 17:49:47 +0200 Subject: [PATCH 04/21] feat: declare OKF type on document and capture mirrors OKF requires a non-empty `type` field naming the concept kind. Add `type: document` to document mirrors and `type: ` to captures, as the first frontmatter key per OKF convention. `type` is a separate axis from the Paperless `document_type` subtype; both coexist. Capture `kind` is kept for now and mirrors `type` until its readers move over. --- stacklets/docs/bot/mirror_format.py | 10 ++++++++-- tests/stacklets/test_git_mirror.py | 5 +++-- tests/stacklets/test_mirror_format.py | 6 +++++- 3 files changed, 16 insertions(+), 5 deletions(-) diff --git a/stacklets/docs/bot/mirror_format.py b/stacklets/docs/bot/mirror_format.py index 759a37c..7a1aaf4 100644 --- a/stacklets/docs/bot/mirror_format.py +++ b/stacklets/docs/bot/mirror_format.py @@ -210,7 +210,10 @@ def document_frontmatter( """ import datetime as dt now = dt.datetime.now(dt.timezone.utc).replace(microsecond=0).isoformat().replace("+00:00", "Z") - fm: dict = {"title": title} + # `type` is the OKF-required concept kind ("document"). It is a + # different axis from `document_type` (the Paperless subtype: + # invoice, contract); both coexist. First key per OKF convention. + fm: dict = {"type": "document", "title": title} if date: fm["date"] = date if correspondent: @@ -284,7 +287,10 @@ def capture_frontmatter( .isoformat() .replace("+00:00", "Z") ) - fm: dict = {"title": title, "kind": kind} + # `type` is the OKF-required concept kind and mirrors `kind` + # (note/bookmark). `kind` is retained for now; a later commit + # promotes it away once its readers move to `type`. + fm: dict = {"type": kind, "title": title, "kind": kind} if captured_at: fm["date"] = captured_at if persons: diff --git a/tests/stacklets/test_git_mirror.py b/tests/stacklets/test_git_mirror.py index 01f06d9..8562190 100644 --- a/tests/stacklets/test_git_mirror.py +++ b/tests/stacklets/test_git_mirror.py @@ -114,6 +114,7 @@ def test_ai_full(self, mirror): processing="ai_formatted", model="qwen2.5:14b", ) + assert fm["type"] == "document" assert fm["title"] == "ADAC Rechnung März 2026" assert fm["paperless_id"] == 247 assert fm["processing"] == "ai_formatted" @@ -121,8 +122,8 @@ def test_ai_full(self, mirror): assert fm["paperless_version"] == "2.14.5" assert fm["source"] == "paperless" assert fm["added"].endswith("Z") - # key order: title first, added last, key ordering reflects insertion - assert list(fm.keys())[0] == "title" + # key order: type first (OKF convention), added last; reflects insertion + assert list(fm.keys())[0] == "type" assert list(fm.keys())[-1] == "added" def test_ocr_omits_model(self, mirror): diff --git a/tests/stacklets/test_mirror_format.py b/tests/stacklets/test_mirror_format.py index 6c55417..db927f5 100644 --- a/tests/stacklets/test_mirror_format.py +++ b/tests/stacklets/test_mirror_format.py @@ -204,10 +204,11 @@ def test_full_frontmatter(self): processing="ai_formatted", model="mlx-model", ) + assert fm["type"] == "document" # OKF concept kind assert fm["title"] == "ADAC - Kfz-Versicherung" assert fm["date"] == "2025-03-27" assert fm["correspondent"] == "ADAC" - assert fm["document_type"] == "Invoice" + assert fm["document_type"] == "Invoice" # Paperless subtype, separate axis assert fm["category"] == "Insurance" assert fm["persons"] == ["Homer"] assert fm["tags"] == ["Insurance", "Vehicle"] @@ -232,6 +233,7 @@ def test_minimal_frontmatter(self): processing="ocr", model=None, ) + assert fm["type"] == "document" assert fm["title"] == "Untitled" assert "date" not in fm assert "correspondent" not in fm @@ -270,6 +272,7 @@ def test_full_capture_frontmatter(self): model="mlx-model", ) assert fm["title"] == "Reddit Thread" + assert fm["type"] == "bookmark" # OKF concept kind, mirrors kind assert fm["kind"] == "bookmark" assert fm["date"] == "2025-03-27" assert fm["source_uri"] == "https://reddit.com/r/famstack/..." @@ -293,6 +296,7 @@ def test_note_without_source_uri(self): tags=[], model=None, ) + assert fm["type"] == "note" assert fm["kind"] == "note" assert "source_uri" not in fm assert "model" not in fm From 7002ebb94f1261c9f28464d04a436b0d0294219f Mon Sep 17 00:00:00 2001 From: Arthur Date: Fri, 19 Jun 2026 17:51:22 +0200 Subject: [PATCH 05/21] feat: declare OKF type on member pages Member about.md pages omitted the OKF-required `type` field while topic pages already wrote `type: topic`. Add `type: person` to member preambles so every generated entity page is self-describing and OKF-valid standalone. --- stacklets/memory/bot/cli/wiki.py | 1 + tests/stacklets/test_memory_wiki.py | 23 +++++++++++++++++++++++ 2 files changed, 24 insertions(+) diff --git a/stacklets/memory/bot/cli/wiki.py b/stacklets/memory/bot/cli/wiki.py index bbf0c5e..0beb944 100644 --- a/stacklets/memory/bot/cli/wiki.py +++ b/stacklets/memory/bot/cli/wiki.py @@ -366,6 +366,7 @@ def _member_preamble(slug: str, display: str, synonyms: list[str]) -> str: "---", f"title: {canonical}", f"slug: {slug}", + "type: person", f"canonical: {canonical}", ] if others: diff --git a/tests/stacklets/test_memory_wiki.py b/tests/stacklets/test_memory_wiki.py index cd7a2af..85c0e72 100644 --- a/tests/stacklets/test_memory_wiki.py +++ b/tests/stacklets/test_memory_wiki.py @@ -21,6 +21,7 @@ sys.path.insert(0, str(_REPO_ROOT / "stacklets" / "memory" / "bot" / "cli")) from wiki import ( # noqa: E402 + _member_preamble, _topic_cross_refs, _topic_entries, _topic_locations, @@ -291,6 +292,28 @@ def test_missing_topics_and_tags_fields(self): assert refs == [] +# ── _member_preamble ────────────────────────────────────────────────── + + +class TestMemberPreamble: + """First-creation frontmatter for a member `about.md`.""" + + def test_carries_okf_type_and_canonical(self): + pre = _member_preamble("maggie", "Maggie", ["Maggie", "Margaret"]) + assert "type: person" in pre # OKF concept kind + assert "title: Margaret" in pre # longest synonym is canonical + assert "canonical: Margaret" in pre + assert "slug: maggie" in pre + assert pre.startswith("---") + assert pre.rstrip().endswith("---") + + def test_no_synonyms_collapses_to_display(self): + pre = _member_preamble("homer", "Homer", []) + assert "type: person" in pre + assert "title: Homer" in pre + assert "synonyms:" not in pre + + # ── _topic_preamble ─────────────────────────────────────────────────── From 4a1d259cfa72c5cb75ebb9873215a3badafff44d Mon Sep 17 00:00:00 2001 From: Arthur Date: Fri, 19 Jun 2026 17:56:48 +0200 Subject: [PATCH 06/21] docs: record the tags convention decision (present-when-nonempty, not required) --- docs/design/brain/open-knowledge-format.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/docs/design/brain/open-knowledge-format.md b/docs/design/brain/open-knowledge-format.md index 57d052b..aeabf1a 100644 --- a/docs/design/brain/open-knowledge-format.md +++ b/docs/design/brain/open-knowledge-format.md @@ -168,6 +168,23 @@ already maintains -- the exporter is small. the beta window is the time to reconsider option 2. Flag it, do not silently switch.) +## The `tags` convention + +OKF lists `tags` as *optional* (a recommended field, not a required one). +We considered making it a famstack-required field across every concept. +**Decision: keep it a convention, present-when-nonempty -- not required.** + +Two reasons. First, "always emit `tags`, even empty" breaks the +presence-as-signal idiom the builders already rely on (the capture +renderer omits an absent `source_uri` precisely so a Dataview +`where source_uri` cleanly filters); an always-present `tags: []` is +noise that defeats "has tags vs untagged" filtering. Second, document +and capture tags come from the classifier, but entity and topic pages +have no tag source today -- a required field there would be empty +theater. So: documents and captures emit `tags` when the classifier +produced some (already the case); entity/topic pages gain a `tags` slot +only once something populates it. + ## What we change vs what we build ### Changes (the beta-window renames -- cheap now, expensive after 1.0) From abd18eadef98ba8faeb93ac1ec7bff5c621b143c Mon Sep 17 00:00:00 2001 From: Arthur Date: Fri, 19 Jun 2026 18:27:32 +0200 Subject: [PATCH 07/21] refactor: rename mirror_format to vault_entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "mirror_format" was vague about what it produces. The module builds a vault entry — one markdown file mirroring a Paperless document or capture (an OKF "document") — across three pure steps: path, frontmatter, body. Rename the module and its test to say so. --- stacklets/docs/bot/git_mirror.py | 16 ++++++++-------- .../bot/{mirror_format.py => vault_entry.py} | 7 ++++++- ...test_mirror_format.py => test_vault_entry.py} | 4 ++-- 3 files changed, 16 insertions(+), 11 deletions(-) rename stacklets/docs/bot/{mirror_format.py => vault_entry.py} (98%) rename tests/stacklets/{test_mirror_format.py => test_vault_entry.py} (99%) diff --git a/stacklets/docs/bot/git_mirror.py b/stacklets/docs/bot/git_mirror.py index 37c48c3..cd193eb 100644 --- a/stacklets/docs/bot/git_mirror.py +++ b/stacklets/docs/bot/git_mirror.py @@ -53,7 +53,7 @@ from stack.forgejo import ForgejoClient, ForgejoError -from mirror_format import ( +from vault_entry import ( slug, document_filepath, capture_filepath, @@ -294,11 +294,11 @@ async def _lookup_path(self, client: ForgejoClient, paperless_id: int) -> str | # ── Filename, frontmatter, body ────────────────────────────────────── def _slug(self, text: str) -> str: - """Filesystem-safe slug. Delegates to ``mirror_format.slug``.""" + """Filesystem-safe slug. Delegates to ``vault_entry.slug``.""" return slug(text) def _filepath(self, date: str | None, paperless_id: int, title: str | None, has_title: bool) -> str: - """Document mirror path. Delegates to ``mirror_format.document_filepath``, + """Document mirror path. Delegates to ``vault_entry.document_filepath``, injecting this mirror's configured shared bucket.""" return document_filepath(self.shared_bucket, date, paperless_id, title, has_title) @@ -317,7 +317,7 @@ def _frontmatter( processing: str, model: str | None, ) -> dict: - """Document frontmatter. Delegates to ``mirror_format.document_frontmatter``, + """Document frontmatter. Delegates to ``vault_entry.document_frontmatter``, injecting this mirror's known Paperless server version.""" return document_frontmatter( title=title, date=date, @@ -340,7 +340,7 @@ def _render( action_items: list | None = None, source_link: tuple[str, str] | None = None, ) -> str: - """Document mirror markdown. Delegates to ``mirror_format.render_document``.""" + """Document mirror markdown. Delegates to ``vault_entry.render_document``.""" return render_document( frontmatter=frontmatter, body=body, correspondent=correspondent, persons=persons, @@ -359,7 +359,7 @@ def _render_capture( summary: str | None = None, facts: list | None = None, ) -> str: - """Capture mirror markdown. Delegates to ``mirror_format.render_capture``.""" + """Capture mirror markdown. Delegates to ``vault_entry.render_capture``.""" return render_capture( frontmatter=frontmatter, body=body, kind=kind, captured_at=captured_at, source_uri=source_uri, persons=persons, @@ -578,7 +578,7 @@ def _capture_filepath( title: str | None, hash_key: str, ) -> str: - """Capture mirror path. Delegates to ``mirror_format.capture_filepath``, + """Capture mirror path. Delegates to ``vault_entry.capture_filepath``, injecting this mirror's capture-hash length.""" return capture_filepath( entity, kind, captured_at, title, hash_key, self._CAPTURE_HASH_LEN, @@ -595,7 +595,7 @@ def _capture_frontmatter( model: str | None, capture_id: str | None = None, ) -> dict: - """Capture frontmatter. Delegates to ``mirror_format.capture_frontmatter``.""" + """Capture frontmatter. Delegates to ``vault_entry.capture_frontmatter``.""" return capture_frontmatter( title=title, captured_at=captured_at, kind=kind, source_uri=source_uri, persons=persons, tags=tags, diff --git a/stacklets/docs/bot/mirror_format.py b/stacklets/docs/bot/vault_entry.py similarity index 98% rename from stacklets/docs/bot/mirror_format.py rename to stacklets/docs/bot/vault_entry.py index 7a1aaf4..619b5fa 100644 --- a/stacklets/docs/bot/mirror_format.py +++ b/stacklets/docs/bot/vault_entry.py @@ -1,4 +1,9 @@ -"""Pure mirror formatting — path generation, frontmatter, markdown rendering. +"""Vault entry formatting — path generation, frontmatter, markdown rendering. + +A vault entry is one markdown file mirroring a Paperless document or a +capture (an OKF "document" within the vault bundle). This module owns the +three pure steps that produce it: pick the path, build the frontmatter, +render the body. No Forgejo I/O, no git operations, no filesystem writes. These functions take structured data and produce strings (filepaths, YAML, markdown). diff --git a/tests/stacklets/test_mirror_format.py b/tests/stacklets/test_vault_entry.py similarity index 99% rename from tests/stacklets/test_mirror_format.py rename to tests/stacklets/test_vault_entry.py index db927f5..69fce0a 100644 --- a/tests/stacklets/test_mirror_format.py +++ b/tests/stacklets/test_vault_entry.py @@ -1,4 +1,4 @@ -"""Unit tests for mirror_format — pure path generation and markdown rendering. +"""Unit tests for vault_entry — pure path generation and markdown rendering. No Forgejo I/O, no git operations. Tests use Springfield-themed names.""" @@ -11,7 +11,7 @@ REPO_ROOT = Path(__file__).resolve().parent.parent.parent sys.path.insert(0, str(REPO_ROOT / "stacklets" / "docs" / "bot")) -from mirror_format import ( +from vault_entry import ( slug, document_filepath, capture_filepath, From 35b9f5b2c04b729e8ee4d6fb4f22174e82e9c9d0 Mon Sep 17 00:00:00 2001 From: Arthur Date: Fri, 19 Jun 2026 18:43:14 +0200 Subject: [PATCH 08/21] feat: rename frontmatter added to OKF timestamp OKF's recommended chronology field is `timestamp` (ISO 8601 datetime of last meaningful change) -- the field OKF index/search/viewers sort by. We write it on every publish and reprocess, so it is exactly last-write time. Rename `added` -> `timestamp` so OKF consumers pick up the recency signal instead of ignoring a custom key. (Paperless's own API `added` field, read in reprocess, is unrelated and untouched.) --- stacklets/docs/bot/vault_entry.py | 4 ++-- tests/stacklets/test_git_mirror.py | 8 ++++---- tests/stacklets/test_vault_entry.py | 4 ++-- 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/stacklets/docs/bot/vault_entry.py b/stacklets/docs/bot/vault_entry.py index 619b5fa..99f948a 100644 --- a/stacklets/docs/bot/vault_entry.py +++ b/stacklets/docs/bot/vault_entry.py @@ -240,7 +240,7 @@ def document_frontmatter( if paperless_version: fm["paperless_version"] = paperless_version fm["source"] = "paperless" - fm["added"] = now + fm["timestamp"] = now return fm @@ -312,7 +312,7 @@ def capture_frontmatter( fm["capture_id"] = capture_id if model: fm["model"] = model - fm["added"] = now + fm["timestamp"] = now return fm diff --git a/tests/stacklets/test_git_mirror.py b/tests/stacklets/test_git_mirror.py index 8562190..a750c28 100644 --- a/tests/stacklets/test_git_mirror.py +++ b/tests/stacklets/test_git_mirror.py @@ -121,10 +121,10 @@ def test_ai_full(self, mirror): assert fm["model"] == "qwen2.5:14b" assert fm["paperless_version"] == "2.14.5" assert fm["source"] == "paperless" - assert fm["added"].endswith("Z") - # key order: type first (OKF convention), added last; reflects insertion + assert fm["timestamp"].endswith("Z") + # key order: type first (OKF convention), timestamp last; reflects insertion assert list(fm.keys())[0] == "type" - assert list(fm.keys())[-1] == "added" + assert list(fm.keys())[-1] == "timestamp" def test_ocr_omits_model(self, mirror): fm = mirror._frontmatter( @@ -514,7 +514,7 @@ def test_bookmark_minimum_shape(self, mirror): assert "category" not in fm assert "paperless_id" not in fm assert "paperless_url" not in fm - assert fm["added"].endswith("Z") + assert fm["timestamp"].endswith("Z") def test_note_with_source_uri(self, mirror): # Pasted Reddit thread with the URL in the body — kind=note, diff --git a/tests/stacklets/test_vault_entry.py b/tests/stacklets/test_vault_entry.py index 69fce0a..ee20bfe 100644 --- a/tests/stacklets/test_vault_entry.py +++ b/tests/stacklets/test_vault_entry.py @@ -217,7 +217,7 @@ def test_full_frontmatter(self): assert fm["processing"] == "ai_formatted" assert fm["model"] == "mlx-model" assert fm["source"] == "paperless" - assert "added" in fm # timestamp is always present + assert "timestamp" in fm # always present def test_minimal_frontmatter(self): fm = document_frontmatter( @@ -279,7 +279,7 @@ def test_full_capture_frontmatter(self): assert fm["persons"] == ["Arthur"] assert fm["tags"] == ["Technology"] assert fm["model"] == "mlx-model" - assert "added" in fm + assert "timestamp" in fm # Document fields should NOT be present assert "paperless_id" not in fm assert "paperless_url" not in fm From 86de6e3ef250ddb9ce3279465541b9c47763df63 Mon Sep 17 00:00:00 2001 From: Arthur Date: Fri, 19 Jun 2026 18:47:47 +0200 Subject: [PATCH 09/21] feat: rename capture source_uri to OKF resource OKF's `resource` field is "a URI that uniquely identifies the underlying asset the concept describes" -- an exact match for a bookmark's source URL. Rename the capture frontmatter key `source_uri` -> `resource` and update the reprocess reader. The publish/extractor argument stays `source_uri` (it is the input); only the OKF-facing key changes. The event-envelope `source_uri` is a separate contract and is untouched. --- stacklets/docs/bot/capture_pipeline.py | 4 ++-- stacklets/docs/bot/vault_entry.py | 11 +++++++---- tests/stacklets/test_git_mirror.py | 10 +++++----- tests/stacklets/test_vault_entry.py | 4 ++-- 4 files changed, 16 insertions(+), 13 deletions(-) diff --git a/stacklets/docs/bot/capture_pipeline.py b/stacklets/docs/bot/capture_pipeline.py index ac5fa3c..3457c8b 100644 --- a/stacklets/docs/bot/capture_pipeline.py +++ b/stacklets/docs/bot/capture_pipeline.py @@ -571,14 +571,14 @@ async def reprocess( source = SourceContent( text=source_text, title_hint=meta.get("title") or None, - source_uri=meta.get("source_uri") or None, + source_uri=meta.get("resource") or None, ) kind = meta.get("kind") or "bookmark" captured_at = meta.get("date") or _dt.date.today().isoformat() capture_id = meta.get("capture_id") return await self._publish( source=source, kind=str(kind), sender_mxid=sender_mxid, - display_link=meta.get("source_uri") or "(capture)", + display_link=meta.get("resource") or "(capture)", user_hint=user_hint, existing_path=vault_path, captured_at=str(captured_at), diff --git a/stacklets/docs/bot/vault_entry.py b/stacklets/docs/bot/vault_entry.py index 99f948a..ab8f9aa 100644 --- a/stacklets/docs/bot/vault_entry.py +++ b/stacklets/docs/bot/vault_entry.py @@ -263,9 +263,10 @@ def capture_frontmatter( paperless_url) are intentionally absent — captures aren't part of the Paperless ontology. - ``source_uri`` is optional — a pure text note with no embedded - link omits the field entirely, so a Dataview ``where source_uri`` - cleanly filters to "captures that point at a source." + The OKF ``resource`` field (the source URL) is optional — a pure + text note with no embedded link omits it entirely, so a Dataview + ``where resource`` cleanly filters to "captures that point at a + source." It is fed from the ``source_uri`` argument. ``date`` carries the capture date — the article's own publish date (if any) lives in the briefing block. The capture log is @@ -303,7 +304,9 @@ def capture_frontmatter( if tags: fm["tags"] = tags if source_uri: - fm["source_uri"] = source_uri + # OKF `resource`: the URI of the underlying asset this concept + # describes (the captured source URL). + fm["resource"] = source_uri if capture_id: # Same identifier the `dev.famstack.event` envelope carries under # `data.capture_id`. Stored on the file too so a later grep (or diff --git a/tests/stacklets/test_git_mirror.py b/tests/stacklets/test_git_mirror.py index a750c28..d6ae9eb 100644 --- a/tests/stacklets/test_git_mirror.py +++ b/tests/stacklets/test_git_mirror.py @@ -489,7 +489,7 @@ def test_invalid_date_lands_in_unfiled(self, mirror): class TestCaptureFrontmatter: - """Captures carry `kind: bookmark|note` + optional `source_uri`. + """Captures carry `kind: bookmark|note` + optional `resource`. Bookmark = URL pointer; Note = pasted body the user typed. Document-shaped fields (correspondent, document_type, category) are @@ -507,7 +507,7 @@ def test_bookmark_minimum_shape(self, mirror): ) assert fm["title"] == "Why local LLMs matter" assert fm["kind"] == "bookmark" - assert fm["source_uri"] == "https://example.com/llms" + assert fm["resource"] == "https://example.com/llms" # Document-shaped fields are gone from captures. assert "correspondent" not in fm assert "document_type" not in fm @@ -527,7 +527,7 @@ def test_note_with_source_uri(self, mirror): persons=["Arthur"], tags=["LLMs"], model=None, ) assert fm["kind"] == "note" - assert fm["source_uri"] == "https://reddit.com/r/x/comments/y" + assert fm["resource"] == "https://reddit.com/r/x/comments/y" assert fm["tags"] == ["LLMs"] def test_note_without_source_uri(self, mirror): @@ -542,7 +542,7 @@ def test_note_without_source_uri(self, mirror): persons=["Arthur"], tags=[], model=None, ) assert fm["kind"] == "note" - assert "source_uri" not in fm + assert "resource" not in fm def test_full_shape(self, mirror): fm = mirror._capture_frontmatter( @@ -594,7 +594,7 @@ def test_bookmark_renders_without_body(self, mirror): action_items=[], ) assert "kind: bookmark" in out - assert "source_uri: https://example.com/llms" in out + assert "resource: https://example.com/llms" in out assert "# Why local LLMs matter" in out assert "**About:** [[Arthur]]" in out assert "> [!summary]" in out diff --git a/tests/stacklets/test_vault_entry.py b/tests/stacklets/test_vault_entry.py index ee20bfe..5ceacbe 100644 --- a/tests/stacklets/test_vault_entry.py +++ b/tests/stacklets/test_vault_entry.py @@ -275,7 +275,7 @@ def test_full_capture_frontmatter(self): assert fm["type"] == "bookmark" # OKF concept kind, mirrors kind assert fm["kind"] == "bookmark" assert fm["date"] == "2025-03-27" - assert fm["source_uri"] == "https://reddit.com/r/famstack/..." + assert fm["resource"] == "https://reddit.com/r/famstack/..." assert fm["persons"] == ["Arthur"] assert fm["tags"] == ["Technology"] assert fm["model"] == "mlx-model" @@ -298,7 +298,7 @@ def test_note_without_source_uri(self): ) assert fm["type"] == "note" assert fm["kind"] == "note" - assert "source_uri" not in fm + assert "resource" not in fm assert "model" not in fm From 1227d0e58e54b7851b29034b0c4cc549f1460e42 Mon Sep 17 00:00:00 2001 From: Arthur Date: Fri, 19 Jun 2026 18:51:21 +0200 Subject: [PATCH 10/21] feat: promote capture kind to OKF type The note/bookmark distinction is the OKF concept kind, which `type` already carries. Drop the redundant `kind` frontmatter key (added alongside `type` in the prior commit) so captures have a single, OKF-native home for it, and move the reprocess reader to `type`. The `kind` function arguments stay -- only the frontmatter key is removed. --- stacklets/docs/bot/capture_pipeline.py | 2 +- stacklets/docs/bot/vault_entry.py | 7 +++---- tests/stacklets/test_git_mirror.py | 10 +++++----- tests/stacklets/test_vault_entry.py | 4 ++-- 4 files changed, 11 insertions(+), 12 deletions(-) diff --git a/stacklets/docs/bot/capture_pipeline.py b/stacklets/docs/bot/capture_pipeline.py index 3457c8b..0a62ed2 100644 --- a/stacklets/docs/bot/capture_pipeline.py +++ b/stacklets/docs/bot/capture_pipeline.py @@ -573,7 +573,7 @@ async def reprocess( title_hint=meta.get("title") or None, source_uri=meta.get("resource") or None, ) - kind = meta.get("kind") or "bookmark" + kind = meta.get("type") or "bookmark" captured_at = meta.get("date") or _dt.date.today().isoformat() capture_id = meta.get("capture_id") return await self._publish( diff --git a/stacklets/docs/bot/vault_entry.py b/stacklets/docs/bot/vault_entry.py index ab8f9aa..9834c9a 100644 --- a/stacklets/docs/bot/vault_entry.py +++ b/stacklets/docs/bot/vault_entry.py @@ -293,10 +293,9 @@ def capture_frontmatter( .isoformat() .replace("+00:00", "Z") ) - # `type` is the OKF-required concept kind and mirrors `kind` - # (note/bookmark). `kind` is retained for now; a later commit - # promotes it away once its readers move to `type`. - fm: dict = {"type": kind, "title": title, "kind": kind} + # `type` is the OKF-required concept kind (note/bookmark). It is the + # single home for that distinction; there is no separate `kind` key. + fm: dict = {"type": kind, "title": title} if captured_at: fm["date"] = captured_at if persons: diff --git a/tests/stacklets/test_git_mirror.py b/tests/stacklets/test_git_mirror.py index d6ae9eb..7134d8c 100644 --- a/tests/stacklets/test_git_mirror.py +++ b/tests/stacklets/test_git_mirror.py @@ -506,7 +506,7 @@ def test_bookmark_minimum_shape(self, mirror): persons=[], tags=[], model=None, ) assert fm["title"] == "Why local LLMs matter" - assert fm["kind"] == "bookmark" + assert fm["type"] == "bookmark" assert fm["resource"] == "https://example.com/llms" # Document-shaped fields are gone from captures. assert "correspondent" not in fm @@ -526,7 +526,7 @@ def test_note_with_source_uri(self, mirror): source_uri="https://reddit.com/r/x/comments/y", persons=["Arthur"], tags=["LLMs"], model=None, ) - assert fm["kind"] == "note" + assert fm["type"] == "note" assert fm["resource"] == "https://reddit.com/r/x/comments/y" assert fm["tags"] == ["LLMs"] @@ -541,7 +541,7 @@ def test_note_without_source_uri(self, mirror): source_uri=None, persons=["Arthur"], tags=[], model=None, ) - assert fm["kind"] == "note" + assert fm["type"] == "note" assert "resource" not in fm def test_full_shape(self, mirror): @@ -593,7 +593,7 @@ def test_bookmark_renders_without_body(self, mirror): facts=["Mac Mini idles under 10W"], action_items=[], ) - assert "kind: bookmark" in out + assert "type: bookmark" in out assert "resource: https://example.com/llms" in out assert "# Why local LLMs matter" in out assert "**About:** [[Arthur]]" in out @@ -617,7 +617,7 @@ def test_note_renders_with_body(self, mirror): summary="Comment thread comparing on-device inference speeds.", facts=[], action_items=[], ) - assert "kind: note" in out + assert "type: note" in out assert "> [!summary]" in out assert "Comment thread comparing" in out assert "Top comment quotes 60 tok/s" in out diff --git a/tests/stacklets/test_vault_entry.py b/tests/stacklets/test_vault_entry.py index 5ceacbe..e60e17d 100644 --- a/tests/stacklets/test_vault_entry.py +++ b/tests/stacklets/test_vault_entry.py @@ -273,7 +273,7 @@ def test_full_capture_frontmatter(self): ) assert fm["title"] == "Reddit Thread" assert fm["type"] == "bookmark" # OKF concept kind, mirrors kind - assert fm["kind"] == "bookmark" + assert "kind" not in fm # promoted to type assert fm["date"] == "2025-03-27" assert fm["resource"] == "https://reddit.com/r/famstack/..." assert fm["persons"] == ["Arthur"] @@ -297,7 +297,7 @@ def test_note_without_source_uri(self): model=None, ) assert fm["type"] == "note" - assert fm["kind"] == "note" + assert "kind" not in fm # promoted to type assert "resource" not in fm assert "model" not in fm From 82f8e2cee61de960abc2f7c90d763dbbdbd4c634 Mon Sep 17 00:00:00 2001 From: Arthur Date: Fri, 19 Jun 2026 18:59:46 +0200 Subject: [PATCH 11/21] feat: add OKF resource (per-document URL) to document mirrors OKF's `resource` is the URI of the asset a concept describes. Documents carried only `paperless_url` (the instance base); the per-document details URL existed only for the briefing link. Add `resource` = the document's Paperless details page so OKF consumers link to the original. A shared `document_resource_url` helper now backs both the frontmatter field and the "Show Document" briefing link (was duplicated inline). --- stacklets/docs/bot/git_mirror.py | 11 ++++------- stacklets/docs/bot/vault_entry.py | 21 +++++++++++++++++++++ tests/stacklets/test_git_mirror.py | 3 ++- tests/stacklets/test_vault_entry.py | 2 ++ 4 files changed, 29 insertions(+), 8 deletions(-) diff --git a/stacklets/docs/bot/git_mirror.py b/stacklets/docs/bot/git_mirror.py index cd193eb..cee40d9 100644 --- a/stacklets/docs/bot/git_mirror.py +++ b/stacklets/docs/bot/git_mirror.py @@ -56,6 +56,7 @@ from vault_entry import ( slug, document_filepath, + document_resource_url, capture_filepath, document_frontmatter, capture_frontmatter, @@ -460,13 +461,9 @@ async def publish( ) # The briefing's "Show Document" link wants the per-document - # details page, not the instance root the caller hands us. - # Frontmatter keeps the base URL — that's the canonical - # `paperless_url` field; scripts compose deeper paths off it. - paperless_doc_url = ( - f"{paperless_url.rstrip('/')}/documents/{paperless_id}/details" - if paperless_url else "" - ) + # details page, not the instance root the caller hands us — the + # same URL stamped into the frontmatter `resource` field. + paperless_doc_url = document_resource_url(paperless_url, paperless_id) # Briefing block inputs come straight from the classifier. The # `summary` parameter on `publish()` is the multi-section diff --git a/stacklets/docs/bot/vault_entry.py b/stacklets/docs/bot/vault_entry.py index 9834c9a..44c0e22 100644 --- a/stacklets/docs/bot/vault_entry.py +++ b/stacklets/docs/bot/vault_entry.py @@ -177,6 +177,24 @@ def capture_filepath( return f"{entity}/{kind_dir}/_unfiled/{slug_str}-{digest}.md" +def document_resource_url(paperless_url: str, paperless_id: int) -> str: + """The canonical URI of a document: its Paperless details page. + + OKF's ``resource`` field and the human-facing "Show Document" link + both point here. ``paperless_url`` is the instance base (not + page-specific); this composes the per-document path off it. Empty + when no public Paperless URL is configured. + + >>> document_resource_url("http://docs.home.local", 247) + 'http://docs.home.local/documents/247/details' + >>> document_resource_url("", 247) + '' + """ + if not paperless_url: + return "" + return f"{paperless_url.rstrip('/')}/documents/{paperless_id}/details" + + def document_frontmatter( *, title: str, @@ -234,6 +252,9 @@ def document_frontmatter( fm["paperless_id"] = paperless_id if paperless_url: fm["paperless_url"] = paperless_url + # OKF `resource`: the URI of this specific document (its Paperless + # details page), not the instance root kept in `paperless_url`. + fm["resource"] = document_resource_url(paperless_url, paperless_id) fm["processing"] = processing if model: fm["model"] = model diff --git a/tests/stacklets/test_git_mirror.py b/tests/stacklets/test_git_mirror.py index 7134d8c..794511d 100644 --- a/tests/stacklets/test_git_mirror.py +++ b/tests/stacklets/test_git_mirror.py @@ -110,11 +110,12 @@ def test_ai_full(self, mirror): persons=["Homer"], tags=["Insurance", "Person: Homer"], paperless_id=247, - paperless_url="http://docs.home.local/documents/247", + paperless_url="http://docs.home.local", processing="ai_formatted", model="qwen2.5:14b", ) assert fm["type"] == "document" + assert fm["resource"] == "http://docs.home.local/documents/247/details" assert fm["title"] == "ADAC Rechnung März 2026" assert fm["paperless_id"] == 247 assert fm["processing"] == "ai_formatted" diff --git a/tests/stacklets/test_vault_entry.py b/tests/stacklets/test_vault_entry.py index e60e17d..4e7ef07 100644 --- a/tests/stacklets/test_vault_entry.py +++ b/tests/stacklets/test_vault_entry.py @@ -214,6 +214,7 @@ def test_full_frontmatter(self): assert fm["tags"] == ["Insurance", "Vehicle"] assert fm["paperless_id"] == 42 assert fm["paperless_url"] == "http://paperless:8000" + assert fm["resource"] == "http://paperless:8000/documents/42/details" assert fm["processing"] == "ai_formatted" assert fm["model"] == "mlx-model" assert fm["source"] == "paperless" @@ -243,6 +244,7 @@ def test_minimal_frontmatter(self): assert "tags" not in fm assert fm["paperless_id"] == 1 assert "paperless_url" not in fm + assert "resource" not in fm # no public URL -> no resource assert fm["processing"] == "ocr" assert "model" not in fm assert fm["source"] == "paperless" From 985c869d11938385f2783c29bf7790c1a06672a7 Mon Sep 17 00:00:00 2001 From: Arthur Date: Fri, 19 Jun 2026 19:29:20 +0200 Subject: [PATCH 12/21] docs: align brain design docs with the OKF Phase 1 renames Update family-memory.md's example frontmatter to the shipped shape (type, timestamp, resource; kind promoted into type) and mark the OKF gap-analysis items landed. Fix the stale mirror_format.py references (module is now vault_entry.py). --- docs/design/brain/family-memory.md | 19 +++++++++++-------- docs/design/brain/open-knowledge-format.md | 17 ++++++++++++----- 2 files changed, 23 insertions(+), 13 deletions(-) diff --git a/docs/design/brain/family-memory.md b/docs/design/brain/family-memory.md index 29bc20e..b3cf2dd 100644 --- a/docs/design/brain/family-memory.md +++ b/docs/design/brain/family-memory.md @@ -74,6 +74,7 @@ family/memory.git (Forgejo repo, seeded by `memory` stackl ```markdown --- +type: document title: ADAC Kfz-Versicherung 2026 date: 2026-03-15 correspondent: ADAC @@ -83,10 +84,11 @@ persons: [Homer] tags: [Versicherung, Fahrzeug, "Person: Homer"] paperless_id: 247 paperless_url: http://docs.local +resource: http://docs.local/documents/247/details processing: ai_formatted model: qwen3.5-vl-7b source: paperless -added: 2026-05-20T14:23:00Z +timestamp: 2026-05-20T14:23:00Z --- # ADAC Kfz-Versicherung 2026 @@ -113,8 +115,9 @@ Notes: in Obsidian; labeled blockquote in Forgejo). Visually distinct from the OCR body below. - `Show Document` is a per-document link composed from `paperless_url` - + `/documents//details`; frontmatter `paperless_url` keeps the - base URL so scripts can compose other deep links. + + `/documents//details`. The same URL is stamped into the OKF + `resource` field; frontmatter `paperless_url` keeps the base URL so + scripts can compose other deep links. - Sections inside the callout use `**Bold labels**` instead of `##` headings — Obsidian renders them as section starts within the callout, and callouts don't nest H2s cleanly. @@ -123,14 +126,14 @@ Notes: ```markdown --- +type: bookmark title: Local-LLM benchmarks roundup -kind: bookmark date: 2026-05-17 persons: [Arthur] tags: [local-llms, benchmarks, "Person: Arthur"] -source_uri: https://example.com/llms +resource: https://example.com/llms model: qwen3.5-vl-7b -added: 2026-05-17T09:00:00Z +timestamp: 2026-05-17T09:00:00Z --- # Local-LLM benchmarks roundup @@ -148,8 +151,8 @@ added: 2026-05-17T09:00:00Z ``` Notes: -- `kind: note` keeps the user's pasted body in a collapsed - `> [!quote]- Original paste` callout below the summary. `kind: bookmark` +- `type: note` keeps the user's pasted body in a collapsed + `> [!quote]- Original paste` callout below the summary. `type: bookmark` has no body — the URL plus the digest IS the entry. - No "Action items" section on captures. A bookmark to a Reddit thread is not a todo; pasting links shouldn't flood the system with chores. diff --git a/docs/design/brain/open-knowledge-format.md b/docs/design/brain/open-knowledge-format.md index aeabf1a..f93a885 100644 --- a/docs/design/brain/open-knowledge-format.md +++ b/docs/design/brain/open-knowledge-format.md @@ -1,6 +1,6 @@ # Open Knowledge Format (OKF) support -> Status: design draft +> Status: Phase 1 landed (at-rest conformance) on branch `okf-conformance`; Phase 2 (exporter + conformance test) pending > Created: 2026-06-16 > Author: Arthur + Claude > Depends on: [knowledge-structure.md](knowledge-structure.md) (vault shape), @@ -76,9 +76,16 @@ browser we could adopt or fork for families who do not run Obsidian. ## Gap analysis (grounded in current code) -All field names below are verified against `stacklets/docs/bot/mirror_format.py` +All field names below are verified against `stacklets/docs/bot/vault_entry.py` and `docs/design/brain/family-ontology.md` as of this branch. +> **Phase 1 landed.** The "action" items in the tables below are done at +> rest: every concept now writes a required `type`; `added` -> `timestamp`, +> capture `source_uri` -> `resource`, documents carry a per-document +> `resource`, and capture `kind` was promoted into `type`. The tables are +> kept as the original analysis. What remains is Phase 2 (the exporter and +> the wikilink translation). + ### Document mirror frontmatter Current (`document_frontmatter`): `title`, `date?`, `correspondent?`, @@ -156,7 +163,7 @@ Two options: 2. **Switch natively to relative markdown links.** Conformant by construction, no exporter. But it degrades the Obsidian experience (wikilinks are why backlinks and the graph view "just work") and is a - wider rename touching every renderer in `mirror_format.py`. + wider rename touching every renderer in `vault_entry.py`. **Recommendation: option 1 (translate on export).** The vault's primary consumer is a family in Obsidian/Forgejo, not an OKF agent. Optimize the @@ -210,7 +217,7 @@ just let old entries age out, but a script is safer and trivial. ` (or a vault flag) that: emits per-folder `index.md` nav files, translates `[[wikilinks]]` to relative links, and writes a top-level bundle `index.md`. Reuses the wiki engine's roster + the existing - `mirror_format` renderers. + `vault_entry` renderers. 6. **Conformance test.** A small validator (the OKF spec ships conformance criteria) run in `stacktests` against a fabricated Simpsons vault, so we do not silently drift out of conformance. @@ -339,7 +346,7 @@ The one nugget worth keeping: **identity lives in frontmatter, the filesystem path is derived; reindex by globbing and reading the stored id, so files can be renamed/moved without breaking identity.** That directly informs our rename work -- if we stamp a stable concept id, the -"filename is stable after first AI pass" constraint in `mirror_format.py` +"filename is stable after first AI pass" constraint in `vault_entry.py` relaxes. Their separate `.state` checksum file (detect human edits vs machine writes, fail-fast on drift) is also a clean pattern for the Deriver to avoid clobbering hand-edits. From bf8e3a748dd6b8a1e22d49096289c9ff9d37c1c0 Mon Sep 17 00:00:00 2001 From: Arthur Date: Fri, 19 Jun 2026 22:35:10 +0200 Subject: [PATCH 13/21] refactor: move vault-layout conventions into stack.vault The slug rules and entity-path conventions were split across stacklets (slug in docs/vault_entry, slugify_person in memory/wiki, correspondents_dir in memory/lib), and resolving an entity link needed docs to reach into memory's internals. Introduce stack.vault as the framework home for vault layout: slug, slugify_person, correspondents_dir, entity_page_path, and entity_relpath. Both stacklets import down into the framework instead of across into each other. Pure relocation plus the new (unused) entity_relpath/entity_page_path helpers. --- lib/stack/vault.py | 126 ++++++++++++++++++++++++++++++ stacklets/docs/bot/vault_entry.py | 29 +------ stacklets/memory/bot/cli/wiki.py | 15 +--- stacklets/memory/lib.py | 13 ++- 4 files changed, 136 insertions(+), 47 deletions(-) create mode 100644 lib/stack/vault.py diff --git a/lib/stack/vault.py b/lib/stack/vault.py new file mode 100644 index 0000000..f6c19d0 --- /dev/null +++ b/lib/stack/vault.py @@ -0,0 +1,126 @@ +"""Vault layout — how ontology entities project to vault file paths. + +Pure path/slug conventions shared across stacklets: the slug rules, where +each kind of entity page lives, and how to compute a relative link from +one vault file to another. No I/O, no third-party deps. + +Both the docs archivist (which writes document and capture mirrors) and +the memory wiki (which writes entity and topic pages) import from here, +so neither stacklet has to reach into the other's internals for a shared +convention. This is the projection layer that sits between the ontology +(`stack.ontology` — what entities *are*) and the vault on disk (where +their pages *live*). + +Entity layouts: + + - Container entities own a folder of routed content plus an `about.md`: + person -> /about.md (bucket = Matrix localpart) + topic -> //about.md + - Leaf entities are a single reference page, no folder: + correspondent -> /correspondents/.md +""" + +from __future__ import annotations + +import posixpath +import re +import unicodedata + + +DEFAULT_SHARED_BUCKET = "family" + + +def slug(text: str) -> str: + """Filesystem-safe slug: ASCII-ish, lowercase, hyphen-separated. + + The cap is a defensive ceiling, not a primary length control — the + classifier title prompt asks for short identifying titles (no dates, + no amounts), so well-shaped inputs land far under this cap. The slice + is hard at 60 chars; the title prompt keeps titles human-scannable, + not the slug. + + >>> slug("ADAC - Kfz-Versicherung 2025") + 'adac-kfz-versicherung-2025' + >>> slug("Rechnung Müller & Söhne") + 'rechnung-muller-sohne' + >>> slug(" Leading Spaces ") + 'leading-spaces' + >>> slug("") + 'document' + >>> slug("!!!") + 'document' + """ + normalized = unicodedata.normalize("NFKD", text) + ascii_ = normalized.encode("ascii", "ignore").decode() + slug_str = re.sub(r"[^a-zA-Z0-9]+", "-", ascii_).strip("-").lower() + return slug_str[:60] or "document" + + +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 + 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 + lives at ``/about.md``. + + >>> slugify_person("Homer Simpson") + 'homer' + >>> slugify_person("marge") + 'marge' + >>> slugify_person("") + '' + """ + token = name.strip().split()[0] if name.strip() else "" + return token.lower() + + +def correspondents_dir(shared_bucket: str = DEFAULT_SHARED_BUCKET) -> str: + """Repo-relative path to the correspondents folder for a bucket.""" + return f"{shared_bucket}/correspondents" + + +def entity_page_path(name: str, kind: str, shared_bucket: str = DEFAULT_SHARED_BUCKET) -> str: + """Vault-relative path to the page describing an entity. + + ``kind`` selects the layout (see module docstring): + - "person": container entity at ``/about.md``. + - "correspondent": leaf entity at + ``/correspondents/.md``. + + >>> entity_page_path("Homer Simpson", "person") + 'homer/about.md' + >>> entity_page_path("ADAC", "correspondent") + 'family/correspondents/adac.md' + """ + if kind == "person": + return f"{slugify_person(name)}/about.md" + if kind == "correspondent": + return f"{correspondents_dir(shared_bucket)}/{slug(name)}.md" + raise ValueError(f"unknown entity kind: {kind!r}") + + +def entity_relpath( + name: str, + kind: str, + from_path: str, + shared_bucket: str = DEFAULT_SHARED_BUCKET, +) -> str: + """Relative link from ``from_path`` to an entity's page. + + The result is relative to the *directory* of ``from_path``, so the + link resolves in Obsidian, on GitHub/Forgejo, and as an OKF graph + edge. The target page need not exist yet (OKF treats unwritten links + as valid "not-yet-written knowledge"). + + >>> entity_relpath("Homer", "person", "family/documents/2026/03/x.md") + '../../../../homer/about.md' + >>> entity_relpath("ADAC", "correspondent", "family/documents/2026/03/x.md") + '../../../correspondents/adac.md' + >>> entity_relpath("Homer", "person", "x.md") + 'homer/about.md' + """ + target = entity_page_path(name, kind, shared_bucket) + base = posixpath.dirname(from_path) + return posixpath.relpath(target, base) if base else target diff --git a/stacklets/docs/bot/vault_entry.py b/stacklets/docs/bot/vault_entry.py index 44c0e22..60038a1 100644 --- a/stacklets/docs/bot/vault_entry.py +++ b/stacklets/docs/bot/vault_entry.py @@ -37,35 +37,12 @@ import hashlib import re -import unicodedata import yaml - -def slug(text: str) -> str: - """Filesystem-safe slug: ASCII-ish, lowercase, hyphen-separated. - - The cap is a defensive ceiling, not a primary length control — - the classifier title prompt asks for short identifying titles - (no dates, no amounts), so well-shaped inputs land far under - this cap. The slice is hard at 60 chars; the title prompt is - responsible for keeping titles human-scannable, not the slug. - - >>> slug("ADAC - Kfz-Versicherung 2025") - 'adac-kfz-versicherung-2025' - >>> slug("Rechnung Müller & Söhne") - 'rechnung-muller-sohne' - >>> slug(" Leading Spaces ") - 'leading-spaces' - >>> slug("") - 'document' - >>> slug("!!!") - 'document' - """ - normalized = unicodedata.normalize("NFKD", text) - ascii_ = normalized.encode("ascii", "ignore").decode() - slug_str = re.sub(r"[^a-zA-Z0-9]+", "-", ascii_).strip("-").lower() - return slug_str[:60] or "document" +# Slug and entity-path conventions live in the framework (`stack.vault`) +# so the memory wiki and this docs archivist share one source. +from stack.vault import slug # noqa: F401 (re-exported for callers/tests) def document_filepath( diff --git a/stacklets/memory/bot/cli/wiki.py b/stacklets/memory/bot/cli/wiki.py index 0beb944..85488a0 100644 --- a/stacklets/memory/bot/cli/wiki.py +++ b/stacklets/memory/bot/cli/wiki.py @@ -52,6 +52,7 @@ _parse_frontmatter, extract_summary_callout, ) +from stack.vault import slugify_person # noqa: E402 from stack.ai.client import LLM, LLMUnavailableError # noqa: E402 from stack.forgejo import ForgejoClient, ForgejoError # noqa: E402 @@ -408,7 +409,7 @@ def _index_vault(vault: Path) -> list[dict]: for p in (fm.get("persons") or []) if isinstance(p, str) and p.strip() ] - slugged = [(_slugify_person(p), p) for p in raw_persons] + slugged = [(slugify_person(p), p) for p in raw_persons] out.append({ "title": fm.get("title") or md.stem, "date": fm.get("date") or "", @@ -602,18 +603,6 @@ def _topic_preamble(slug: str, display: str, scope: str) -> str: ]) -def _slugify_person(name: str) -> str: - """Map a frontmatter person name to its vault bucket slug. - - Buckets are the Matrix localpart lowercased; for the default family - 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. - """ - token = name.strip().split()[0] if name.strip() else "" - return token.lower() - - # ── Facts ──────────────────────────────────────────────────────────────── def _load_facts(vault: Path, slug: str) -> list[tuple[str, str]]: diff --git a/stacklets/memory/lib.py b/stacklets/memory/lib.py index ebbe56d..b428874 100644 --- a/stacklets/memory/lib.py +++ b/stacklets/memory/lib.py @@ -35,6 +35,11 @@ from pathlib import Path from typing import List, Optional +# Vault-layout conventions live in the framework so both stacklets share +# one source. Re-exported here for memory's own callers (and back-compat +# with `from lib import correspondents_dir`). +from stack.vault import DEFAULT_SHARED_BUCKET, correspondents_dir # noqa: F401 + # `python-frontmatter` is intentionally not imported at module load. # The CLI runs on a stdlib-only `python3` (see `./stack`), so the # install hook would crash at import time if we pulled in a third- @@ -291,14 +296,6 @@ def load_ontology_from_vault(vault_path: Path) -> Optional[Ontology]: # classifier prompt embeds the (canonical, aliases) pairs so the LLM # can canonicalize new variants before they hit Paperless. -DEFAULT_SHARED_BUCKET = "family" - - -def correspondents_dir(shared_bucket: str = DEFAULT_SHARED_BUCKET) -> str: - """Repo-relative path to the correspondents folder for a bucket.""" - return f"{shared_bucket}/correspondents" - - @dataclass class Correspondent: """A single correspondent wiki entry.""" From e651ad45ff792d7c2194f21a161fda959c616397 Mon Sep 17 00:00:00 2001 From: Arthur Date: Fri, 19 Jun 2026 22:39:12 +0200 Subject: [PATCH 14/21] feat: relative markdown links in entity headers (drop wikilinks) The document/capture From/About headers emitted Obsidian wikilinks ([[ADAC]], [[Homer]]) that only Obsidian understands. Render them as relative markdown links via stack.vault.entity_relpath instead -- they resolve in Obsidian, render and click on GitHub/Forgejo, and form OKF graph edges. The vault is now natively dual-format with no export step. Renderers take the entry's own path to compute the relative climb; correspondent links target the leaf page correspondents/.md (which the correspondent-entity pass will populate). --- stacklets/docs/bot/git_mirror.py | 6 ++++++ stacklets/docs/bot/vault_entry.py | 31 ++++++++++++++++++++++------- tests/stacklets/test_git_mirror.py | 22 +++++++++++++++++--- tests/stacklets/test_vault_entry.py | 21 +++++++++++++++---- 4 files changed, 66 insertions(+), 14 deletions(-) diff --git a/stacklets/docs/bot/git_mirror.py b/stacklets/docs/bot/git_mirror.py index cee40d9..3609ec5 100644 --- a/stacklets/docs/bot/git_mirror.py +++ b/stacklets/docs/bot/git_mirror.py @@ -336,6 +336,7 @@ def _render( body: str, correspondent: str | None, persons: list[str], + from_path: str, summary: str | None = None, facts: list | None = None, action_items: list | None = None, @@ -345,6 +346,7 @@ def _render( return render_document( frontmatter=frontmatter, body=body, correspondent=correspondent, persons=persons, + from_path=from_path, shared_bucket=self.shared_bucket, summary=summary, facts=facts, action_items=action_items, source_link=source_link, ) @@ -357,6 +359,7 @@ def _render_capture( captured_at: str | None, source_uri: str | None, persons: list[str], + from_path: str, summary: str | None = None, facts: list | None = None, ) -> str: @@ -364,6 +367,7 @@ def _render_capture( return render_capture( frontmatter=frontmatter, body=body, kind=kind, captured_at=captured_at, source_uri=source_uri, persons=persons, + from_path=from_path, shared_bucket=self.shared_bucket, summary=summary, facts=facts, ) @@ -484,6 +488,7 @@ async def publish( content = self._render( frontmatter=fm, body=body_text, correspondent=correspondent, persons=persons, + from_path=target_path, summary=briefing_summary, facts=briefing_facts, action_items=briefing_actions, @@ -724,6 +729,7 @@ async def publish_capture( captured_at=captured_at, source_uri=source_uri, persons=persons, + from_path=target_path, summary=briefing_summary, facts=briefing_facts, ) diff --git a/stacklets/docs/bot/vault_entry.py b/stacklets/docs/bot/vault_entry.py index 60038a1..8bf9650 100644 --- a/stacklets/docs/bot/vault_entry.py +++ b/stacklets/docs/bot/vault_entry.py @@ -42,7 +42,7 @@ # Slug and entity-path conventions live in the framework (`stack.vault`) # so the memory wiki and this docs archivist share one source. -from stack.vault import slug # noqa: F401 (re-exported for callers/tests) +from stack.vault import slug, entity_relpath # noqa: F401 (slug re-exported for callers/tests) def document_filepath( @@ -322,6 +322,8 @@ def render_document( body: str, correspondent: str | None, persons: list[str], + from_path: str, + shared_bucket: str, summary: str | None = None, facts: list | None = None, action_items: list | None = None, @@ -333,7 +335,9 @@ def render_document( - YAML frontmatter (machine view: structured metadata) - H1 title - - wiki-link header (``**From:** [[ADAC]] · **About:** [[Homer]]``) + - entity-link header (``**From:** [ADAC](…) · **About:** [Homer](…)``) + as relative markdown links, so they resolve in Obsidian, on + GitHub/Forgejo, and as OKF graph edges. - **briefing callout** — ``> [!summary]`` with prose, optional source link, facts, and action items. Wrapped in a callout so the briefing reads as a distinct block from the OCR body @@ -344,8 +348,10 @@ def render_document( Args: frontmatter: YAML frontmatter dict. body: Document body text (OCR-cleaned or reformatted). - correspondent: Correspondent name for wiki header. - persons: List of person names for wiki header. + correspondent: Correspondent name for the entity header. + persons: List of person names for the entity header. + from_path: This entry's own vault path, for relative link math. + shared_bucket: The institutional bucket slug (for correspondents). summary: Prose summary for the briefing callout. facts: List of fact strings for the briefing. action_items: List of action item dicts or strings. @@ -365,9 +371,13 @@ def render_document( if (correspondent or persons): bits = [] if correspondent: - bits.append(f"**From:** [[{correspondent}]]") + href = entity_relpath(correspondent, "correspondent", from_path, shared_bucket) + bits.append(f"**From:** [{correspondent}]({href})") if persons: - bits.append("**About:** " + ", ".join(f"[[{p}]]" for p in persons)) + bits.append("**About:** " + ", ".join( + f"[{p}]({entity_relpath(p, 'person', from_path, shared_bucket)})" + for p in persons + )) parts.append("> " + " · ".join(bits)) parts.append("") @@ -394,6 +404,8 @@ def render_capture( captured_at: str | None, source_uri: str | None, persons: list[str], + from_path: str, + shared_bucket: str, summary: str | None = None, facts: list | None = None, ) -> str: @@ -418,6 +430,8 @@ def render_capture( captured_at: Capture date. source_uri: Original source URL, or None. persons: List of person names. + from_path: This entry's own vault path, for relative link math. + shared_bucket: The institutional bucket slug (for correspondents). summary: Prose summary for the briefing callout. facts: List of fact strings for the briefing. @@ -435,7 +449,10 @@ def render_capture( meta_lines: list[str] = [] if persons: meta_lines.append( - "**About** " + ", ".join(f"[[{p}]]" for p in persons) + "**About** " + ", ".join( + f"[{p}]({entity_relpath(p, 'person', from_path, shared_bucket)})" + for p in persons + ) ) line2_bits = [] if captured_at: diff --git a/tests/stacklets/test_git_mirror.py b/tests/stacklets/test_git_mirror.py index 794511d..d7002be 100644 --- a/tests/stacklets/test_git_mirror.py +++ b/tests/stacklets/test_git_mirror.py @@ -230,6 +230,7 @@ def test_full_document(self, mirror): "processing": "ai_formatted", "source": "paperless", } out = mirror._render( + from_path="family/documents/2026/03/entry.md", frontmatter=fm, body="Policy number: KFZ-2024-XXX\n\nAmount: EUR 340.", correspondent="ADAC", @@ -243,12 +244,15 @@ def test_full_document(self, mirror): assert parsed["paperless_id"] == 247 assert "# ADAC Rechnung" in out - assert "**From:** [[ADAC]]" in out - assert "**About:** [[Homer]]" in out + assert "**From:** [ADAC](" in out + assert "correspondents/adac.md)" in out + assert "**About:** [Homer](" in out + assert "homer/about.md)" in out assert "Policy number: KFZ-2024-XXX" in out def test_no_wiki_header_when_no_entities(self, mirror): out = mirror._render( + from_path="family/documents/2026/03/entry.md", frontmatter={"title": "t"}, body="body", correspondent=None, persons=[], ) @@ -264,6 +268,7 @@ class TestBriefingBlock: def test_full_briefing_in_rendered_output(self, mirror): out = mirror._render( + from_path="family/documents/2026/03/entry.md", frontmatter={"title": "ADAC Rechnung"}, body="(OCR body)", correspondent="ADAC", persons=["Homer"], @@ -286,6 +291,7 @@ def test_full_briefing_in_rendered_output(self, mirror): def test_briefing_appears_before_body(self, mirror): out = mirror._render( + from_path="family/documents/2026/03/entry.md", frontmatter={"title": "t"}, body="OCR PAYLOAD", correspondent=None, persons=[], summary="One liner.", @@ -295,6 +301,7 @@ def test_briefing_appears_before_body(self, mirror): def test_omitted_entirely_when_classifier_returned_nothing(self, mirror): out = mirror._render( + from_path="family/documents/2026/03/entry.md", frontmatter={"title": "t"}, body="body", correspondent=None, persons=[], summary=None, facts=None, action_items=None, @@ -305,6 +312,7 @@ def test_omitted_entirely_when_classifier_returned_nothing(self, mirror): def test_individual_sections_drop_when_empty(self, mirror): out = mirror._render( + from_path="family/documents/2026/03/entry.md", frontmatter={"title": "t"}, body="body", correspondent=None, persons=[], summary="Just a summary.", facts=[], action_items=[], @@ -316,6 +324,7 @@ def test_individual_sections_drop_when_empty(self, mirror): def test_string_action_item_renders_as_task(self, mirror): out = mirror._render( + from_path="family/documents/2026/03/entry.md", frontmatter={"title": "t"}, body="body", correspondent=None, persons=[], summary=None, facts=None, @@ -326,6 +335,7 @@ def test_string_action_item_renders_as_task(self, mirror): def test_null_string_due_is_treated_as_no_due(self, mirror): # LLMs sometimes return the string "null" instead of true null. out = mirror._render( + from_path="family/documents/2026/03/entry.md", frontmatter={"title": "t"}, body="body", correspondent=None, persons=[], summary=None, facts=None, @@ -337,6 +347,7 @@ def test_null_string_due_is_treated_as_no_due(self, mirror): def test_skips_blank_facts_and_empty_actions(self, mirror): out = mirror._render( + from_path="family/documents/2026/03/entry.md", frontmatter={"title": "t"}, body="body", correspondent=None, persons=[], summary=None, @@ -352,6 +363,7 @@ def test_skips_blank_facts_and_empty_actions(self, mirror): def test_source_link_appears_inside_callout(self, mirror): out = mirror._render( + from_path="family/documents/2026/03/entry.md", frontmatter={"title": "t"}, body="(body)", correspondent=None, persons=[], summary="Prose.", @@ -366,6 +378,7 @@ def test_source_link_dropped_when_url_missing(self, mirror): # Half-supplied source_link tuple (label without url, or vice versa) # must not produce a malformed `[label]()` link. out = mirror._render( + from_path="family/documents/2026/03/entry.md", frontmatter={"title": "t"}, body="(body)", correspondent=None, persons=[], summary="Prose.", @@ -588,6 +601,7 @@ def test_bookmark_renders_without_body(self, mirror): persons=["Arthur"], tags=["LLMs"], model="qwen2.5:14b", ) out = mirror._render( + from_path="family/documents/2026/03/entry.md", frontmatter=fm, body="", correspondent=None, persons=["Arthur"], summary="A 200-word digest of the article's main points.", @@ -597,7 +611,8 @@ def test_bookmark_renders_without_body(self, mirror): assert "type: bookmark" in out assert "resource: https://example.com/llms" in out assert "# Why local LLMs matter" in out - assert "**About:** [[Arthur]]" in out + assert "**About:** [Arthur](" in out + assert "arthur/about.md)" in out assert "> [!summary]" in out assert "A 200-word digest" in out # No trailing empty body section — file ends after briefing. @@ -612,6 +627,7 @@ def test_note_renders_with_body(self, mirror): persons=["Arthur"], tags=["Benchmarks"], model=None, ) out = mirror._render( + from_path="family/documents/2026/03/entry.md", frontmatter=fm, body="Top comment quotes 60 tok/s on M2 Pro.\n\nRest of thread...", correspondent=None, persons=["Arthur"], diff --git a/tests/stacklets/test_vault_entry.py b/tests/stacklets/test_vault_entry.py index 4e7ef07..e4f1077 100644 --- a/tests/stacklets/test_vault_entry.py +++ b/tests/stacklets/test_vault_entry.py @@ -323,6 +323,7 @@ def test_full_document_render(self): model="mlx-model", ) content = render_document( + from_path="family/documents/2026/03/entry.md", shared_bucket="family", frontmatter=fm, body="Vehicle registration insurance renewal.\nAmount: EUR 340.00.", correspondent="ADAC", @@ -334,8 +335,10 @@ def test_full_document_render(self): ) assert "---" in content assert "# ADAC - Kfz-Versicherung" in content - assert "**From:** [[ADAC]]" in content - assert "**About:** [[Homer]]" in content + assert "**From:** [ADAC](" in content + assert "correspondents/adac.md)" in content + assert "**About:** [Homer](" in content + assert "homer/about.md)" in content assert "> [!summary]" in content assert "Kfz-Versicherung renewal" in content assert "**Facts**" in content @@ -352,6 +355,7 @@ def test_document_without_correspondent_or_persons(self): processing="ocr", model=None, ) content = render_document( + from_path="family/documents/2026/03/entry.md", shared_bucket="family", frontmatter=fm, body="Test content.", correspondent=None, @@ -374,6 +378,7 @@ def test_document_without_body(self): processing="ocr", model=None, ) content = render_document( + from_path="family/documents/2026/03/entry.md", shared_bucket="family", frontmatter=fm, body="", correspondent=None, @@ -395,6 +400,7 @@ def test_briefing_suppressed_when_all_empty(self): processing="ocr", model=None, ) content = render_document( + from_path="family/documents/2026/03/entry.md", shared_bucket="family", frontmatter=fm, body="Content.", correspondent=None, @@ -414,6 +420,7 @@ def test_action_item_as_string(self): processing="ocr", model=None, ) content = render_document( + from_path="family/documents/2026/03/entry.md", shared_bucket="family", frontmatter=fm, body="", correspondent=None, @@ -433,6 +440,7 @@ def test_action_item_with_null_due(self): processing="ocr", model=None, ) content = render_document( + from_path="family/documents/2026/03/entry.md", shared_bucket="family", frontmatter=fm, body="", correspondent=None, @@ -461,6 +469,7 @@ def test_bookmark_render(self): model="mlx-model", ) content = render_capture( + from_path="homer/bookmarks/2026/05/entry.md", shared_bucket="family", frontmatter=fm, body="", # bookmarks have no body kind="bookmark", @@ -472,7 +481,8 @@ def test_bookmark_render(self): ) assert "---" in content assert "# Reddit Thread" in content - assert "**About** [[Arthur]]" in content + assert "**About** [Arthur](" in content + assert "arthur/about.md)" in content assert "**Captured** 2025-03-27" in content assert "**Kind** bookmark" in content assert "**Source** " in content @@ -491,6 +501,7 @@ def test_note_render(self): model=None, ) content = render_capture( + from_path="homer/bookmarks/2026/05/entry.md", shared_bucket="family", frontmatter=fm, body="Discussed Q2 budget.\nAction: review expenses.", kind="note", @@ -502,7 +513,8 @@ def test_note_render(self): ) assert "---" in content assert "# Meeting notes" in content - assert "**About** [[Marge]]" in content + assert "**About** [Marge](" in content + assert "marge/about.md)" in content assert "**Kind** note" in content assert "**Captured** 2025-03-27" in content assert "Q2 budget discussion notes" in content @@ -522,6 +534,7 @@ def test_note_without_body(self): model=None, ) content = render_capture( + from_path="homer/bookmarks/2026/05/entry.md", shared_bucket="family", frontmatter=fm, body="", kind="note", From 10f0efc104403e8b122013872f8383a279133ef0 Mon Sep 17 00:00:00 2001 From: Arthur Date: Fri, 19 Jun 2026 22:42:21 +0200 Subject: [PATCH 15/21] docs: reflect the native-relative-links decision The wikilink-decision section recommended translate-on-export (option 1); we shipped native relative markdown links (option 2). Rewrite it to match what landed and why, and update the rendered examples in family-memory.md from [[wikilinks]] to relative links. --- docs/design/brain/family-memory.md | 4 +- docs/design/brain/open-knowledge-format.md | 63 +++++++++++----------- 2 files changed, 35 insertions(+), 32 deletions(-) diff --git a/docs/design/brain/family-memory.md b/docs/design/brain/family-memory.md index b3cf2dd..d38e831 100644 --- a/docs/design/brain/family-memory.md +++ b/docs/design/brain/family-memory.md @@ -93,7 +93,7 @@ timestamp: 2026-05-20T14:23:00Z # ADAC Kfz-Versicherung 2026 -> **From:** [[ADAC]] · **About:** [[Homer]] +> **From:** [ADAC](../../../correspondents/adac.md) · **About:** [Homer](../../../../homer/about.md) > [!summary] > Jährliche Erneuerung der Kfz-Vollkasko, Police KFZ-2026-987. @@ -138,7 +138,7 @@ timestamp: 2026-05-17T09:00:00Z # Local-LLM benchmarks roundup -> **About** [[Arthur]] +> **About** [Arthur](../../../about.md) > **Captured** 2026-05-17 · **Kind** bookmark > **Source** diff --git a/docs/design/brain/open-knowledge-format.md b/docs/design/brain/open-knowledge-format.md index f93a885..ad6b009 100644 --- a/docs/design/brain/open-knowledge-format.md +++ b/docs/design/brain/open-knowledge-format.md @@ -144,36 +144,39 @@ contract, not a break. | `index.md` (per-folder nav) | one L4 master `index.md`; entity hubs are `about.md` | add lightweight per-folder `index.md` listings (the wiki rebuild can emit these). `about.md` stays the human hub. | | `log.md` | `stack memory log` over git history; no materialized file | optional. Could materialize a `log.md` on export, or leave it (git IS the log). | | reserved filenames | `_unfiled`, `about` | check `index.md`/`log.md` do not collide with any generated slug; the archivist's reserved-slug set already guards `about`. | -| relationship links | Obsidian `[[wikilinks]]` (`[[Homer]]`, `[[ADAC]]`) | **The one real divergence.** OKF wants relative markdown links `[Homer](/family/people/homer.md)`. See below. | - -## The wikilink decision - -This is the only non-trivial fork. OKF's graph is built from standard -relative markdown links. We author Obsidian `[[wikilinks]]` (name-based, -resolved by Obsidian's index), which OKF parsers do not understand as -edges. - -Two options: - -1. **Translate on export.** Keep `[[wikilinks]]` as the internal authoring - convention (Obsidian-native, matches `wiki-engine`, matches olw, what - families browsing the vault actually use). The OKF exporter rewrites - `[[Name]]` -> `[Name](relative/path.md)` by resolving against the entity - roster. Internal UX unchanged; OKF surface is clean. -2. **Switch natively to relative markdown links.** Conformant by - construction, no exporter. But it degrades the Obsidian experience - (wikilinks are why backlinks and the graph view "just work") and is a - wider rename touching every renderer in `vault_entry.py`. - -**Recommendation: option 1 (translate on export).** The vault's primary -consumer is a family in Obsidian/Forgejo, not an OKF agent. Optimize the -internal surface for them; treat OKF as the portable export. Resolving -`[[Name]]` to a path requires the entity roster, which the wiki engine -already maintains -- the exporter is small. - -(If we ever find the export translation is lossy or annoying to maintain, -the beta window is the time to reconsider option 2. Flag it, do not -silently switch.) +| relationship links | relative markdown links (`[Homer](../people/homer.md)`) | **Done.** Was the one real divergence (Obsidian `[[wikilinks]]`); now native relative links via `stack.vault.entity_relpath`. See below. | + +## The link decision (landed: native relative markdown links) + +OKF's graph is built from standard relative markdown links. We used to +author Obsidian `[[wikilinks]]` (name-based, resolved by Obsidian's +index), which OKF parsers and plain markdown renderers do not understand. + +The fork was between two options: + +1. **Translate on export.** Keep `[[wikilinks]]` internally; an exporter + rewrites them to relative links on the way out. Adds an export surface + to build and maintain, and leaves the vault-at-rest non-portable. +2. **Author relative markdown links natively.** `[Homer](../people/homer.md)` + everywhere, no export. + +We chose **option 2**. The premise behind option 1 -- that wikilinks are +required for Obsidian's backlinks and graph -- was wrong: Obsidian +resolves relative markdown links too (backlinks, graph, click-through all +work). So relative markdown links are the *one syntax all consumers +understand*: Obsidian, GitHub/Forgejo rendering, and OKF graph edges. The +vault is dual-format **at rest**, with no export step to build or keep in +sync. We generate the links (nobody hand-types them), so wikilinks' only +real advantage -- terseness -- does not apply. + +This shipped on branch `okf-conformance`: the document/capture headers +render relative links via `stack.vault.entity_relpath`, which resolves an +entity name to its page path (person -> `/about.md`, correspondent +-> `correspondents/.md`) and computes the relative climb from the +entry's own location. Broken links (a correspondent referenced before its +page exists) are valid per OKF ("not-yet-written knowledge"), render +create-on-click in Obsidian, and self-heal once the wiki pass writes the +page -- no review queue. ## The `tags` convention From 8ac2541e2fbdb45ed99020c0e1a9a65f1fd950c5 Mon Sep 17 00:00:00 2001 From: Arthur Date: Fri, 19 Jun 2026 22:50:10 +0200 Subject: [PATCH 16/21] feat: generate correspondent entity pages Correspondents were referenced by documents and read by the CLI, but no generator wrote their pages -- the From: links dead-ended. Add a correspondent pass to the wiki command: discover correspondents from the classifier's correspondent: frontmatter, and write a leaf page /correspondents/.md (type: correspondent) whose body is a deterministic backlink list of the documents referencing it. Rebuilt each run, so it auto-extends with no queue. Adds --correspondent and covers them in the default loop. --- stacklets/memory/bot/cli/wiki.py | 133 +++++++++++++++++++++++++++- tests/stacklets/test_memory_wiki.py | 78 ++++++++++++++++ 2 files changed, 208 insertions(+), 3 deletions(-) diff --git a/stacklets/memory/bot/cli/wiki.py b/stacklets/memory/bot/cli/wiki.py index 85488a0..0e0b309 100644 --- a/stacklets/memory/bot/cli/wiki.py +++ b/stacklets/memory/bot/cli/wiki.py @@ -52,7 +52,7 @@ _parse_frontmatter, extract_summary_callout, ) -from stack.vault import slugify_person # noqa: E402 +from stack.vault import correspondents_dir, slug, slugify_person # noqa: E402 from stack.ai.client import LLM, LLMUnavailableError # noqa: E402 from stack.forgejo import ForgejoClient, ForgejoError # noqa: E402 @@ -127,6 +127,7 @@ async def run(llm: LLM, argv: list[str]) -> int: topics_only = "--topics" in argv single_member = _arg_value(argv, "--member") single_topic = _arg_value(argv, "--topic") + single_correspondent = _arg_value(argv, "--correspondent") vault_dir = os.environ.get("MEMORY_VAULT_DIR", "") if not vault_dir: @@ -188,6 +189,22 @@ async def run(llm: LLM, argv: list[str]) -> int: shared_bucket=shared_bucket, lang=lang, write=not dry_run, ) + if single_correspondent: + matched = [ + (s, c) for (s, c) in _correspondent_roster(index) + if single_correspondent in (c, s) + ] + if not matched: + _err(f"no correspondent named '{single_correspondent}' in documents") + return 1 + rc = 0 + for s, c in matched: + sub_rc = await _generate_correspondent( + s, c, index, shared_bucket=shared_bucket, write=not dry_run, + ) + rc = sub_rc if sub_rc else rc + return rc + # ── Default loop ────────────────────────────────────────────────── # `--topics` skips home + members; otherwise we cover home, every # member, and every topic in turn. @@ -203,12 +220,19 @@ async def run(llm: LLM, argv: list[str]) -> int: # empty bucket doesn't sink the whole run. `_generate_member` # returns 1 for skipped-empty, which we deliberately swallow # here -- the overall run still succeeds. - for slug in roster: + for member_slug in roster: await _generate_member( - llm, slug, index, vault, + llm, member_slug, index, vault, shared_bucket=shared_bucket, lang=lang, write=not dry_run, ) + # Correspondent leaf pages, one per name across all documents. + for corr_slug, canonical in _correspondent_roster(index): + await _generate_correspondent( + corr_slug, canonical, index, + shared_bucket=shared_bucket, write=not dry_run, + ) + for bucket_prefix, topic_slug in topics: await _generate_topic( llm, bucket_prefix, topic_slug, index, @@ -410,6 +434,7 @@ def _index_vault(vault: Path) -> list[dict]: if isinstance(p, str) and p.strip() ] slugged = [(slugify_person(p), p) for p in raw_persons] + _corr = fm.get("correspondent") out.append({ "title": fm.get("title") or md.stem, "date": fm.get("date") or "", @@ -417,6 +442,9 @@ def _index_vault(vault: Path) -> list[dict]: "rel": rel, "persons": [s for s, _ in slugged if s], "person_names": [n for s, n in slugged if s], + # The document's correspondent (already canonicalised by the + # classifier). Drives the correspondent leaf-page roster. + "correspondent": _corr.strip() if isinstance(_corr, str) else "", # Both taxonomy surfaces a capture can carry. Documents # use `topics:` for the ontology classification; captures # use `tags:` (the topic-room seed merges into the @@ -497,6 +525,105 @@ def _member_entries(index: list[dict], slug: str) -> list[dict]: ] +# ── Correspondents ───────────────────────────────────────────────────── +# +# Correspondents (an insurer, a school, a tax office) are leaf entities: +# a single reference page, no folder. Documents route to the date-filed +# documents tree and merely name their correspondent in frontmatter, so a +# correspondent owns no captures -- its page is identity plus a backlink +# list of the documents that reference it. Discovered from the +# `correspondent:` field the classifier writes on each document. + +def _correspondent_roster(index: list[dict]) -> list[tuple[str, str]]: + """(slug, canonical) for every correspondent named across documents. + + The classifier canonicalises the correspondent against the ontology + before writing it, so each distinct name is already one entity. Sorted + by canonical name for stable, deterministic output. + """ + canon: dict[str, str] = {} + for entry in index: + name = (entry.get("correspondent") or "").strip() + if name: + canon[slug(name)] = name + return [(s, canon[s]) for s in sorted(canon, key=lambda s: canon[s].lower())] + + +def _correspondent_entries(index: list[dict], canonical: str) -> list[dict]: + """Documents whose `correspondent:` matches `canonical`, newest first.""" + hits = [ + e for e in index + if (e.get("correspondent") or "").strip() == canonical + ] + return sorted(hits, key=lambda e: e.get("date") or "", reverse=True) + + +def _correspondent_preamble(slug_: str, canonical: str) -> str: + """First-creation frontmatter for a correspondent leaf page. + + Leaf entities are a single file, not a folder, so there is no + `about.md`/`index.md` split -- the page IS the concept file. Carries + `type: correspondent` (the OKF concept kind) and the canonical name + the `memory.lib.correspondents()` reader keys on. + """ + return "\n".join([ + "---", + f"title: {canonical}", + f"slug: {slug_}", + "type: correspondent", + f"canonical: {canonical}", + "---", + ]) + + +def _correspondent_body(entries: list[dict], *, page_dir: str) -> str: + """A deterministic `## Documents` backlink list (no LLM). + + Each document that names this correspondent becomes a relative link, + so the page is the correspondent's index into the vault. Rebuilt on + every run, so it auto-extends as new documents arrive -- no queue. + """ + rows = ["## Documents", ""] + for e in entries: + title = (e.get("title") or "").strip() or "(untitled)" + rel = e.get("rel") or "" + link = _relative_link(rel, page_dir) if rel else "" + date = (e.get("date") or "").strip() + row = f"- [{title}]({link})" if link else f"- **{title}**" + if date: + row += f" - {date}" + rows.append(row) + return "\n".join(rows) + + +async def _generate_correspondent( + slug_: str, canonical: str, index: list[dict], *, + shared_bucket: str, write: bool, +) -> int: + """Compose one correspondent's leaf page from its referencing documents. + + Deterministic: no LLM. The page is identity (preamble) plus the + backlink list. Lives at `/correspondents/.md`. + """ + entries = _correspondent_entries(index, canonical) + if not entries: + _err(f"no documents reference '{canonical}' — skipping") + return 1 + + page_dir = correspondents_dir(shared_bucket) + body = _correspondent_body(entries, page_dir=page_dir) + target_path = f"{page_dir}/{slug_}.md" + + if not write: + print(f"\n\n{body}") + return 0 + return await _publish( + body, target_path=target_path, shared_bucket=shared_bucket, + commit_msg=f"docs(memory): refresh correspondent {canonical}", + default_preamble=_correspondent_preamble(slug_, canonical), + ) + + # ── Topic folders ────────────────────────────────────────────────────── # # Topics nest inside the bucket that owns them: shared topics under diff --git a/tests/stacklets/test_memory_wiki.py b/tests/stacklets/test_memory_wiki.py index 85c0e72..e2ba7e8 100644 --- a/tests/stacklets/test_memory_wiki.py +++ b/tests/stacklets/test_memory_wiki.py @@ -21,6 +21,10 @@ sys.path.insert(0, str(_REPO_ROOT / "stacklets" / "memory" / "bot" / "cli")) from wiki import ( # noqa: E402 + _correspondent_body, + _correspondent_entries, + _correspondent_preamble, + _correspondent_roster, _member_preamble, _topic_cross_refs, _topic_entries, @@ -314,6 +318,80 @@ def test_no_synonyms_collapses_to_display(self): assert "synonyms:" not in pre +# ── Correspondents ──────────────────────────────────────────────────── + + +def _doc(correspondent, *, title="A Doc", date="2026-03-15", rel=None): + """A minimal index entry for a document referencing a correspondent.""" + return { + "title": title, + "date": date, + "rel": rel or f"family/documents/2026/03/{title.lower().replace(' ', '-')}.md", + "correspondent": correspondent, + "persons": [], + } + + +class TestCorrespondentRoster: + + def test_dedups_and_sorts_by_canonical(self): + index = [ + _doc("Duff Insurance"), _doc("Springfield Mutual"), + _doc("Duff Insurance"), _doc(""), + ] + assert _correspondent_roster(index) == [ + ("duff-insurance", "Duff Insurance"), + ("springfield-mutual", "Springfield Mutual"), + ] + + def test_blank_correspondents_excluded(self): + index = [_doc(""), _doc(" "), _doc(None)] + assert _correspondent_roster(index) == [] + + def test_slug_normalises_name(self): + roster = _correspondent_roster([_doc("Springfield Tax Office")]) + assert roster == [("springfield-tax-office", "Springfield Tax Office")] + + +class TestCorrespondentEntries: + + def test_filters_by_canonical_newest_first(self): + index = [ + _doc("Duff Insurance", title="Old", date="2024-01-01"), + _doc("Springfield Mutual", title="Other", date="2026-01-01"), + _doc("Duff Insurance", title="New", date="2026-05-01"), + ] + entries = _correspondent_entries(index, "Duff Insurance") + assert [e["title"] for e in entries] == ["New", "Old"] + + +class TestCorrespondentPreamble: + + def test_carries_okf_type_and_canonical(self): + pre = _correspondent_preamble("duff-insurance", "Duff Insurance") + assert "type: correspondent" in pre # OKF concept kind + assert "title: Duff Insurance" in pre + assert "canonical: Duff Insurance" in pre + assert "slug: duff-insurance" in pre + assert pre.startswith("---") + assert pre.rstrip().endswith("---") + + +class TestCorrespondentBody: + + def test_lists_documents_as_relative_links(self): + entries = _correspondent_entries( + [_doc("Duff Insurance", title="Auto Policy", date="2026-03-15", + rel="family/documents/2026/03/auto-policy-p247.md")], + "Duff Insurance", + ) + body = _correspondent_body(entries, page_dir="family/correspondents") + assert body.startswith("## Documents") + # leaf page lives in family/correspondents/ -> climb two to root + assert "[Auto Policy](../../family/documents/2026/03/auto-policy-p247.md)" in body + assert "2026-03-15" in body + + # ── _topic_preamble ─────────────────────────────────────────────────── From 0be51b5f601bd48db6c9ed92441a02bbfcc30b21 Mon Sep 17 00:00:00 2001 From: Arthur Date: Fri, 19 Jun 2026 22:54:57 +0200 Subject: [PATCH 17/21] docs: replace real location Friedrichshafen with fictional Springfield Design docs mirrored a real location. Swap it for a fabricated one so no real personal data lives in the repo. --- docs/design/brain/engram-prototype.md | 2 +- docs/design/brain/knowledge-implementation.md | 2 +- docs/design/brain/knowledge-structure.md | 8 ++++---- docs/design/brain/ontology-design.md | 4 ++-- docs/design/brain/ontology-v1.md | 2 +- 5 files changed, 9 insertions(+), 9 deletions(-) diff --git a/docs/design/brain/engram-prototype.md b/docs/design/brain/engram-prototype.md index 86587c0..1d76972 100644 --- a/docs/design/brain/engram-prototype.md +++ b/docs/design/brain/engram-prototype.md @@ -180,7 +180,7 @@ Source: Arthur via Kit Bot, 2026-04-29 ``` Dentist appointment for Sabrina When: 2026-04-17 10:00 -Where: Dr. Weber, Friedrichshafen +Where: Dr. Weber, Springfield Source: Calendar sync ``` diff --git a/docs/design/brain/knowledge-implementation.md b/docs/design/brain/knowledge-implementation.md index 016a54c..c9d879f 100644 --- a/docs/design/brain/knowledge-implementation.md +++ b/docs/design/brain/knowledge-implementation.md @@ -63,7 +63,7 @@ This costs zero extra LLM calls -- it's the same prompt, richer output schema. T Before creating a new tag, fuzzy-match against existing: - Lowercase + strip whitespace comparison -- Common prefix matching ("Finanzamt Friedrichshafen" matches "Finanzamt") +- Common prefix matching ("Finanzamt Springfield" matches "Finanzamt") - The prompt already asks for this, but code enforces it as a safety net **Time: 3-4 hours.** Changes to `archivist.py` only: updated prompt, tag validation, richer JSON parsing. diff --git a/docs/design/brain/knowledge-structure.md b/docs/design/brain/knowledge-structure.md index b28370f..a8369a4 100644 --- a/docs/design/brain/knowledge-structure.md +++ b/docs/design/brain/knowledge-structure.md @@ -363,7 +363,7 @@ related_persons: spouse: Marge children: [Bart, Lisa, Maggie] topics: [insurance, vehicle, employment, taxes] -correspondents: [adac, aok, finanzamt_friedrichshafen] +correspondents: [adac, aok, finanzamt_springfield] documents: 47 updated_at: 2026-04-21T03:00:00Z --- @@ -383,11 +383,11 @@ Family member. Married to [[family/marge]]. Father of [[family/bart]], [[family/ - [[family/insurance]] — car (ADAC), health (AOK), liability - [[family/vehicle]] — BMW 320d, M-AB-1234 - [[family/employment]] — Springfield Nuclear -- [[family/taxes]] — annual filing, Finanzamt Friedrichshafen +- [[family/taxes]] — annual filing, Finanzamt Springfield ## Related -[[family/adac]] · [[family/aok]] · [[family/finanzamt_friedrichshafen]] · ... +[[family/adac]] · [[family/aok]] · [[family/finanzamt_springfield]] · ... ## Sources @@ -562,7 +562,7 @@ totals: { documents: 312, facts: 487, entities: 47 } ### Correspondents (16) - [[family/adac]] — car insurance · Homer · 14 docs · last 2026-04-15 - [[family/aok]] — health insurance · family · 27 docs · last 2026-04-10 -- [[family/finanzamt_friedrichshafen]] — tax office · 8 docs · last 2026-03-22 +- [[family/finanzamt_springfield]] — tax office · 8 docs · last 2026-03-22 - ... ### Persons (5) diff --git a/docs/design/brain/ontology-design.md b/docs/design/brain/ontology-design.md index 4cb15d5..eaa2ea5 100644 --- a/docs/design/brain/ontology-design.md +++ b/docs/design/brain/ontology-design.md @@ -115,7 +115,7 @@ organizations: finanzamt: name: Finanzamt - aliases: [Finanzamt, "Finanzamt Friedrichshafen", FA] + aliases: [Finanzamt, "Finanzamt Springfield", FA] categories: [finance] paperless_correspondent: Finanzamt @@ -295,7 +295,7 @@ When Kit Bot serves Arthur, it can query "everything associated with Arthur" acr **Maintenance (dream cycle):** Nightly review: - Detect near-duplicate entries (fuzzy matching on names + aliases) -- Suggest merges ("Finanzamt" and "Finanzamt Friedrichshafen" should be one entry) +- Suggest merges ("Finanzamt" and "Finanzamt Springfield" should be one entry) - Flag orphans (entities referenced nowhere) - Update alias lists from observed usage - Count usage per tag to identify the most/least used diff --git a/docs/design/brain/ontology-v1.md b/docs/design/brain/ontology-v1.md index 68d6c3a..f401244 100644 --- a/docs/design/brain/ontology-v1.md +++ b/docs/design/brain/ontology-v1.md @@ -597,7 +597,7 @@ For people who'd rather use the terminal, the same operations work via CLI (`fk The dream cycle eventually handles janitor work the Archivist shouldn't synchronously care about: -- **Near-duplicate detection** — "Finanzamt" vs "Finanzamt Friedrichshafen" with overlapping doc sets → auto-merge if confidence is high, post a one-line summary in #documents otherwise +- **Near-duplicate detection** — "Finanzamt" vs "Finanzamt Springfield" with overlapping doc sets → auto-merge if confidence is high, post a one-line summary in #documents otherwise - **Orphan archival** — entries with `docs_seen: 0` for 6+ months move to an `archive:` section (still queryable, no longer suggested in classification prompts) - **Alias mining** — tokens that co-occur near a canonical key in 10+ doc summaries → auto-add as aliases - **Confidence promotion** — entries with `docs_seen >= 5` and no corrections → auto-flip `confirmed: true` From fc847bf6ed4e9ee658a870319bbf565a545d0a24 Mon Sep 17 00:00:00 2001 From: Arthur Date: Fri, 19 Jun 2026 23:30:20 +0200 Subject: [PATCH 18/21] refactor: fictionalize real data in code and shipped seeds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Production code, docstrings, the classifier prompt examples, and the shipped correspondent/seed READMEs contained real personal data — real insurers (ADAC, AOK, Allianz), a real local chapter (Ortsverband Manzell) and adac.de, real names. The seed files ship to every user, so this was a privacy leak, not just test hygiene. Replace with Simpsons-universe fictional entities (Duff Insurance, Springfield Mutual, Globex, Homer). --- lib/stack/installer.py | 2 +- lib/stack/vault.py | 12 ++++++------ stacklets/docs/bot/archivist.py | 10 +++++----- stacklets/docs/bot/capture_pipeline.py | 4 ++-- stacklets/docs/bot/cli/reprocess.py | 2 +- stacklets/docs/bot/matching.py | 16 ++++++++-------- stacklets/docs/bot/pipeline.py | 12 ++++++------ stacklets/docs/bot/recall.py | 2 +- stacklets/docs/bot/reply_presenter.py | 6 +++--- stacklets/docs/bot/text_utils.py | 4 ++-- stacklets/docs/bot/vault_entry.py | 10 +++++----- stacklets/memory/bot/cli/wiki.py | 2 +- stacklets/memory/lib.py | 14 +++++++------- stacklets/memory/seeds/README.md | 8 ++++---- .../seeds/_shared/correspondents/README.md | 16 ++++++++-------- stacklets/messages/hooks/on_configure.py | 4 ++-- stacklets/photos/cli/setup.py | 2 +- 17 files changed, 63 insertions(+), 63 deletions(-) diff --git a/lib/stack/installer.py b/lib/stack/installer.py index 6c37cb7..3fc9cee 100644 --- a/lib/stack/installer.py +++ b/lib/stack/installer.py @@ -598,7 +598,7 @@ def wizard(): write_users_toml(users) with Spinner("Writing secrets"): - # Default passwords match the user ID (e.g. arthur/arthur). + # Default passwords match the user ID (e.g. homer/homer). # Simple and frictionless for a local network setup. Users can # change their passwords in each service after first login. from .secrets import TomlSecretStore diff --git a/lib/stack/vault.py b/lib/stack/vault.py index f6c19d0..1868a3b 100644 --- a/lib/stack/vault.py +++ b/lib/stack/vault.py @@ -39,8 +39,8 @@ def slug(text: str) -> str: is hard at 60 chars; the title prompt keeps titles human-scannable, not the slug. - >>> slug("ADAC - Kfz-Versicherung 2025") - 'adac-kfz-versicherung-2025' + >>> slug("Duff Insurance - Kfz-Versicherung 2025") + 'duff-insurance-kfz-versicherung-2025' >>> slug("Rechnung Müller & Söhne") 'rechnung-muller-sohne' >>> slug(" Leading Spaces ") @@ -91,8 +91,8 @@ def entity_page_path(name: str, kind: str, shared_bucket: str = DEFAULT_SHARED_B >>> entity_page_path("Homer Simpson", "person") 'homer/about.md' - >>> entity_page_path("ADAC", "correspondent") - 'family/correspondents/adac.md' + >>> entity_page_path("Duff Insurance", "correspondent") + 'family/correspondents/duff-insurance.md' """ if kind == "person": return f"{slugify_person(name)}/about.md" @@ -116,8 +116,8 @@ def entity_relpath( >>> entity_relpath("Homer", "person", "family/documents/2026/03/x.md") '../../../../homer/about.md' - >>> entity_relpath("ADAC", "correspondent", "family/documents/2026/03/x.md") - '../../../correspondents/adac.md' + >>> entity_relpath("Duff Insurance", "correspondent", "family/documents/2026/03/x.md") + '../../../correspondents/duff-insurance.md' >>> entity_relpath("Homer", "person", "x.md") 'homer/about.md' """ diff --git a/stacklets/docs/bot/archivist.py b/stacklets/docs/bot/archivist.py index 788e1d7..dc0b5fb 100644 --- a/stacklets/docs/bot/archivist.py +++ b/stacklets/docs/bot/archivist.py @@ -496,7 +496,7 @@ def _init_mirror(self) -> None: ) return - # @arthur:homestead.me → arthur + # @homer:homestead.me → homer admin_usernames = [] for raw in admin_ids.split(","): raw = raw.strip() @@ -600,8 +600,8 @@ async def on_room_joined(self, room_id: str) -> None: # filed under the sender's own entity bucket (`/notes/...` # or `/bookmarks/...`), no Paperless write. # - # Family members can spin up their own per-person rooms ("arthur - # notes", "sabrina notes") or DM the bot directly. As soon as the + # Family members can spin up their own per-person rooms ("homer + # notes", "marge notes") or DM the bot directly. As soon as the # bot accepts the invite, that room is in capture mode by default. # No allowlist to curate. # @@ -642,7 +642,7 @@ def _reserved_topic_slugs(self) -> set[str]: used in the shared bucket, the `_unfiled` rescue folder, and the derived `about` page. - Top-level vault names (`family`, `arthur`, `marge`, `meta`, + Top-level vault names (`family`, `homer`, `marge`, `meta`, `wiki`, `archive`) no longer need to appear here -- the new layout makes top-level collisions impossible because topic folders never live at the top level. @@ -1580,7 +1580,7 @@ async def _on_text(self, room, event: RoomMessageText) -> None: # Free-text search runs whenever the user explicitly # addressed the bot (any room) or the message landed in # the documents room (where search is the default). A - # short chat-shaped query like "ADAC" is fine — that's + # short chat-shaped query like "Duff Insurance" is fine — that's # exactly the kind of thing recall is for. await self._handle_search( room.room_id, query, reply_to, diff --git a/stacklets/docs/bot/capture_pipeline.py b/stacklets/docs/bot/capture_pipeline.py index 0a62ed2..7bd141e 100644 --- a/stacklets/docs/bot/capture_pipeline.py +++ b/stacklets/docs/bot/capture_pipeline.py @@ -170,7 +170,7 @@ async def capture_url( ``bucket`` overrides the sender-derived entity routing. Used by topic-room captures so a memo in #Thema:Camping files - under ``camping/`` (shared) or ``arthur/camping/`` (personal) + under ``camping/`` (shared) or ``homer/camping/`` (personal) instead of the sender's default personal bucket. The classifier still sees the sender's name in ``persons``; only the path changes. @@ -540,7 +540,7 @@ async def reprocess( re-reads; this stays cheap (no vision round-trip, no re-fetch of the original binary) at the cost of corrections having to work against the model's own paraphrase rather than the raw - page. Pure text corrections (Arthur's "It is a Mac Studio") + page. Pure text corrections (Homer's "It is a Mac Studio") compose cleanly under that constraint. """ if self._mirror is None: diff --git a/stacklets/docs/bot/cli/reprocess.py b/stacklets/docs/bot/cli/reprocess.py index 1171c87..bd8c441 100644 --- a/stacklets/docs/bot/cli/reprocess.py +++ b/stacklets/docs/bot/cli/reprocess.py @@ -94,7 +94,7 @@ async def run(paperless: PaperlessAPI, classifier: Classifier, # flow uses. Examples: # # stack docs reprocess 7 --msg "Der Urlaub ist im Februar 2026" - # stack docs reprocess 1-5 --msg "this is for Sabrina" + # stack docs reprocess 1-5 --msg "this is for Marge" # # Stripped from argv before flag/positional parsing so the rest of # the parser only sees id specs and known flags. diff --git a/stacklets/docs/bot/matching.py b/stacklets/docs/bot/matching.py index f712b77..359c236 100644 --- a/stacklets/docs/bot/matching.py +++ b/stacklets/docs/bot/matching.py @@ -67,8 +67,8 @@ def _tokenize(name: str) -> list[str]: ['kwik', 'e', 'mart', 'inc'] >>> _tokenize("Moe's Tavern") ['moe', 's', 'tavern'] - >>> _tokenize("ADAC e.V.") - ['adac', 'e', 'v'] + >>> _tokenize("Duff Insurance e.V.") + ['duff-insurance', 'e', 'v'] """ return [t for t in _WORD_SPLIT.split(name.lower().strip()) if t] @@ -78,12 +78,12 @@ def _is_word_boundary_match(shorter: str, longer: str) -> bool: Safe: "Springfield" in "Springfield Nuclear Power Plant" -> True - "ADAC" in "ADAC e.V." -> True + "Duff Insurance" in "Duff Insurance e.V." -> True Blocked: "Spring" in "Springfield" -> False (mid-word) "Bank" in "Bundesbank" -> False (mid-word) - "Art" in "Arthur" -> False (mid-word) + "Art" in "Homer" -> False (mid-word) >>> _is_word_boundary_match("Springfield", "Springfield Nuclear Power Plant") True @@ -331,13 +331,13 @@ def build_document_event( {source, type, summary, data, actor, ts} - `summary` is the one-line activity description ("ADAC … filed + `summary` is the one-line activity description ("Duff Insurance … filed (#42)"); the LLM's prose summary lives inside `data.summary`. `actor` is the human who triggered the filing when known, else the bot itself; callers pass it in because the matching module has no Matrix context. - >>> evt = build_document_event(42, {"title": "ADAC - Kfz EUR 340", "summary": "Insurance renewal"}, resolved_topics=["Insurance"], resolved_persons=["Homer"], resolved_correspondent="ADAC") + >>> evt = build_document_event(42, {"title": "Duff Insurance - Kfz EUR 340", "summary": "Insurance renewal"}, resolved_topics=["Insurance"], resolved_persons=["Homer"], resolved_correspondent="Duff Insurance") >>> evt["source"] 'docs' >>> evt["type"] @@ -494,7 +494,7 @@ def fuzzy_match_entity( When multiple candidates match: - prefer_longest=False (default): picks shortest (most general). - Good for correspondents: "ADAC" over "ADAC Versicherung". + Good for correspondents: "Duff Insurance" over "Duff Insurance Versicherung". - prefer_longest=True: picks longest (most specific). Good for persons: "Homer Jr" over "Homer". @@ -520,7 +520,7 @@ def fuzzy_match_entity( # Strategy 2: word-boundary containment. # The shorter name's tokens must be a prefix of the longer name's tokens. - # "ADAC" -> ["adac"] is a prefix of "ADAC e.V." -> ["adac", "e", "v"] -> match + # "Duff Insurance" -> ["duff-insurance"] is a prefix of "Duff Insurance e.V." -> ["duff-insurance", "e", "v"] -> match # "Spring" -> ["spring"] is NOT a prefix of "Springfield" -> ["springfield"] -> no match containment_matches = [] for existing_name in existing: diff --git a/stacklets/docs/bot/pipeline.py b/stacklets/docs/bot/pipeline.py index 352a10d..d48f9b7 100644 --- a/stacklets/docs/bot/pipeline.py +++ b/stacklets/docs/bot/pipeline.py @@ -857,14 +857,14 @@ def _build_classify_prompt(*, ocr_text: str, person_names: list[str], Simplified to three clear axes: topic = what is this about? "Insurance", "Shopping" person = which family member? "Homer", "Bart", or null - correspondent = who sent it? "ADAC", "Kwik-E-Mart" + correspondent = who sent it? "Duff Insurance", "Kwik-E-Mart" Worked examples — a Kwik-E-Mart receipt for Homer: topic="Shopping", person="Homer", correspondent="Kwik-E-Mart" A school letter about Bart: topics=["School"], person="Bart", correspondent="Springfield Elementary" A health insurance invoice for Homer: - topics=["Insurance", "Medical"], person="Homer", correspondent="AOK" + topics=["Insurance", "Medical"], person="Homer", correspondent="Springfield Mutual" Person names arrive stripped of the "Person: " prefix so the LLM sees clean first names like "Homer" rather than "Person: Homer". @@ -879,7 +879,7 @@ def _build_classify_prompt(*, ocr_text: str, person_names: list[str], Correspondent context comes from the memory wiki when `correspondents_section` is supplied — canonical names with their - learned aliases inline ("ADAC (ADAC Ortsverband Manzell)"). This + learned aliases inline ("Duff Insurance (Duff Insurance Ortsverband Springfield)"). This teaches the LLM what to canonicalize before Paperless ever sees a duplicate. Without it, we fall back to the flat Paperless list, which has no alias signal. @@ -941,10 +941,10 @@ def _build_classify_prompt(*, ocr_text: str, person_names: list[str], "topics": ["what is this document about? One or two subject areas. E.g. ['Insurance'], ['Insurance', 'Vehicle'], ['Shopping']. A health insurance bill is ['Insurance', 'Medical']. A car repair invoice is ['Vehicle']. Pick the canonical name from existing topic tags. Usually one topic, two only when the document genuinely spans two areas."], "persons": ["which NAMED family members does this belong to? Pick from the family members list by first name. Names MUST appear in the document text (or be inferable from a labeled field like 'Customer: John Doe', 'Versicherter: Homer Simpson'). EXCEPTION: when the human note above explicitly names or attributes the document to a household member ('Marges Rechnung', 'für Bart', 'this is Lisa's'), include that member here even if their name does not appear in the OCR text -- the human's explicit attribution is authoritative for this field. When neither the document nor the human note names anyone (a booking confirmation that says '2 Erwachsene, 2 Kinder', 'die Familie' WITHOUT naming individuals, a receipt with no named customer and no human note), return an empty list -- the system has a separate fallback to attribute the doc to whoever uploaded it. Can be multiple for joint documents where everyone is named (a marriage certificate listing both spouses)."], "document_type": "optional: the document's format. Pick the canonical name from existing document types. null if unclear.", - "correspondent": "the SENDER's CANONICAL short name. If the printed sender matches one of the Existing correspondents (or one of its aliases in parens), return the canonical exactly. Otherwise return the cleanest short form — strip regional, branch, and legal-form suffixes. 'ADAC Ortsverband Manzell' → 'ADAC'. 'Burns Industries LLC' → 'Burns Industries'. 'Springfield Nuclear Power Plant Division 7' → 'Springfield Nuclear'. null is better than guessing from fragments.", + "correspondent": "the SENDER's CANONICAL short name. If the printed sender matches one of the Existing correspondents (or one of its aliases in parens), return the canonical exactly. Otherwise return the cleanest short form — strip regional, branch, and legal-form suffixes. 'Duff Insurance Ortsverband Springfield' → 'Duff Insurance'. 'Burns Industries LLC' → 'Burns Industries'. 'Springfield Nuclear Power Plant Division 7' → 'Springfield Nuclear'. null is better than guessing from fragments.", "correspondent_aliases": ["full names as printed on THIS document, useful for growing the wiki. Include only when the printed name differs from the canonical you returned above. Empty list when the printed name matches the canonical exactly."], "correspondent_facts": ["STABLE facts about the SENDER organization that are useful on every future document from them: address, phone, email, website, IBAN, your customer/membership/policy number with them. NOT facts about THIS document (totals, invoice numbers, dates — those go in facts). Empty list if none visible."], - "summary": "2-3 sentence summary in the document's language. Lead with the document type (Invoice/Receipt/Letter/Certificate or Rechnung/Quittung/Brief/Bescheinigung — whichever matches) and the correspondent; include the document's date and any total amount; name the person(s) involved (use the human note when the document doesn't name them). Examples — match the document's language, not these literal strings: EN 'Invoice from ADAC for EUR 340/year covering Homer Simpson's car insurance, effective 2026-04-01.' / EN 'Receipt from Kwik-E-Mart dated 2026-05-12 for EUR 7.42 (cash purchase).' / DE 'Rechnung der ADAC über EUR 340/Jahr für die Kfz-Versicherung von Homer Simpson, gültig ab 01.04.2026.' / DE 'Quittung von Kwik-E-Mart vom 12.05.2026 über EUR 7,42 (Einkauf, bar bezahlt).' Omit a field only when the document genuinely lacks it; never invent.", + "summary": "2-3 sentence summary in the document's language. Lead with the document type (Invoice/Receipt/Letter/Certificate or Rechnung/Quittung/Brief/Bescheinigung — whichever matches) and the correspondent; include the document's date and any total amount; name the person(s) involved (use the human note when the document doesn't name them). Examples — match the document's language, not these literal strings: EN 'Invoice from Duff Insurance for EUR 340/year covering Homer Simpson's car insurance, effective 2026-04-01.' / EN 'Receipt from Kwik-E-Mart dated 2026-05-12 for EUR 7.42 (cash purchase).' / DE 'Rechnung der Duff Insurance über EUR 340/Jahr für die Kfz-Versicherung von Homer Simpson, gültig ab 01.04.2026.' / DE 'Quittung von Kwik-E-Mart vom 12.05.2026 über EUR 7,42 (Einkauf, bar bezahlt).' Omit a field only when the document genuinely lacks it; never invent.", "facts": ["key structured facts about THIS document, one bullet per fact. Top-level facts: totals, account/invoice/policy numbers, dates, plan/tariff names, deadlines — e.g. 'Total: EUR 90.00', 'Invoice: #12345', 'Plan: Premium'. Line items: when the document lists individual purchases or services, include each one as its own bullet with quantity/unit-price/total — e.g. 'Donuts 5x EUR 5.00', 'Cola 1x EUR 2.42', 'Reparatur Bremsbeläge EUR 240.00'. Don't fabricate line items the document doesn't print; a one-line receipt has no line items, only a total."], "action_items": [{{"action": "what needs to happen", "due": "YYYY-MM-DD or null"}}] }} @@ -959,7 +959,7 @@ def _build_classify_prompt(*, ocr_text: str, person_names: list[str], - LANGUAGE: use the document's original language for title, summary, facts, and action_items. A German document gets a German title and German facts. Never translate. - topics: the subject area(s), not the document format. An invoice from a shop is ["Shopping"], not ["Invoice"]. An invoice for insurance is ["Insurance"]. A health insurance claim is ["Insurance", "Medical"]. When the document uses a synonym of a listed topic (the list shows synonyms in parentheses), return the canonical name. Use the document's language for new topic tags too. Most documents have one topic; use two only when clearly spanning two areas. EXCEPTION: when the human note block above explicitly assigns a topic ("Arbeit war richtig", "this is health insurance", "tag as Steuer"), that topic IS the right answer for this document regardless of what the OCR text would suggest. The human's intent overrides OCR-derived defaults for this field; pick the canonical that matches their term. - persons: return names that EXPLICITLY appear in the document text OR are explicitly attributed by the human note block above. Match by first name against the family members list. A marriage certificate naming "Homer Simpson" and "Marge Simpson": ["Homer", "Marge"]. A booking confirmation that says "2 Erwachsene, 2 Kinder (0 und 6 Jahre alt)" with no actual names AND no human note: []. A health insurance bill in Marge's name only: ["Marge"]. A receipt with no printed customer name AND a human note "Marges Tankquittung" or "its marges invoice": ["Marge"] — the human attribution stands in for a missing customer field. NEVER guess based on group counts ("2 Personen" is not "Homer + Marge"), document type ("Kinderarztrechnung" doesn't mean "Bart" or "Lisa" unless the human note says so), or who you think the doc is "probably for". When neither the document nor the human note names anyone, return [] — the system attributes the doc to the uploader as a fallback. -- correspondent: always the SENDER, never the addressee/customer/recipient. When the existing list shows aliases in parentheses, those are previous spellings of the same correspondent — use the canonical (the name OUTSIDE the parentheses). Strip regional/branch/legal-form suffixes for new correspondents. Use null if the sender is not clearly identifiable. Do not guess from fragments. EXCEPTION: when the human note block above explicitly names the correspondent ("File it under Leapter GmbH", "this is from ADAC"), use that name as the canonical -- the human knows the institution better than the printed letterhead. Add any additional sender forms the human mentions ("Leapter GmbH" alongside "Leapter") to `correspondent_aliases` so the wiki grows the alias set. +- correspondent: always the SENDER, never the addressee/customer/recipient. When the existing list shows aliases in parentheses, those are previous spellings of the same correspondent — use the canonical (the name OUTSIDE the parentheses). Strip regional/branch/legal-form suffixes for new correspondents. Use null if the sender is not clearly identifiable. Do not guess from fragments. EXCEPTION: when the human note block above explicitly names the correspondent ("File it under Leapter GmbH", "this is from Duff Insurance"), use that name as the canonical -- the human knows the institution better than the printed letterhead. Add any additional sender forms the human mentions ("Leapter GmbH" alongside "Leapter") to `correspondent_aliases` so the wiki grows the alias set. - correspondent_aliases: only when the printed sender name on THIS document differs from your canonical answer. Single-element list is fine. - correspondent_facts: stable across documents from the same sender. Address and customer numbers belong here; this month's total does not. - facts: concrete numbers, dates, account numbers, amounts that describe THIS document. When the document itemises purchases or services, each line item is its own fact bullet alongside the top-level totals. Empty list if none. diff --git a/stacklets/docs/bot/recall.py b/stacklets/docs/bot/recall.py index ce6d531..9ede2f8 100644 --- a/stacklets/docs/bot/recall.py +++ b/stacklets/docs/bot/recall.py @@ -2,7 +2,7 @@ The archivist routes most chat messages straight into Paperless + memory-vault search as a literal regex. That works for keyword recall -("Allianz", "Radlager") but fails on natural-language questions: +("Globex", "Radlager") but fails on natural-language questions: "When did Bart get vaccinated?" doesn't literally appear in any document, so the regex walker returns nothing. diff --git a/stacklets/docs/bot/reply_presenter.py b/stacklets/docs/bot/reply_presenter.py index 6f42ff0..67bf9fa 100644 --- a/stacklets/docs/bot/reply_presenter.py +++ b/stacklets/docs/bot/reply_presenter.py @@ -39,11 +39,11 @@ def render_filing_reply( Layout, optimised for scanning in Element: - ✅ Filed: ADAC - Kfz-Versicherung (#10) + ✅ Filed: Duff Insurance - Kfz-Versicherung (#10) - Insurance | Homer | Invoice | ADAC | 2026-03-15 + Insurance | Homer | Invoice | Duff Insurance | 2026-03-15 - Annual car insurance renewal at ADAC. + Annual car insurance renewal at Duff Insurance. - EUR 340.00/year diff --git a/stacklets/docs/bot/text_utils.py b/stacklets/docs/bot/text_utils.py index 7ad37ea..f645b5d 100644 --- a/stacklets/docs/bot/text_utils.py +++ b/stacklets/docs/bot/text_utils.py @@ -31,8 +31,8 @@ def clean_filename(raw_filename: str, msgtype: str = "") -> str: 'photo.jpg' >>> clean_filename("a1b2c3d4-e5f6-7890-abcd-ef1234567890.pdf") 'document.pdf' - >>> clean_filename("Rechnung_ADAC_Marz2025.pdf") - 'Rechnung_ADAC_Marz2025.pdf' + >>> clean_filename("Rechnung_Duff Insurance_Marz2025.pdf") + 'Rechnung_Duff Insurance_Marz2025.pdf' >>> clean_filename("IMG_1234.HEIC") 'photo.HEIC' >>> clean_filename("document") diff --git a/stacklets/docs/bot/vault_entry.py b/stacklets/docs/bot/vault_entry.py index 8bf9650..ae2c04b 100644 --- a/stacklets/docs/bot/vault_entry.py +++ b/stacklets/docs/bot/vault_entry.py @@ -76,12 +76,12 @@ def document_filepath( Vault-relative path string. Examples: - >>> document_filepath("family", "2025-03-27", 42, "ADAC - Kfz", True) - 'family/documents/2025/03/2025-03-27-adac-kfz-p42.md' + >>> document_filepath("family", "2025-03-27", 42, "Duff Insurance - Kfz", True) + 'family/documents/2025/03/2025-03-27-duff-insurance-kfz-p42.md' >>> document_filepath("family", "2025-03-27", 42, None, False) 'family/documents/2025/03/2025-03-27-p42.md' - >>> document_filepath("family", None, 42, "ADAC - Kfz", True) - 'family/documents/_unfiled/adac-kfz-p42.md' + >>> document_filepath("family", None, 42, "Duff Insurance - Kfz", True) + 'family/documents/_unfiled/duff-insurance-kfz-p42.md' >>> document_filepath("family", None, 42, None, False) 'family/documents/_unfiled/p42.md' """ @@ -335,7 +335,7 @@ def render_document( - YAML frontmatter (machine view: structured metadata) - H1 title - - entity-link header (``**From:** [ADAC](…) · **About:** [Homer](…)``) + - entity-link header (``**From:** [Duff Insurance](…) · **About:** [Homer](…)``) as relative markdown links, so they resolve in Obsidian, on GitHub/Forgejo, and as OKF graph edges. - **briefing callout** — ``> [!summary]`` with prose, optional diff --git a/stacklets/memory/bot/cli/wiki.py b/stacklets/memory/bot/cli/wiki.py index 0e0b309..41c865d 100644 --- a/stacklets/memory/bot/cli/wiki.py +++ b/stacklets/memory/bot/cli/wiki.py @@ -167,7 +167,7 @@ async def run(llm: LLM, argv: list[str]) -> int: # ── Single-surface flags ────────────────────────────────────────── # `--topic camping` finds the (bucket, slug) match anywhere in the - # vault; ambiguity (both `family/camping/` and `arthur/camping/`) + # vault; ambiguity (both `family/camping/` and `homer/camping/`) # generates both pages -- they're genuinely separate topics. if single_topic: matched = [t for t in topics if t[1] == single_topic] diff --git a/stacklets/memory/lib.py b/stacklets/memory/lib.py index b428874..6a3e9ff 100644 --- a/stacklets/memory/lib.py +++ b/stacklets/memory/lib.py @@ -276,19 +276,19 @@ def load_ontology_from_vault(vault_path: Path) -> Optional[Ontology]: # Living outside `wiki/` keeps hand-curated correspondents safe from # the wiki-engine's regenerate pass. # -# Example shape (family/correspondents/adac.md): +# Example shape (family/correspondents/duff-insurance.md): # # --- # kind: correspondent -# canonical: ADAC +# canonical: Duff Insurance # aliases: -# - "ADAC Ortsverband Manzell" +# - "Duff Insurance Ortsverband Springfield" # topics: [insurance, vehicle] # address: "Hansastraße 19, 80686 München" -# website: "https://www.adac.de" +# website: "https://www.duff-insurance.example" # --- # -# # ADAC +# # Duff Insurance # [free-form notes] # # `aliases` rolls up everything the classifier has seen as the @@ -378,8 +378,8 @@ def correspondents_prompt_section(correspondents: List[Correspondent]) -> str: Output shape: Existing correspondents (canonical; aliases in parens): - - ADAC (ADAC Ortsverband Manzell, ADAC Versicherung AG) - - AOK + - Duff Insurance (Duff Insurance Ortsverband Springfield, Duff Insurance Versicherung AG) + - Springfield Mutual - Anthropic Returns an empty string when no correspondents are known yet — diff --git a/stacklets/memory/seeds/README.md b/stacklets/memory/seeds/README.md index 92a2985..360c196 100644 --- a/stacklets/memory/seeds/README.md +++ b/stacklets/memory/seeds/README.md @@ -18,12 +18,12 @@ family/ shared bucket (slug configurable via the default) documents/ Paperless documents the archivist writes here YYYY/MM/ - 2026-05-15-adac-rechnung-p247.md + 2026-05-15-duff-insurance-rechnung-p247.md _unfiled/ entries with no usable date correspondents/ hand-curated sender pages (banks, schools, ...) README.md the shape and conventions - adac.md one file per organization - aok.md + duff-insurance.md one file per organization + springfield-mutual.md homer/ personal entity bucket — one per family member notes/ pasted-text captures (Matrix `note` flow) @@ -64,7 +64,7 @@ other entities. - One concept per file. Filename is the slug; canonical name lives in frontmatter. - Frontmatter is the machine view (Dataview-compatible plain values). -- Wiki links (`[[ADAC]]`) belong in the body, not in frontmatter. +- Wiki links (`[[Duff Insurance]]`) belong in the body, not in frontmatter. - Generated regions in correspondent pages are bracketed ` ... `; hand edits outside those brackets are preserved. diff --git a/stacklets/memory/seeds/_shared/correspondents/README.md b/stacklets/memory/seeds/_shared/correspondents/README.md index 43ed248..b80b069 100644 --- a/stacklets/memory/seeds/_shared/correspondents/README.md +++ b/stacklets/memory/seeds/_shared/correspondents/README.md @@ -6,7 +6,7 @@ bank, the kids' school, the insurance company, an online service. Pages live here, in the shared bucket's `correspondents/` folder (typically `family/correspondents/` — the slug is configurable via `stack.toml [core] shared_bucket`). The file stem is the -correspondent's identifier (`adac.md`, `aok.md`). The frontmatter +correspondent's identifier (`duff-insurance.md`, `springfield-mutual.md`). The frontmatter is the machine view — the archivist reads it on startup to canonicalize new correspondents before they hit Paperless. @@ -24,16 +24,16 @@ Two things this layout buys us: ```markdown --- kind: correspondent -canonical: ADAC +canonical: Duff Insurance aliases: - - "ADAC Ortsverband Manzell" - - "ADAC Versicherung AG" + - "Duff Insurance Ortsverband Springfield" + - "Duff Insurance Versicherung AG" topics: [insurance, vehicle] address: "Hansastraße 19, 80686 München" -website: "https://www.adac.de" +website: "https://www.duff-insurance.example" --- -# ADAC +# Duff Insurance > Notes: > Hand-write anything here — kept across rebuilds. @@ -43,8 +43,8 @@ website: "https://www.adac.de" [[insurance]], [[vehicle]] ## Documents -- 2024-03-15 [[ADAC - Kfz-Versicherung 2024]] -- 2025-03-12 [[ADAC - Kfz-Versicherung 2025]] +- 2024-03-15 [[Duff Insurance - Kfz-Versicherung 2024]] +- 2025-03-12 [[Duff Insurance - Kfz-Versicherung 2025]] ``` diff --git a/stacklets/messages/hooks/on_configure.py b/stacklets/messages/hooks/on_configure.py index f490e36..1694e27 100644 --- a/stacklets/messages/hooks/on_configure.py +++ b/stacklets/messages/hooks/on_configure.py @@ -1,7 +1,7 @@ """Prompt for the Matrix server name on first setup. The server name is permanent — it becomes part of every user ID -(e.g. @arthur:familyname) and cannot be changed after Synapse starts. +(e.g. @homer:familyname) and cannot be changed after Synapse starts. This hook explains the consequences and writes the choice to stack.toml. Runs before on_install. Only fires on first 'stack up messages'. @@ -28,7 +28,7 @@ def run(ctx): section("Messages", "Family messaging and notification backbone") out("Every Matrix user ID includes the server name:") - out(" @arthur:familyname ← this part is permanent") + out(" @homer:familyname ← this part is permanent") nl() out("Pick something short and meaningful — your family name") out("works well. This cannot be changed later without starting") diff --git a/stacklets/photos/cli/setup.py b/stacklets/photos/cli/setup.py index f59ff83..2b123ef 100644 --- a/stacklets/photos/cli/setup.py +++ b/stacklets/photos/cli/setup.py @@ -9,7 +9,7 @@ the one-time admin-sign-up endpoint). All other users are created as regular accounts through the admin API. -Passwords default to the user's id (e.g. "arthur") unless overridden +Passwords default to the user's id (e.g. "homer") unless overridden with a password field in users.toml. This is intentional — famstack runs on a local network, not the internet. """ From 3516140e1a42b602f676fe429abf89c7c15484fe Mon Sep 17 00:00:00 2001 From: Arthur Date: Fri, 19 Jun 2026 23:30:26 +0200 Subject: [PATCH 19/21] test: fictionalize real data in test fixtures Replace real insurers/names (ADAC, AOK, Arthur, etc.) with fabricated Simpsons-universe data across all test fixtures and assertions. Never use real data in tests. --- tests/framework/test_strip_mention.py | 6 +- tests/framework/test_user_id.py | 12 ++-- tests/integration/bdd.py | 2 +- tests/integration/conftest.py | 2 +- tests/integration/test_archivist_e2e.py | 32 ++++----- tests/integration/test_git_mirror_e2e.py | 12 ++-- tests/integration/test_room_context_e2e.py | 8 +-- tests/stacklets/test_archivist_matching.py | 22 +++--- tests/stacklets/test_archivist_routing.py | 12 ++-- .../test_archivist_topic_bootstrap.py | 68 +++++++++---------- tests/stacklets/test_archivist_welcome.py | 34 +++++----- tests/stacklets/test_capture_pipeline.py | 50 +++++++------- .../test_classify_prompt_ontology.py | 22 +++--- tests/stacklets/test_git_mirror.py | 64 ++++++++--------- tests/stacklets/test_memory_correspondents.py | 58 ++++++++-------- tests/stacklets/test_memory_persons.py | 4 +- tests/stacklets/test_memory_wiki.py | 54 +++++++-------- tests/stacklets/test_pipeline.py | 44 ++++++------ tests/stacklets/test_recall.py | 6 +- tests/stacklets/test_reply_presenter.py | 18 ++--- tests/stacklets/test_search_format.py | 6 +- tests/stacklets/test_search_service.py | 20 +++--- tests/stacklets/test_text_utils.py | 2 +- tests/stacklets/test_topic_rooms.py | 40 +++++------ tests/stacklets/test_vault_entry.py | 48 ++++++------- 25 files changed, 323 insertions(+), 323 deletions(-) diff --git a/tests/framework/test_strip_mention.py b/tests/framework/test_strip_mention.py index fa14cc4..673d6a5 100644 --- a/tests/framework/test_strip_mention.py +++ b/tests/framework/test_strip_mention.py @@ -71,11 +71,11 @@ def test_display_name_with_colon(self): ) == "find MLX" def test_display_name_with_comma(self): - body = "Archivist, search for ADAC" - formatted = self._html("Archivist") + ", search for ADAC" + body = "Archivist, search for Duff Insurance" + formatted = self._html("Archivist") + ", search for Duff Insurance" assert MicroBot.strip_mention( body, BOT, formatted_body=formatted, - ) == "search for ADAC" + ) == "search for Duff Insurance" def test_display_name_bare(self): body = "Archivist do the thing" diff --git a/tests/framework/test_user_id.py b/tests/framework/test_user_id.py index a176135..c1546a3 100644 --- a/tests/framework/test_user_id.py +++ b/tests/framework/test_user_id.py @@ -24,23 +24,23 @@ class TestExplicitId: """When users.toml has an explicit 'id' field, use it unchanged.""" def test_uses_explicit_id(self): - assert user_id({"id": "artie", "name": "Arthur Schmidt"}) == "artie" + assert user_id({"id": "artie", "name": "Homer Schmidt"}) == "artie" def test_explicit_id_takes_precedence_over_name(self): - assert user_id({"id": "admin", "name": "Arthur"}) == "admin" + assert user_id({"id": "admin", "name": "Homer"}) == "admin" class TestDerivedFromName: """When no 'id' is set, derive from the name: first word, lowercased.""" def test_single_name(self): - assert user_id({"name": "Arthur"}) == "arthur" + assert user_id({"name": "Homer"}) == "homer" def test_full_name_uses_first_word(self): - assert user_id({"name": "Arthur Schmidt"}) == "arthur" + assert user_id({"name": "Homer Schmidt"}) == "homer" def test_lowercased(self): - assert user_id({"name": "ARTHUR"}) == "arthur" + assert user_id({"name": "HOMER"}) == "homer" def test_mixed_case(self): assert user_id({"name": "Sarah-Jane"}) == "sarah-jane" @@ -49,7 +49,7 @@ def test_mixed_case(self): class TestEdgeCases: def test_empty_id_falls_through_to_name(self): - assert user_id({"id": "", "name": "Arthur"}) == "arthur" + assert user_id({"id": "", "name": "Homer"}) == "homer" def test_missing_name_raises(self): with pytest.raises(KeyError): diff --git a/tests/integration/bdd.py b/tests/integration/bdd.py index 2e215c0..d32748f 100644 --- a/tests/integration/bdd.py +++ b/tests/integration/bdd.py @@ -9,7 +9,7 @@ [12:04:01.345] GIVEN OpenAI mock stubbed for classify + reformat [12:04:01.580] WHEN Homer uploads invoice.pdf (1.2 KB) [12:04:02.120] . room event sent: $abc123 - [12:04:12.345] THEN Paperless has a document titled 'ADAC - Kfz…' + [12:04:12.345] THEN Paperless has a document titled 'Duff Insurance - Kfz…' [12:04:12.500] ✓ id=17, tags=['Insurance', 'Person: Homer'] Use `-s` with pytest to stream this live. On failure, the last N lines diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py index c9bdf09..8161439 100644 --- a/tests/integration/conftest.py +++ b/tests/integration/conftest.py @@ -188,7 +188,7 @@ def sample_invoice_pdf(scope) -> bytes: img = Image.new("RGB", (1200, 1600), "white") draw = ImageDraw.Draw(img) draw.text((80, 80), - "ADAC Autoversicherung\n\n" + "Duff Insurance Autoversicherung\n\n" "Kfz-Versicherung 2026\n" "Jahresbeitrag: EUR 340,00\n" "Versicherungsnehmer: Homer Simpson\n" diff --git a/tests/integration/test_archivist_e2e.py b/tests/integration/test_archivist_e2e.py index c290f17..b32ebb4 100644 --- a/tests/integration/test_archivist_e2e.py +++ b/tests/integration/test_archivist_e2e.py @@ -75,28 +75,28 @@ async def test_homer_uploads_invoice_archivist_classifies_and_files_it( homer, sample_invoice_pdf, ): - """Homer sends an ADAC invoice → archivist classifies + files it. + """Homer sends an Duff Insurance invoice → archivist classifies + files it. Scenario -------- Given the archivist bot is running And the OpenAI mock will classify the document as an insurance - invoice from ADAC for Homer - When Homer uploads an ADAC invoice PDF to the #documents room + invoice from Duff Insurance for Homer + When Homer uploads an Duff Insurance invoice PDF to the #documents room Then Paperless has the document tagged 'Insurance' and - 'Person: Homer', with correspondent 'ADAC' and type 'Invoice' + 'Person: Homer', with correspondent 'Duff Insurance' and type 'Invoice' And the #documents room contains a classification summary And the summary m.room.message carries a dev.famstack.event envelope (Matrix is the canonical ledger — one event per filing, full payload on the visible message) """ scope = paperless_scope - bdd.scenario("Homer uploads an ADAC invoice; archivist classifies it") + bdd.scenario("Homer uploads an Duff Insurance invoice; archivist classifies it") # ── Given ──────────────────────────────────────────────────────── - expected_title = scope.tag("ADAC - Kfz-Versicherung 2026") + expected_title = scope.tag("Duff Insurance - Kfz-Versicherung 2026") expected_topic = scope.tag("Insurance") - expected_correspondent = scope.tag("ADAC") + expected_correspondent = scope.tag("Duff Insurance") bdd.given("the #documents room exists and Homer has access") room_id = await resolve_room(homer, DOCS_ROOM_ALIAS) @@ -120,11 +120,11 @@ async def test_homer_uploads_invoice_archivist_classifies_and_files_it( "correspondent": expected_correspondent, "document_type": "Invoice", "date": "2026-03-15", - "summary": "Annual car insurance renewal at ADAC. EUR 340/year.", + "summary": "Annual car insurance renewal at Duff Insurance. EUR 340/year.", "facts": ["EUR 340.00/year", "Contract KFZ-2026-000123"], "action_items": [{"action": "Pay by 2026-03-15", "due": "2026-03-15"}], }) - stub_reformat(openai, "# Kfz-Versicherung 2026\n\nADAC — EUR 340/year.") + stub_reformat(openai, "# Kfz-Versicherung 2026\n\nDuff Insurance — EUR 340/year.") bdd.detail("classify stub → title, topics, correspondent, type") bdd.detail("reformat stub → 1-line markdown") @@ -184,7 +184,7 @@ async def _find_doc(): # sweep it; sections are untitled (no '## Summary' label) so the # language stays native to the document. assert "" in body, f"missing marker in: {body!r}" - assert "Annual car insurance renewal at ADAC" in body, body + assert "Annual car insurance renewal at Duff Insurance" in body, body assert "EUR 340.00/year" in body, body assert f"{expected_correspondent} → Homer" in body, body bdd.ok(f"summary note present ({len(body)} chars)") @@ -232,7 +232,7 @@ async def test_homer_replies_to_filing_and_archivist_reprocesses( Scenario -------- - Given the archivist filed Homer's invoice (correspondent ADAC) + Given the archivist filed Homer's invoice (correspondent Duff Insurance) When Homer replies to the filing message with "this is from Globex" Then the archivist reprocesses doc and posts a reclassified confirmation carrying the user's hint in its envelope @@ -240,8 +240,8 @@ async def test_homer_replies_to_filing_and_archivist_reprocesses( scope = paperless_scope bdd.scenario("Homer corrects a filing by replying to it") - title = scope.tag("ADAC - Kfz-Versicherung reprocess") - expected_correspondent = scope.tag("ADAC") + title = scope.tag("Duff Insurance - Kfz-Versicherung reprocess") + expected_correspondent = scope.tag("Duff Insurance") bdd.given("the OpenAI mock will classify, reformat, then reclassify") # Initial filing pass: classify + reformat. stub_classify(openai, { @@ -250,7 +250,7 @@ async def test_homer_replies_to_filing_and_archivist_reprocesses( "date": "2026-03-15", "summary": "Car insurance renewal.", "facts": ["EUR 340.00/year"], "action_items": [], }) - stub_reformat(openai, "# Kfz-Versicherung\n\nADAC.") + stub_reformat(openai, "# Kfz-Versicherung\n\nDuff Insurance.") # Reprocess pass (triggered by the reply): classify only. stub_classify(openai, { "title": title, "topics": [scope.tag("Insurance")], "persons": ["Homer"], @@ -287,7 +287,7 @@ async def test_homer_replies_to_filing_and_archivist_reprocesses( bdd.ok(f"filed doc #{paperless_id}, event {filing.event_id}") bdd.when("Homer replies to the filing with a correction") - hint = "this is from Globex, not ADAC" + hint = "this is from Globex, not Duff Insurance" await homer.room_send( room_id, "m.room.message", { @@ -379,7 +379,7 @@ async def test_group_mention_triggers_search(bdd, paperless, matrix): room_id, "m.room.message", { "msgtype": "m.text", - "body": f"{ARCHIVIST_MXID} ADAC", + "body": f"{ARCHIVIST_MXID} Duff Insurance", "m.mentions": {"user_ids": [ARCHIVIST_MXID]}, }, ) diff --git a/tests/integration/test_git_mirror_e2e.py b/tests/integration/test_git_mirror_e2e.py index 3eee06c..fd673b1 100644 --- a/tests/integration/test_git_mirror_e2e.py +++ b/tests/integration/test_git_mirror_e2e.py @@ -76,7 +76,7 @@ async def test_archivist_mirrors_classified_document_to_forgejo( Scenario -------- Given the code stacklet is running and the archivist is configured - When Homer uploads an ADAC invoice to #documents + When Homer uploads an Duff Insurance invoice to #documents Then Paperless has the classified document And Forgejo has a mirror file at family/documents/YYYY/MM/-p.md And the file's frontmatter carries the full classification @@ -87,9 +87,9 @@ async def test_archivist_mirrors_classified_document_to_forgejo( bdd.scenario("Archivist mirrors a classified document to Forgejo") # ── Given ──────────────────────────────────────────────────────── - expected_title = scope.tag("ADAC - Kfz-Versicherung 2026") + expected_title = scope.tag("Duff Insurance - Kfz-Versicherung 2026") expected_topic = scope.tag("Insurance") - expected_correspondent = scope.tag("ADAC") + expected_correspondent = scope.tag("Duff Insurance") expected_date = "2026-03-15" bdd.given("the code (Forgejo) stacklet is reachable") @@ -109,11 +109,11 @@ async def test_archivist_mirrors_classified_document_to_forgejo( "correspondent": expected_correspondent, "document_type": "Invoice", "date": expected_date, - "summary": "Annual car insurance renewal at ADAC. EUR 340/year.", + "summary": "Annual car insurance renewal at Duff Insurance. EUR 340/year.", "facts": ["EUR 340.00/year", "Contract KFZ-2026-000123"], "action_items": [{"action": "Pay by 2026-03-15", "due": expected_date}], }) - reformatted = f"# {expected_title}\n\nADAC — EUR 340/year. Contract KFZ-2026-000123." + reformatted = f"# {expected_title}\n\nDuff Insurance — EUR 340/year. Contract KFZ-2026-000123." stub_reformat(openai, reformatted) # ── When ───────────────────────────────────────────────────────── @@ -160,7 +160,7 @@ async def test_archivist_mirrors_classified_document_to_forgejo( bdd.ok(f"processing={fm['processing']} correspondent={fm['correspondent']}") bdd.and_("the body contains the AI-reformatted content") - assert "ADAC — EUR 340/year" in body, f"body missing reformatted text: {body[:200]!r}" + assert "Duff Insurance — EUR 340/year" in body, f"body missing reformatted text: {body[:200]!r}" bdd.ok(f"body length = {len(body)} chars") # ── Then: commit carries the Paperless-Id trailer ─────────────── diff --git a/tests/integration/test_room_context_e2e.py b/tests/integration/test_room_context_e2e.py index 6f8dc2c..f8f9956 100644 --- a/tests/integration/test_room_context_e2e.py +++ b/tests/integration/test_room_context_e2e.py @@ -155,11 +155,11 @@ async def test_at_mention_via_m_mentions_detected(family): room_id, "m.room.message", { "msgtype": "m.text", - "body": "please search for the ADAC invoice", + "body": "please search for the Duff Insurance invoice", "m.mentions": {"user_ids": [bart.user_id]}, }, ) - event = await _find_event(bart, room_id, "please search for the ADAC invoice") + event = await _find_event(bart, room_id, "please search for the Duff Insurance invoice") assert event is not None, "mention message never arrived" assert bot._is_bot_mentioned(event) is True @@ -196,12 +196,12 @@ async def test_reply_to_our_filing_resolves_envelope(family): # The bot files a document, riding the envelope on the message. await bot._send( - room_id, "Filed: ADAC invoice (#5)", + room_id, "Filed: Duff Insurance invoice (#5)", metadata={"dev.famstack.event": { "type": "document.filed", "data": {"paperless_id": 5}, }}, ) - filing = await _find_event(homer, room_id, "Filed: ADAC invoice (#5)") + filing = await _find_event(homer, room_id, "Filed: Duff Insurance invoice (#5)") assert filing is not None, "bot filing never reached Homer" # Homer replies with a correction. diff --git a/tests/stacklets/test_archivist_matching.py b/tests/stacklets/test_archivist_matching.py index a9782fc..846a356 100644 --- a/tests/stacklets/test_archivist_matching.py +++ b/tests/stacklets/test_archivist_matching.py @@ -72,7 +72,7 @@ def test_possessive(self): assert _tokenize("Moe's Tavern") == ["moe", "s", "tavern"] def test_abbreviation_with_dots(self): - assert _tokenize("ADAC e.V.") == ["adac", "e", "v"] + assert _tokenize("Globex e.V.") == ["globex", "e", "v"] def test_strips_whitespace(self): assert _tokenize(" Burns Industries ") == ["burns", "industries"] @@ -95,7 +95,7 @@ def test_suffix_substring_blocked(self): assert _is_word_boundary_match("Bank", "Bundesbank") is False def test_name_inside_longer_name_blocked(self): - assert _is_word_boundary_match("Art", "Arthur") is False + assert _is_word_boundary_match("Art", "Homer") is False def test_multi_word_prefix(self): assert _is_word_boundary_match("Springfield Nuclear", "Springfield Nuclear Power Plant") is True @@ -104,7 +104,7 @@ def test_hyphenated_match(self): assert _is_word_boundary_match("Kwik-E-Mart", "Kwik-E-Mart Inc.") is True def test_abbreviation_match(self): - assert _is_word_boundary_match("ADAC", "ADAC e.V.") is True + assert _is_word_boundary_match("Duff Insurance", "Duff Insurance e.V.") is True def test_empty_shorter(self): assert _is_word_boundary_match("", "Springfield") is False @@ -142,7 +142,7 @@ def test_llm_drops_suffix(self): assert fuzzy_match_entity("Springfield Nuclear", {"Springfield Nuclear Power Plant": 1}) == "Springfield Nuclear Power Plant" def test_abbreviation_suffix(self): - assert fuzzy_match_entity("ADAC e.V.", {"ADAC": 1}) == "ADAC" + assert fuzzy_match_entity("Duff Insurance e.V.", {"Duff Insurance": 1}) == "Duff Insurance" def test_single_word_full_token(self): assert fuzzy_match_entity("Springfield", {"Springfield Elementary": 1}) == "Springfield Elementary" @@ -326,8 +326,8 @@ def test_no_args(self): def test_multiple_persons(self): # Joint document: topic + two persons + correspondent - result = deduplicate_hashtags("Insurance", "Homer", "Marge", "ADAC") - assert result == ["#Insurance", "#Homer", "#Marge", "#ADAC"] + result = deduplicate_hashtags("Insurance", "Homer", "Marge", "Duff Insurance") + assert result == ["#Insurance", "#Homer", "#Marge", "#Duff Insurance"] # ── Topic matching ───────────────────────────────────────────────────── @@ -540,12 +540,12 @@ class TestBuildDocumentEvent: """ def test_envelope_shape(self): - evt = build_document_event(42, {"title": "ADAC - Kfz EUR 340"}) + evt = build_document_event(42, {"title": "Duff Insurance - Kfz EUR 340"}) assert evt["source"] == "docs" assert evt["type"] == "document.filed" - assert evt["summary"] == "ADAC - Kfz EUR 340 filed (#42)" + assert evt["summary"] == "Duff Insurance - Kfz EUR 340 filed (#42)" assert evt["data"]["paperless_id"] == 42 - assert evt["data"]["title"] == "ADAC - Kfz EUR 340" + assert evt["data"]["title"] == "Duff Insurance - Kfz EUR 340" def test_resolved_fields_land_in_data(self): evt = build_document_event( @@ -553,12 +553,12 @@ def test_resolved_fields_land_in_data(self): {"title": "Test", "summary": "A test doc"}, resolved_topics=["Insurance", "Vehicle"], resolved_persons=["Homer"], - resolved_correspondent="ADAC", + resolved_correspondent="Duff Insurance", resolved_type="Invoice", ) assert evt["data"]["topics"] == ["Insurance", "Vehicle"] assert evt["data"]["persons"] == ["Homer"] - assert evt["data"]["correspondent"] == "ADAC" + assert evt["data"]["correspondent"] == "Duff Insurance" assert evt["data"]["document_type"] == "Invoice" def test_includes_paperless_url(self): diff --git a/tests/stacklets/test_archivist_routing.py b/tests/stacklets/test_archivist_routing.py index 846ae83..b8973e1 100644 --- a/tests/stacklets/test_archivist_routing.py +++ b/tests/stacklets/test_archivist_routing.py @@ -98,7 +98,7 @@ def test_room_matching_alias_is_documents(self, tmp_path): def test_room_with_other_alias_is_not_documents(self, tmp_path): bot = _build_bot(tmp_path) room = _room( - canonical_alias="#arthur-notes:server", + canonical_alias="#homer-notes:server", members=[BOT_ID, "@homer:server"], ) ctx = bot._room_context(room) @@ -323,18 +323,18 @@ class TestStripReplyFallback: def test_strips_single_line_quoted_fallback(self): from archivist import _strip_reply_fallback body = ( - "> <@bot:test.local> Filed: ADAC Kfz-Versicherung (#42)\n" + "> <@bot:test.local> Filed: Duff Insurance Kfz-Versicherung (#42)\n" "\n" - "this is for Sabrina, not Homer" + "this is for Marge, not Homer" ) - assert _strip_reply_fallback(body) == "this is for Sabrina, not Homer" + assert _strip_reply_fallback(body) == "this is for Marge, not Homer" def test_strips_multi_line_quoted_fallback(self): from archivist import _strip_reply_fallback body = ( - "> <@bot:test.local> Filed: ADAC (#42)\n" + "> <@bot:test.local> Filed: Duff Insurance (#42)\n" "> \n" - "> Insurance | Homer | Invoice | ADAC | 2026-03-15\n" + "> Insurance | Homer | Invoice | Duff Insurance | 2026-03-15\n" "\n" "wrong year, it's actually 2025" ) diff --git a/tests/stacklets/test_archivist_topic_bootstrap.py b/tests/stacklets/test_archivist_topic_bootstrap.py index f97a662..94fe1ce 100644 --- a/tests/stacklets/test_archivist_topic_bootstrap.py +++ b/tests/stacklets/test_archivist_topic_bootstrap.py @@ -106,8 +106,8 @@ class TestNonTopicRoom: async def test_plain_room_returns_none(self, tmp_path): client = FakeStateClient(initial_state=None) bot = _bot(tmp_path, client=client) - room = _room(name="Family Chat", members=[BOT_ID, "@arthur:server"]) - binding = await bot._topic_binding(room, "@arthur:server") + room = _room(name="Family Chat", members=[BOT_ID, "@homer:server"]) + binding = await bot._topic_binding(room, "@homer:server") assert binding is None # No state was written -- nothing to bootstrap. assert client.writes == [] @@ -116,8 +116,8 @@ async def test_plain_room_returns_none(self, tmp_path): async def test_empty_name_returns_none(self, tmp_path): client = FakeStateClient() bot = _bot(tmp_path, client=client) - room = _room(name=None, members=[BOT_ID, "@arthur:server"]) - binding = await bot._topic_binding(room, "@arthur:server") + room = _room(name=None, members=[BOT_ID, "@homer:server"]) + binding = await bot._topic_binding(room, "@homer:server") assert binding is None @@ -144,8 +144,8 @@ async def test_returns_binding_without_writing(self, tmp_path): client = FakeStateClient(initial_state=self.SHARED_STATE) bot = _bot(tmp_path, client=client) room = _room(name="Thema: Camping", - members=[BOT_ID, "@arthur:server", "@marge:server"]) - binding = await bot._topic_binding(room, "@arthur:server") + members=[BOT_ID, "@homer:server", "@marge:server"]) + binding = await bot._topic_binding(room, "@homer:server") assert binding is not None assert binding.bucket == "family/camping" assert binding.seed_topics == ["camping"] @@ -163,8 +163,8 @@ async def test_state_for_a_non_topic_kind_returns_none(self, tmp_path): "kind": "capture", "extract_knowledge": True, }) bot = _bot(tmp_path, client=client) - room = _room(name="Family Chat", members=[BOT_ID, "@arthur:server"]) - binding = await bot._topic_binding(room, "@arthur:server") + room = _room(name="Family Chat", members=[BOT_ID, "@homer:server"]) + binding = await bot._topic_binding(room, "@homer:server") assert binding is None @@ -185,9 +185,9 @@ async def test_shared_topic_bootstrap(self, tmp_path): bot = _bot(tmp_path, client=client) room = _room( name="Thema: Camping", - members=[BOT_ID, "@arthur:server", "@marge:server"], + members=[BOT_ID, "@homer:server", "@marge:server"], ) - binding = await bot._topic_binding(room, "@arthur:server") + binding = await bot._topic_binding(room, "@homer:server") assert binding is not None assert binding.bucket == "family/camping" assert binding.scope == "shared" @@ -199,7 +199,7 @@ async def test_shared_topic_bootstrap(self, tmp_path): assert content["kind"] == "topic" assert content["bucket"] == "family/camping" assert content["scope"] == "shared" - assert content["bootstrapped_by"] == "@arthur:server" + assert content["bootstrapped_by"] == "@homer:server" @pytest.mark.asyncio async def test_personal_topic_bootstrap(self, tmp_path): @@ -209,14 +209,14 @@ async def test_personal_topic_bootstrap(self, tmp_path): bot = _bot(tmp_path, client=client) room = _room( name="Thema: Gravel", - members=[BOT_ID, "@arthur:server"], + members=[BOT_ID, "@homer:server"], ) - binding = await bot._topic_binding(room, "@arthur:server") + binding = await bot._topic_binding(room, "@homer:server") assert binding is not None - assert binding.bucket == "arthur/gravel" + assert binding.bucket == "homer/gravel" assert binding.scope == "personal" content = client.writes[0][2] - assert content["bucket"] == "arthur/gravel" + assert content["bucket"] == "homer/gravel" assert content["scope"] == "personal" @pytest.mark.asyncio @@ -225,8 +225,8 @@ async def test_english_prefix_works_in_de_household(self, tmp_path): client = FakeStateClient(initial_state=None) bot = _bot(tmp_path, client=client, ) room = _room(name="Topic: Photography", - members=[BOT_ID, "@arthur:server", "@marge:server"]) - binding = await bot._topic_binding(room, "@arthur:server") + members=[BOT_ID, "@homer:server", "@marge:server"]) + binding = await bot._topic_binding(room, "@homer:server") assert binding is not None assert binding.slug == "photography" @@ -235,10 +235,10 @@ async def test_bootstrap_records_sender_provenance(self, tmp_path): client = FakeStateClient(initial_state=None) bot = _bot(tmp_path, client=client) room = _room(name="Thema: Camping", - members=[BOT_ID, "@arthur:server", "@marge:server"]) - await bot._topic_binding(room, "@arthur:server") + members=[BOT_ID, "@homer:server", "@marge:server"]) + await bot._topic_binding(room, "@homer:server") content = client.writes[0][2] - assert content["bootstrapped_by"] == "@arthur:server" + assert content["bootstrapped_by"] == "@homer:server" # bootstrapped_at is an ISO timestamp ending in Z. assert content["bootstrapped_at"].endswith("Z") @@ -251,7 +251,7 @@ class TestReservedSlugRefusal: directory (notes, bookmarks, documents, correspondents, _unfiled, about) is refused at bootstrap. The archivist logs and returns None; routing falls back to sender-based. Top-level vault names - (`family`, `arthur`, ...) no longer need to be reserved because + (`family`, `homer`, ...) no longer need to be reserved because topics never live at the top level.""" @pytest.mark.asyncio @@ -261,8 +261,8 @@ async def test_notes_slug_refused(self, tmp_path): client = FakeStateClient(initial_state=None) bot = _bot(tmp_path, client=client) room = _room(name="Thema: Notes", - members=[BOT_ID, "@arthur:server", "@marge:server"]) - binding = await bot._topic_binding(room, "@arthur:server") + members=[BOT_ID, "@homer:server", "@marge:server"]) + binding = await bot._topic_binding(room, "@homer:server") assert binding is None assert client.writes == [] @@ -271,8 +271,8 @@ async def test_correspondents_slug_refused(self, tmp_path): client = FakeStateClient(initial_state=None) bot = _bot(tmp_path, client=client) room = _room(name="Thema: Correspondents", - members=[BOT_ID, "@arthur:server", "@marge:server"]) - binding = await bot._topic_binding(room, "@arthur:server") + members=[BOT_ID, "@homer:server", "@marge:server"]) + binding = await bot._topic_binding(room, "@homer:server") assert binding is None assert client.writes == [] @@ -283,8 +283,8 @@ async def test_about_slug_refused(self, tmp_path): client = FakeStateClient(initial_state=None) bot = _bot(tmp_path, client=client) room = _room(name="Thema: About", - members=[BOT_ID, "@arthur:server", "@marge:server"]) - binding = await bot._topic_binding(room, "@arthur:server") + members=[BOT_ID, "@homer:server", "@marge:server"]) + binding = await bot._topic_binding(room, "@homer:server") assert binding is None @pytest.mark.asyncio @@ -297,8 +297,8 @@ async def test_shared_bucket_name_now_allowed_as_topic(self, tmp_path): client = FakeStateClient(initial_state=None) bot = _bot(tmp_path, client=client) room = _room(name="Thema: Family", - members=[BOT_ID, "@arthur:server", "@marge:server"]) - binding = await bot._topic_binding(room, "@arthur:server") + members=[BOT_ID, "@homer:server", "@marge:server"]) + binding = await bot._topic_binding(room, "@homer:server") assert binding is not None assert binding.bucket == "family/family" @@ -320,8 +320,8 @@ async def test_state_read_error_treats_as_no_state(self, tmp_path): client = FakeStateClient(initial_state=None, read_raises=True) bot = _bot(tmp_path, client=client) room = _room(name="Thema: Camping", - members=[BOT_ID, "@arthur:server", "@marge:server"]) - binding = await bot._topic_binding(room, "@arthur:server") + members=[BOT_ID, "@homer:server", "@marge:server"]) + binding = await bot._topic_binding(room, "@homer:server") # Read failed, so we treated it as no-state and bootstrapped. assert binding is not None assert binding.bucket == "family/camping" @@ -334,8 +334,8 @@ async def test_state_write_failure_still_returns_binding(self, tmp_path): client = FakeStateClient(initial_state=None, write_raises=True) bot = _bot(tmp_path, client=client) room = _room(name="Thema: Camping", - members=[BOT_ID, "@arthur:server", "@marge:server"]) - binding = await bot._topic_binding(room, "@arthur:server") + members=[BOT_ID, "@homer:server", "@marge:server"]) + binding = await bot._topic_binding(room, "@homer:server") assert binding is not None assert binding.bucket == "family/camping" @@ -349,7 +349,7 @@ class TestHumanCounting: def test_excludes_self(self, tmp_path): bot = _bot(tmp_path) - room = _room(members=[BOT_ID, "@arthur:server"]) + room = _room(members=[BOT_ID, "@homer:server"]) assert bot._count_humans_in_room(room) == 1 def test_no_members_is_zero(self, tmp_path): diff --git a/tests/stacklets/test_archivist_welcome.py b/tests/stacklets/test_archivist_welcome.py index 0a8caff..27688bc 100644 --- a/tests/stacklets/test_archivist_welcome.py +++ b/tests/stacklets/test_archivist_welcome.py @@ -100,7 +100,7 @@ def test_topic_room_takes_precedence(self, tmp_path): room = _room( name="Thema: Camping", canonical_alias="#documents:server", - members=[BOT_ID, "@arthur:server", "@marge:server"], + members=[BOT_ID, "@homer:server", "@marge:server"], ) ctx = bot._room_context(room) assert bot._welcome_kind_for(room, ctx) == "topic" @@ -110,14 +110,14 @@ def test_documents_room_detected(self, tmp_path): room = _room( name="Documents", canonical_alias="#documents:server", - members=[BOT_ID, "@arthur:server", "@marge:server"], + members=[BOT_ID, "@homer:server", "@marge:server"], ) ctx = bot._room_context(room) assert bot._welcome_kind_for(room, ctx) == "documents" def test_dm_falls_back_to_personal(self, tmp_path): bot = _bot(tmp_path) - room = _room(name="DM", members=[BOT_ID, "@arthur:server"]) + room = _room(name="DM", members=[BOT_ID, "@homer:server"]) ctx = bot._room_context(room) assert bot._welcome_kind_for(room, ctx) == "personal" @@ -125,7 +125,7 @@ def test_generic_room_falls_back_to_capture(self, tmp_path): bot = _bot(tmp_path) room = _room( name="Family Chat", - members=[BOT_ID, "@arthur:server", "@marge:server"], + members=[BOT_ID, "@homer:server", "@marge:server"], ) ctx = bot._room_context(room) assert bot._welcome_kind_for(room, ctx) == "capture" @@ -134,7 +134,7 @@ def test_english_topic_prefix_also_detected(self, tmp_path): bot = _bot(tmp_path) room = _room( name="Topic: Photography", - members=[BOT_ID, "@arthur:server", "@marge:server"], + members=[BOT_ID, "@homer:server", "@marge:server"], ) ctx = bot._room_context(room) assert bot._welcome_kind_for(room, ctx) == "topic" @@ -153,7 +153,7 @@ def test_topic_welcome_carries_display_and_slug(self, tmp_path): bot = _bot(tmp_path) room = _room( name="Thema: Van Life", - members=[BOT_ID, "@arthur:server", "@marge:server"], + members=[BOT_ID, "@homer:server", "@marge:server"], ) ctx = bot._room_context(room) text = bot._welcome_text_for(room, ctx) @@ -167,7 +167,7 @@ def test_topic_welcome_carries_shared_bucket_path(self, tmp_path): bot = _bot(tmp_path) room = _room( name="Thema: Camping", - members=[BOT_ID, "@arthur:server", "@marge:server"], + members=[BOT_ID, "@homer:server", "@marge:server"], ) ctx = bot._room_context(room) text = bot._welcome_text_for(room, ctx) @@ -180,11 +180,11 @@ def test_topic_welcome_carries_personal_bucket_path(self, tmp_path): bot = _bot(tmp_path) room = _room( name="Thema: Gravel", - members=[BOT_ID, "@arthur:server"], + members=[BOT_ID, "@homer:server"], ) ctx = bot._room_context(room) text = bot._welcome_text_for(room, ctx) - assert "arthur/gravel" in text + assert "homer/gravel" in text def test_documents_welcome_carries_paperless_url(self, tmp_path): """The documents welcome ends with a link the user can click @@ -194,7 +194,7 @@ def test_documents_welcome_carries_paperless_url(self, tmp_path): room = _room( name="Documents", canonical_alias="#documents:server", - members=[BOT_ID, "@arthur:server", "@marge:server"], + members=[BOT_ID, "@homer:server", "@marge:server"], ) ctx = bot._room_context(room) text = bot._welcome_text_for(room, ctx) @@ -202,7 +202,7 @@ def test_documents_welcome_carries_paperless_url(self, tmp_path): def test_personal_welcome_renders(self, tmp_path): bot = _bot(tmp_path) - room = _room(name="DM", members=[BOT_ID, "@arthur:server"]) + room = _room(name="DM", members=[BOT_ID, "@homer:server"]) ctx = bot._room_context(room) text = bot._welcome_text_for(room, ctx) # Variant-specific marker: the DM-shaped welcome calls itself @@ -216,7 +216,7 @@ def test_capture_welcome_mentions_topic_pro_tip(self, tmp_path): bot = _bot(tmp_path) room = _room( name="Family Chat", - members=[BOT_ID, "@arthur:server", "@marge:server"], + members=[BOT_ID, "@homer:server", "@marge:server"], ) ctx = bot._room_context(room) text = bot._welcome_text_for(room, ctx) @@ -240,7 +240,7 @@ async def test_fresh_room_gets_welcome(self, tmp_path): room = _room( name="Documents", canonical_alias="#documents:server", - members=[BOT_ID, "@arthur:server", "@marge:server"], + members=[BOT_ID, "@homer:server", "@marge:server"], ) ctx = bot._room_context(room) await bot._send_room_welcome_if_needed(room, ctx) @@ -256,7 +256,7 @@ async def test_room_with_own_history_skipped(self, tmp_path): room = _room( name="Documents", canonical_alias="#documents:server", - members=[BOT_ID, "@arthur:server", "@marge:server"], + members=[BOT_ID, "@homer:server", "@marge:server"], ) ctx = bot._room_context(room) await bot._send_room_welcome_if_needed(room, ctx) @@ -272,7 +272,7 @@ async def test_history_check_failure_still_welcomes(self, tmp_path): room = _room( name="Documents", canonical_alias="#documents:server", - members=[BOT_ID, "@arthur:server", "@marge:server"], + members=[BOT_ID, "@homer:server", "@marge:server"], ) ctx = bot._room_context(room) await bot._send_room_welcome_if_needed(room, ctx) @@ -288,7 +288,7 @@ async def test_second_encounter_uses_cache_not_history(self, tmp_path): bot = _bot(tmp_path, client=client) room = _room( name="Thema: Camping", - members=[BOT_ID, "@arthur:server", "@marge:server"], + members=[BOT_ID, "@homer:server", "@marge:server"], ) ctx = bot._room_context(room) await bot._send_room_welcome_if_needed(room, ctx) @@ -305,7 +305,7 @@ async def test_skipped_room_is_cached_too(self, tmp_path): room = _room( name="Documents", canonical_alias="#documents:server", - members=[BOT_ID, "@arthur:server", "@marge:server"], + members=[BOT_ID, "@homer:server", "@marge:server"], ) ctx = bot._room_context(room) await bot._send_room_welcome_if_needed(room, ctx) diff --git a/tests/stacklets/test_capture_pipeline.py b/tests/stacklets/test_capture_pipeline.py index 7714577..de89a36 100644 --- a/tests/stacklets/test_capture_pipeline.py +++ b/tests/stacklets/test_capture_pipeline.py @@ -303,23 +303,23 @@ class TestTagList: tag per attributed person, normalising types and whitespace.""" def test_tags_and_persons(self): - c = {"tags": ["AI", "Productivity"], "persons": ["Arthur"]} - assert CapturePipeline._tag_list(c) == ["AI", "Productivity", "Person: Arthur"] + c = {"tags": ["AI", "Productivity"], "persons": ["Homer"]} + assert CapturePipeline._tag_list(c) == ["AI", "Productivity", "Person: Homer"] def test_single_string_tag_becomes_list(self): - c = {"tags": "AI", "persons": ["Arthur"]} - assert CapturePipeline._tag_list(c) == ["AI", "Person: Arthur"] + c = {"tags": "AI", "persons": ["Homer"]} + assert CapturePipeline._tag_list(c) == ["AI", "Person: Homer"] def test_empty_classification_yields_empty(self): assert CapturePipeline._tag_list({}) == [] def test_skips_non_string_values(self): - c = {"tags": ["AI", None, 42, ""], "persons": ["Arthur", ""]} - assert CapturePipeline._tag_list(c) == ["AI", "Person: Arthur"] + c = {"tags": ["AI", None, 42, ""], "persons": ["Homer", ""]} + assert CapturePipeline._tag_list(c) == ["AI", "Person: Homer"] def test_strips_whitespace(self): - c = {"tags": [" AI ", "Productivity"], "persons": ["Arthur"]} - assert CapturePipeline._tag_list(c) == ["AI", "Productivity", "Person: Arthur"] + c = {"tags": [" AI ", "Productivity"], "persons": ["Homer"]} + assert CapturePipeline._tag_list(c) == ["AI", "Productivity", "Person: Homer"] class TestClassifyDegradation: @@ -667,7 +667,7 @@ def test_classifier_missing_tags_key(self): """When the classifier degraded (no tags key in the payload), the seed still applies. The result has a fresh `tags` list.""" merged = CapturePipeline._merge_seed_topics( - {"persons": ["Arthur"]}, seed_topics=["camping"], + {"persons": ["Homer"]}, seed_topics=["camping"], ) assert merged["tags"] == ["camping"] @@ -970,17 +970,17 @@ async def test_capture_text_with_seed_files_seed_tag(self): classifier=FakeClassifier(payload={ "title": "Note", "tags": [], - "persons": ["Arthur"], + "persons": ["Homer"], "summary": "s", }), ) out = await pipe.capture_text( text="we packed everything in the roof box", - sender_mxid="@arthur:s", + sender_mxid="@homer:s", seed_topics=["camping"], ) assert out.status == "captured" - # Mirror gets the merged list (seed + Person: Arthur). + # Mirror gets the merged list (seed + Person: Homer). assert "camping" in mirror.captures[0]["tags"] # Classification dict also carries the merge. assert "camping" in out.classification["tags"] @@ -995,13 +995,13 @@ async def test_capture_url_with_seed_keeps_classifier_additions(self): classifier=FakeClassifier(payload={ "title": "Article", "tags": ["gear", "vans"], - "persons": ["Arthur"], + "persons": ["Homer"], "summary": "s", }), ) out = await pipe.capture_url( url="http://example.com/best-vanlife-gear", - sender_mxid="@arthur:s", + sender_mxid="@homer:s", notifier=FakeNotifier(), seed_topics=["van-life"], ) @@ -1023,7 +1023,7 @@ async def test_capture_voice_batch_with_seed_files_seed_tag(self): out = await pipe.capture_voice_batch( transcripts=["first memo", "second memo"], primary_mxc="mxc://server/abc", - sender_mxid="@arthur:s", + sender_mxid="@homer:s", seed_topics=["camping"], ) assert out.status == "captured" @@ -1041,7 +1041,7 @@ async def test_seed_recorded_in_capture_tag_cache(self): url_extractor=FakeExtractor(_source(source_uri="http://src")), text_extractor=FakeExtractor(_source(source_uri="http://embedded")), classifier=FakeClassifier(payload={ - "title": "Note", "tags": [], "persons": ["Arthur"], + "title": "Note", "tags": [], "persons": ["Homer"], }), mirror=mirror, capture_tags=tags, @@ -1053,7 +1053,7 @@ async def test_seed_recorded_in_capture_tag_cache(self): ) await pipe.capture_text( text="some pasted thought", - sender_mxid="@arthur:s", + sender_mxid="@homer:s", seed_topics=["camping"], ) # The seed survives into the topic-tag list the cache records. @@ -1069,29 +1069,29 @@ async def test_bucket_override_routes_to_topic_folder(self): pipe = _pipeline(mirror=mirror) out = await pipe.capture_text( text="we packed everything in the roof box", - sender_mxid="@arthur:s", + sender_mxid="@homer:s", seed_topics=["camping"], bucket="camping", ) assert out.status == "captured" - # Mirror sees the topic bucket as entity, not arthur. + # Mirror sees the topic bucket as entity, not homer. assert mirror.captures[0]["entity"] == "camping" @pytest.mark.asyncio async def test_bucket_override_handles_nested_personal_bucket(self): """Personal topic rooms nest under the sender bucket - (`arthur/camping/`). The mirror writer treats the entity + (`homer/camping/`). The mirror writer treats the entity string as a path prefix, so this just works.""" mirror = FakeMirror() pipe = _pipeline(mirror=mirror) out = await pipe.capture_text( text="solo trip planning", - sender_mxid="@arthur:s", + sender_mxid="@homer:s", seed_topics=["gravel"], - bucket="arthur/gravel", + bucket="homer/gravel", ) assert out.status == "captured" - assert mirror.captures[0]["entity"] == "arthur/gravel" + assert mirror.captures[0]["entity"] == "homer/gravel" @pytest.mark.asyncio async def test_no_bucket_override_falls_back_to_sender(self): @@ -1115,12 +1115,12 @@ async def test_seed_propagates_to_envelope(self): pipe = _pipeline( mirror=mirror, classifier=FakeClassifier(payload={ - "title": "Note", "tags": [], "persons": ["Arthur"], + "title": "Note", "tags": [], "persons": ["Homer"], }), ) out = await pipe.capture_text( text="a note", - sender_mxid="@arthur:s", + sender_mxid="@homer:s", seed_topics=["camping"], ) assert out.envelope is not None diff --git a/tests/stacklets/test_classify_prompt_ontology.py b/tests/stacklets/test_classify_prompt_ontology.py index aa04d14..4a102cb 100644 --- a/tests/stacklets/test_classify_prompt_ontology.py +++ b/tests/stacklets/test_classify_prompt_ontology.py @@ -32,7 +32,7 @@ person_names=["Homer"], category_tags=["Insurance", "Tax"], doc_types=["Invoice", "Letter"], - correspondents=["AOK"], + correspondents=["Springfield Mutual"], ) @@ -49,7 +49,7 @@ def test_uses_existing_document_types_line(self): def test_correspondents_line_present_in_either_mode(self): prompt = _build_classify_prompt(**COMMON) - assert 'Existing correspondents: ["AOK"]' in prompt + assert 'Existing correspondents: ["Springfield Mutual"]' in prompt class TestOntologyAware: @@ -84,7 +84,7 @@ def test_keeps_family_members_and_correspondents_lines(self): # Persons + correspondents are dynamic (Paperless) and stay # alongside the ontology — they're not part of the ontology. assert 'Family members: ["Homer"]' in prompt - assert 'Existing correspondents: ["AOK"]' in prompt + assert 'Existing correspondents: ["Springfield Mutual"]' in prompt class TestCorrespondentsBlock: @@ -95,21 +95,21 @@ class TestCorrespondentsBlock: def test_embeds_section_verbatim(self): section = ( "Existing correspondents (canonical; aliases in parens):\n" - " - ADAC (ADAC Ortsverband Manzell)\n" - " - AOK\n" + " - Duff Insurance (Duff Insurance Ortsverband Springfield)\n" + " - Springfield Mutual\n" ) prompt = _build_classify_prompt(**COMMON, correspondents_section=section) assert section in prompt def test_drops_legacy_existing_correspondents_line(self): - section = "Existing correspondents (canonical; aliases in parens):\n - ADAC\n" + section = "Existing correspondents (canonical; aliases in parens):\n - Duff Insurance\n" prompt = _build_classify_prompt(**COMMON, correspondents_section=section) - # The flat `Existing correspondents: ["AOK"]` line is suppressed. - assert 'Existing correspondents: ["AOK"]' not in prompt + # The flat `Existing correspondents: ["Springfield Mutual"]` line is suppressed. + assert 'Existing correspondents: ["Springfield Mutual"]' not in prompt def test_falls_back_to_legacy_line_when_empty(self): prompt = _build_classify_prompt(**COMMON, correspondents_section="") - assert 'Existing correspondents: ["AOK"]' in prompt + assert 'Existing correspondents: ["Springfield Mutual"]' in prompt class TestNewSchemaFields: @@ -128,8 +128,8 @@ def test_schema_advertises_correspondent_facts(self): def test_prompt_includes_strip_suffix_examples(self): prompt = _build_classify_prompt(**COMMON) # The strip-suffix rule must be concrete to be useful. - assert "ADAC Ortsverband Manzell" in prompt - assert "ADAC" in prompt + assert "Duff Insurance Ortsverband Springfield" in prompt + assert "Duff Insurance" in prompt assert "Burns Industries" in prompt diff --git a/tests/stacklets/test_git_mirror.py b/tests/stacklets/test_git_mirror.py index d7002be..67450f6 100644 --- a/tests/stacklets/test_git_mirror.py +++ b/tests/stacklets/test_git_mirror.py @@ -38,7 +38,7 @@ def mirror(tmp_path): class TestSlug: def test_ascii(self, mirror): - assert mirror._slug("ADAC Rechnung Marz 2026") == "adac-rechnung-marz-2026" + assert mirror._slug("Duff Insurance Rechnung Marz 2026") == "duff-insurance-rechnung-marz-2026" def test_umlauts_normalize(self, mirror): # Non-ASCII (ü, ä, ö) becomes their base letters after NFKD decompose @@ -66,16 +66,16 @@ class TestFilepath: def test_with_title_and_date(self, mirror): path = mirror._filepath( date="2026-03-15", paperless_id=247, - title="ADAC Rechnung", has_title=True, + title="Duff Insurance Rechnung", has_title=True, ) - assert path == "family/documents/2026/03/2026-03-15-adac-rechnung-p247.md" + assert path == "family/documents/2026/03/2026-03-15-duff-insurance-rechnung-p247.md" def test_with_title_without_date_goes_to_unfiled(self, mirror): path = mirror._filepath( date=None, paperless_id=247, - title="ADAC Rechnung", has_title=True, + title="Duff Insurance Rechnung", has_title=True, ) - assert path == "family/documents/_unfiled/adac-rechnung-p247.md" + assert path == "family/documents/_unfiled/duff-insurance-rechnung-p247.md" def test_with_title_invalid_date_falls_through(self, mirror): path = mirror._filepath( @@ -102,9 +102,9 @@ def test_no_title_without_date(self, mirror): class TestFrontmatter: def test_ai_full(self, mirror): fm = mirror._frontmatter( - title="ADAC Rechnung März 2026", + title="Duff Insurance Rechnung März 2026", date="2026-03-15", - correspondent="ADAC", + correspondent="Duff Insurance", document_type="Invoice", category="Insurance", persons=["Homer"], @@ -116,7 +116,7 @@ def test_ai_full(self, mirror): ) assert fm["type"] == "document" assert fm["resource"] == "http://docs.home.local/documents/247/details" - assert fm["title"] == "ADAC Rechnung März 2026" + assert fm["title"] == "Duff Insurance Rechnung März 2026" assert fm["paperless_id"] == 247 assert fm["processing"] == "ai_formatted" assert fm["model"] == "qwen2.5:14b" @@ -170,11 +170,11 @@ def test_no_paperless_version_when_unset(self, tmp_path): class TestCommitMessage: def test_learn_with_model(self, mirror): msg = mirror._commit_message( - verb="learn", title="ADAC Rechnung", + verb="learn", title="Duff Insurance Rechnung", paperless_id=247, processing="ai_formatted", model="qwen2.5:14b", ) lines = msg.split("\n") - assert lines[0] == "learn: ADAC Rechnung" + assert lines[0] == "learn: Duff Insurance Rechnung" assert lines[1] == "" assert "Paperless-Id: 247" in lines assert "Processing: ai_formatted" in lines @@ -193,14 +193,14 @@ def test_update_without_model(self, mirror): def test_summary_rides_between_subject_and_trailers(self, mirror): # The body sits in its own paragraph so `git log` renders it as a # readable summary and trailers stay parseable as trailers. - summary = "## Summary\nADAC car insurance EUR 340/year.\n\n## Parties\nADAC → Homer" + summary = "## Summary\nDuff Insurance car insurance EUR 340/year.\n\n## Parties\nDuff Insurance → Homer" msg = mirror._commit_message( - verb="learn", title="ADAC", paperless_id=42, + verb="learn", title="Duff Insurance", paperless_id=42, processing="ai_formatted", model="qwen2.5:14b", summary=summary, ) lines = msg.split("\n") - assert lines[0] == "learn: ADAC" + assert lines[0] == "learn: Duff Insurance" assert lines[1] == "" # Summary block follows, ends with a blank line, then trailers. body_start = 2 @@ -226,14 +226,14 @@ def test_no_summary_keeps_old_layout(self, mirror): class TestRender: def test_full_document(self, mirror): fm = { - "title": "ADAC Rechnung", "paperless_id": 247, + "title": "Duff Insurance Rechnung", "paperless_id": 247, "processing": "ai_formatted", "source": "paperless", } out = mirror._render( from_path="family/documents/2026/03/entry.md", frontmatter=fm, body="Policy number: KFZ-2024-XXX\n\nAmount: EUR 340.", - correspondent="ADAC", + correspondent="Duff Insurance", persons=["Homer"], ) # Frontmatter fenced with --- @@ -243,9 +243,9 @@ def test_full_document(self, mirror): parsed = yaml.safe_load(fm_block) assert parsed["paperless_id"] == 247 - assert "# ADAC Rechnung" in out - assert "**From:** [ADAC](" in out - assert "correspondents/adac.md)" in out + assert "# Duff Insurance Rechnung" in out + assert "**From:** [Duff Insurance](" in out + assert "correspondents/duff-insurance.md)" in out assert "**About:** [Homer](" in out assert "homer/about.md)" in out assert "Policy number: KFZ-2024-XXX" in out @@ -269,9 +269,9 @@ class TestBriefingBlock: def test_full_briefing_in_rendered_output(self, mirror): out = mirror._render( from_path="family/documents/2026/03/entry.md", - frontmatter={"title": "ADAC Rechnung"}, + frontmatter={"title": "Duff Insurance Rechnung"}, body="(OCR body)", - correspondent="ADAC", persons=["Homer"], + correspondent="Duff Insurance", persons=["Homer"], summary="Annual renewal of comprehensive auto insurance.", facts=["Total: EUR 340.00", "Policy: KH-2026-987"], action_items=[ @@ -538,7 +538,7 @@ def test_note_with_source_uri(self, mirror): captured_at="2026-05-17", kind="note", source_uri="https://reddit.com/r/x/comments/y", - persons=["Arthur"], tags=["LLMs"], model=None, + persons=["Homer"], tags=["LLMs"], model=None, ) assert fm["type"] == "note" assert fm["resource"] == "https://reddit.com/r/x/comments/y" @@ -553,7 +553,7 @@ def test_note_without_source_uri(self, mirror): captured_at="2026-05-17", kind="note", source_uri=None, - persons=["Arthur"], tags=[], model=None, + persons=["Homer"], tags=[], model=None, ) assert fm["type"] == "note" assert "resource" not in fm @@ -564,12 +564,12 @@ def test_full_shape(self, mirror): captured_at="2026-05-17", kind="bookmark", source_uri="https://example.com/llms", - persons=["Arthur"], - tags=["LLMs", "Local Inference", "Person: Arthur"], + persons=["Homer"], + tags=["LLMs", "Local Inference", "Person: Homer"], model="qwen2.5:14b", ) - assert fm["persons"] == ["Arthur"] - assert fm["tags"] == ["LLMs", "Local Inference", "Person: Arthur"] + assert fm["persons"] == ["Homer"] + assert fm["tags"] == ["LLMs", "Local Inference", "Person: Homer"] assert fm["model"] == "qwen2.5:14b" # date echoes the capture date, not the article's publish date. assert fm["date"] == "2026-05-17" @@ -598,12 +598,12 @@ def test_bookmark_renders_without_body(self, mirror): captured_at="2026-05-17", kind="bookmark", source_uri="https://example.com/llms", - persons=["Arthur"], tags=["LLMs"], model="qwen2.5:14b", + persons=["Homer"], tags=["LLMs"], model="qwen2.5:14b", ) out = mirror._render( from_path="family/documents/2026/03/entry.md", frontmatter=fm, body="", - correspondent=None, persons=["Arthur"], + correspondent=None, persons=["Homer"], summary="A 200-word digest of the article's main points.", facts=["Mac Mini idles under 10W"], action_items=[], @@ -611,8 +611,8 @@ def test_bookmark_renders_without_body(self, mirror): assert "type: bookmark" in out assert "resource: https://example.com/llms" in out assert "# Why local LLMs matter" in out - assert "**About:** [Arthur](" in out - assert "arthur/about.md)" in out + assert "**About:** [Homer](" in out + assert "homer/about.md)" in out assert "> [!summary]" in out assert "A 200-word digest" in out # No trailing empty body section — file ends after briefing. @@ -624,13 +624,13 @@ def test_note_renders_with_body(self, mirror): captured_at="2026-05-17", kind="note", source_uri="https://reddit.com/r/x/y", - persons=["Arthur"], tags=["Benchmarks"], model=None, + persons=["Homer"], tags=["Benchmarks"], model=None, ) out = mirror._render( from_path="family/documents/2026/03/entry.md", frontmatter=fm, body="Top comment quotes 60 tok/s on M2 Pro.\n\nRest of thread...", - correspondent=None, persons=["Arthur"], + correspondent=None, persons=["Homer"], summary="Comment thread comparing on-device inference speeds.", facts=[], action_items=[], ) diff --git a/tests/stacklets/test_memory_correspondents.py b/tests/stacklets/test_memory_correspondents.py index 3d7e1c0..20103f5 100644 --- a/tests/stacklets/test_memory_correspondents.py +++ b/tests/stacklets/test_memory_correspondents.py @@ -52,51 +52,51 @@ def test_returns_empty_when_directory_missing(self, tmp_path): assert load_correspondents_from_vault(tmp_path) == [] def test_loads_a_minimal_page(self, vault): - _write_page(vault, "adac", """--- + _write_page(vault, "duff-insurance", """--- kind: correspondent -canonical: ADAC +canonical: Duff Insurance --- -# ADAC +# Duff Insurance """) cs = load_correspondents_from_vault(vault) assert len(cs) == 1 c = cs[0] - assert c.canonical == "ADAC" + assert c.canonical == "Duff Insurance" assert c.aliases == [] assert c.topics == [] def test_loads_aliases_topics_and_contact_fields(self, vault): - _write_page(vault, "adac", """--- + _write_page(vault, "duff-insurance", """--- kind: correspondent -canonical: ADAC +canonical: Duff Insurance aliases: - - "ADAC Ortsverband Manzell" - - "ADAC Versicherung AG" + - "Duff Insurance Ortsverband Springfield" + - "Duff Insurance Versicherung AG" topics: [insurance, vehicle] address: "Hansastraße 19, 80686 München" phone: "089 7676 0" -website: "https://www.adac.de" +website: "https://www.duff-insurance.de" --- -# ADAC +# Duff Insurance """) c = load_correspondents_from_vault(vault)[0] - assert c.aliases == ["ADAC Ortsverband Manzell", "ADAC Versicherung AG"] + assert c.aliases == ["Duff Insurance Ortsverband Springfield", "Duff Insurance Versicherung AG"] assert c.topics == ["insurance", "vehicle"] assert c.address == "Hansastraße 19, 80686 München" assert c.phone == "089 7676 0" - assert c.website == "https://www.adac.de" + assert c.website == "https://www.duff-insurance.de" def test_falls_back_to_filename_when_canonical_missing(self, vault): - _write_page(vault, "aok", """--- + _write_page(vault, "springfield-mutual", """--- kind: correspondent --- -# AOK +# Springfield Mutual """) c = load_correspondents_from_vault(vault)[0] - assert c.canonical == "aok" + assert c.canonical == "springfield-mutual" def test_skips_pages_with_wrong_kind(self, vault): _write_page(vault, "topic-page", """--- @@ -108,11 +108,11 @@ def test_skips_pages_with_wrong_kind(self, vault): def test_orders_results_by_filename(self, vault): _write_page(vault, "zappos", "---\nkind: correspondent\ncanonical: Zappos\n---\n") - _write_page(vault, "aok", "---\nkind: correspondent\ncanonical: AOK\n---\n") + _write_page(vault, "springfield-mutual", "---\nkind: correspondent\ncanonical: Springfield Mutual\n---\n") _write_page(vault, "moe", "---\nkind: correspondent\ncanonical: Moes\n---\n") names = [c.canonical for c in load_correspondents_from_vault(vault)] - assert names == ["AOK", "Moes", "Zappos"] + assert names == ["Moes", "Springfield Mutual", "Zappos"] def test_skips_pages_without_frontmatter(self, vault): # A README or stray markdown should not crash the loader. @@ -122,12 +122,12 @@ def test_skips_pages_without_frontmatter(self, vault): assert load_correspondents_from_vault(vault) == [] def test_known_names_includes_canonical_first(self): - c = Correspondent(canonical="ADAC", aliases=["ADAC Ortsverband Manzell"]) - assert c.all_known_names() == ["ADAC", "ADAC Ortsverband Manzell"] + c = Correspondent(canonical="Duff Insurance", aliases=["Duff Insurance Ortsverband Springfield"]) + assert c.all_known_names() == ["Duff Insurance", "Duff Insurance Ortsverband Springfield"] def test_known_names_deduplicates(self): - c = Correspondent(canonical="ADAC", aliases=["ADAC", "ADAC Ortsverband Manzell"]) - assert c.all_known_names() == ["ADAC", "ADAC Ortsverband Manzell"] + c = Correspondent(canonical="Duff Insurance", aliases=["Duff Insurance", "Duff Insurance Ortsverband Springfield"]) + assert c.all_known_names() == ["Duff Insurance", "Duff Insurance Ortsverband Springfield"] # ─── Prompt renderer ───────────────────────────────────────────────────── @@ -139,24 +139,24 @@ def test_empty_when_no_correspondents(self): def test_lists_canonical_without_aliases(self): section = correspondents_prompt_section([ - Correspondent(canonical="AOK"), + Correspondent(canonical="Springfield Mutual"), ]) - assert section == "Existing correspondents (canonical; aliases in parens):\n - AOK" + assert section == "Existing correspondents (canonical; aliases in parens):\n - Springfield Mutual" def test_inlines_aliases_in_parens(self): section = correspondents_prompt_section([ Correspondent( - canonical="ADAC", - aliases=["ADAC Ortsverband Manzell", "ADAC Versicherung AG"], + canonical="Duff Insurance", + aliases=["Duff Insurance Ortsverband Springfield", "Duff Insurance Versicherung AG"], ), ]) - assert "ADAC (ADAC Ortsverband Manzell, ADAC Versicherung AG)" in section + assert "Duff Insurance (Duff Insurance Ortsverband Springfield, Duff Insurance Versicherung AG)" in section def test_preserves_caller_order(self): section = correspondents_prompt_section([ - Correspondent(canonical="ADAC"), - Correspondent(canonical="AOK"), + Correspondent(canonical="Duff Insurance"), + Correspondent(canonical="Springfield Mutual"), Correspondent(canonical="Anthropic"), ]) lines = section.splitlines()[1:] - assert lines == [" - ADAC", " - AOK", " - Anthropic"] + assert lines == [" - Duff Insurance", " - Springfield Mutual", " - Anthropic"] diff --git a/tests/stacklets/test_memory_persons.py b/tests/stacklets/test_memory_persons.py index fb99b57..b4415ad 100644 --- a/tests/stacklets/test_memory_persons.py +++ b/tests/stacklets/test_memory_persons.py @@ -114,9 +114,9 @@ def test_skips_pages_with_wrong_kind(self, vault): # `kind:` is optional, but when present must be "person". # A correspondent-shaped page sitting at a member slug path # is still not a person. - _write_about(vault, "adac", """--- + _write_about(vault, "duff-insurance", """--- kind: correspondent -canonical: ADAC +canonical: Duff Insurance --- """) assert load_persons_from_vault(vault) == [] diff --git a/tests/stacklets/test_memory_wiki.py b/tests/stacklets/test_memory_wiki.py index e2ba7e8..bfd654e 100644 --- a/tests/stacklets/test_memory_wiki.py +++ b/tests/stacklets/test_memory_wiki.py @@ -68,16 +68,16 @@ class TestTopicLocations: def test_shared_topic_discovered(self, tmp_path): _make_topic_folder(tmp_path, "family", "camping") locs = _topic_locations( - tmp_path, shared_bucket="family", member_slugs=["arthur", "marge"], + tmp_path, shared_bucket="family", member_slugs=["homer", "marge"], ) assert ("family", "camping") in locs def test_personal_topic_discovered(self, tmp_path): - _make_topic_folder(tmp_path, "arthur", "gravel") + _make_topic_folder(tmp_path, "homer", "gravel") locs = _topic_locations( - tmp_path, shared_bucket="family", member_slugs=["arthur", "marge"], + tmp_path, shared_bucket="family", member_slugs=["homer", "marge"], ) - assert ("arthur", "gravel") in locs + assert ("homer", "gravel") in locs def test_personal_topic_skipped_for_unknown_member(self, tmp_path): """A folder at `bart/whatever/notes/...` doesn't get discovered @@ -85,7 +85,7 @@ def test_personal_topic_skipped_for_unknown_member(self, tmp_path): generates pages for known members.""" _make_topic_folder(tmp_path, "bart", "skateboarding") locs = _topic_locations( - tmp_path, shared_bucket="family", member_slugs=["arthur"], + tmp_path, shared_bucket="family", member_slugs=["homer"], ) assert ("bart", "skateboarding") not in locs @@ -102,19 +102,19 @@ def test_reserved_subdir_is_not_a_topic(self, tmp_path): def test_correspondents_reserved(self, tmp_path): _make_bare_folder(tmp_path, "family", "correspondents") - (tmp_path / "family" / "correspondents" / "adac.md").write_text("...") + (tmp_path / "family" / "correspondents" / "duff-insurance.md").write_text("...") locs = _topic_locations( tmp_path, shared_bucket="family", member_slugs=[], ) assert ("family", "correspondents") not in locs def test_unfiled_reserved(self, tmp_path): - _make_bare_folder(tmp_path, "arthur", "_unfiled") - (tmp_path / "arthur" / "_unfiled" / "x.md").write_text("...") + _make_bare_folder(tmp_path, "homer", "_unfiled") + (tmp_path / "homer" / "_unfiled" / "x.md").write_text("...") locs = _topic_locations( - tmp_path, shared_bucket="family", member_slugs=["arthur"], + tmp_path, shared_bucket="family", member_slugs=["homer"], ) - assert ("arthur", "_unfiled") not in locs + assert ("homer", "_unfiled") not in locs def test_empty_folder_is_not_a_topic(self, tmp_path): """A folder under a bucket with no `notes/`, `bookmarks/`, or @@ -140,9 +140,9 @@ def test_results_are_sorted(self, tmp_path): which makes test assertions and human review easier.""" _make_topic_folder(tmp_path, "family", "photography") _make_topic_folder(tmp_path, "family", "camping") - _make_topic_folder(tmp_path, "arthur", "gravel") + _make_topic_folder(tmp_path, "homer", "gravel") locs = _topic_locations( - tmp_path, shared_bucket="family", member_slugs=["arthur"], + tmp_path, shared_bucket="family", member_slugs=["homer"], ) assert locs == sorted(locs) @@ -188,16 +188,16 @@ def test_slice_by_bucket_and_slug(self): assert "family/photography/notes/2026/06/d.md" not in rels def test_personal_topic_slice(self): - """A personal topic (`arthur/gravel/`) is isolated from arthur's - plain personal notes (`arthur/notes/`).""" + """A personal topic (`homer/gravel/`) is isolated from homer's + plain personal notes (`homer/notes/`).""" index = [ - {"rel": "arthur/gravel/notes/2026/06/a.md", "title": "Gravel A"}, - {"rel": "arthur/notes/2026/06/b.md", "title": "Personal B"}, - {"rel": "arthur/bookmarks/2026/06/c.md", "title": "Personal C"}, + {"rel": "homer/gravel/notes/2026/06/a.md", "title": "Gravel A"}, + {"rel": "homer/notes/2026/06/b.md", "title": "Personal B"}, + {"rel": "homer/bookmarks/2026/06/c.md", "title": "Personal C"}, ] - entries = _topic_entries(index, "arthur", "gravel") + entries = _topic_entries(index, "homer", "gravel") rels = [e["rel"] for e in entries] - assert rels == ["arthur/gravel/notes/2026/06/a.md"] + assert rels == ["homer/gravel/notes/2026/06/a.md"] def test_slug_prefix_collision(self): """A topic `camp` must not pull in `camping` content (the slash @@ -231,7 +231,7 @@ def test_cross_ref_via_tags(self): {"rel": "family/camping/notes/a.md", "tags": ["camping", "gear"], "topics": []}, # Outside, has camping in tags — pulled in - {"rel": "family/documents/2026/06/adac-camping.md", + {"rel": "family/documents/2026/06/duff-insurance-camping.md", "tags": ["camping", "Versicherung"], "topics": []}, # Outside, no camping reference — excluded {"rel": "family/documents/2026/06/other.md", @@ -239,7 +239,7 @@ def test_cross_ref_via_tags(self): ] refs = _topic_cross_refs(index, "family", "camping") rels = [e["rel"] for e in refs] - assert "family/documents/2026/06/adac-camping.md" in rels + assert "family/documents/2026/06/duff-insurance-camping.md" in rels assert "family/documents/2026/06/other.md" not in rels assert "family/camping/notes/a.md" not in rels @@ -261,31 +261,31 @@ def test_cross_ref_across_buckets(self): index = [ {"rel": "marge/notes/2026/05/gear.md", "tags": ["camping", "warmth"], "topics": []}, - {"rel": "arthur/notes/2026/05/idea.md", + {"rel": "homer/notes/2026/05/idea.md", "tags": ["camping", "route"], "topics": []}, ] refs = _topic_cross_refs(index, "family", "camping") rels = {e["rel"] for e in refs} assert rels == { "marge/notes/2026/05/gear.md", - "arthur/notes/2026/05/idea.md", + "homer/notes/2026/05/idea.md", } def test_personal_topic_cross_refs(self): """A personal topic's cross-refs are everything OUTSIDE - `arthur/gravel/`. The wider scope is intentional — the deriver's + `homer/gravel/`. The wider scope is intentional — the deriver's view of 'all gravel-relevant captures in the household' still includes shared captures.""" index = [ - {"rel": "arthur/gravel/notes/a.md", + {"rel": "homer/gravel/notes/a.md", "tags": ["gravel"], "topics": []}, {"rel": "family/documents/bike-insurance.md", "tags": ["gravel"], "topics": []}, ] - refs = _topic_cross_refs(index, "arthur", "gravel") + refs = _topic_cross_refs(index, "homer", "gravel") rels = [e["rel"] for e in refs] assert "family/documents/bike-insurance.md" in rels - assert "arthur/gravel/notes/a.md" not in rels + assert "homer/gravel/notes/a.md" not in rels def test_missing_topics_and_tags_fields(self): """Index entries with neither `topics` nor `tags` (a hand-edited diff --git a/tests/stacklets/test_pipeline.py b/tests/stacklets/test_pipeline.py index cd5ef30..fec3307 100644 --- a/tests/stacklets/test_pipeline.py +++ b/tests/stacklets/test_pipeline.py @@ -365,11 +365,11 @@ def seeded_paperless(): "Person: Homer": 10, "Person: Marge": 11, "Person: Bart": 12, }, doc_types={"Invoice": 100, "Receipt": 101, "Letter": 102}, - correspondents={"ADAC": 200, "Kwik-E-Mart": 201}, + correspondents={"Duff Insurance": 200, "Kwik-E-Mart": 201}, ) -def _doc(doc_id=42, content="Invoice text from ADAC for car insurance.", +def _doc(doc_id=42, content="Invoice text from Duff Insurance for car insurance.", tags=None, document_type=None): """Build a Paperless doc dict in the shape the pipeline expects.""" return { @@ -388,11 +388,11 @@ class TestEnrichHappyPath: @pytest.mark.asyncio async def test_full_classification_applied(self, seeded_paperless): classifier = StubClassifier(payload={ - "title": "ADAC - Kfz-Versicherung 2026 EUR 340", + "title": "Duff Insurance - Kfz-Versicherung 2026 EUR 340", "date": "2026-03-15", "topics": ["Insurance"], "persons": ["Homer"], - "correspondent": "ADAC", + "correspondent": "Duff Insurance", "document_type": "Invoice", "summary": "Annual renewal.", "facts": ["EUR 340.00"], @@ -406,7 +406,7 @@ async def test_full_classification_applied(self, seeded_paperless): assert result.resolved_topics == ["Insurance"] assert result.resolved_persons == ["Homer"] - assert result.resolved_correspondent == "ADAC" + assert result.resolved_correspondent == "Duff Insurance" assert result.resolved_type == "Invoice" assert result.created_new == [] assert result.llm_error is None @@ -415,9 +415,9 @@ async def test_full_classification_applied(self, seeded_paperless): assert len(seeded_paperless.updates) == 1 doc_id, updates = seeded_paperless.updates[0] assert doc_id == 42 - assert updates["title"] == "ADAC - Kfz-Versicherung 2026 EUR 340" + assert updates["title"] == "Duff Insurance - Kfz-Versicherung 2026 EUR 340" assert updates["created"] == "2026-03-15" - assert updates["correspondent"] == 200 # ADAC id + assert updates["correspondent"] == 200 # Duff Insurance id assert updates["document_type"] == 100 # Invoice id # Tag ids — Insurance + Person: Homer assert set(updates["tags"]) == {1, 10} @@ -537,14 +537,14 @@ class TestEnrichFuzzyMatching: @pytest.mark.asyncio async def test_correspondent_fuzzy_match_avoids_duplicate(self, seeded_paperless): - # LLM says "ADAC e.V." — should match existing "ADAC", not create new + # LLM says "Duff Insurance e.V." — should match existing "Duff Insurance", not create new classifier = StubClassifier(payload={ - "title": "Invoice", "correspondent": "ADAC e.V.", + "title": "Invoice", "correspondent": "Duff Insurance e.V.", }) result = await enrich_document( paperless=seeded_paperless, classifier=classifier, doc=_doc(), ) - assert result.resolved_correspondent == "ADAC" + assert result.resolved_correspondent == "Duff Insurance" assert seeded_paperless.created_correspondents == [] _, updates = seeded_paperless.updates[0] assert updates["correspondent"] == 200 @@ -854,7 +854,7 @@ async def test_long_content_truncated_to_explicit_cap(self, seeded_paperless): @pytest.mark.asyncio async def test_short_content_passes_through_unchanged(self, seeded_paperless): classifier = StubClassifier(payload={"title": "x"}) - doc = _doc(content="Invoice from ADAC") + doc = _doc(content="Invoice from Duff Insurance") await enrich_document( paperless=seeded_paperless, classifier=classifier, doc=doc, @@ -862,7 +862,7 @@ async def test_short_content_passes_through_unchanged(self, seeded_paperless): ) (call,) = classifier.classify_calls - assert call["ocr_text"] == "Invoice from ADAC" + assert call["ocr_text"] == "Invoice from Duff Insurance" @pytest.mark.asyncio async def test_default_cap_is_generous(self, seeded_paperless): @@ -1062,13 +1062,13 @@ def test_full_payload_renders_all_sections(self): "action_items": [{"action": "pay", "due": "2026-04-30"}], }, persons=["Homer"], - correspondent="ADAC", + correspondent="Duff Insurance", ) # No section headings — prose, bulleted facts, parties line all # separated by blank lines. The structure is obvious from shape. assert "Kfz-Versicherung Jahresbeitrag." in text assert "- Total: EUR 340\n- Policy: #12345" in text - assert "ADAC → Homer" in text + assert "Duff Insurance → Homer" in text # Headings the old format used must NOT appear — they forced an # English label onto non-English content. assert "## Summary" not in text @@ -1094,8 +1094,8 @@ def test_facts_skip_empty_strings(self): assert "Total: EUR 5" in text def test_parties_correspondent_only(self): - text = self._fmt({"summary": "x"}, correspondent="ADAC") - assert "ADAC" in text + text = self._fmt({"summary": "x"}, correspondent="Duff Insurance") + assert "Duff Insurance" in text assert "→" not in text def test_parties_persons_only(self): @@ -1110,21 +1110,21 @@ def test_returns_none_when_nothing_to_say(self): def test_parties_alone_still_writes(self): """Even with no summary prose, a sender → recipient line is useful context on a bare doc.""" - text = self._fmt({}, persons=["Homer"], correspondent="ADAC") - assert text == "ADAC → Homer\n\n" + text = self._fmt({}, persons=["Homer"], correspondent="Duff Insurance") + assert text == "Duff Insurance → Homer\n\n" def test_sections_separated_by_blank_lines(self): # Without headings, blank lines do the work of section # boundaries. Two newlines between every block. text = self._fmt( {"summary": "Prose.", "facts": ["A", "B"]}, - persons=["Homer"], correspondent="ADAC", + persons=["Homer"], correspondent="Duff Insurance", ) # Prose → blank → facts → blank → parties → blank → marker. assert text == ( "Prose.\n\n" "- A\n- B\n\n" - "ADAC → Homer\n\n" + "Duff Insurance → Homer\n\n" "" ) @@ -1148,7 +1148,7 @@ async def test_summary_written_on_classify(self, seeded_paperless): "title": "x", "summary": "Annual premium renewal.", "facts": ["Total: EUR 340"], - "correspondent": "ADAC", + "correspondent": "Duff Insurance", "persons": ["Homer"], }) result = await enrich_document( @@ -1157,7 +1157,7 @@ async def test_summary_written_on_classify(self, seeded_paperless): assert result.summary is not None assert "Annual premium renewal." in result.summary assert "- Total: EUR 340" in result.summary - assert "ADAC → Homer" in result.summary + assert "Duff Insurance → Homer" in result.summary notes = seeded_paperless.notes[42] assert len(notes) == 1 assert notes[0]["note"] == result.summary diff --git a/tests/stacklets/test_recall.py b/tests/stacklets/test_recall.py index d60f489..09c01f7 100644 --- a/tests/stacklets/test_recall.py +++ b/tests/stacklets/test_recall.py @@ -58,14 +58,14 @@ class TestQuestionTrigger: async def test_no_question_mark_skips_rewrite(self): c = _StubClassifier(keywords=["should", "not", "run"]) memory, paperless, kw = await resolve_search_query( - "Allianz", classifier=c, ontology_section="(...)", language="de", + "Globex", classifier=c, ontology_section="(...)", language="de", ) # Literal path: both queries equal the input (the Paperless # client wildcards the bare token downstream), no keywords, # classifier never called. This is the fast path the family # hits 99% of the time. - assert memory == "Allianz" - assert paperless == "Allianz" + assert memory == "Globex" + assert paperless == "Globex" assert kw == [] assert c.calls == [] diff --git a/tests/stacklets/test_reply_presenter.py b/tests/stacklets/test_reply_presenter.py index aeda574..bee06f4 100644 --- a/tests/stacklets/test_reply_presenter.py +++ b/tests/stacklets/test_reply_presenter.py @@ -43,7 +43,7 @@ def _de(key, **kw): FULL = { - "summary": "Annual car insurance renewal at ADAC.", + "summary": "Annual car insurance renewal at Duff Insurance.", "facts": ["EUR 340.00/year", "Contract KFZ-2026"], "action_items": [ {"action": "Pay invoice", "due": "2026-03-15"}, @@ -57,21 +57,21 @@ class TestRenderFilingReply: def test_full_happy_path(self): out = render_filing_reply( _en, - display_title="ADAC - Kfz-Versicherung", + display_title="Duff Insurance - Kfz-Versicherung", doc_id=10, resolved_topics=["Insurance"], resolved_persons=["Homer"], resolved_type="Invoice", - resolved_correspondent="ADAC", + resolved_correspondent="Duff Insurance", date_applied="2026-03-15", classification=FULL, created_new=["Insurance"], reformat_failed=False, link="http://paperless/documents/10/details", ) - assert "✅ Filed: ADAC - Kfz-Versicherung (#10)" in out - assert "Insurance | Homer | Invoice | ADAC | 2026-03-15" in out - assert "Annual car insurance renewal at ADAC." in out + assert "✅ Filed: Duff Insurance - Kfz-Versicherung (#10)" in out + assert "Insurance | Homer | Invoice | Duff Insurance | 2026-03-15" in out + assert "Annual car insurance renewal at Duff Insurance." in out assert "- EUR 340.00/year" in out assert "Pay invoice (due 2026-03-15)" in out assert "File copy" in out and "(due )" not in out @@ -141,7 +141,7 @@ class TestRenderReprocessedReply: def test_title_and_meta(self): out = render_reprocessed_reply( - _en, title="ADAC Invoice", doc_id=42, + _en, title="Duff Insurance Invoice", doc_id=42, resolved_topics=["Insurance"], resolved_persons=["Homer"], resolved_type="Invoice", resolved_correspondent="Globex", ) @@ -165,14 +165,14 @@ def test_full(self): source_title_hint="Reddit thread", classification={ "title": "Local LLM benchmarks", - "topics": ["AI"], "persons": ["Arthur"], + "topics": ["AI"], "persons": ["Homer"], "summary": "M2 numbers.", "facts": ["60 tok/s"], "action_items": [{"action": "ignored for captures", "due": ""}], }, link="https://reddit.com/r/LocalLLaMA/...", ) assert "✅ Captured: Local LLM benchmarks" in out - assert "AI | Arthur" in out + assert "AI | Homer" in out assert "M2 numbers." in out assert "- 60 tok/s" in out assert "https://reddit.com/r/LocalLLaMA/..." in out diff --git a/tests/stacklets/test_search_format.py b/tests/stacklets/test_search_format.py index 2335c47..bb3ecae 100644 --- a/tests/stacklets/test_search_format.py +++ b/tests/stacklets/test_search_format.py @@ -161,7 +161,7 @@ class TestFormatPaperlessHit: def _doc(**overrides) -> dict: return { "id": 42, - "title": "Allianz KFZ Versicherung 2026", + "title": "Globex KFZ Versicherung 2026", "created": "2026-01-15T08:00:00Z", } | overrides @@ -169,7 +169,7 @@ def test_includes_linked_title_with_public_url(self): out = format_paperless_hit( self._doc(), 1, public_url="https://paperless.example", ) - assert "[Allianz KFZ Versicherung 2026](https://paperless.example/documents/42/details)" in out + assert "[Globex KFZ Versicherung 2026](https://paperless.example/documents/42/details)" in out def test_meta_carries_date_and_doc_id(self): out = format_paperless_hit( @@ -181,7 +181,7 @@ def test_meta_carries_date_and_doc_id(self): def test_falls_back_to_bold_title_without_url(self): out = format_paperless_hit(self._doc(), 1) - assert "**Allianz KFZ Versicherung 2026**" in out + assert "**Globex KFZ Versicherung 2026**" in out assert "https://" not in out def test_untitled_doc_renders_placeholder(self): diff --git a/tests/stacklets/test_search_service.py b/tests/stacklets/test_search_service.py index cc53e33..c11dd2f 100644 --- a/tests/stacklets/test_search_service.py +++ b/tests/stacklets/test_search_service.py @@ -94,7 +94,7 @@ class TestScopesWithTopicBucket: def test_topic_bucket_overrides_sender_defaults(self): svc = _service(FakePaperless()) scopes = svc.scopes_for_sender( - "@arthur:home", topic_bucket="camping", + "@homer:home", topic_bucket="camping", ) assert scopes == ["camping/"] @@ -115,13 +115,13 @@ def test_no_topic_bucket_falls_back_to_default(self): ) == ["family/", "marge/"] def test_personal_topic_bucket_passes_through_intact(self): - """A personal topic (`arthur/camping`) is a nested bucket; + """A personal topic (`homer/camping`) is a nested bucket; the scope writer must not strip the slash inside.""" svc = _service(FakePaperless()) scopes = svc.scopes_for_sender( - "@arthur:home", topic_bucket="arthur/camping", + "@homer:home", topic_bucket="homer/camping", ) - assert scopes == ["arthur/camping/"] + assert scopes == ["homer/camping/"] class TestRun: @@ -130,21 +130,21 @@ class TestRun: async def test_no_results(self, tmp_path, monkeypatch): monkeypatch.delenv("MEMORY_VAULT_DIR", raising=False) svc = _service(FakePaperless(results=[])) - reply = await svc.run(query="ADAC", sender="@homer:test", notifier=_FakeNotifier()) + reply = await svc.run(query="Duff Insurance", sender="@homer:test", notifier=_FakeNotifier()) assert reply.startswith("search_no_results") @pytest.mark.asyncio async def test_literal_paperless_hits(self, monkeypatch): monkeypatch.delenv("MEMORY_VAULT_DIR", raising=False) hits = [ - {"id": 10, "title": "ADAC Invoice", "created": "2026-03-15"}, - {"id": 11, "title": "ADAC Renewal", "created": "2025-03-01"}, + {"id": 10, "title": "Duff Insurance Invoice", "created": "2026-03-15"}, + {"id": 11, "title": "Duff Insurance Renewal", "created": "2025-03-01"}, ] svc = _service(FakePaperless(results=hits)) - reply = await svc.run(query="ADAC", sender="@homer:test", notifier=_FakeNotifier()) + reply = await svc.run(query="Duff Insurance", sender="@homer:test", notifier=_FakeNotifier()) # Literal mode: a Paperless section header + one line per hit. assert "search_paperless_results" in reply - assert "ADAC Invoice" in reply - assert "ADAC Renewal" in reply + assert "Duff Insurance Invoice" in reply + assert "Duff Insurance Renewal" in reply # No question-mode rewrite header on a literal query. assert "search_rewritten" not in reply diff --git a/tests/stacklets/test_text_utils.py b/tests/stacklets/test_text_utils.py index cd970ca..84e6c6c 100644 --- a/tests/stacklets/test_text_utils.py +++ b/tests/stacklets/test_text_utils.py @@ -43,7 +43,7 @@ def test_uuid_with_suffix_no_tilde(self): assert clean_filename("a1b2c3d4-e5f6-7890-abcd-ef12345678901.pdf") == "document.pdf" def test_non_noise_filename_preserved(self): - assert clean_filename("Rechnung_ADAC_Marz2025.pdf") == "Rechnung_ADAC_Marz2025.pdf" + assert clean_filename("Rechnung_Duff Insurance_Marz2025.pdf") == "Rechnung_Duff Insurance_Marz2025.pdf" def test_heic_image(self): # Non-UUID filename: preserved as-is diff --git a/tests/stacklets/test_topic_rooms.py b/tests/stacklets/test_topic_rooms.py index c572f41..9c36b2a 100644 --- a/tests/stacklets/test_topic_rooms.py +++ b/tests/stacklets/test_topic_rooms.py @@ -142,7 +142,7 @@ def test_numbers_preserved(self): def test_special_characters_become_hyphens(self): """Apostrophes, slashes, parens, and other punctuation collapse to a single hyphen. The result stays slug-shaped.""" - assert derive_slug("Sabrina's Garden") == "sabrina-s-garden" + assert derive_slug("Marge's Garden") == "marge-s-garden" assert derive_slug("Side/Projects") == "side-projects" assert derive_slug("Plans (2027)") == "plans-2027" @@ -195,12 +195,12 @@ def test_empty_slug_treated_as_reserved(self): assert is_reserved("", self.RESERVED) is True def test_personal_localparts_can_be_passed_as_reserved(self): - """Personal buckets (`arthur/`, `marge/`) are reserved on a + """Personal buckets (`homer/`, `marge/`) are reserved on a per-instance basis. The archivist passes them in alongside the built-ins. The function does not know the household's member list directly.""" - reserved = (*self.RESERVED, "arthur", "marge") - assert is_reserved("arthur", reserved) is True + reserved = (*self.RESERVED, "homer", "marge") + assert is_reserved("homer", reserved) is True assert is_reserved("marge", reserved) is True assert is_reserved("camping", reserved) is False @@ -272,7 +272,7 @@ class TestBucketForScope: def test_shared_topic_nested_under_shared_bucket(self): assert bucket_for_scope( "camping", "shared", - sender_localpart="arthur", shared_bucket="family", + sender_localpart="homer", shared_bucket="family", ) == "family/camping" def test_shared_bucket_slug_is_configurable(self): @@ -281,7 +281,7 @@ def test_shared_bucket_slug_is_configurable(self): a hard-coded `family`.""" assert bucket_for_scope( "camping", "shared", - sender_localpart="arthur", shared_bucket="office", + sender_localpart="homer", shared_bucket="office", ) == "office/camping" def test_personal_topic_nested_under_localpart(self): @@ -289,35 +289,35 @@ def test_personal_topic_nested_under_localpart(self): `/`. The privacy boundary is unchanged.""" assert bucket_for_scope( "gravel", "personal", - sender_localpart="arthur", shared_bucket="family", - ) == "arthur/gravel" + sender_localpart="homer", shared_bucket="family", + ) == "homer/gravel" def test_different_localparts_get_different_personal_paths(self): - """A personal `camping` topic by Arthur is a different bucket + """A personal `camping` topic by Homer is a different bucket from a personal `camping` topic by Marge. They never collide.""" assert bucket_for_scope( "camping", "personal", - sender_localpart="arthur", shared_bucket="family", - ) == "arthur/camping" + sender_localpart="homer", shared_bucket="family", + ) == "homer/camping" assert bucket_for_scope( "camping", "personal", sender_localpart="marge", shared_bucket="family", ) == "marge/camping" def test_shared_and_personal_can_coexist_with_same_slug(self): - """`family/camping/` (shared) and `arthur/camping/` (personal) + """`family/camping/` (shared) and `homer/camping/` (personal) live in different bucket trees. Step 5 promotion is the bucket-to-bucket move between them.""" shared = bucket_for_scope( "camping", "shared", - sender_localpart="arthur", shared_bucket="family", + sender_localpart="homer", shared_bucket="family", ) personal = bucket_for_scope( "camping", "personal", - sender_localpart="arthur", shared_bucket="family", + sender_localpart="homer", shared_bucket="family", ) assert shared == "family/camping" - assert personal == "arthur/camping" + assert personal == "homer/camping" assert shared != personal @@ -334,7 +334,7 @@ class TestMakeRoomState: def _state(self, scope="shared", shared_bucket="family"): return make_room_state( parsed=self.PARSED, scope=scope, - bootstrapped_by="@arthur:home", sender_localpart="arthur", + bootstrapped_by="@homer:home", sender_localpart="homer", shared_bucket=shared_bucket, bootstrapped_at=self.BOOTSTRAPPED_AT, ) @@ -358,7 +358,7 @@ def test_shared_topic_respects_configured_shared_bucket(self): def test_personal_topic_bucket_nests_under_sender(self): state = self._state(scope="personal") - assert state["bucket"] == "arthur/camping" + assert state["bucket"] == "homer/camping" assert state["scope"] == "personal" def test_default_topics_is_single_slug(self): @@ -381,7 +381,7 @@ def test_bootstrap_provenance_recorded(self): on-invite handler and for `stack memory topic list`.""" state = self._state() assert state["bootstrapped_at"] == self.BOOTSTRAPPED_AT - assert state["bootstrapped_by"] == "@arthur:home" + assert state["bootstrapped_by"] == "@homer:home" # ── Routing binding ───────────────────────────────────────────────────── @@ -412,10 +412,10 @@ def test_extracts_binding_from_valid_state(self): def test_personal_topic_carries_nested_bucket(self): state = {**self.SHARED_STATE, - "bucket": "arthur/camping", "scope": "personal"} + "bucket": "homer/camping", "scope": "personal"} binding = binding_from_state(state) assert binding is not None - assert binding.bucket == "arthur/camping" + assert binding.bucket == "homer/camping" assert binding.scope == "personal" def test_none_state_returns_none(self): diff --git a/tests/stacklets/test_vault_entry.py b/tests/stacklets/test_vault_entry.py index e4f1077..421ae2b 100644 --- a/tests/stacklets/test_vault_entry.py +++ b/tests/stacklets/test_vault_entry.py @@ -45,7 +45,7 @@ def test_only_special_chars(self): assert slug("!!!") == "document" def test_numbers_preserved(self): - assert slug("ADAC 2025") == "adac-2025" + assert slug("Duff Insurance 2025") == "duff-insurance-2025" def test_ampersand(self): assert slug("Müller & Söhne") == "muller-sohne" @@ -69,9 +69,9 @@ class TestDocumentFilepath: def test_with_date_and_title(self): path = document_filepath( - "family", "2025-03-27", 42, "ADAC - Kfz", True, + "family", "2025-03-27", 42, "Duff Insurance - Kfz", True, ) - assert path == "family/documents/2025/03/2025-03-27-adac-kfz-p42.md" + assert path == "family/documents/2025/03/2025-03-27-duff-insurance-kfz-p42.md" def test_with_date_no_title(self): path = document_filepath( @@ -81,9 +81,9 @@ def test_with_date_no_title(self): def test_no_date_with_title(self): path = document_filepath( - "family", None, 42, "ADAC - Kfz", True, + "family", None, 42, "Duff Insurance - Kfz", True, ) - assert path == "family/documents/_unfiled/adac-kfz-p42.md" + assert path == "family/documents/_unfiled/duff-insurance-kfz-p42.md" def test_no_date_no_title(self): path = document_filepath( @@ -105,14 +105,14 @@ def test_umlauts_in_slug(self): def test_title_with_special_chars(self): path = document_filepath( - "family", "2025-03-27", 42, "ADAC - Kfz-Versicherung 2025", True, + "family", "2025-03-27", 42, "Duff Insurance - Kfz-Versicherung 2025", True, ) - assert path == "family/documents/2025/03/2025-03-27-adac-kfz-versicherung-2025-p42.md" + assert path == "family/documents/2025/03/2025-03-27-duff-insurance-kfz-versicherung-2025-p42.md" def test_has_title_false_no_slug(self): """has_title=False means don't use title in path, even if provided.""" path = document_filepath( - "family", "2025-03-27", 42, "ADAC - Kfz", False, + "family", "2025-03-27", 42, "Duff Insurance - Kfz", False, ) assert path == "family/documents/2025/03/2025-03-27-p42.md" @@ -192,9 +192,9 @@ class TestDocumentFrontmatter: def test_full_frontmatter(self): fm = document_frontmatter( - title="ADAC - Kfz-Versicherung", + title="Duff Insurance - Kfz-Versicherung", date="2025-03-27", - correspondent="ADAC", + correspondent="Duff Insurance", document_type="Invoice", category="Insurance", persons=["Homer"], @@ -205,9 +205,9 @@ def test_full_frontmatter(self): model="mlx-model", ) assert fm["type"] == "document" # OKF concept kind - assert fm["title"] == "ADAC - Kfz-Versicherung" + assert fm["title"] == "Duff Insurance - Kfz-Versicherung" assert fm["date"] == "2025-03-27" - assert fm["correspondent"] == "ADAC" + assert fm["correspondent"] == "Duff Insurance" assert fm["document_type"] == "Invoice" # Paperless subtype, separate axis assert fm["category"] == "Insurance" assert fm["persons"] == ["Homer"] @@ -269,7 +269,7 @@ def test_full_capture_frontmatter(self): captured_at="2025-03-27", kind="bookmark", source_uri="https://reddit.com/r/famstack/...", - persons=["Arthur"], + persons=["Homer"], tags=["Technology"], model="mlx-model", ) @@ -278,7 +278,7 @@ def test_full_capture_frontmatter(self): assert "kind" not in fm # promoted to type assert fm["date"] == "2025-03-27" assert fm["resource"] == "https://reddit.com/r/famstack/..." - assert fm["persons"] == ["Arthur"] + assert fm["persons"] == ["Homer"] assert fm["tags"] == ["Technology"] assert fm["model"] == "mlx-model" assert "timestamp" in fm @@ -310,9 +310,9 @@ class TestRenderDocument: def test_full_document_render(self): fm = document_frontmatter( - title="ADAC - Kfz-Versicherung", + title="Duff Insurance - Kfz-Versicherung", date="2025-03-27", - correspondent="ADAC", + correspondent="Duff Insurance", document_type="Invoice", category="Insurance", persons=["Homer"], @@ -326,7 +326,7 @@ def test_full_document_render(self): from_path="family/documents/2026/03/entry.md", shared_bucket="family", frontmatter=fm, body="Vehicle registration insurance renewal.\nAmount: EUR 340.00.", - correspondent="ADAC", + correspondent="Duff Insurance", persons=["Homer"], summary="Kfz-Versicherung renewal for Homer Simpson's 2025 vehicle.", facts=["Policy: 12345678", "Amount: EUR 340.00"], @@ -334,9 +334,9 @@ def test_full_document_render(self): source_link=("Show Document", "http://paperless:8000/documents/42/details"), ) assert "---" in content - assert "# ADAC - Kfz-Versicherung" in content - assert "**From:** [ADAC](" in content - assert "correspondents/adac.md)" in content + assert "# Duff Insurance - Kfz-Versicherung" in content + assert "**From:** [Duff Insurance](" in content + assert "correspondents/duff-insurance.md)" in content assert "**About:** [Homer](" in content assert "homer/about.md)" in content assert "> [!summary]" in content @@ -464,7 +464,7 @@ def test_bookmark_render(self): captured_at="2025-03-27", kind="bookmark", source_uri="https://reddit.com/r/famstack/...", - persons=["Arthur"], + persons=["Marge"], tags=["Technology"], model="mlx-model", ) @@ -475,14 +475,14 @@ def test_bookmark_render(self): kind="bookmark", captured_at="2025-03-27", source_uri="https://reddit.com/r/famstack/...", - persons=["Arthur"], + persons=["Marge"], summary="Discussion about document filing.", facts=["Subreddit: r/famstack"], ) assert "---" in content assert "# Reddit Thread" in content - assert "**About** [Arthur](" in content - assert "arthur/about.md)" in content + assert "**About** [Marge](" in content + assert "marge/about.md)" in content assert "**Captured** 2025-03-27" in content assert "**Kind** bookmark" in content assert "**Source** " in content From 797adc4e471b784aaa83fa837f1ee6ca50de1d4d Mon Sep 17 00:00:00 2001 From: Arthur Date: Fri, 19 Jun 2026 23:30:27 +0200 Subject: [PATCH 20/21] docs: fictionalize real data in design docs Same scrub for the design docs, which mirrored the real household setup. --- docs/admin-guide.md | 4 +- docs/adr/adr-002-port-mode-first.md | 2 +- docs/adr/adr-005-bonjour-discovery.md | 8 +- .../rfc-001-dashboard-and-companion-apps.md | 4 +- docs/design/brain/engram-prototype.md | 40 +++--- docs/design/brain/family-memory.md | 26 ++-- docs/design/brain/interaction-patterns.md | 10 +- docs/design/brain/knowledge-architecture.md | 52 +++---- docs/design/brain/knowledge-implementation.md | 38 ++--- docs/design/brain/knowledge-structure.md | 110 +++++++------- docs/design/brain/ontology-design.md | 134 +++++++++--------- docs/design/brain/ontology-v1.md | 78 +++++----- docs/design/brain/open-knowledge-format.md | 2 +- docs/design/brain/phase-2-consolidation.md | 34 ++--- docs/design/brain/topic-rooms.md | 80 +++++------ docs/stack-reference.md | 6 +- docs/user-guide.md | 2 +- 17 files changed, 315 insertions(+), 315 deletions(-) diff --git a/docs/admin-guide.md b/docs/admin-guide.md index cc20d34..e4d65b0 100644 --- a/docs/admin-guide.md +++ b/docs/admin-guide.md @@ -484,8 +484,8 @@ Your family members. Generated by the installer. User accounts are seeded on the ```toml [[users]] -name = "Arthur" -email = "arthur@home.local" +name = "Homer" +email = "homer@home.local" role = "admin" [[users]] diff --git a/docs/adr/adr-002-port-mode-first.md b/docs/adr/adr-002-port-mode-first.md index 6497bdc..21bc164 100644 --- a/docs/adr/adr-002-port-mode-first.md +++ b/docs/adr/adr-002-port-mode-first.md @@ -18,7 +18,7 @@ Caddy is opt-in, activated when `domain` is set in stack.toml. **Port mode (domain empty):** - Services bind to `0.0.0.0:` — reachable from the network -- URLs shown as `hostname:port` (e.g. `mac-arthur.local:42010`) +- URLs shown as `hostname:port` (e.g. `mac-homer.local:42010`) - Caddy does not start, core only runs Watchtower - Zero DNS setup required diff --git a/docs/adr/adr-005-bonjour-discovery.md b/docs/adr/adr-005-bonjour-discovery.md index 6b908c4..daa595e 100644 --- a/docs/adr/adr-005-bonjour-discovery.md +++ b/docs/adr/adr-005-bonjour-discovery.md @@ -62,7 +62,7 @@ One instance per famstack server. The service name is the server's human-readable name from `stack.toml`: ``` -Arthur's famstack._famstack._tcp.local. +Homer's famstack._famstack._tcp.local. ``` ### Port @@ -79,7 +79,7 @@ first API call: |---|---|---| | `uuid` | `a1b2c3d4-e5f6-7890-abcd-ef1234567890` | Persistent server identity | | `version` | `0.1.0` | famstack version (for compatibility checks) | -| `name` | `Arthur's famstack` | Human-readable display name | +| `name` | `Homer's famstack` | Human-readable display name | | `api` | `/api` | API base path (future-proofing) | **TXT record size:** ~150 bytes. Well within the DNS-SD 8900-byte limit @@ -229,7 +229,7 @@ The QR code fallback is more important on Android. Browsers cannot perform mDNS discovery. The web dashboard has two paths: -1. **Direct URL.** User navigates to `http://mac-arthur.local:42000` or +1. **Direct URL.** User navigates to `http://mac-homer.local:42000` or the server's IP. Works on Apple devices (Safari resolves `.local` via mDNS). Inconsistent on Android browsers. @@ -251,7 +251,7 @@ camera, requires no mDNS support, and transfers the API key in one step. { "famstack": 1, "uuid": "a1b2c3d4-e5f6-7890-abcd-ef1234567890", - "host": "mac-arthur.local", + "host": "mac-homer.local", "ip": "192.0.2.10", "port": 42000, "key": "fs_ak_..." diff --git a/docs/adr/rfc-001-dashboard-and-companion-apps.md b/docs/adr/rfc-001-dashboard-and-companion-apps.md index bd0cc6c..11ac5a1 100644 --- a/docs/adr/rfc-001-dashboard-and-companion-apps.md +++ b/docs/adr/rfc-001-dashboard-and-companion-apps.md @@ -470,7 +470,7 @@ Key properties broadcast in the TXT record: _famstack._tcp.local. uuid = "a1b2c3d4-..." # persistent, survives IP changes version = "0.1.0" - name = "Arthur's famstack" + name = "Homer's famstack" api_port = 42000 ``` @@ -508,7 +508,7 @@ How does a new device find the famstack server? Full protocol in Not found? → Show "Scan QR code" or "Enter address" 4. App stores the server UUID in Keychain (persistent pairing) 5. Dashboard shows a QR code at /onboard containing: - { "uuid": "a1b2c3d4-...", "host": "mac-arthur.local", + { "uuid": "a1b2c3d4-...", "host": "mac-homer.local", "port": 42000, "key": "..." } 6. Phone scans QR → paired, UUID + API key stored in Keychain ``` diff --git a/docs/design/brain/engram-prototype.md b/docs/design/brain/engram-prototype.md index 1d76972..a184388 100644 --- a/docs/design/brain/engram-prototype.md +++ b/docs/design/brain/engram-prototype.md @@ -2,7 +2,7 @@ > Status: Design document > Created: 2026-04-29 -> Author: Arthur + Claude +> Author: Homer + Claude > Depends on: [knowledge-architecture.md](knowledge-architecture.md), [knowledge-implementation.md](knowledge-implementation.md) > External: https://github.com/Gentleman-Programming/engram @@ -44,7 +44,7 @@ services, knowledge typing with decay, and proactive intelligence. v engram (Go binary, MCP + HTTP) ├── SQLite + FTS5 full-text search - ├── Project-scoped storage (shared, arthur, sabrina, calendar) + ├── Project-scoped storage (shared, homer, marge, calendar) ├── Topic-key upserts (evolving knowledge updates in place) ├── Deduplication via normalized hash ├── Conflict detection + judgment flow @@ -68,9 +68,9 @@ How Family Brain concepts land on engram's data model. | Family Brain | engram | How it works | |---|---|---| -| Knowledge domain (`shared`, `arthur`, `calendar`) | **Project** | Each domain is a project. `mem_search` filters by project. `mem_context` returns per-project. | +| Knowledge domain (`shared`, `homer`, `calendar`) | **Project** | Each domain is a project. `mem_search` filters by project. `mem_context` returns per-project. | | Knowledge type (`rule`, `fact`, `habit`, `goal`, `preference`, `context`, `event`) | **Type field** | The `type` field is a string, not an enforced enum. We use our own values. | -| Wiki entry that evolves over time | **Observation + topic_key** | `topic_key="fact/insurance-adac"` -- saving with the same key upserts the existing observation, increments `revision_count`. | +| Wiki entry that evolves over time | **Observation + topic_key** | `topic_key="fact/insurance-duff-insurance"` -- saving with the same key upserts the existing observation, increments `revision_count`. | | Pointer brain / compact index | **`mem_context` per project** | Returns recent sessions + observations. The agent's map of what's known. | | Cross-domain search | **`mem_search`** | FTS5 across all projects or filtered to one. Supports type/scope filters. | | Short-term memory | **Session-scoped observations** | Tied to a Kit Bot conversation session. Temporal context via `mem_timeline`. | @@ -91,11 +91,11 @@ How Family Brain concepts land on engram's data model. id INTEGER auto-increment session_id TEXT which session created this type TEXT "rule" | "fact" | "habit" | "goal" | "preference" | "context" | "event" -title TEXT short, searchable -- "Car insurance ADAC" +title TEXT short, searchable -- "Car insurance Duff Insurance" content TEXT freeform -- structured however we want -project TEXT "shared" | "arthur" | "sabrina" | "calendar" +project TEXT "shared" | "homer" | "marge" | "calendar" scope TEXT "project" (shared within domain) | "personal" -topic_key TEXT canonical ID for upserts -- "fact/adac/car-insurance" +topic_key TEXT canonical ID for upserts -- "fact/duff-insurance/car-insurance" normalized_hash TEXT deduplication fingerprint revision_count INTEGER incremented on topic-key upsert duplicate_count INTEGER rolled-up exact duplicates @@ -109,7 +109,7 @@ deleted_at TIMESTAMP soft-delete (used by decay) **Topic-key upsert.** When `mem_save` receives a `topic_key` that already exists within the same project + scope, it updates the existing observation instead of -creating a new one. `revision_count` increments. This is how "ADAC sent a new invoice" +creating a new one. `revision_count` increments. This is how "Duff Insurance sent a new invoice" updates the existing insurance knowledge rather than creating a duplicate. **Conflict detection.** When `mem_save` finds observations with similar content, @@ -132,7 +132,7 @@ Core tools available to Kit Bot via MCP: | Tool | What Kit does with it | |---|---| | `mem_search` | "What do we know about insurance?" -- FTS5 query, filter by project/type/scope | -| `mem_save` | "Remember: Sabrina is allergic to peanuts" -- save with type=rule, topic_key | +| `mem_save` | "Remember: Marge is allergic to peanuts" -- save with type=rule, topic_key | | `mem_context` | Session start -- load recent knowledge for the current domain | | `mem_get_observation` | Drill into a search result for full untruncated content | | `mem_timeline` | Chronological context around an observation | @@ -165,22 +165,22 @@ Premium: EUR 340/year Policy: KFZ-2024-XXXXX Coverage: Vollkasko + Haftpflicht Expires: 2026-06-30 -Source: Paperless #247, ADAC Rechnung 2026-03 +Source: Paperless #247, Duff Insurance Rechnung 2026-03 ``` ### Rules (from conversations or manual) ``` -Sabrina is allergic to peanuts. -Source: Arthur via Kit Bot, 2026-04-29 +Marge is allergic to peanuts. +Source: Homer via Kit Bot, 2026-04-29 ``` ### Events (from calendar, transient) ``` -Dentist appointment for Sabrina +Dentist appointment for Marge When: 2026-04-17 10:00 -Where: Dr. Weber, Springfield +Where: Dr. Hibbert, Springfield Source: Calendar sync ``` @@ -194,9 +194,9 @@ Source: Dream cycle promotion, 2026-04-29 ``` Topic keys follow a `{type}/{entity}/{slug}` convention: -- `fact/adac/car-insurance` -- `rule/sabrina/allergy-peanuts` -- `event/calendar/dentist-sabrina-20260417` +- `fact/duff-insurance/car-insurance` +- `rule/marge/allergy-peanuts` +- `event/calendar/dentist-marge-20260417` - `habit/family/friday-pizza` @@ -255,7 +255,7 @@ After classification, POST each fact to engram: ```python async def _save_knowledge(self, classification: dict, doc_id: int): person = classification.get("person", "").lower() - project = person if person in ("arthur", "sabrina") else "shared" + project = person if person in ("homer", "marge") else "shared" for fact in classification.get("facts", []): slug = slugify(fact[:60]) @@ -326,7 +326,7 @@ $ fk knowledge show $ fk knowledge search "insurance" -d personal [error] unknown domain: personal - Available domains: shared, arthur, sabrina, calendar + Available domains: shared, homer, marge, calendar ``` @@ -376,7 +376,7 @@ store later means reimplementing the API contract, not rewriting the whole syste 1. Add engram to Kit Bot's MCP server config 2. Kit Bot system prompt: explain available memory tools and when to use them -3. Test: "Kit, remember Sabrina is allergic to peanuts" → `mem_save` +3. Test: "Kit, remember Marge is allergic to peanuts" → `mem_save` 4. Test: "What do we know about insurance?" → `mem_search` 5. Test: "Kit, our insurance went up to EUR 380" → conflict detection + judgment diff --git a/docs/design/brain/family-memory.md b/docs/design/brain/family-memory.md index d38e831..2e816ca 100644 --- a/docs/design/brain/family-memory.md +++ b/docs/design/brain/family-memory.md @@ -33,7 +33,7 @@ family/memory.git (Forgejo repo, seeded by `memory` stackl │ │ │ └── YYYY-MM-DD--p.md │ │ └── _unfiled/ │ │ └── p.md (when the doc has no usable date) -│ └── correspondents/ (wiki pages for senders: ADAC, Booking.com, …) +│ └── correspondents/ (wiki pages for senders: Duff Insurance, Booking.com, …) │ └── .md │ ├── / (one bucket per family member; sender mxid → entity slug) @@ -75,9 +75,9 @@ family/memory.git (Forgejo repo, seeded by `memory` stackl ```markdown --- type: document -title: ADAC Kfz-Versicherung 2026 +title: Duff Insurance Kfz-Versicherung 2026 date: 2026-03-15 -correspondent: ADAC +correspondent: Duff Insurance document_type: Rechnung category: Versicherung persons: [Homer] @@ -91,9 +91,9 @@ source: paperless timestamp: 2026-05-20T14:23:00Z --- -# ADAC Kfz-Versicherung 2026 +# Duff Insurance Kfz-Versicherung 2026 -> **From:** [ADAC](../../../correspondents/adac.md) · **About:** [Homer](../../../../homer/about.md) +> **From:** [Duff Insurance](../../../correspondents/duff-insurance.md) · **About:** [Homer](../../../../homer/about.md) > [!summary] > Jährliche Erneuerung der Kfz-Vollkasko, Police KFZ-2026-987. @@ -129,8 +129,8 @@ Notes: type: bookmark title: Local-LLM benchmarks roundup date: 2026-05-17 -persons: [Arthur] -tags: [local-llms, benchmarks, "Person: Arthur"] +persons: [Homer] +tags: [local-llms, benchmarks, "Person: Homer"] resource: https://example.com/llms model: qwen3.5-vl-7b timestamp: 2026-05-17T09:00:00Z @@ -138,7 +138,7 @@ timestamp: 2026-05-17T09:00:00Z # Local-LLM benchmarks roundup -> **About** [Arthur](../../../about.md) +> **About** [Homer](../../../about.md) > **Captured** 2026-05-17 · **Kind** bookmark > **Source** @@ -163,14 +163,14 @@ The classifier writes one note per filed document into Paperless's note slot (FTS-searchable): ``` -Jährliche Erneuerung der Kfz-Vollkasko bei ADAC. Police KFZ-2026-987, +Jährliche Erneuerung der Kfz-Vollkasko bei Duff Insurance. Police KFZ-2026-987, Beitrag EUR 340 jährlich, fällig zum 01.04. - Versicherungsnummer: KFZ-2026-987 - Beitrag: EUR 340,00 - Fälligkeit: 2026-04-01 -ADAC → Homer +Duff Insurance → Homer ``` @@ -199,7 +199,7 @@ shapes: { "source": "docs", "type": "document.filed", - "summary": "ADAC Kfz-Versicherung 2026 filed (#247)", + "summary": "Duff Insurance Kfz-Versicherung 2026 filed (#247)", "actor": "@homer:home", "ts": "2026-05-20T14:23:00Z", "data": {"paperless_id": 247, "title": "…", "topics": [...], ...} @@ -216,7 +216,7 @@ envelope as a content field: "type": "m.room.message", "content": { "msgtype": "m.text", - "body": "Filed: ADAC Kfz-Versicherung 2026 (#247)…", + "body": "Filed: Duff Insurance Kfz-Versicherung 2026 (#247)…", "format": "org.matrix.custom.html", "formatted_body": "

", "dev.famstack.event": { …envelope… } @@ -364,7 +364,7 @@ classifier `persons:` list takes precedence. User replies to a bot's filing message with a correction: ``` -[bot] ✅ Filed: ADAC Kfz-Versicherung 2026 (#247) — Versicherung | Homer | … +[bot] ✅ Filed: Duff Insurance Kfz-Versicherung 2026 (#247) — Versicherung | Homer | … [user reply]: das ist eigentlich Marges Versicherung, nicht Homers ``` diff --git a/docs/design/brain/interaction-patterns.md b/docs/design/brain/interaction-patterns.md index abbfa57..60c7cc8 100644 --- a/docs/design/brain/interaction-patterns.md +++ b/docs/design/brain/interaction-patterns.md @@ -2,7 +2,7 @@ > Status: Design note — future direction, no implementation yet > Created: 2026-06-09 -> Author: Arthur + Claude +> Author: Homer + Claude > Depends on: [topic-rooms.md](topic-rooms.md) (room-state-as-intent, the seed of this design), [knowledge-architecture.md](knowledge-architecture.md), [wiki-engine.md](wiki-engine.md) ## Why this exists @@ -50,7 +50,7 @@ Reactions run in **both directions** — the user reacts to signal intent to the | 👎 | "Bot, your classification was wrong" — opens a reply-thread correction prompt | | 📅 | Surface this for the calendar / reminder bot (once it lands) | -A 🔖 on Homer's message means "save this." A 👎 on the bot's `Filed: ADAC camping addendum` means "you got the topic wrong." A 🗑 on the bot's own capture confirmation means "undo." Each binding maps to an existing handler the archivist already implements; the reaction is just a different trigger. +A 🔖 on Homer's message means "save this." A 👎 on the bot's `Filed: Duff Insurance camping addendum` means "you got the topic wrong." A 🗑 on the bot's own capture confirmation means "undo." Each binding maps to an existing handler the archivist already implements; the reaction is just a different trigger. #### Bot reacts to the user's message to signal processing state @@ -86,10 +86,10 @@ Bounded intent set (so the prompt stays tight and dispatch stays deterministic): | Intent | Example | Dispatch | |---|---|---| | save | `@archivist save this https://...` | `_handle_capture` | -| forget | `@archivist forget that ADAC stuff` | redaction + tombstone | +| forget | `@archivist forget that Duff Insurance stuff` | redaction + tombstone | | search | `@archivist what did we note about camping?` | `_handle_search` (existing) | | reclassify | `@archivist this is actually Marge's, not Homer's` | `_handle_reply_reprocess` (existing) | -| remind | `@archivist remind me about the ADAC renewal in November` | reminder bot when it lands | +| remind | `@archivist remind me about the Duff Insurance renewal in November` | reminder bot when it lands | | status | `@archivist what have you filed today?` | digest | A thin LLM pass classifies the mention's intent into one of those slots, then dispatches to the existing handler. The user does not learn a command syntax — they ask naturally; the LLM bridges to the existing surface. @@ -151,7 +151,7 @@ That alone gives the family a real-feeling per-message knob without ripping out ## Status of this document -A design note, not a prescriptive plan. Captures the direction agreed in the 2026-06-09 session as a marker so future-Arthur and future-Claude do not have to rederive the conclusion. When the first layer ships, this document graduates into a proper design doc — until then, it sits here as the canonical reference for "we know the routing is fragile; here's the direction we are taking when we get to it." +A design note, not a prescriptive plan. Captures the direction agreed in the 2026-06-09 session as a marker so future-Homer and future-Claude do not have to rederive the conclusion. When the first layer ships, this document graduates into a proper design doc — until then, it sits here as the canonical reference for "we know the routing is fragile; here's the direction we are taking when we get to it." ## Related diff --git a/docs/design/brain/knowledge-architecture.md b/docs/design/brain/knowledge-architecture.md index f7ea448..944765c 100644 --- a/docs/design/brain/knowledge-architecture.md +++ b/docs/design/brain/knowledge-architecture.md @@ -2,7 +2,7 @@ > Status: Research & design document > Created: 2026-04-14 -> Author: Arthur + Claude +> Author: Homer + Claude > Depends on: [ai-hub.md](ai-hub.md) (Matrix as nerve center) ## Vision @@ -251,8 +251,8 @@ A solo-knowledge-worker build: dump raw captures into one folder, and overnight | meta/ master index, ontology | | shared/ household, insurance, etc | | calendar/ events, routines, patterns| - | arthur/ personal knowledge | - | sabrina/ personal knowledge | + | homer/ personal knowledge | + | marge/ personal knowledge | | | | Each repo has: | | index.md (pointer brain) | @@ -283,8 +283,8 @@ Each domain gets its own repo on Forgejo. Repos are context boundaries -- Kit Bo | `meta` | master index, ontology schema, deriver config | system | never | | `shared` | household, insurance, contacts, home, recipes | all family | a subtopic exceeds ~50 entries | | `calendar` | events, routines, weekly patterns | all family | probably never | -| `arthur` | work, hobbies, preferences, private notes | Arthur only | work dominates (split to `arthur-work`) | -| `sabrina` | her world, preferences, private notes | Sabrina only | by domain as needed | +| `homer` | work, hobbies, preferences, private notes | Homer only | work dominates (split to `homer-work`) | +| `marge` | her world, preferences, private notes | Marge only | by domain as needed | Git makes splitting cheap. `git filter-repo` extracts a subdirectory into a new repo with full history. @@ -304,13 +304,13 @@ household (12 entries): insurance, contacts, home, appliances calendar (8 entries): upcoming events, weekly patterns, routines [latest: d3c1a0e] calendar/index.md -## Arthur +## Homer personal (15 entries): work/famstack, running, photography, preferences - [latest: a7b3e2f] arthur/index.md + [latest: a7b3e2f] homer/index.md -## Sabrina +## Marge personal (12 entries): school, dance class, preferences - [latest: e1d4c5a] sabrina/index.md + [latest: e1d4c5a] marge/index.md ``` **Domain index** (per repo, ~300-500 tokens, loaded on demand): @@ -320,11 +320,11 @@ personal (12 entries): school, dance class, preferences # Updated: 2026-04-14T03:00:00Z | Entries: 12 ## Insurance -- [fact] ADAC car: EUR 340/yr, expires 2026-06-30 [c3d4e5f:household/insurance.md] +- [fact] Duff Insurance car: EUR 340/yr, expires 2026-06-30 [c3d4e5f:household/insurance.md] - [fact] TK health: family plan, EUR 892/mo [c3d4e5f:household/insurance.md#health] ## Contacts -- [rule] Emergency pediatrician: Dr. Weber +49-89-XXX [d4e5f6g:household/contacts.md] +- [rule] Emergency pediatrician: Dr. Hibbert +49-89-XXX [d4e5f6g:household/contacts.md] - [rule] Plumber: Firma Huber +49-89-XXX [d4e5f6g:household/contacts.md] ## Home @@ -335,9 +335,9 @@ personal (12 entries): school, dance class, preferences **Full documents** (variable size, retrieved only when Kit needs details): ```markdown -# Car Insurance - ADAC +# Car Insurance - Duff Insurance -Policy: ADAC Autoversicherung +Policy: Duff Insurance Autoversicherung Number: KFZ-2024-XXXXX Coverage: Vollkasko + Haftpflicht Premium: EUR 340/year @@ -377,13 +377,13 @@ Seven types with decay rates, adapted from Total Recall for household use: | Type | Tag | Decay | Example | |------|-----|-------|---------| -| rule | `[rule]` | never | Sabrina is allergic to peanuts | +| rule | `[rule]` | never | Marge is allergic to peanuts | | habit | `[habit]` | 365d | Family orders pizza on Fridays | | goal | `[goal]` | 365d | Save for Italy trip summer 2027 | -| preference | `[pref]` | 180d | Arthur prefers dark roast coffee | +| preference | `[pref]` | 180d | Homer prefers dark roast coffee | | fact | `[fact]` | 90d | Car insurance is EUR 340/year | | context | `[ctx]` | 30d | Renovating the bathroom | -| event | `[event]` | 14d | Sabrina had dentist Apr 17 | +| event | `[event]` | 14d | Marge had dentist Apr 17 | Decay moves entries from `index.md` to `archive/`. Git history always retains them. The dream cycle also handles promotion: an `[event]` that recurs 3+ times becomes a `[habit]`. @@ -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: "@arthur:merles.eu", "archivist-bot" + actor: str | None # who/what caused it: "@homer:merles.eu", "archivist-bot" class EventSink(ABC): """Where events go. First implementation: Matrix.""" @@ -453,14 +453,14 @@ class StackletEventFactory: | Stacklet | Event Type | Example Summary | Data | |----------|-----------|-----------------|------| -| docs | `document.filed` | "ADAC Rechnung 2026-03 filed" | `{paperless_id, title, correspondent, tags}` | +| docs | `document.filed` | "Duff Insurance Rechnung 2026-03 filed" | `{paperless_id, title, correspondent, tags}` | | docs | `document.classified` | "Classified as invoice/insurance" | `{paperless_id, category, confidence}` | -| docs | `document.searched` | "Arthur searched for 'insurance'" | `{query, result_count}` | +| docs | `document.searched` | "Homer searched for 'insurance'" | `{query, result_count}` | | messages | `voice.transcribed` | "45s voice message transcribed" | `{room, user, length_s, text_preview}` | | messages | `conversation.summary` | "Discussion about vacation plans" | `{room, participants, topics}` | | photos | `photo.added` | "12 photos from Munich uploaded" | `{count, faces, location, date}` | | photos | `album.created` | "New album: Easter 2026" | `{album_name, photo_count}` | -| calendar | `event.upcoming` | "Dentist - Sabrina, Apr 17 10:00" | `{title, who, when, calendar}` | +| calendar | `event.upcoming` | "Dentist - Marge, Apr 17 10:00" | `{title, who, when, calendar}` | | calendar | `event.created` | "New event: Family dinner Friday" | `{title, when, recurring}` | | core | `service.started` | "Photos stacklet started" | `{stacklet_id, services}` | | core | `service.error` | "Paperless health check failed" | `{stacklet_id, error, severity}` | @@ -481,7 +481,7 @@ A MicroBot that joins all rooms and listens for `dev.famstack.event` typed messa 4. Send event batch + indexes to Ollama with extraction prompt 5. LLM produces: updated wiki Markdown files + updated index entries 6. Commit atomically to the appropriate git repos -7. Structured commit messages: `learn: ADAC renewal notice, expires 2026-06-30` +7. Structured commit messages: `learn: Duff Insurance renewal notice, expires 2026-06-30` **Opt-in conversation extraction (Layer 2, future):** For rooms explicitly flagged via room state (`dev.famstack.deriver.config: {extract_knowledge: true}`), the deriver also listens for `RoomMessageText` and batches natural language messages for LLM extraction. DMs and unflagged rooms are never read for NLP -- only structured events are processed there. This respects privacy: organic conversations are only analyzed in rooms where the family explicitly opted in. @@ -505,7 +505,7 @@ Nightly cron (e.g. 3:00 AM). ### Cross-Domain Queries -"Find everything about Munich" spans calendar (trip events), shared (photos), and arthur (work notes). +"Find everything about Munich" spans calendar (trip events), shared (photos), and homer (work notes). **Phase 1:** `fk knowledge search` runs grep across all repos the requesting user has access to. Simple, fast, no index needed. @@ -539,7 +539,7 @@ $ fk knowledge $ fk knowledge show [error] usage: fk knowledge show [-r ] - Available domains: shared, calendar, arthur, sabrina + Available domains: shared, calendar, homer, marge $ fk knowledge show shared [error] usage: fk knowledge show shared @@ -564,7 +564,7 @@ Not everything needs an agent loop. Some of the highest value is passive or temp **Household Wiki.** Manually curated pages (emergency contacts, WiFi, appliance warranties, medication lists) are valuable without any AI. Browsable on Forgejo's web UI. -**Smart Tags.** When Archivist files a document, the deriver extracts facts into the wiki. "ADAC sent a renewal. Policy expires 2026-06-30. Premium EUR 340/year." Kit can answer "when does our car insurance expire?" without touching Paperless. +**Smart Tags.** When Archivist files a document, the deriver extracts facts into the wiki. "Duff Insurance sent a renewal. Policy expires 2026-06-30. Premium EUR 340/year." Kit can answer "when does our car insurance expire?" without touching Paperless. --- @@ -581,7 +581,7 @@ Not everything needs an agent loop. Some of the highest value is passive or temp ## Implementation Path ### Phase 0: Forgejo repos + manual wiki (3-4 hours) -- Create `knowledge/meta`, `knowledge/shared`, `knowledge/arthur` on Forgejo +- Create `knowledge/meta`, `knowledge/shared`, `knowledge/homer` on Forgejo - Seed `shared/` with household contacts, insurance, key facts - Write master index and shared domain index by hand - Test: browse on Forgejo web UI, verify structure @@ -660,7 +660,7 @@ Hermes-agent's memory system is the most proven in production (83K stars, users ## Open Design Questions -**1. Conversational writes.** Should family members be able to update the wiki via Matrix? ("Kit, remember that I'm allergic to peanuts" -> Kit commits `[rule]` to personal repo.) This is the natural UX for non-technical family members. Needs confirmation flow: "I'll remember that Sabrina is allergic to peanuts. Correct?" +**1. Conversational writes.** Should family members be able to update the wiki via Matrix? ("Kit, remember that I'm allergic to peanuts" -> Kit commits `[rule]` to personal repo.) This is the natural UX for non-technical family members. Needs confirmation flow: "I'll remember that Marge is allergic to peanuts. Correct?" **2. Conflict resolution.** What happens when the deriver extracts a fact that contradicts an existing entry? Options: flag for human review (safe but requires attention), newer-wins with changelog (automated but lossy), keep both with `[contradiction]` tag and let the dream cycle resolve. diff --git a/docs/design/brain/knowledge-implementation.md b/docs/design/brain/knowledge-implementation.md index c9d879f..e280995 100644 --- a/docs/design/brain/knowledge-implementation.md +++ b/docs/design/brain/knowledge-implementation.md @@ -2,7 +2,7 @@ > Status: Implementation plan > Created: 2026-04-14 -> Author: Arthur + Claude +> Author: Homer + Claude > Depends on: [knowledge-architecture.md](knowledge-architecture.md) ## Starting Point @@ -25,7 +25,7 @@ Enforce naming conventions so tags are consistent and queryable: ``` Category: Insurance, Finance, Medical, School, Home, Vehicle, Legal, Travel -Person: Person: Arthur, Person: Sabrina +Person: Person: Homer, Person: Marge Status: Action: Review, Action: Expiring, Action: Paid, Action: Return Period: Year: 2026, Quarter: Q2 ``` @@ -38,12 +38,12 @@ The LLM already reads the OCR text. Add to the classification response: ```json { - "title": "ADAC Rechnung Marz 2026", + "title": "Duff Insurance Rechnung Marz 2026", "date": "2026-03-15", "category": "Insurance", - "person": "Person: Arthur", + "person": "Person: Homer", "document_type": "Invoice", - "correspondent": "ADAC", + "correspondent": "Duff Insurance", "summary": "Annual car insurance invoice, EUR 340, policy KFZ-2024-XXXXX", "facts": [ "Car insurance premium: EUR 340/year", @@ -53,7 +53,7 @@ The LLM already reads the OCR text. Add to the classification response: "action_items": [ {"action": "Insurance renewal due", "due": "2026-06-30"} ], - "related_to": "ADAC" + "related_to": "Duff Insurance" } ``` @@ -63,7 +63,7 @@ This costs zero extra LLM calls -- it's the same prompt, richer output schema. T Before creating a new tag, fuzzy-match against existing: - Lowercase + strip whitespace comparison -- Common prefix matching ("Finanzamt Springfield" matches "Finanzamt") +- Common prefix matching ("Springfield Tax Office" matches "Springfield Tax Office") - The prompt already asks for this, but code enforces it as a safety net **Time: 3-4 hours.** Changes to `archivist.py` only: updated prompt, tag validation, richer JSON parsing. @@ -115,32 +115,32 @@ await self.events.emit( - `knowledge/meta` -- master index, ontology schema - `knowledge/shared` -- household facts, insurance, contacts -- `knowledge/arthur` -- personal -- `knowledge/sabrina` -- personal +- `knowledge/homer` -- personal +- `knowledge/marge` -- personal - `knowledge/calendar` -- events, patterns **3b. Seed with manual knowledge.** -Start with what Arthur already knows: insurance details, emergency contacts, household facts. Use the Obsidian-compatible format: +Start with what Homer already knows: insurance details, emergency contacts, household facts. Use the Obsidian-compatible format: ```markdown --- type: fact domain: household -tags: [insurance, car, ADAC] +tags: [insurance, car, Duff Insurance] created: 2026-04-14 source: manual decay: 90d --- -# Car Insurance - ADAC +# Car Insurance - Duff Insurance Premium: EUR 340/year Policy: KFZ-2024-XXXXX Coverage: Vollkasko + Haftpflicht Expires: 2026-06-30 -See also: [[contacts#ADAC]] | [[actions#insurance-renewal]] +See also: [[contacts#Duff Insurance]] | [[actions#insurance-renewal]] ``` **3c. Implement fk knowledge CLI.** @@ -169,7 +169,7 @@ For `document.filed` events, the pipeline: 2. Load relevant domain index via `fk knowledge index shared` 3. Determine: update existing entry or create new one? 4. Write Markdown with frontmatter + wiki links -5. Git commit with structured message: `learn: ADAC invoice, premium EUR 340/yr` +5. Git commit with structured message: `learn: Duff Insurance invoice, premium EUR 340/yr` 6. Update domain index **4b. The extraction prompt.** @@ -208,15 +208,15 @@ Facts with due dates go to `shared/household/actions.md`. Each entry has a statu **5a. Context-aware extraction.** -The Deriver loads relevant wiki sections before processing a new event. When a new ADAC invoice arrives, it sees the existing insurance.md and can: +The Deriver loads relevant wiki sections before processing a new event. When a new Duff Insurance invoice arrives, it sees the existing insurance.md and can: - Update the premium if it changed -- Note the invoice as part of a series ("3rd ADAC invoice this year") +- Note the invoice as part of a series ("3rd Duff Insurance invoice this year") - Flag contradictions ("last document said EUR 340, this one says EUR 380") **5b. Relationship tracking via wiki links.** The Deriver adds `[[wiki links]]` between related entries: -- `insurance.md` links to `contacts.md#ADAC` +- `insurance.md` links to `contacts.md#Duff Insurance` - `contacts.md#Dr-Weber` links to `medical.md` - Invoice documents link to the correspondent's wiki entry @@ -224,11 +224,11 @@ Obsidian's graph view renders these connections automatically. **5c. Series detection.** -The Deriver notices patterns: monthly invoices from the same correspondent, recurring document types. These become `[habit]` entries in the wiki: "ADAC sends annual invoice in March." +The Deriver notices patterns: monthly invoices from the same correspondent, recurring document types. These become `[habit]` entries in the wiki: "Duff Insurance sends annual invoice in March." **Time: 4 hours.** Prompt enhancement + cross-reference logic. -**Delivers:** Knowledge graph emerges naturally. Kit can answer "what do we know about ADAC?" with the full picture across all documents. +**Delivers:** Knowledge graph emerges naturally. Kit can answer "what do we know about Duff Insurance?" with the full picture across all documents. ### Layer 6: Smart Tag Management diff --git a/docs/design/brain/knowledge-structure.md b/docs/design/brain/knowledge-structure.md index a8369a4..0f108e4 100644 --- a/docs/design/brain/knowledge-structure.md +++ b/docs/design/brain/knowledge-structure.md @@ -2,7 +2,7 @@ > Status: Design distillation > Created: 2026-04-21 -> Author: Arthur + Claude +> Author: Homer + Claude > Companion to: [knowledge-architecture.md](knowledge-architecture.md), [knowledge-implementation.md](knowledge-implementation.md), [ontology-design.md](ontology-design.md) ## Why this document @@ -112,7 +112,7 @@ WikiEntity kind "correspondent" | "person" | "topic" | "asset" | "story" names per-language canonical names aliases alternative names that should resolve to this entity - bucket which bucket this entity belongs to (family, arthur, marge, ...) + bucket which bucket this entity belongs to (family, homer, marge, ...) # story-specific fields (only when kind == "story") noun user-facing word ("trip", "renovation", "birthday", "side project") @@ -164,7 +164,7 @@ Each layer answers a different reader's question: | L0 | Paperless UI, audit | "What's the original artifact?" | | L1 | Obsidian browser, search engine | "What did this single document say?" | | L2 | Retriever, scripts | "What claims have we accumulated about X?" | -| L3 | Family member, Kit Bot | "What do we know about ADAC / Homer / insurance?" | +| L3 | Family member, Kit Bot | "What do we know about Duff Insurance / Homer / insurance?" | | L4 | LLM in its first read | "Where is everything?" | ### L0: Source documents @@ -183,13 +183,13 @@ One markdown file per Document, written by the Archivist. This layer exists toda --- type: document paperless_id: 247 -title: ADAC - Kfz-Versicherung 2026 EUR 340 +title: Duff Insurance - Kfz-Versicherung 2026 EUR 340 date: 2026-03-15 language: de doc_type: policy topics: [insurance, vehicle] persons: [Homer] -correspondent: ADAC +correspondent: Duff Insurance aliases: [] tags: [Insurance, Vehicle, "Person: Homer", Policy] facts: @@ -203,7 +203,7 @@ processing: ai_formatted model: qwen3-32b --- -# ADAC - Kfz-Versicherung 2026 EUR 340 +# Duff Insurance - Kfz-Versicherung 2026 EUR 340 (clean markdown body — either LLM-reformatted OCR or raw OCR fallback) @@ -211,7 +211,7 @@ model: qwen3-32b ## Related -- [[family/adac]] +- [[family/duff-insurance]] - [[family/homer]] - [[family/insurance]] - [[family/vehicle]] @@ -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":"@arthur: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:merles.eu","extracted_at":"2026-04-20T14:22:00Z","expires_at":"2026-07-19T00:00Z","confidence":0.85,"superseded_by":null} ``` **Invariants:** @@ -272,8 +272,8 @@ Flat buckets, one entity per file. Privacy and ownership live in *buckets* (top- / ├── family/ # shared — everyone in the household │ ├── index.md -│ ├── adac.md # kind: correspondent -│ ├── aok.md # kind: correspondent +│ ├── duff-insurance.md # kind: correspondent +│ ├── springfield-mutual.md # kind: correspondent │ ├── homer.md # kind: person (family-visible profile) │ ├── marge.md # kind: person │ ├── insurance.md # kind: topic @@ -283,7 +283,7 @@ Flat buckets, one entity per file. Privacy and ownership live in *buckets* (top- │ ├── bathroom-2026.md # kind: story (renovation, completed) │ ├── marge-40th.md # kind: story (birthday, planning) │ └── ... -├── arthur/ # arthur's personal bucket +├── homer/ # homer's personal bucket │ ├── index.md │ ├── work.md # kind: topic │ ├── side-projects.md # kind: topic @@ -305,15 +305,15 @@ Flat buckets, one entity per file. Privacy and ownership live in *buckets* (top- 4. **One person profile per bucket they appear in.** `family/homer.md` is Homer's family-visible profile. `homer/` (if it exists) is what Homer keeps to himself. 5. **Sub-folders are an optimization, not the design.** Earn them by file count past ~200 per bucket. -**Correspondent page template** (`family/adac.md`): +**Correspondent page template** (`family/duff-insurance.md`): ```markdown --- type: entity kind: correspondent -id: adac -name: ADAC -aliases: ["ADAC e.V.", "Allgemeiner Deutscher Automobil-Club"] +id: duff-insurance +name: Duff Insurance +aliases: ["Duff Insurance e.V.", "Allgemeiner Deutscher Automobil-Club"] topics: [insurance, vehicle] persons: [Homer] documents: 14 @@ -322,7 +322,7 @@ last_seen: 2026-04-15 updated_at: 2026-04-21T03:00:00Z --- -# ADAC +# Duff Insurance German automobile club, primary car-insurance provider for the family since 2023. @@ -335,10 +335,10 @@ German automobile club, primary car-insurance provider for the family since 2023 ## Timeline -- 2026-03-15 — Renewal notice [[documents/2026/03/2026-03-15-adac-renewal|#247]] -- 2025-01-10 — Annual renewal [[documents/2025/01/2025-01-10-adac-policy|#189]] -- 2024-03-12 — Coverage adjustment [[documents/2024/03/2024-03-12-adac-amendment|#102]] -- 2023-01-10 — Initial policy [[documents/2023/01/2023-01-10-adac-contract|#54]] +- 2026-03-15 — Renewal notice [[documents/2026/03/2026-03-15-duff-insurance-renewal|#247]] +- 2025-01-10 — Annual renewal [[documents/2025/01/2025-01-10-duff-insurance-policy|#189]] +- 2024-03-12 — Coverage adjustment [[documents/2024/03/2024-03-12-duff-insurance-amendment|#102]] +- 2023-01-10 — Initial policy [[documents/2023/01/2023-01-10-duff-insurance-contract|#54]] ## Related @@ -363,7 +363,7 @@ related_persons: spouse: Marge children: [Bart, Lisa, Maggie] topics: [insurance, vehicle, employment, taxes] -correspondents: [adac, aok, finanzamt_springfield] +correspondents: [duff-insurance, springfield-mutual, springfield-tax-office_springfield] documents: 47 updated_at: 2026-04-21T03:00:00Z --- @@ -380,14 +380,14 @@ Family member. Married to [[family/marge]]. Father of [[family/bart]], [[family/ ## Areas of activity -- [[family/insurance]] — car (ADAC), health (AOK), liability -- [[family/vehicle]] — BMW 320d, M-AB-1234 +- [[family/insurance]] — car (Duff Insurance), health (Springfield Mutual), liability +- [[family/vehicle]] — Canyonero, SPR-1234 - [[family/employment]] — Springfield Nuclear -- [[family/taxes]] — annual filing, Finanzamt Springfield +- [[family/taxes]] — annual filing, Springfield Tax Office ## Related -[[family/adac]] · [[family/aok]] · [[family/finanzamt_springfield]] · ... +[[family/duff-insurance]] · [[family/springfield-mutual]] · [[family/springfield-tax-office_springfield]] · ... ## Sources @@ -403,7 +403,7 @@ kind: topic id: insurance name.de: Versicherung name.en: Insurance -correspondents: [adac, aok, allianz] +correspondents: [duff-insurance, springfield-mutual, globex] persons: [Homer, Marge, Maggie] documents: 31 updated_at: 2026-04-21T03:00:00Z @@ -417,24 +417,24 @@ The family carries coverage in four categories: car, health, household liability | Category | Provider | Insured | Policy | Premium | |---|---|---|---|---| -| Car | [[family/adac]] | Homer | KFZ-2024-XXXXX | EUR 340/yr | -| Health | [[family/aok]] | family plan | XX-XX | EUR 892/mo | -| Liability | [[family/allianz]] | household | HFT-XXX | EUR 87/yr | -| Life | [[family/allianz]] | Homer | LBV-XXX | EUR 412/yr | +| Car | [[family/duff-insurance]] | Homer | KFZ-2024-XXXXX | EUR 340/yr | +| Health | [[family/springfield-mutual]] | family plan | XX-XX | EUR 892/mo | +| Liability | [[family/globex]] | household | HFT-XXX | EUR 87/yr | +| Life | [[family/globex]] | Homer | LBV-XXX | EUR 412/yr | ## Recent activity -- 2026-03-15 — ADAC renewal notice [doc:#247] -- 2026-02-10 — AOK monthly statement [doc:#241] +- 2026-03-15 — Duff Insurance renewal notice [doc:#247] +- 2026-02-10 — Springfield Mutual monthly statement [doc:#241] - ... ## Action items -- Compare ADAC prices before renewal (due 2026-11-30) +- Compare Duff Insurance prices before renewal (due 2026-11-30) ## Related -[[family/vehicle]] · [[family/medical]] · [[family/adac]] · ... +[[family/vehicle]] · [[family/medical]] · [[family/duff-insurance]] · ... ``` **Story page template** (`family/tuscany-2027.md`): @@ -452,7 +452,7 @@ starts: 2027-07-15 ends: 2027-07-29 participants: [Homer, Marge, Bart, Lisa, Maggie] topics: [travel, italy] -related_correspondents: [lufthansa, agriturismo-il-bosco, hertz] +related_correspondents: [springfield-air, springfield-lodge, hertz] budget: { amount: 8000, currency: "EUR" } documents: 6 updated_at: 2026-04-21T03:00:00Z @@ -468,8 +468,8 @@ Planning. Flights booked, accommodations confirmed for week 1, week 2 still open ## Confirmed -- [fact] Lufthansa MUC ↔ FLR, EUR 1,840 family of 5 [doc:#314] -- [fact] Agriturismo Il Bosco, Lucca, 2027-07-15 to 2027-07-22 [doc:#316] +- [fact] Springfield Air MUC ↔ FLR, EUR 1,840 family of 5 [doc:#314] +- [fact] Springfield Lodge, Lucca, 2027-07-15 to 2027-07-22 [doc:#316] ## Wish list @@ -486,12 +486,12 @@ Planning. Flights booked, accommodations confirmed for week 1, week 2 still open ## Timeline - 2026-04-21 — Booked Agriturismo [[documents/2026/04/2026-04-21-agriturismo-confirmation|#316]] -- 2026-04-18 — Booked Lufthansa flights [[documents/2026/04/2026-04-18-lufthansa-booking|#314]] +- 2026-04-18 — Booked Springfield Air flights [[documents/2026/04/2026-04-18-springfield-air-booking|#314]] - 2026-03-10 — Marge mentioned wanting to see the David [chat memo] ## Related -[[family/travel]] · [[family/lufthansa]] · [[family/agriturismo-il-bosco]] +[[family/travel]] · [[family/springfield-air]] · [[family/springfield-lodge]] ``` **Invariants:** @@ -545,7 +545,7 @@ One markdown file. The LLM's map. Always loaded into Kit Bot's context. --- type: index updated_at: 2026-04-21T03:00:00Z -domains: [shared, arthur, marge] +domains: [shared, homer, marge] totals: { documents: 312, facts: 487, entities: 47 } --- @@ -554,15 +554,15 @@ totals: { documents: 312, facts: 487, entities: 47 } ## Domains - shared (38 entities) — household, contacts, recurring patterns -- arthur (12 entities) — personal docs, work, hobbies +- homer (12 entities) — personal docs, work, hobbies - marge (9 entities) — personal docs ## Entities ### Correspondents (16) -- [[family/adac]] — car insurance · Homer · 14 docs · last 2026-04-15 -- [[family/aok]] — health insurance · family · 27 docs · last 2026-04-10 -- [[family/finanzamt_springfield]] — tax office · 8 docs · last 2026-03-22 +- [[family/duff-insurance]] — car insurance · Homer · 14 docs · last 2026-04-15 +- [[family/springfield-mutual]] — health insurance · family · 27 docs · last 2026-04-10 +- [[family/springfield-tax-office_springfield]] — tax office · 8 docs · last 2026-03-22 - ... ### Persons (5) @@ -573,13 +573,13 @@ totals: { documents: 312, facts: 487, entities: 47 } ### Topics (24) - [[family/insurance]] — 31 docs · car, health, liability, life -- [[family/vehicle]] — 19 docs · BMW 320d, registration, service +- [[family/vehicle]] — 19 docs · Canyonero, registration, service - [[family/taxes]] — 14 docs · annual returns, assessments - ... ## Recent facts (last 30) -- 2026-04-20 [fact] ADAC premium EUR 340/yr [#247] -- 2026-04-15 [event] AOK annual statement received [#241] +- 2026-04-20 [fact] Duff Insurance premium EUR 340/yr [#247] +- 2026-04-15 [event] Springfield Mutual annual statement received [#241] - ... ``` @@ -614,18 +614,18 @@ Matrix gives us authoritative signals about *who* dropped *what* *where*. We tre |---|---| | **DM with one user** | Bucket = that user's personal bucket. Owner = that user. | | **Shared family room** (`#documents`, `#assistant`) | Bucket = `family`. Sender is a strong candidate for `persons`. | -| **Personal room** (`#arthur-private`, configured) | Bucket = the room's declared bucket. | +| **Personal room** (`#homer-private`, configured) | Bucket = the room's declared bucket. | | **Sender of the message** | Strong candidate for `persons` field, regardless of room. | These are *defaults*, not hard rules. The classifier overrides when content clearly disagrees: -- Arthur drops a school letter for Bart in `#documents` → defaults: bucket=family, persons=[Homer]. Classifier reads OCR ("Springfield Elementary," "Bart Simpson") → overrides to persons=[Bart], bucket stays family. -- Marge sends Arthur an electronics receipt photo in DM → defaults: bucket=arthur (Arthur's DM), persons=[Marge]. Classifier sees household appliance → overrides bucket=family, keeps persons=[Marge]. +- Homer drops a school letter for Bart in `#documents` → defaults: bucket=family, persons=[Homer]. Classifier reads OCR ("Springfield Elementary," "Bart Simpson") → overrides to persons=[Bart], bucket stays family. +- Marge sends Homer an electronics receipt photo in DM → defaults: bucket=homer (Homer's DM), persons=[Marge]. Classifier sees household appliance → overrides bucket=family, keeps persons=[Marge]. Override discipline: 1. Defaults stand unless content provides a clear, named contradiction. 2. Ambiguity → keep the default. "Might be Marge's" is not enough to override. -3. Override decisions are recorded in the L1 mirror's frontmatter (`bucket_default: arthur`, `bucket_resolved: family`, `bucket_override_reason: "household appliance"`). +3. Override decisions are recorded in the L1 mirror's frontmatter (`bucket_default: homer`, `bucket_resolved: family`, `bucket_override_reason: "household appliance"`). ### Room configuration @@ -767,7 +767,7 @@ The wiki pages are *regenerated*. But humans might want to edit them too — fix **Strategy B — frontmatter-as-source-of-truth.** The frontmatter persists human overrides (e.g. `manual_notes: "..."`); the body is regenerated. Bot reads existing frontmatter on regeneration, preserves designated user-fields, rewrites the body around them. -Recommend Strategy A for v1. If real pain emerges (Arthur wants to add a personal note to ADAC's page), add a single `notes:` frontmatter field that survives regeneration. YAGNI everything else. +Recommend Strategy A for v1. If real pain emerges (Homer wants to add a personal note to Duff Insurance's page), add a single `notes:` frontmatter field that survives regeneration. YAGNI everything else. --- @@ -811,7 +811,7 @@ Proactive surfacing belongs to Kit Bot — morning briefings or N days before re ### Accumulating interests -Every new capture about a topic appends to that entity page's Timeline. After months, `arthur/llm-benchmarks.md` has 30 timeline entries showing how Arthur's reading evolved. No special logic required — the entity wiki *is* the accumulation. +Every new capture about a topic appends to that entity page's Timeline. After months, `homer/llm-benchmarks.md` has 30 timeline entries showing how Homer's reading evolved. No special logic required — the entity wiki *is* the accumulation. Proactive opportunity: detect engagement velocity per topic. A topic with 8 new docs this month is "active" for that person; one with 0 in 6 months is "dormant." Master index surfaces active interests; the family knows what each person is into right now without asking. @@ -819,7 +819,7 @@ Proactive opportunity: detect engagement velocity per topic. A topic with 8 new Insurance renewals, school terms, annual contracts, subscription cycles. Each occurrence is an `[event]` fact with a due date. After 3 occurrences with stable cadence, the dream cycle promotes to a `[habit]` with a "next expected" date. Kit Bot warns proactively: -> ADAC car insurance renews annually in January. Next renewal expected 2027-01-10. +> Duff Insurance car insurance renews annually in January. Next renewal expected 2027-01-10. > Your house contents insurance is due for renewal in 6 weeks. This is the killer feature of household memory: it remembers cycles so nobody has to. @@ -856,7 +856,7 @@ The five layers, the entity page templates, and the backend interface are the *i 3. **Asset entities.** A car or a house feels like an entity, but it's often easier to model as a topic + correspondent pair. v1: no asset entity. Revisit when there's clear pain. -4. **Language of synthesized wiki pages.** A German family's `family/adac.md` body — German or English? Probably the user's primary language from `stack.toml`. Frontmatter fields stay English (id, kind, etc); display names follow language. +4. **Language of synthesized wiki pages.** A German family's `family/duff-insurance.md` body — German or English? Probably the user's primary language from `stack.toml`. Frontmatter fields stay English (id, kind, etc); display names follow language. 5. **Master index format.** Markdown (this doc proposes) or YAML/JSON for machine read + a separate markdown render? Markdown is dual-purpose; bots parse the table sections, humans browse. Start there; add structured sidecars only if parsing pain hits. diff --git a/docs/design/brain/ontology-design.md b/docs/design/brain/ontology-design.md index eaa2ea5..34cb8e2 100644 --- a/docs/design/brain/ontology-design.md +++ b/docs/design/brain/ontology-design.md @@ -2,7 +2,7 @@ > Status: Design document > Created: 2026-04-14 -> Author: Arthur + Claude +> Author: Homer + Claude > Used by: All famstack stacklets, Paperless-ngx, knowledge wiki, Kit Bot ## The Problem @@ -14,7 +14,7 @@ Every service in the stack has its own tagging: - Calendar: calendars, event categories - Knowledge wiki: ontology types, domain tags, wiki links -These don't talk to each other. "ADAC" is a Paperless correspondent, a wiki entry, and maybe a photo album from the roadside assistance visit. The person "Sabrina" is a Paperless person tag, an Immich face, a Matrix user, and a wiki domain. But nothing connects them. +These don't talk to each other. "Duff Insurance" is a Paperless correspondent, a wiki entry, and maybe a photo album from the roadside assistance visit. The person "Marge" is a Paperless person tag, an Immich face, a Matrix user, and a wiki domain. But nothing connects them. ## The Design @@ -54,7 +54,7 @@ categories: school: aliases: [Schule, education, Unterricht] - persons: [sabrina] + persons: [marge] paperless_tag: School paperless_color: "#ff9800" @@ -81,54 +81,54 @@ categories: paperless_color: "#00bcd4" persons: - arthur: - aliases: [Arthur, Papa] + homer: + aliases: [Homer, Papa] services: - matrix: "@arthur:merles.eu" - paperless: "Person: Arthur" + matrix: "@homer:merles.eu" + paperless: "Person: Homer" immich: face-abc123 - forgejo: arthur - calendar: arthur@merles.eu + forgejo: homer + calendar: homer@merles.eu - sabrina: - aliases: [Sabrina, Mama] + marge: + aliases: [Marge, Mama] services: - matrix: "@sabrina:merles.eu" - paperless: "Person: Sabrina" + matrix: "@marge:merles.eu" + paperless: "Person: Marge" immich: face-def456 - calendar: sabrina@merles.eu + calendar: marge@merles.eu organizations: - adac: - name: ADAC - aliases: ["ADAC e.V.", "ADAC Autoversicherung"] + duff-insurance: + name: Duff Insurance + aliases: ["Duff Insurance e.V.", "Duff Insurance Autoversicherung"] categories: [insurance, vehicle] - persons: [arthur] - paperless_correspondent: ADAC + persons: [homer] + paperless_correspondent: Duff Insurance tk: name: Techniker Krankenkasse aliases: [TK, "Techniker Krankenkasse"] categories: [insurance, medical] - persons: [arthur, sabrina] + persons: [homer, marge] paperless_correspondent: TK - finanzamt: - name: Finanzamt - aliases: [Finanzamt, "Finanzamt Springfield", FA] + springfield-tax-office: + name: Springfield Tax Office + aliases: [Springfield Tax Office, "Springfield Tax Office", FA] categories: [finance] - paperless_correspondent: Finanzamt + paperless_correspondent: Springfield Tax Office knowledge_types: rule: decay: null description: Permanent, safety-critical facts - examples: ["Sabrina is allergic to peanuts", "Emergency number: 112"] + examples: ["Marge is allergic to peanuts", "Emergency number: 112"] paperless_action: "Action: Critical" habit: decay: 365 description: Recurring pattern, auto-promoted from repeated events - examples: ["ADAC invoice arrives in March", "Pizza night every Friday"] + examples: ["Duff Insurance invoice arrives in March", "Pizza night every Friday"] promotion_threshold: 3 # events before promotion to habit goal: decay: 365 @@ -137,7 +137,7 @@ knowledge_types: preference: decay: 180 description: Personal choice or taste - examples: ["Arthur prefers dark roast coffee"] + examples: ["Homer prefers dark roast coffee"] fact: decay: 90 description: Verifiable information with limited shelf life @@ -149,11 +149,11 @@ knowledge_types: event: decay: 14 description: Something that happened at a specific time - examples: ["Sabrina had dentist appointment Apr 17"] + examples: ["Marge had dentist appointment Apr 17"] reference: decay: null description: Pointer to external resource - examples: ["ADAC phone: 089-XXX", "Insurance policy in Paperless #247"] + examples: ["Duff Insurance phone: 089-XXX", "Insurance policy in Paperless #247"] ``` ### Projectional Views @@ -173,13 +173,13 @@ The YAML is the source of truth. Derived views are generated from it: - ... ## Organizations -- [[ADAC]] -- categories: [[Insurance]], [[Vehicle]] -- persons: [[Arthur]] -- [[TK]] -- categories: [[Insurance]], [[Medical]] -- persons: [[Arthur]], [[Sabrina]] +- [[Duff Insurance]] -- categories: [[Insurance]], [[Vehicle]] -- persons: [[Homer]] +- [[TK]] -- categories: [[Insurance]], [[Medical]] -- persons: [[Homer]], [[Marge]] - ... ## Persons -- [[Arthur]] -- organizations: [[ADAC]], [[TK]], [[Finanzamt]] -- [[Sabrina]] -- organizations: [[TK]] +- [[Homer]] -- organizations: [[Duff Insurance]], [[TK]], [[Springfield Tax Office]] +- [[Marge]] -- organizations: [[TK]] ``` **Paperless tag report** -- generated view showing how ontology maps to current Paperless state: @@ -189,7 +189,7 @@ fk ontology paperless-sync Insurance → Paperless tag "Insurance" (exists, 23 documents) Finance → Paperless tag "Finance" (exists, 15 documents) Vehicle → Paperless tag "Vehicle" (MISSING — will be created on next classify) - ADAC → Paperless correspondent "ADAC" (exists, 8 documents) + Duff Insurance → Paperless correspondent "Duff Insurance" (exists, 8 documents) ``` The projectional views are read-only artifacts. Change the YAML, regenerate the views. @@ -199,14 +199,14 @@ The projectional views are read-only artifacts. Change the YAML, regenerate the When the Archivist classifies a document, it doesn't just pick a tag string. It resolves through the ontology: ``` -OCR text mentions "ADAC" and "Rechnung" - → Ontology lookup: ADAC is a known correspondent in Insurance category +OCR text mentions "Duff Insurance" and "Rechnung" + → Ontology lookup: Duff Insurance is a known correspondent in Insurance category → Category: Insurance (resolved, not guessed) - → Person: Arthur (ADAC is associated with Arthur's car) + → Person: Homer (Duff Insurance is associated with Homer's car) → Knowledge type: fact (it's an invoice with amounts and dates) - → Related: [[insurance.md#ADAC]], [[vehicle.md]] - → Paperless tags: Insurance, Person: Arthur, Invoice, ADAC - → Wiki entry: update shared/household/insurance.md#ADAC + → Related: [[insurance.md#Duff Insurance]], [[vehicle.md]] + → Paperless tags: Insurance, Person: Homer, Invoice, Duff Insurance + → Wiki entry: update shared/household/insurance.md#Duff Insurance ``` The LLM gets the ontology as context in its classification prompt. Instead of guessing tags from scratch every time, it picks from a known vocabulary with relationships already defined. @@ -232,29 +232,29 @@ Categories (use canonical key, not aliases): ... Persons: - arthur: [Arthur, Papa] - sabrina: [Sabrina, Mama] + homer: [Homer, Papa] + marge: [Marge, Mama] Organizations (known correspondents): - adac: [ADAC, "ADAC e.V."] → categories: insurance, vehicle → persons: arthur - tk: [TK, "Techniker Krankenkasse"] → categories: insurance, medical → persons: arthur, sabrina - finanzamt: [Finanzamt, FA] → categories: finance + duff-insurance: [Duff Insurance, "Duff Insurance e.V."] → categories: insurance, vehicle → persons: homer + tk: [TK, "Techniker Krankenkasse"] → categories: insurance, medical → persons: homer, marge + springfield-tax-office: [Springfield Tax Office, FA] → categories: finance ``` -The LLM now has relationships. It doesn't just see tag strings -- it sees a graph. "This is from ADAC" immediately implies insurance + vehicle + arthur without the LLM having to figure that out from OCR text alone. +The LLM now has relationships. It doesn't just see tag strings -- it sees a graph. "This is from Duff Insurance" immediately implies insurance + vehicle + homer without the LLM having to figure that out from OCR text alone. ### Cross-Service Entity Resolution The ontology connects entities across services. When the Deriver processes events: ``` -Document event: correspondent=ADAC, person=Arthur -Photo event: faces=[Arthur], location=Highway A8 -Calendar event: "ADAC Pannenservice", date=2026-03-15 +Document event: correspondent=Duff Insurance, person=Homer +Photo event: faces=[Homer], location=Highway A8 +Calendar event: "Duff Insurance Pannenservice", date=2026-03-15 -→ Ontology resolves: all three relate to entity "ADAC" + person "Arthur" +→ Ontology resolves: all three relate to entity "Duff Insurance" + person "Homer" → Wiki: shared/household/vehicle.md gets updated with the roadside assistance event -→ Cross-references: [[insurance.md#ADAC]] + [[calendar/2026-03.md]] +→ Cross-references: [[insurance.md#Duff Insurance]] + [[calendar/2026-03.md]] ``` Without the ontology, these three events are unrelated -- different services, different data formats, different tag systems. With the ontology, they're one story. @@ -265,7 +265,7 @@ Paperless's person tagging is good. Extend it across the stack: **Every piece of knowledge can be associated with one or more persons.** -- Documents: `Person: Arthur` tag in Paperless + `person` field in classification +- Documents: `Person: Homer` tag in Paperless + `person` field in classification - Photos: face detection in Immich maps to person entities - Calendar events: attendees map to persons - Wiki entries: `persons` field in frontmatter @@ -274,18 +274,18 @@ Paperless's person tagging is good. Extend it across the stack: The person entity in the ontology is the join key: ```markdown -## Arthur -- matrix: @arthur:merles.eu -- paperless: "Person: Arthur" +## Homer +- matrix: @homer:merles.eu +- paperless: "Person: Homer" - immich: face-id-abc123 -- calendar: arthur@merles.eu +- calendar: homer@merles.eu ``` -When Kit Bot serves Arthur, it can query "everything associated with Arthur" across all services by resolving through the ontology. When Sabrina asks Kit something, the person filter scopes results to what's relevant to her. +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. ### Ontology Lifecycle -**Bootstrap (manual):** Arthur seeds the ontology files by hand. Categories based on Paperless tags that already exist. Persons from the user list. Correspondents from Paperless. +**Bootstrap (manual):** Homer seeds the ontology files by hand. Categories based on Paperless tags that already exist. Persons from the user list. Correspondents from Paperless. **Growth (Archivist + Deriver):** When the Archivist encounters a truly new correspondent or category that doesn't match anything in the ontology, it: 1. Creates the Paperless tag (as today) @@ -295,7 +295,7 @@ When Kit Bot serves Arthur, it can query "everything associated with Arthur" acr **Maintenance (dream cycle):** Nightly review: - Detect near-duplicate entries (fuzzy matching on names + aliases) -- Suggest merges ("Finanzamt" and "Finanzamt Springfield" should be one entry) +- Suggest merges ("Springfield Tax Office" and "Springfield Tax Office" should be one entry) - Flag orphans (entities referenced nowhere) - Update alias lists from observed usage - Count usage per tag to identify the most/least used @@ -343,8 +343,8 @@ Updated classification flow: 4. Resolve to Paperless tags via ontology dict lookups: category "insurance" → ontology["categories"]["insurance"]["paperless_tag"] → "Insurance" - person "arthur" → ontology["persons"]["arthur"]["services"]["paperless"] → "Person: Arthur" - org "adac" → ontology["organizations"]["adac"]["paperless_correspondent"] → "ADAC" + person "homer" → ontology["persons"]["homer"]["services"]["paperless"] → "Person: Homer" + org "duff-insurance" → ontology["organizations"]["duff-insurance"]["paperless_correspondent"] → "Duff Insurance" 5. If new entity: emit tag.created event, Deriver adds to ontology.yaml, commits ``` @@ -357,7 +357,7 @@ The ontology makes queries intelligent: ``` fk knowledge search "car insurance" → grep for "car insurance" across all files -→ misses: "ADAC", "Vollkasko", "KFZ", "Autoversicherung" +→ misses: "Duff Insurance", "Vollkasko", "KFZ", "Autoversicherung" ``` **With ontology:** @@ -365,7 +365,7 @@ fk knowledge search "car insurance" fk knowledge search "car insurance" → Ontology lookup: "car insurance" matches category Insurance + subcategory Vehicle → Aliases: Autoversicherung, KFZ, Vollkasko -→ Known correspondents: ADAC, HUK, Allianz +→ Known correspondents: Duff Insurance, HUK, Globex → Expanded search: grep for all aliases + correspondent names → Finds everything related, regardless of language or terminology ``` @@ -374,10 +374,10 @@ This is the "super fast queries" insight. The ontology is a search expansion lay **Kit Bot uses the same expansion:** ``` -Arthur: "What do we know about car insurance?" +Homer: "What do we know about car insurance?" Kit: loads ontology → Insurance+Vehicle → aliases + correspondents + persons Kit: fk knowledge search with expanded terms -Kit: finds insurance.md, ADAC correspondent entry, recent invoice, action item +Kit: finds insurance.md, Duff Insurance correspondent entry, recent invoice, action item Kit: synthesizes answer from all sources ``` @@ -416,10 +416,10 @@ Each stacklet reads the ontology for context and emits events that the Deriver u - fk CLI: `fk ontology` commands ### Who writes it -- Arthur: manual bootstrap and corrections (edit YAML directly or via CLI) +- Homer: manual bootstrap and corrections (edit YAML directly or via CLI) - Deriver: adds new entities discovered from events (appends to YAML, commits) - Dream cycle: merges, cleanup, alias updates -- Family assistant: conversational writes ("remember that Dr. Weber is our pediatrician") +- Family assistant: conversational writes ("remember that Dr. Hibbert is our pediatrician") ### Runtime loading Load `ontology.yaml` once into a Python dict at startup. Refresh on git webhook or periodic poll (every 5 min). The file is ~5-10 KB. All lookups are O(1) dict access. No parsing on every query. diff --git a/docs/design/brain/ontology-v1.md b/docs/design/brain/ontology-v1.md index f401244..c16f53b 100644 --- a/docs/design/brain/ontology-v1.md +++ b/docs/design/brain/ontology-v1.md @@ -2,17 +2,17 @@ > Status: Design document — implementation-ready > Created: 2026-04-21 -> Author: Arthur + Claude +> Author: Homer + Claude > Companion to: [ontology-design.md](ontology-design.md), [knowledge-architecture.md](knowledge-architecture.md), [knowledge-implementation.md](knowledge-implementation.md) > Triggered by: paperless-gpt deep dive — they have no ontology layer; that gap is exactly what blocks LLM Q&A over documents. ## Why this exists -We are building toward LLM-driven retrieval ("what do we know about car insurance?", "when does the ADAC policy expire?", "which family member has medical bills outstanding?"). Those questions cannot be answered by full-text search alone: +We are building toward LLM-driven retrieval ("what do we know about car insurance?", "when does the Duff Insurance policy expire?", "which family member has medical bills outstanding?"). Those questions cannot be answered by full-text search alone: - "car insurance" misses every German document that says *Vollkasko*, *Haftpflicht*, *Kfz-Versicherung* - "medical" misses *Gesundheit*, *Arzt*, *Krankenkasse* -- "Arthur's bills" requires joining persons + categories + custom fields +- "Homer's bills" requires joining persons + categories + custom fields - "expires soon" requires a typed `due_date` / `expiry_date` field, not prose The ontology is the bridge between the user's natural question and the structured data living in Paperless, the wiki, and (later) the event bus. @@ -41,8 +41,8 @@ paperless-gpt is the closest comparable project. Their design choice is worth re **Why this is limiting (and why we shouldn't copy it):** 1. **No alias resolution.** A German "Vollkasko" doc gets a *Vollkasko* tag; a German "KFZ-Versicherung" doc gets a *Kfz-Versicherung* tag; an English "comprehensive insurance" doc gets a *Comprehensive Insurance* tag. All three are car insurance. Search for any one misses the others. -2. **No relationships.** Knowing "ADAC is a car insurance correspondent" requires the LLM to re-derive that from prose every time. Wasted tokens, inconsistent results. -3. **No persons.** Documents about Sabrina vs. Arthur cannot be filtered without manual tagging discipline. +2. **No relationships.** Knowing "Duff Insurance is a car insurance correspondent" requires the LLM to re-derive that from prose every time. Wasted tokens, inconsistent results. +3. **No persons.** Documents about Marge vs. Homer cannot be filtered without manual tagging discipline. 4. **No retrieval surface.** They don't attempt cross-doc Q&A. The product ends at "tag exists in Paperless." The one piece worth stealing from them is the **typed custom-field schema** (`type: monetary` → `"EUR1664.58"`). We adopt it directly into v1 below. @@ -98,7 +98,7 @@ Layer 0 Implicit ontology (today) flat taxonomy.toml + users.toml - One I/O backend (`ForgejoClient`) for all bot-managed git data — documents *and* ontology - One bot user (`archivist-bot`) with one credential set — already provisioned by the existing setup flow - One audit surface (Forgejo's web UI, `git log`) — the family member who clicks "history" in the docs repo will find ontology history in the same UI -- One mental model — when we add the wiki repos later (`family/shared`, `family/arthur`), they all use the same pattern +- One mental model — when we add the wiki repos later (`family/shared`, `family/homer`), they all use the same pattern **Why YAML, not JSON / TOML / Markdown:** - YAML is structured-but-readable; survives hand-editing as a fallback @@ -216,22 +216,22 @@ document_types: # ── Persons ──────────────────────────────────────────────────── # Closed set, seeded from users.toml. The LLM picks from this list # but cannot create new entries. Service mappings let other -# stacklets resolve "Arthur" to a Matrix ID, Immich face, etc. +# stacklets resolve "Homer" to a Matrix ID, Immich face, etc. persons: - arthur: - label: Arthur + homer: + label: Homer aliases: [Papa] services: - paperless_tag: "Person: Arthur" - matrix: "@arthur:merles.eu" + paperless_tag: "Person: Homer" + matrix: "@homer:merles.eu" - sabrina: - label: Sabrina + marge: + label: Marge aliases: [Mama] services: - paperless_tag: "Person: Sabrina" - matrix: "@sabrina:merles.eu" + paperless_tag: "Person: Marge" + matrix: "@marge:merles.eu" # ── Organizations ────────────────────────────────────────────── # Open set. Bootstrap with a small list of universally-useful orgs @@ -250,12 +250,12 @@ persons: organizations: # Pre-seeded entry — no provenance fields, highest trust. - adac: - label: ADAC - aliases: ["ADAC e.V.", "ADAC Autoversicherung", "ADAC Versicherung"] + duff-insurance: + label: Duff Insurance + aliases: ["Duff Insurance e.V.", "Duff Insurance Autoversicherung", "Duff Insurance Versicherung"] categories: [insurance, vehicle] - persons: [arthur] - paperless_correspondent: ADAC + persons: [homer] + paperless_correspondent: Duff Insurance # Auto-learned entry — Archivist wrote this after seeing the # correspondent on a doc. Categories inferred from that doc's @@ -265,7 +265,7 @@ organizations: label: Stadtwerke München aliases: [SWM] categories: [utilities] - persons: [arthur] + persons: [homer] paperless_correspondent: Stadtwerke München learned: 2026-04-19 docs_seen: 3 @@ -383,7 +383,7 @@ def expand_query(ontology: Ontology, terms: list[str]) -> ExpandedQuery: """Take user query terms, return the alias-expanded set. 'car insurance' -> {categories: [insurance, vehicle], aliases: [Vollkasko, Haftpflicht, KFZ, ...], - organizations: [ADAC, HUK, ...]}""" + organizations: [Duff Insurance, HUK, ...]}""" def resolve_to_paperless(ontology: Ontology, key: str, kind: str) -> str | None: """Map canonical key -> Paperless tag/correspondent/type name. @@ -450,12 +450,12 @@ With v1 ontology: 3. Expand via aliases + related vehicle -> [Vehicle, Fahrzeug, Auto, KFZ, Werkstatt, car] insurance -> [Insurance, Versicherung, Police, Vollkasko, Haftpflicht] - related -> insurance ∩ vehicle organisations: ADAC + related -> insurance ∩ vehicle organisations: Duff Insurance 4. Build the actual queries Paperless full-text: OR(all 11 alias terms) Paperless tag filter: tags ∈ {Versicherung, Fahrzeug} - Paperless corresp.: correspondent ∈ {ADAC, ...} + Paperless corresp.: correspondent ∈ {Duff Insurance, ...} Custom-field filter: pull premium, policy_number, expiry_date from filtered docs @@ -467,7 +467,7 @@ With v1 ontology: stored in Paperless's content) 7. LLM synthesis: - "You have an ADAC Vollkasko + Haftpflicht policy + "You have an Duff Insurance Vollkasko + Haftpflicht policy (#KFZ-2024-XXXXX), premium EUR 340/year, expires 2026-06-30. Last renewal notice was filed 2026-03-15 (paperless #247)." @@ -480,12 +480,12 @@ The ontology turned a 0-result query into a structured answer with citations. | User question | What v1 enables | |---------------|-----------------| | "What do we know about car insurance?" | Category + alias expansion → multi-doc synthesis | -| "When does my ADAC policy expire?" | Org → docs → custom_field `expiry_date` | +| "When does my Duff Insurance policy expire?" | Org → docs → custom_field `expiry_date` | | "How much do we pay for insurance per year?" | Category insurance → all docs → sum custom_field `premium` | -| "Show me Sabrina's school documents" | Person filter + category school | +| "Show me Marge's school documents" | Person filter + category school | | "Anything due in the next 30 days?" | Filter all docs by custom_field `due_date` < today + 30d | | "Who is the dentist again?" | Org search by category medical with role hint | -| "What's our marriage certificate number?" | Doc type certificate + persons [Arthur, Sabrina] → custom_field `reference_number` | +| "What's our marriage certificate number?" | Doc type certificate + persons [Homer, Marge] → custom_field `reference_number` | The first three would be impossible without ontology v1. The rest become reliable rather than guesswork. @@ -532,7 +532,7 @@ Subsequent docs touching the same entity: ontology.yaml is git-tracked (lives with the docs stacklet — eventually in the `knowledge/meta` Forgejo repo). Every auto-extension is a commit with a structured message: ``` -learn: organisation Stadtwerke München (utilities, arthur) +learn: organisation Stadtwerke München (utilities, homer) Paperless-Correspondent: Stadtwerke München First-Seen-In-Doc: 312 @@ -551,15 +551,15 @@ Supported correction grammar — kept tiny on purpose, the LLM resolves anything ``` Reply to a "Filed: ..." message: - "from HUK not ADAC" → re-classify with hint: correspondent should be HUK - (this doc gets fixed; ontology gets ADAC's + "from HUK not Duff Insurance" → re-classify with hint: correspondent should be HUK + (this doc gets fixed; ontology gets Duff Insurance's docs_seen decremented, HUK's incremented) "not insurance, vehicle" → re-classify with hint: category vehicle (this doc gets re-tagged; downweights the insurance-on-this-correspondent association) - "this is Sabrina not Arthur" → re-classify with person hint + "this is Marge not Homer" → re-classify with person hint "wrong" → strip auto-applied tags, leave the doc for manual fixing in Paperless @@ -576,17 +576,17 @@ When something is wrong at the ontology level (an org has the wrong categories, ``` @archivist Stadtwerke München is utilities + finance -@archivist merge "ADAC e.V." into ADAC +@archivist merge "Duff Insurance e.V." into Duff Insurance @archivist forget the org "Spam Inc" -@archivist ADAC is also called "ADAC SE" +@archivist Duff Insurance is also called "Duff Insurance SE" ``` Same handler, parsed by the LLM, written directly to ontology.yaml as a commit: ``` -update: organisation ADAC categories +finance +update: organisation Duff Insurance categories +finance - Confirmed-By: @arthur:merles.eu + Confirmed-By: @homer:merles.eu ``` A correction always sets `confirmed: true` on the affected entries — that locks them against future auto-overrides from low-confidence learning. @@ -597,7 +597,7 @@ For people who'd rather use the terminal, the same operations work via CLI (`fk The dream cycle eventually handles janitor work the Archivist shouldn't synchronously care about: -- **Near-duplicate detection** — "Finanzamt" vs "Finanzamt Springfield" with overlapping doc sets → auto-merge if confidence is high, post a one-line summary in #documents otherwise +- **Near-duplicate detection** — "Springfield Tax Office" vs "Springfield Tax Office" with overlapping doc sets → auto-merge if confidence is high, post a one-line summary in #documents otherwise - **Orphan archival** — entries with `docs_seen: 0` for 6+ months move to an `archive:` section (still queryable, no longer suggested in classification prompts) - **Alias mining** — tokens that co-occur near a canonical key in 10+ doc summaries → auto-add as aliases - **Confidence promotion** — entries with `docs_seen >= 5` and no corrections → auto-flip `confirmed: true` @@ -613,7 +613,7 @@ The fear: the LLM hallucinates a correspondent ("DB" extracted from a fragment), Three mitigations, in order of how aggressive they are: 1. **Provenance prevents propagation.** `learned: ...` + `confirmed: false` + `docs_seen: 1` entries are *included* in the classification prompt but *deprioritized* — the prompt says "prefer confirmed entries." A single bad guess won't snowball. -2. **Corrections cascade.** When a user corrects "from HUK not ADAC" on a doc, the Archivist also recalculates ADAC's `docs_seen` and, if the correction reveals the entry was a hallucination (e.g. the only doc citing "DB" gets fixed to "Deutsche Bank"), the entry auto-prunes itself. +2. **Corrections cascade.** When a user corrects "from HUK not Duff Insurance" on a doc, the Archivist also recalculates Duff Insurance's `docs_seen` and, if the correction reveals the entry was a hallucination (e.g. the only doc citing "DB" gets fixed to "Deutsche Bank"), the entry auto-prunes itself. 3. **Dream cycle reaps junk.** Entries with `docs_seen: 1` and no corrections after 30 days are archived (not deleted — they go to `archive:` and stay grep-able). Net: the system errs on the side of writing, but it doesn't err on the side of *trusting* what it wrote. @@ -678,7 +678,7 @@ Layer 1 only covers what the Archivist needs (categories, doc types, persons, or If an org has `categories: [insurance, vehicle]`, should documents from that org auto-get those tags even if the LLM disagrees? **No** — the LLM has read the actual document text and may have good reason to deviate. The ontology hints; the LLM decides. The exception is when classification fails entirely — fallback to org-implied categories rather than untagged. **4b. Conflicting corrections from different family members.** -Arthur replies "from HUK not ADAC" on the same doc Sabrina later replies "no, that was actually ADAC after all." Last write wins on the doc; the ontology entries log both events (`docs_seen` jitters but converges). For ontology-wide corrections, `confirmed: true` is sticky — once set, only an explicit `@archivist unconfirm ` undoes it. This avoids ping-pong without a conflict-resolution UI. +Homer replies "from HUK not Duff Insurance" on the same doc Marge later replies "no, that was actually Duff Insurance after all." Last write wins on the doc; the ontology entries log both events (`docs_seen` jitters but converges). For ontology-wide corrections, `confirmed: true` is sticky — once set, only an explicit `@archivist unconfirm ` undoes it. This avoids ping-pong without a conflict-resolution UI. **4c. Auto-extension of the persons list.** **No.** Persons stay closed-set, seeded from `users.toml`. The LLM cannot create new ones. Adding a person is a deliberate household action (someone joined the family, a child gets their own account); this happens at the `users.toml` level and propagates on next `stack up docs`. Auto-creating persons would lead to "John Smith" tags from random documents the family received. diff --git a/docs/design/brain/open-knowledge-format.md b/docs/design/brain/open-knowledge-format.md index ad6b009..9bf9fb6 100644 --- a/docs/design/brain/open-knowledge-format.md +++ b/docs/design/brain/open-knowledge-format.md @@ -2,7 +2,7 @@ > Status: Phase 1 landed (at-rest conformance) on branch `okf-conformance`; Phase 2 (exporter + conformance test) pending > Created: 2026-06-16 -> Author: Arthur + Claude +> Author: Homer + Claude > Depends on: [knowledge-structure.md](knowledge-structure.md) (vault shape), > [family-ontology.md](family-ontology.md) (entity pages), > [wiki-engine.md](wiki-engine.md) (derived wiki) diff --git a/docs/design/brain/phase-2-consolidation.md b/docs/design/brain/phase-2-consolidation.md index 2a5e094..b649892 100644 --- a/docs/design/brain/phase-2-consolidation.md +++ b/docs/design/brain/phase-2-consolidation.md @@ -80,17 +80,17 @@ mature problem. │ │ │ documents/ documents-domain reference data │ │ correspondents/ ours, hand-curated │ -│ adac.md canonical + aliases + facts │ -│ aok.md │ +│ duff-insurance.md canonical + aliases + facts │ +│ springfield-mutual.md │ │ │ │ raw/ ← archivist writes here │ │ 2026/05/ │ -│ 2026-05-15-adac-rechnung-p247.md classified document │ +│ 2026-05-15-duff-insurance-rechnung-p247.md classified document │ │ 2026-05-17-reddit-llms-a7b3c2.md capture (bookmark/note) │ │ ... │ │ │ │ wiki/ ← olw writes here │ -│ ADAC.md olw-generated concept page │ +│ Duff Insurance.md olw-generated concept page │ │ LLMs.md │ │ ... │ │ │ @@ -232,13 +232,13 @@ stacklet with one container. documents/ ← documents-domain reference data correspondents/ ← ours, OUTSIDE raw/ and wiki/ (see below) README.md - adac.md - aok.md + duff-insurance.md + springfield-mutual.md ... raw/ ← olw reads (recursive .md glob) 2026/05/... ← archivist writes here from bot-runner wiki/ ← olw writes (concept articles) - ADAC.md ← olw-generated concept page + Duff Insurance.md ← olw-generated concept page LLMs.md ... .olw/ ← olw state (sqlite, content hashes) @@ -266,15 +266,15 @@ the vault root or in other subtrees is invisible to it. Moving correspondents from `wiki/correspondents/` to `documents/correspondents/` keeps it out of olw's reach without needing an exclude feature. Obsidian's wikilink resolution is -name-based, not path-based, so `[[ADAC]]` still works as a -cross-reference between `documents/correspondents/adac.md` and the -olw-generated `wiki/ADAC.md`. +name-based, not path-based, so `[[Duff Insurance]]` still works as a +cross-reference between `documents/correspondents/duff-insurance.md` and the +olw-generated `wiki/Duff Insurance.md`. -Trade-off: two ADAC pages exist (`documents/correspondents/adac.md` -machine-readable + `wiki/ADAC.md` LLM-summarized). Different +Trade-off: two Duff Insurance pages exist (`documents/correspondents/duff-insurance.md` +machine-readable + `wiki/Duff Insurance.md` LLM-summarized). Different purposes, both discoverable in Obsidian's quick switcher. Optionally -link them explicitly: `> See also: [[ADAC]]` at the top of -`documents/correspondents/adac.md`. +link them explicitly: `> See also: [[Duff Insurance]]` at the top of +`documents/correspondents/duff-insurance.md`. **Two writers, one repo, coordinated via git:** @@ -324,7 +324,7 @@ keeps working on the host side, unchanged. Flat by date: ``` -raw/2026/05/2026-05-15-adac-rechnung-p247.md document +raw/2026/05/2026-05-15-duff-insurance-rechnung-p247.md document raw/2026/05/2026-05-17-reddit-llms-a7b3c2.md capture raw/2026/05/2026-05-17-pasted-note-d8e9f.md capture (note kind) ``` @@ -364,7 +364,7 @@ explicitly: merging across concept extractions. If capture-specific tag canonicalization is still needed after a month of usage, revisit. - **Per-person interest derivation.** Dataview queries against - `raw/` + `wiki/` answer "what is Arthur reading lately?" without + `raw/` + `wiki/` answer "what is Homer reading lately?" without new infrastructure. - **A second writer for the brain wiki** beyond olw (e.g., an archivist-driven wiki-update on every document). Single-writer keeps @@ -460,7 +460,7 @@ state. `stacklet.toml` declares the base image (`python:3.11-slim`), not olw itself. 3. **Hand-edit commits for files outside olw's reach.** When a user - edits `documents/correspondents/adac.md` or `ontology.toml` in + edits `documents/correspondents/duff-insurance.md` or `ontology.toml` in Obsidian, no automatic commit happens (archivist doesn't touch those paths, olw doesn't see them). For 0.3.0: users commit + push manually (Obsidian Git plugin or terminal). Follow-up: a host-side diff --git a/docs/design/brain/topic-rooms.md b/docs/design/brain/topic-rooms.md index ef5c27b..0cfc9fd 100644 --- a/docs/design/brain/topic-rooms.md +++ b/docs/design/brain/topic-rooms.md @@ -2,7 +2,7 @@ > Status: Design extension to [knowledge-architecture.md](knowledge-architecture.md) and [family-memory.md](family-memory.md) > Created: 2026-06-09 -> Author: Arthur + Claude +> Author: Homer + Claude > Depends on: memory vault structure ([family-memory.md](family-memory.md)), the `dev.famstack.capture` room state contract ([knowledge-structure.md](knowledge-structure.md) §Room configuration), the archivist's capture routing pipeline (`stacklets/docs/bot/capture_pipeline.py`). ## Why this exists @@ -19,23 +19,23 @@ A Matrix room whose name starts with `Thema:` or `Topic:` is a topic room. The a | Property | Guarantee | |---|---| -| **Routing** | Captures in `#Thema: Camping` always file under `family/camping/` (shared) or `arthur/camping/` (personal), regardless of the classifier's opinion. | +| **Routing** | Captures in `#Thema: Camping` always file under `family/camping/` (shared) or `homer/camping/` (personal), regardless of the classifier's opinion. | | **Tag invariant** | Every file in the topic folder has `camping` in its frontmatter `topics:`. The classifier may add tags; it can never remove this one. | | **Discoverability** | The room list is the topic list. Family members find topics by browsing joined rooms — no separate index UI. | 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 (`arthur//`). 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//` 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. ## Naming convention | Room name | Slug | Bucket path (shared) | Bucket path (personal) | Guaranteed tag | |---|---|---|---|---| -| `Thema: Camping` | `camping` | `family/camping/` | `arthur/camping/` | `camping` | -| `Topic: Photography` | `photography` | `family/photography/` | `arthur/photography/` | `photography` | -| `Thema: Van Life` | `van-life` | `family/van-life/` | `arthur/van-life/` | `van-life` | -| `Topic: 3D printing` | `3d-printing` | `family/3d-printing/` | `arthur/3d-printing/` | `3d-printing` | -| `Thema: Café Hopping` | `cafe-hopping` | `family/cafe-hopping/` | `arthur/cafe-hopping/` | `cafe-hopping` | +| `Thema: Camping` | `camping` | `family/camping/` | `homer/camping/` | `camping` | +| `Topic: Photography` | `photography` | `family/photography/` | `homer/photography/` | `photography` | +| `Thema: Van Life` | `van-life` | `family/van-life/` | `homer/van-life/` | `van-life` | +| `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//`.) @@ -44,7 +44,7 @@ Topic rooms compose with the existing capture pipeline. The classifier, mirror, - **Prefix recognition.** Match `^\s*(thema|topic)\s*:\s*` case-insensitively. Both languages always accepted, regardless of `[core] language`. - **Slug derivation.** NFD-normalize, strip combining marks (`Vélo` → `velo`, `Café` → `cafe`), lowercase, replace runs of non-alphanumeric with `-`, strip leading and trailing `-`. Maximum 40 characters; collisions at the tail receive a numeric suffix during bootstrap. - **Empty after prefix.** `Thema:` with no body fails the parser — not a topic room. The archivist treats it as a regular room. -- **Reserved slugs.** Refused at bootstrap: within-bucket reserved directory names. The mirror writes `notes/`, `bookmarks/`, `documents/` per bucket and the shared bucket also carries `correspondents/` and `_unfiled/`; the derived per-bucket landing page is `about.md`. The reserved set is therefore `{notes, bookmarks, documents, correspondents, _unfiled, about}`. Top-level vault names (`family`, `arthur`, `marge`, `meta`, `wiki`, `archive`) no longer need to be reserved because topics never live at the top level. +- **Reserved slugs.** Refused at bootstrap: within-bucket reserved directory names. The mirror writes `notes/`, `bookmarks/`, `documents/` per bucket and the shared bucket also carries `correspondents/` and `_unfiled/`; the derived per-bucket landing page is `about.md`. The reserved set is therefore `{notes, bookmarks, documents, correspondents, _unfiled, about}`. Top-level vault names (`family`, `homer`, `marge`, `meta`, `wiki`, `archive`) no longer need to be reserved because topics never live at the top level. ### Slug stability @@ -66,7 +66,7 @@ The archivist writes `dev.famstack.capture` room state on first detection and re "scope": "shared", "extract_knowledge": true, "bootstrapped_at": "2026-06-09T12:00:00Z", - "bootstrapped_by": "@arthur:home" + "bootstrapped_by": "@homer:home" } } ``` @@ -123,12 +123,12 @@ Re-joining the room or re-inviting the bot restores `status: active`. Archive de ### Personal → shared promotion -A personal topic (`arthur/camping/`) receives a second human member. The archivist: +A personal topic (`homer/camping/`) receives a second human member. The archivist: 1. Debounces ~10 seconds, in case the invite comes in a batch. 2. Verifies the new joiner is a human (a known Matrix user, not another bot). 3. Re-detects scope: `personal` → `shared`. -4. `git mv arthur/camping/ family/camping/` (full history preserved through the rename — bucket-to-bucket move within the vault). +4. `git mv homer/camping/ family/camping/` (full history preserved through the rename — bucket-to-bucket move within the vault). 5. Rewrites `bucket: family/camping` and `scope: shared` in the room state. 6. The wiki command regenerates `family/camping/about.md` on its next run with the new scope and participant list. 7. Posts: `📁 Topic promoted to shared. Files now live under family/camping/.` @@ -152,7 +152,7 @@ Reverse (shared → personal) is **not automatic**. Once shared, the topic stays |---|---| | Room created with multiple humans from the start | Bootstrap detects scope as `shared`. No personal phase. | | Slug collides with reserved name (`family`, `meta`, ...) | Refuse bootstrap. Post correction prompt: `Topic name conflicts with reserved name ''. Rename the room.` | -| Slug collides with existing personal bucket (`arthur` matches a household member) | Refuse bootstrap. | +| Slug collides with existing personal bucket (`homer` matches a household member) | Refuse bootstrap. | | Two archivists in the same room (multi-instance dev) | Both treat room state as truth. Whichever wrote it first wins. Re-bootstrap is a no-op. | | Room renamed to no longer match the prefix | Topic state stays. Folder, captures, and tag invariant continue working. The room becomes invisible as a topic in the room-list discovery surface; future captures still file under the original bucket. (Operator can run `stack memory topic archive` to finalize.) | | `Thema: Topic: Camping` (both prefixes) | Parser strips the outer prefix once. Display name becomes `Topic: Camping`. Slug derives normally. | @@ -179,7 +179,7 @@ memory.git │ │ └── YYYY/MM/... │ └── photography/ (another shared topic) │ └── ... -├── arthur/ (personal bucket) +├── homer/ (personal bucket) │ ├── notes/, bookmarks/, documents/, _unfiled/ │ └── gravel/ (personal topic, nested under personal bucket) │ ├── about.md @@ -192,7 +192,7 @@ Why this shape: - **One rule for topic placement:** topics live inside the bucket whose access scope they share. Personal topics under `/`; shared topics under `/`. No special case for the top level. - **Default searches naturally include shared topics.** A sender-scoped search returns `["family/", "/"]`. Files under `family/camping/...` are picked up automatically — a family member asking about camping in #documents finds the topic content without knowing the room exists. -- **Promotion (Step 5) is a bucket-to-bucket move.** `git mv arthur/camping/ family/camping/` — both sides of the move are inside an owning bucket. The top level never changes. +- **Promotion (Step 5) is a bucket-to-bucket move.** `git mv homer/camping/ family/camping/` — both sides of the move are inside an owning bucket. The top level never changes. ### about.md is a derived view @@ -204,7 +204,7 @@ type: topic slug: camping display_name: Camping scope: shared -participants: [arthur, sabrina] +participants: [homer, marge] captures: 47 last_capture: 2026-06-08 --- @@ -278,7 +278,7 @@ async def test_seed_deduplicates_when_classifier_repeats_it(): The retrieval test ("find anything we noted about camping") becomes a deterministic grep: ``` -grep -lr 'topics:.*camping' family/camping/ arthur/camping/ marge/camping/ +grep -lr 'topics:.*camping' family/camping/ homer/camping/ marge/camping/ ``` Plus the deriver's cross-reference index. Guaranteed coverage. @@ -287,7 +287,7 @@ Plus the deriver's cross-reference index. Guaranteed coverage. | Surface | Scope behavior | |---|---| -| `?` in `#Thema: Camping` | Search auto-scopes to `family/camping/` (or `arthur/camping/`) plus the topic's cross-references in `about.md`. | +| `?` in `#Thema: Camping` | Search auto-scopes to `family/camping/` (or `homer/camping/`) plus the topic's cross-references in `about.md`. | | `?` in `#documents` or `#assistant` | Default sender-scoped search (`family/`, `/`) — naturally includes shared topic content. | | `stack memory ask "..." --topic camping` | Explicit topic-scoped CLI search. | | `stack memory search "..." --topic camping` | Topic-scoped grep. | @@ -306,14 +306,14 @@ The deriver (forward reference: [knowledge-architecture.md](knowledge-architectu ## Cross-references -- [doc] 2026-04-15 ADAC camping-trailer policy → [[family/documents/2026/04/adac-camping-policy-p247]] +- [doc] 2026-04-15 Duff Insurance camping-trailer policy → [[family/documents/2026/04/duff-insurance-camping-policy-p247]] - [note] 2026-05-02 Marge's gear list comment → [[marge/notes/2026/05/gear-list-comment]] ``` -The source file never moves. The ADAC camping-trailer policy belongs in `family/` (it is a household insurance document); camping just receives the citation. Captures live in the bucket they were posted to; topics are views over the vault, not containers that own data. +The source file never moves. The Duff Insurance camping-trailer policy belongs in `family/` (it is a household insurance document); camping just receives the citation. Captures live in the bucket they were posted to; topics are views over the vault, not containers that own data. -The cross-reference pass is deterministic — a grep for `topics:.*` across the vault, excluding the topic's own bucket (no point citing yourself). For shared topic `family/camping/`, that means scanning `family/documents/`, `family/correspondents/`, every personal bucket, and every *other* shared topic under `family/`. The intra-`family/` scan is what surfaces an ADAC camping-trailer addendum filed in #documents. No LLM call required. (An LLM pass may augment the cross-reference entry with a one-line summary of *why* the source is relevant; deferred to the deriver work.) +The cross-reference pass is deterministic — a grep for `topics:.*` across the vault, excluding the topic's own bucket (no point citing yourself). For shared topic `family/camping/`, that means scanning `family/documents/`, `family/correspondents/`, every personal bucket, and every *other* shared topic under `family/`. The intra-`family/` scan is what surfaces an Duff Insurance camping-trailer addendum filed in #documents. No LLM call required. (An LLM pass may augment the cross-reference entry with a one-line summary of *why* the source is relevant; deferred to the deriver work.) ## CLI surface @@ -352,14 +352,14 @@ Six implementation steps, each independently shippable. Steps 1-4 are shipped on 3. ✅ **Archivist lazy bootstrap.** `_topic_binding` reads existing room state or parses the room name and writes state inline. Bootstrap is idempotent and best-effort. Wired into all four capture entry points. 4. ✅ **In-room query scoping.** `SearchService` accepts `topic_bucket`; the archivist passes the binding's bucket on `?` queries. Default sender-scoped search still picks up shared topic content naturally because shared topics nest under `/`. 5. ⏳ **Wiki-command extension** (replaces the pre-bootstrap scaffold idea). Generate `//about.md` from captures + room state, the same rebuild path that already builds `family/about.md`. -6. ⏳ **Promotion handler.** Member-count watcher, debounce, `git mv arthur// family//`, room-state rewrite, message. +6. ⏳ **Promotion handler.** Member-count watcher, debounce, `git mv homer// family//`, room-state rewrite, message. 7. ⏳ **Deriver cross-reference pass.** Grep-based scan, append to `about.md`. Deferred until the deriver bot exists (post-v0.3). ## Open questions 1. **Topic-aware ontology.** Should the topic slug auto-register as a topic in `stacklets/memory/seeds/ontology.toml`, or stay as a free-form tag? Registering gets language-aware synonyms (`camping` aligned with `Camping` for queries); not registering keeps the seed flat and avoids ontology churn. Default for v1: stay free-form, leave promotion to ontology as an explicit operator step. 2. **Document-room overlap.** What if a user creates `Thema: Versicherungen` and drops actual insurance documents there? They still go to Paperless (the docs pipeline), but the mirror lands in the topic folder, not in `family/`. The existing capture pipeline already supports this for entity buckets; topic buckets reuse the same path. Worth a test pin. -3. **Bot accounts as humans for scope detection.** A topic room with `scribe-bot` plus `archivist-bot` plus Arthur has one human. The scope-detector must filter known bot users. Implementation reads from `users.toml` or the equivalent registry. +3. **Bot accounts as humans for scope detection.** A topic room with `scribe-bot` plus `archivist-bot` plus Homer has one human. The scope-detector must filter known bot users. Implementation reads from `users.toml` or the equivalent registry. ## Future direction: story rooms @@ -367,7 +367,7 @@ This section captures a planned extension agreed in the 2026-06-09 design sessio ### Why stories are a peer concept, not a child -Topics are open-ended (camping as an ongoing interest; cooking as a shared household habit; gravel cycling as Arthur's hobby). Stories are bounded — they have a beginning and an end. The 2027 Italy trip, the bathroom renovation, Marge's 40th birthday party. Conflating them was the earlier mistake: a hobby grows for decades, a trip wraps up in two weeks. The about.md prompt, the lifecycle, and the "find this again later" intent differ enough that one model serves both badly. +Topics are open-ended (camping as an ongoing interest; cooking as a shared household habit; gravel cycling as Homer's hobby). Stories are bounded — they have a beginning and an end. The 2027 Italy trip, the bathroom renovation, Marge's 40th birthday party. Conflating them was the earlier mistake: a hobby grows for decades, a trip wraps up in two weeks. The about.md prompt, the lifecycle, and the "find this again later" intent differ enough that one model serves both badly. Stories live as **peers of topics**, not children. A trip does not nest inside the camping topic. Instead, the story declares which topics it draws from, and the topic page picks up the story's captures via the existing cross-reference grep. This keeps each layer's purpose clean. @@ -399,10 +399,10 @@ Three candidates. Pick before implementation; the parser branches on whichever l "status": "planning", "starts": "2027-07-15", "ends": "2027-07-29", - "participants": ["arthur", "marge"], + "participants": ["homer", "marge"], "extract_knowledge": true, "bootstrapped_at": "2026-06-09T12:00:00Z", - "bootstrapped_by": "@arthur:home" + "bootstrapped_by": "@homer:home" } } ``` @@ -468,7 +468,7 @@ This section captures another planned extension agreed in the 2026-06-09 design ### The problem -Today's classifier extracts persons, dates, topics, and free-form tags per capture. It does *not* learn that "our BMW 320d" is central to this household's camping topic, or that "Thule roof box," "der Thule," and "the rooftop carrier" are three names for the same physical object. Asking "how did we pack the trunk for camping?" relies on tag-level keyword matching; it has no notion of which entities are central to *this* household's camping life. +Today's classifier extracts persons, dates, topics, and free-form tags per capture. It does *not* learn that "our Canyonero" is central to this household's camping topic, or that "Thule roof box," "der Thule," and "the rooftop carrier" are three names for the same physical object. Asking "how did we pack the trunk for camping?" relies on tag-level keyword matching; it has no notion of which entities are central to *this* household's camping life. The fix is a per-topic entity graph the system builds from its own captures, not from a generic vocabulary. @@ -476,7 +476,7 @@ The fix is a per-topic entity graph the system builds from its own captures, not The existing ontology (`stacklets/memory/seeds/ontology.toml`) is generic: universal categories like `insurance`, `vehicle`, `medical`. It ships with the install and applies to every household. -A personal entity graph is the inverse: household-specific things, people, places, products, locations that recur in this household's captures. The BMW 320d, the Karwendel campsite, the Vaude tent, Marge's mosquito allergy. None of these belong in a shipped ontology. They emerge from filings. +A personal entity graph is the inverse: household-specific things, people, places, products, locations that recur in this household's captures. The Canyonero, the Karwendel campsite, the Vaude tent, Marge's mosquito allergy. None of these belong in a shipped ontology. They emerge from filings. The two layers sit alongside each other: @@ -492,10 +492,10 @@ A per-topic registry of entities with aliases and co-occurrence weights: ```toml # family/camping/entities.toml — derived, regenerated by the deriver -[entity.bmw-320d] -display = "BMW 320d" +[entity.canyonero] +display = "Canyonero" kind = "vehicle" -aliases = ["BMW", "der 320er", "das Auto"] +aliases = ["Canyonero", "der Canyonero", "das Auto"] first_seen = 2024-03-12 last_seen = 2026-06-08 captures = 14 @@ -506,7 +506,7 @@ display = "Thule Roof Box" kind = "gear" aliases = ["Thule", "Dachbox", "rooftop carrier"] captures = 9 -co_occurs = ["bmw-320d"] +co_occurs = ["canyonero"] [entity.karwendel-campsite] display = "Karwendel Campsite" @@ -534,18 +534,18 @@ Four downstream consumers, each pulling on a different angle of the registry: | Surface | Use | |---|---| -| **Topic about.md** | A `## Key entities` section lists the highest-weighted entities with one-line context: "BMW 320d (the family car, 14 camping captures), Thule roof box (9), Karwendel campsite (6 visits)." Grows with the topic. | -| **Topic cross-references** | When an entity registered to a topic appears in a capture in another bucket (the BMW 320d shows up in `family/documents/2026-04-15-adac-policy.md`), the topic's cross-reference grep includes it even when no topic tag was applied. The entity *is* the bridge. | -| **Query expansion** | A `?` query in the topic room rewrites "how did we pack the trunk" to include "BMW 320d AND Thule roof box AND camping" for richer recall. Same shape `Classifier.synthesize_answer` already uses for keyword expansion. | -| **LLM context for synthesis** | The top entities feed the synthesis prompt as ambient context: "When this household says 'the car' under camping, they mean their BMW 320d. When they say 'the box,' they usually mean the Thule." Removes ambiguity in the answer without the user having to specify. | +| **Topic about.md** | A `## Key entities` section lists the highest-weighted entities with one-line context: "Canyonero (the family car, 14 camping captures), Thule roof box (9), Karwendel campsite (6 visits)." Grows with the topic. | +| **Topic cross-references** | When an entity registered to a topic appears in a capture in another bucket (the Canyonero shows up in `family/documents/2026-04-15-duff-insurance-policy.md`), the topic's cross-reference grep includes it even when no topic tag was applied. The entity *is* the bridge. | +| **Query expansion** | A `?` query in the topic room rewrites "how did we pack the trunk" to include "Canyonero AND Thule roof box AND camping" for richer recall. Same shape `Classifier.synthesize_answer` already uses for keyword expansion. | +| **LLM context for synthesis** | The top entities feed the synthesis prompt as ambient context: "When this household says 'the car' under camping, they mean their Canyonero. When they say 'the box,' they usually mean the Thule." Removes ambiguity in the answer without the user having to specify. | ### Propagation to the parent topic Entities are local to the topic that learned them, but propagate one level up via `parent_topics` (the same mechanism the story design uses): -- `family/camping/entities.toml` knows the BMW is central to camping -- The parent topic (`family/reisen/` if Reisen is the parent of Camping) inherits the BMW as a candidate, ranked lower until its own captures reinforce it -- A future capture in Reisen that says "the car" can resolve to BMW 320d because the parent topic's candidate registry already carries the alias +- `family/camping/entities.toml` knows the Canyonero is central to camping +- The parent topic (`family/reisen/` if Reisen is the parent of Camping) inherits the Canyonero as a candidate, ranked lower until its own captures reinforce it +- A future capture in Reisen that says "the car" can resolve to Canyonero because the parent topic's candidate registry already carries the alias Propagation is structural (parent-child by `parent_topics`), not heuristic. No cross-bucket leakage; a personal topic's registry never reaches another personal bucket. @@ -570,7 +570,7 @@ Bigger than the story extension because the LLM pass is per-capture, the persist ## Status of this document -This is the design Arthur and Claude agreed to in the 2026-06-09 session. Code follows it. If implementation drifts from the design in non-trivial ways, update this document before shipping the drift — [family-memory.md](family-memory.md) is the descriptive doc for what is running; this is the prescriptive one for topic rooms. +This is the design Homer and Claude agreed to in the 2026-06-09 session. Code follows it. If implementation drifts from the design in non-trivial ways, update this document before shipping the drift — [family-memory.md](family-memory.md) is the descriptive doc for what is running; this is the prescriptive one for topic rooms. ## Related diff --git a/docs/stack-reference.md b/docs/stack-reference.md index 1f75674..a416d0a 100644 --- a/docs/stack-reference.md +++ b/docs/stack-reference.md @@ -838,9 +838,9 @@ password) live in `secrets.toml`. Never in the same file. ```toml [[users]] -id = "arthur" -name = "Arthur" -email = "arthur@home.local" +id = "homer" +name = "Homer" +email = "homer@home.local" role = "admin" [[users]] diff --git a/docs/user-guide.md b/docs/user-guide.md index 8cd0768..a8d02d5 100644 --- a/docs/user-guide.md +++ b/docs/user-guide.md @@ -141,7 +141,7 @@ Two ways to ask: - In **#Documents**, just type. Search is the default there. - In **any other room**, @-mention the bot: `@archivist when does the car insurance renew?` -Plain words work as search ("ADAC", "vaccination Bart"). Questions get real answers: the archivist searches the document archive and the family knowledge vault, reads what it finds, and answers with numbered sources you can check: +Plain words work as search ("Duff Insurance", "vaccination Bart"). Questions get real answers: the archivist searches the document archive and the family knowledge vault, reads what it finds, and answers with numbered sources you can check: > The car insurance renews on March 1, 2026 [1]. The premium is EUR 340/year [1][2]. From cffbb758ef9f150f83a2336740779a556445a613 Mon Sep 17 00:00:00 2001 From: Arthur Date: Sat, 20 Jun 2026 09:43:31 +0200 Subject: [PATCH 21/21] docs(agent): check branch state vs origin/main before major work A branch can be older than it looks - main advances and a local main can be stale. Fetch and compare against origin/main before substantial work, so the branch doesn't silently diverge into PR conflicts. --- docs/agent/dev.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/agent/dev.md b/docs/agent/dev.md index 1dd977a..03034e9 100644 --- a/docs/agent/dev.md +++ b/docs/agent/dev.md @@ -201,6 +201,7 @@ Testing rules: ## Commit & branch rules +- **Check branch state before major work.** `git fetch origin` and see how the working branch relates to `origin/main` (`git log --oneline origin/main..HEAD` and `HEAD..origin/main`) before starting anything substantial, new branch or existing one alike. The branch may be older than you think: `main` advances, and a local `main` can itself be stale. Branch off (or rebase/merge onto) the latest `origin/main`; a branch left behind silently diverges and lands the PR in merge conflicts. - **Feature branches only.** Never commit to `main`. - **Semantic prefix required:** `feat:`, `fix:`, `docs:`, `refactor:`, `chore:`, `test:`, `ci:`, `style:`. - **Message style:** short, end-user POV, present tense. *What changed and why a user cares* - not the internal refactor narrative.