Pablan, as it stands

Self-hosted knowledge management for SMEs: a split-screen Markdown editor
whose sections an LLM refines while you write, and RAG question answering
over the documents that result. FastAPI + Postgres/pgvector on the back,
SvelteKit on the front, everything OpenAI-compatible and self-hostable.

Squashed into a single commit; the development history stays local.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CA43ZJda8Rbp2hKXNy8f6b
This commit is contained in:
ProfessorNova
2026-09-04 08:36:17 +02:00
co-authored by Claude Opus 5
commit 97dbff309c
346 changed files with 43430 additions and 0 deletions
+322
View File
@@ -0,0 +1,322 @@
# Implementation notes
Durable engineering learnings — decisions, calibrations and gotchas that are
still true of the system as it stands. Grouped by theme rather than by when they
were learned. The [roadmap](roadmap.md) says *what* exists; this says *why it is
the way it is* and *what will bite you*. For formal decisions see
[decisions.md](decisions.md); for the as-is contract see
[architecture.md](architecture.md) and [data-model.md](data-model.md).
## Retrieval & embeddings
- **One permission filter, evaluated live.** `rag/permissions.py` is the single
source both the API and retrieval use, and it runs against the current tables —
never against the denormalized visibility/department copy stored in chunk meta
(that copy is display-only and can lag a reindex). This is what makes "a chunk the
user may not read is structurally unreachable" true rather than aspirational.
- **Authors vs retrieval.** An author sees their own drafts through the API
(`readable_documents_filter`), but retrieval is published-only
(`searchable_documents_filter`), so a draft never reaches an LLM prompt. Admins
get **no** read-everything bypass — an admin only sees what the normal rules grant.
- **Hybrid search is one SQL statement.** pgvector top-20 + German FTS top-20 fused
with Reciprocal Rank Fusion, `RRF_K=60`. Keeping it one statement is what lets the
permission CTE gate everything before ranking.
- **Thresholds are calibrated against bge-m3 on the fixture corpus, and must be
re-measured if the embedding model changes.** No-answer fires at vector distance
≥ 0.45 (matched hits land ~0.340.45, unrelated ~0.50+). The similarity path
(`similar_chunks`) uses `max_distance` 0.45 for capture-context.
- **A chunk is embedded WITH its heading path** (`rag/indexing.embedding_text`).
A section reads "Solldruck 180 bar" and never repeats which machine it belongs
to, so on its own it is unreachable by the name the asker uses. Measured on the
dev corpus: "Welcher Solldruck gilt für die Hydraulikpresse?" went from a
no-answer (best distance 0.492, above the 0.45 gate) to a confident hit (0.375,
and the section that literally answers it at rank 2). The genuine no-answers
moved too little to matter (Deutschlandticket 0.503 → 0.488), so the gate keeps
separating them. What is STORED as `content` stays the raw section: the path is
context for the vector, not part of the document, and the excerpt a reader sees
must not grow a header. Tests that search for "the exact chunk text" have to
build it through `embedding_text` or they land at distance ~0.96.
- **The chunk `tsv` covers the heading path too** (generated column over
`content || ' ' || meta->>'heading_path'`). Same reason on the keyword side:
"Solldruck Hydraulikpresse" matched no chunk before, because only the document
title carries the machine name. Note what it does NOT fix: `websearch_to_tsquery`
is AND over every surviving lexeme, and German stopword lists keep verbs, so
"Welcher Solldruck **gilt** für die Hydraulikpresse?" still has no keyword match.
Treat `fts_match` as "the keywords really are in there", not as "this was a
keyword question".
- **Calibrate on what the engine renders, not on what a human writes.** Real capture
drafts are compressed notes and sit further from their source than a hand-written
paraphrase, so thresholds tuned on paraphrases miss real matches.
- **`rag/similarity` is deliberately not the hybrid path.** RRF produces a fusion
rank, not a similarity, and its vector distance is null for FTS-only hits — a
threshold needs a comparable number, so similarity is a pure-cosine query with the
threshold applied in Python *after* `ORDER BY … LIMIT` (a distance predicate in
WHERE fights the HNSW index).
- **HNSW post-filtering can under-fill.** For a heavily permission-filtered user, the
fixed candidate fetch can return fewer rows than expected; keep candidate counts
generous.
- **Hybrid retrieval is robust to statement-vs-question phrasing; the pure-cosine path
is not.** A declarative statement ("Die Zentralschmierung … muss aufgefüllt werden.")
retrieves the same top document as the equivalent question, because the German FTS
component matches the keywords regardless of phrasing — guarded by declarative-
statement cases in `golden_queries.yaml`. The pure-cosine similarity path
(`similar_documents`/`similar_chunks`, used by the capture picker and refinement
grounding) has no FTS to lean on and is therefore more sensitive to how a thought is
phrased. It stays pure-cosine on purpose (a comparable distance for its threshold),
so a phrasing-sensitive picker is a known trade-off, not a bug.
## Permissions & auth
- **FK delete semantics encode the product.** Authored documents survive their
author (`ON DELETE SET NULL`) — "an employee leaving is exactly the case the
product exists for". The same holds for a document's department.
- **The auth boundary is a full-page navigation.** Login and logout do a real
document load (`window.location.assign`), never a client `goto`. Module-level runes
singletons (conversation list, chat state, resolved locale) live for one page load,
so reloading at the boundary guarantees the next user starts clean. A client
navigation kept the previous user's conversation titles on screen — an information
disclosure. Logout also clears the `PARAGLIDE_LOCALE` cookie so a shared terminal
does not pin one user's language for the next.
- **A private helper imported across modules is a design smell, not a shortcut.**
Four of them had grown (`_readable_document`, `_role_config`, `_excerpt`,
`_HEADING_RE`): each marked a place where one module had quietly become part of
another's interface without saying so. Splitting the big API modules surfaced
all four at once. When a second module wants an underscore name, that name is
the interface and should be renamed, not imported.
- **A test double that patches by module name silently rots.** The fake-embedding
fixture patched `indexing` and `retrieval`; the moment the similarity path moved
into its own module, an "offline" test would have called a real endpoint. A
fixture that names the modules it patches has to be updated with every split, so
it says why in a comment right there.
- **A history snapshot is written AFTER its event.** So the content stored on an
`edited` event is the state that edit produced, not the one it replaced. Diffing
a snapshot against the live document therefore shows the change of the NEXT
entry, and the newest entry shows nothing at all. What a timeline entry has to
answer is "what did this one do", so the version endpoint returns the pair
(`content_md` + `previous_content_md`) and the dialog diffs those.
- **The product degrades to a search engine, not to an error page.** Postgres is
already an inverted index (`chunks.tsv`, a stored `to_tsvector('german', …)`
column with a GIN index), so "no model reachable" costs the generated answer and
the semantic half of retrieval, nothing else. The turn ends with the full-text
hits as a list, marked as a search without a model, and is persisted like a
normal reply so a reload replays it. Worth remembering when adding any other
model-dependent surface: ask what remains without the model.
- **Classify an endpoint failure once, in `LLMError.code`.** "Not reachable" and
"busy, try again" need different words to the user, and a timeout means the
second, not the first: the endpoint took the request and never came back.
Retrieval embeds before the model is ever called, so the failure often happens
in `search()`, not in `chat_stream` — the conversations router catches
`LLMError` centrally rather than every mode remembering to.
- **Enumeration-resistant login.** An unknown email still runs a constant-time dummy
verify, so timing does not reveal which accounts exist.
- **Admin CRUD guardrails.** An admin cannot delete themselves or change their own
role (409 `self_modification`); a password change or reset revokes all of that
user's other sessions.
- **Sharing is grants, not new permission logic.** Multi-department sharing plugs
straight into the `EXISTS doc_permission` branch that was already in
`searchable_documents_filter``PUT /documents/{id}/departments` just manages the
rows. No reindex: grants are evaluated live against the table, not denormalized into
chunk meta. Any per-department count therefore measures REACH, not a partition:
a shared document counts under every department it reaches, so such counts can
exceed the distinct document count.
- **Self-lockout only bites admins.** `readable_documents_filter` grants the author
read access unconditionally, so a normal author can never edit themselves out of
their own document; the guard (`_guard_self_lockout`, a Python mirror of the read
rules) only ever fires for an admin changing a document they do not own, who must
resend with `confirm_lockout`. When mirroring a SQL filter in Python for a
pre-commit check, keep the two in the same file so they cannot drift.
- **A `bool = False` Pydantic field serializes as *required* in the generated TS
client** (openapi-typescript), which breaks every existing caller that omitted it.
Use `bool | None = None` (like the other optional fields) and `bool(...)` at the use
site — then it stays optional in `schema.d.ts`.
- **The audit trail is where "who checked it" lives.** `document_events`
(`authoring/history.py::record_event`) appends who-did-what at every transition;
`review_resolved` writes the answering colleague as the event actor — once a
request is answered, the `review_requests` row only says that nothing is open
any more, so the trail is the only place that identity survives. Content-bearing events
(`created`, `edited`) snapshot the Markdown, never the chunks, so per-event
snapshots of the *resulting* state chain into a version diff. Gotcha: a freshly
created document must be `flush()`ed before recording its `created` event — the
UUID PK default lands at flush, not at object construction, so `document.id` is
None until then. History is served through the document's own read gate, so it
cannot leak to a user who may not read the document.
## LLM & prompts
- **`chat_template_kwargs.enable_thinking = false` is the big latency win.** On a
reasoning-capable local model it drops section refinement from ~10s to ~1s with no
quality loss, because refinement is a mechanical rewrite. Passed through
`extra_body`; ignored by endpoints that do not support it.
- **Prompt shape for caching.** Keep the system prompt byte-identical and the history
stable, and put the volatile part (retrieval excerpts, the grounding block) last —
the endpoint then reuses the cached prefix and only reprocesses what changed.
- **Content never reaches logs.** `LLMError` is raised `from None` so a model
response cannot ride out in a chained traceback; SQL bind params are stripped; the
openai SDK's DEBUG body-logging is capped unless `PABLAN_DEBUG_LOG_PROMPTS` is set
(never in production). This is canary-tested.
- **One SDK-level retry** covers the occasional llama.cpp `APIConnectionError`;
`chat_json` additionally validates and retries once on a schema mismatch.
- **Prompts are admin-editable, defaulting to code.** Every system prompt renders
through `app/prompts/overrides.py::get_prompt(key)`, which returns a DB override
(`prompt_settings`) or the code default (`app/prompts/defaults.py`). This mirrors the
LLM-settings cache but is simpler: the reset target is the code default (prompts have
no `.env`), and there is no bootstrap — a missing row just means "use the default".
Keep the module-cache clear in the test teardown, or an override leaks into the next
test. Prompt-cache byte-identity (D22) still holds because a value changes only on an
admin write.
- **The context inspector needs the FULL retrieval, so a no-answer keeps its results.**
Query mode used to discard the low-confidence hits (`results = []`); now it keeps them,
sends them in the `sources` event marked `used=False`, and passes only `used` ones to
the prompt. That is what lets the "?" inspector explain a no-answer ("these were close
but too weak") instead of showing nothing. The cited-source badges filter to `used`.
- **The shipped system prompt was picked by measurement, not by taste.** A bench
over 15 objective checks (grounding phase, the load-bearing fact, an honest
"not documented", answer language) on the dev corpus: the long rules-list
version scored 42/45 at 486 chars mean answer, a four-line version 45/45 at 279
chars. Everything the list spelled out, the model already did; what it was
missing was the one instruction that carries its weight — *say when something
is not documented* — without which the three no-answer cases all produced
plausible filler. Shorter prompt, shorter answers, better score. The bench lives
in the session scratch, not in the repo: it needs a seeded instance and a live
endpoint, which is what `make eval` is for — port a case there when it earns a
permanent floor.
- **Asking about the product is a retrieval question, not a special case.** The
`help/` pages are indexed like anything else, so "how do I share a document?"
is answered through the same path — and `tests/evals/test_self_knowledge_eval.py`
measures it (10/10, and asserts the help pages do NOT outrank company documents
for a company question, which is the failure mode that would matter).
- **Cloud validation is still owed.** Every shipped prompt (query, refinement,
grounding, topic summary, title) has only run against a local Gemma-class model.
The first cloud-model eval pass is roadmap epic 15 — expect to fix behaviour a 26B
model tolerated.
## Jobs & runtime
- **The claim transaction stays open for the whole handler.** Claiming a job
(`FOR UPDATE SKIP LOCKED`) and completing it happen in one transaction, so a crash
mid-handler rolls the claim back and the job is retried — at the cost of one pinned
connection per in-flight job.
- **Backoff** is 30s·2^n over 5 attempts; failure bookkeeping runs in its own
transaction. Retention cleanup reschedules itself daily.
- **The endpoint gate is per base_url, not per role** (`llm/gate.py`). chat and
utility point at the same server in the shipped config, so a per-role limit
would let one server take twice its slots. The waiting is bounded twice (a wait
timeout and a queue-length cap) because an unbounded wait in front of a
saturated endpoint is the same failure as no gate at all, just quieter. Both
refusals reuse `llm_busy`, so the UI has one sentence rather than two. The
background job worker is strictly sequential, so a big reindex contributes ONE
concurrent embedding call, not a burst.
- **Exactly one uvicorn worker.** The job loop, the metrics registry and the LLM
client cache are per-process; a second worker would double-run the queue and split
the metrics. The compose file pins `--workers 1`.
## Frontend & i18n
- **Citations are snapshotted into `messages.meta`.** Chunks are disposable and get
reindexed, so an answer freezes the exact passages it cited at answer time; the
excerpt is re-cleaned to plain prose on the way out (cleaning is idempotent).
- **SSE via fetch + ReadableStream, not `EventSource`.** The turn is a POST that must
be abortable (the stop button), which `EventSource` cannot do. A client abort still
persists the partial assistant message (`asyncio.shield` on the backend).
- **Theme per device, language per account, both applied pre-paint.** Theme lives in
localStorage and is stamped onto `<html>` by a boot script before first paint;
language comes from `users.locale` and is baked into the first SSR byte — otherwise
the page flashes the wrong theme/language on every load.
- **Paraglide pitfalls.** Source is **de** (informal "du"), en written in the same
change; two lint rules fail the build (a missing `en` key, an em/en dash). There is
no `url` locale strategy (locale is not in the path). `Intl` formatters must be
passed the resolved locale explicitly or they default to the server's. ICU plurals
have their own syntax trap. Recompiling messages under a running dev server can
wedge it — restart after adding keys.
- **`hooks.server.ts` needs an absolute backend URL.** A relative `event.fetch` never
reaches the vite proxy, which silently breaks server-side auth calls.
- **The chat renders Markdown LIVE while streaming.** Rendering plain-text tokens and
then re-rendering to Markdown on completion read as a snap; the turn now goes through
the sanitizing `Markdown` component every token, so the formatted output is unveiled
progressively. Partial constructs (an unclosed `**` or `$`) render as literal text
until closed — a small local flicker, far better than the whole-reply snap.
- **The local model emits real LaTeX**, inline `$...$` and display `$$...$$` with
`\frac`, `\sqrt`, superscripts. `$lib/markdown.ts` runs `marked-katex-extension` +
KaTeX BEFORE DOMPurify, and the KaTeX CSS/fonts are bundled locally (imported in the
root layout — no CDN, the artifacts self-host). DOMPurify keeps `<span class style>`
by default, so the typeset math survives sanitizing; `throwOnError: false` degrades a
malformed formula to text instead of throwing mid-answer.
- **Prefer `SvelteSet` over a plain `Set`** for reactive membership state — eslint
(`svelte/prefer-svelte-reactivity`) enforces it, and mutating it (`add`/`delete`)
is reactive without reassigning.
- **The refine suggestion is an inline CodeMirror block widget, not a side pane.**
It appears right under the section being edited (`WritingEditor.svelte`): a
`WidgetType` wrapping a persistent DOM element (built once, `eq()` compares by
element identity so CodeMirror reuses it) placed by a `StateField` +
`StateEffect`. Tokens stream by mutating that DOM directly; CodeMirror does not
learn a widget's height changed on its own, so call `view.requestMeasure()`
(rAF-throttled) after each update or the lines below overlap. `ignoreEvent()`
returns true so the accept/dismiss buttons handle their own clicks, and their
`mousedown` preventDefault keeps the editor from blurring first.
- **The editor is not continuously autosaved** (that would empty the
diff you are shown before saving). A `beforeNavigate` guard instead discards an
abandoned, never-filled template draft (only headings/blank lines) and saves an
edited-but-unsaved draft on the way out — fixing both the data-loss trap and the
empty-draft clutter without touching the save flow. Skip it once the document
was published from the editor, or it would re-save over the publish.
- **Seeded documents are built through the real process, not inserted finished**
(`app/seed.py`). Each one gets the history it would have had: the empty draft,
one `edited` snapshot per section as the author works down the page, the
publish, and the questions colleagues asked afterwards — plus a few documents
left as drafts and one archived, so every screen has something to show. Without
it the history view, the version diffs and "recently changed" are empty or lie
in every dev stack. Order the corpus deliberately (the overdue document has to
be an old one, drafts have to be the newest), and give `document_events` and
`review_requests` explicit timestamps: `server_default=now()` would stamp the
whole life of a document at seed time.
- **Bits UI keeps inactive tab panels mounted**, just hidden. On a page of
independent panels (`/admin`) that means all four fetch their data on
arrival, and controls nobody can see sit in the DOM where a click never
reaches them (Playwright finds the element, then times out waiting for it to
be visible). `lib/components/Tabs.svelte` renders only the open panel.
- **A grant that ends needs its own access reason.** Answering a review request
on someone else's draft takes the access away again — correct, and it dropped
the reviewer on a 404 for doing what they were asked. `AccessReason.review`
names the case ("the only thing letting you in is the request"), so the UI can
say thank you instead. It mirrors `readable_documents_filter`, where the
visibility rules only apply to a PUBLISHED document: on a draft, `public`
never explains access, the request does.
- **The classic-search fallback ORs its terms; the hybrid path ANDs them**
(`rag/retrieval._any_term_tsquery`). `websearch_to_tsquery` builds an AND over
every lexeme, which is fine when the vector half carries the recall — but alone
it answers a typed-out question ("Wie läuft die Qualitätsprüfung im
Wareneingang?") with nothing at all, because no single chunk contains every
word. Rewriting the operators between the groups (`' & '``' | '` on the
tsquery text) keeps quoted phrases and exclusions intact and leaves the ranking
to `ts_rank_cd`. `NULLIF` the result: a query of nothing but stop words
rewrites to an empty string, and `to_tsquery('')` is a syntax error rather than
an empty match.
- **The fallback needs chunks, so it needs a past embedding run.** `chunks.tsv`
is generated from `chunks.content`, and a chunk row is only written when
`reindex_document` succeeded — which calls the embedding endpoint. A document
published while the embedding endpoint is down has no chunks at all, so the
"classic search" fallback cannot find it either. It keeps an already-indexed
knowledge base usable without a model; it does not index without one.
- **The e2e suite pins English with Accept-Language, which an ACCOUNT language
outranks** — that is the product rule (a language a person picked follows
them to every device), so a dev stack where somebody switched the admin to
German failed every spec that reads a label. The login helper hands the
account back to "follow the browser"; specs that run as that user select
tabs by testid rather than by label.
- **`npm run check` compiles Paraglide with the CLI**, which used to write the
DEFAULT strategy chain over the one `vite.config.ts` configures — leaving the
dev server (and the e2e suite, which pins `Accept-Language`) resolving German
for everyone until the next vite restart. The `messages` script now passes the
same `--strategy` flags. The other half of the trap stands: recompiling while
`vite dev` runs breaks its module graph until restart, which is why `make lint`
skips the recompile when :5173 is listening.
## History
A dialogue-driven "interview" capture engine (a state machine that interviewed the
employee and assembled a document from the answers) was built first, then removed
and replaced by the writing-first editor that exists today. Nothing from it remains
in the code path; the roadmap describes building the current system directly. The
only ideas that carried across are generic: template import/upsert by config id, and
the `pyyaml` dependency.