Files
pablan/docs/architecture.md
T
ProfessorNovaandClaude Opus 5 97dbff309c 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
2026-09-04 08:36:17 +02:00

466 lines
27 KiB
Markdown

# Architecture
Pablan is a self-hosted knowledge management application for SMEs. Employees
capture knowledge by **writing Markdown directly** into a document in an
editor that matures the section at the cursor with the LLM (a writing-first
flow, not a chatbot interview — D19); the results are Markdown documents. It
answers questions about the knowledge base via RAG, and (as a paid feature)
generates aggregated insights for management.
Core pain points addressed: knowledge leaves the company when employees do
("tacit knowledge" is never written down), and departments solve problems in
isolation without learning from each other.
## Deployment model (Variant A — the pragmatic monolith)
Everything runs on the customer's infrastructure via Docker Compose:
```
[Browser] ⇄ [Reverse proxy :443]
├── /api/* → backend (FastAPI, :8000)
└── /* → frontend (SvelteKit node adapter, :3000)
[backend] ⇄ [PostgreSQL + pgvector]
[backend] ⇄ [LLM endpoints] (OpenAI-compatible: llama.cpp local OR cloud API)
```
- **One origin.** The reverse proxy makes frontend and API same-origin: no
CORS, httpOnly session cookies just work, SSE streams flow directly from
FastAPI to the browser without passing through Node.
- **One database.** PostgreSQL holds relational data, documents, embeddings
(pgvector), full-text indexes, background jobs, and auth sessions. No
dedicated vector DB, no Redis, no message broker.
- **Reverse proxy** is a reference config in `deploy/`, not part of the app.
Caddy is the supported customer setup (5-line config, automatic TLS).
Component overview (built vs. planned):
[`diagrams/components.svg`](diagrams/components.svg)
## Backend structure (`backend/app/`)
| Module | Responsibility |
|---|---|
| `models/` | SQLAlchemy 2.0 models — see `data-model.md` |
| `auth/` | Server-side sessions, argon2, permission dependencies |
| `api/` | Thin HTTP routers; no business logic. The big ones (`documents/`, `admin/`, `conversations/`, `authoring/`, `templates/`) are packages split by what a caller is doing |
| `modes/` | Mode engine: query (+ ee: insights) |
| `authoring/` | Capture: authoring-template schema + document/section refinement for the writing editor |
| `llm/` | The ONLY code speaking to LLM endpoints |
| `rag/` | Chunking, indexing, hybrid retrieval with permission filter |
| `ingestion/` | Background jobs via a Postgres `jobs` table |
| `log.py` | Structured JSON logging, correlation ids; content-free by policy (rule 12) |
| `metrics.py` | In-process metrics registry, exposed via `GET /api/admin/metrics` |
| `ee_hooks.py` | Optional import of `pablan_ee`, registers EE modules |
### How an API package is cut
By what a caller is doing, not by HTTP verb: `documents/` has `browse`, `crud`,
`history`, `workflow` and `sharing`, with the gates in `access.py` and the
response shapes in `view.py`, so a rule like "an author keeps access to their
own document" exists once. Each module builds its router through the package's
`routing.py`, which is also where a shared dependency lives — the admin gate
sits on the router, so a new admin endpoint is gated by where it is added
rather than by remembering a decorator.
**Route order matters**: FastAPI matches in registration order, so the module
with the static paths (`/search`, `/export`, `/catalog`) is included before the
one with `/{id}`.
### Mode engine
Every interaction type implements the `Mode` protocol:
```python
class Mode(Protocol):
name: str
async def handle_turn(conv, user_message, db) -> AsyncIterator[ModeEvent]
```
`ModeEvent` = `Token | Sources | StateChanged | Done | Error | Degraded`
(`StateChanged` carries a `phase` and an optional `count` — metadata only,
rule 12; `Degraded` says no model could be reached, so the accompanying
`Sources` are a plain full-text result list rather than citations). The
conversations router converts events 1:1 into SSE events (see
`api-protocol.md`). Modes know nothing about HTTP; routers know nothing about
mode logic. Modes register in `modes/registry.py` — this is also the EE
extension point (the insights mode lives entirely in `ee/backend`).
**`query` (RAG Q&A) is the only core mode.** Capture is deliberately NOT a
mode: it is writing into a Document directly (`app/authoring/`), not a
conversation, so it never touches `handle_turn` or the SSE turn loop. That
keeps rule 5 clean — `modes/` stays the home of the turn protocol.
### LLM abstraction
Three independently configured model roles, each just `base_url` +
`api_key` + `model` (env vars `PABLAN_CHAT_*`, `PABLAN_UTILITY_*`,
`PABLAN_EMBEDDING_*`):
- **chat** — conversation turns (quality matters; Gemma-class 12B+ locally,
Claude/cloud in production)
- **utility** — summarization, entity extraction (cheap + fast)
- **embedding** — multilingual model (e.g. bge-m3); German quality is a
first-class requirement
The client exposes exactly three functions: `chat_stream`, `chat_json`
(structured output enforced via `response_format` JSON schema + Pydantic
validation + one retry), and `embed`. llama.cpp's server implements all
required endpoints including grammar-constrained JSON. `chat_stream` takes an
optional `extra_body` passed through to the endpoint verbatim — used only by
section refinement to send `chat_template_kwargs.enable_thinking = false`,
which turns off a reasoning model's hidden thinking so the refine call returns
in ~1s instead of ~10s (a mechanical rewrite needs no chain of thought).
**Configuration: bootstrap from env, then the DB owns it.** At first start
`bootstrap_llm_settings()` copies the `PABLAN_CHAT_*` / `PABLAN_UTILITY_*` /
`PABLAN_EMBEDDING_*` values into `llm_settings`, one row per role. From then
on **the table is authoritative and later `.env` edits are ignored** — a
configuration the admin can change in the UI and the deployment can change
underneath them cannot both be the truth, and silently losing a UI change
on the next restart is the worse of the two failures.
The environment stays reachable as the value a field can be *reset* to.
Each field carries a `*_from_env` flag recording where its current value
came from, so the UI labels every field "from .env" or "changed here" and
offers a per-field reset that writes back what `.env` currently says.
Provenance is tracked explicitly rather than inferred by comparing against
the environment, which would mislabel every field the moment someone edits
`.env` after install.
`_role_config()` reads a module-level cache (`app/llm/overrides.py`)
because it is synchronous and hot; the cache is filled at startup and
refreshed on every admin write, which also calls `rebuild_clients()` — the
cached `AsyncOpenAI` instances hold the old URL and key, so changes apply
without a restart. This works because the deployment pins one uvicorn
worker: exactly one cache to refresh. A multi-worker setup would need a
notification channel.
The `api_key` is stored in plaintext — it must be replayed to the endpoint,
so there is nothing to compare a hash against. It is never returned by the
API (only `api_key_set` / `api_key_from_env` booleans) and never logged
(rule 12). Admins validate a candidate endpoint with `probe()` before
saving, so a wrong URL fails in the settings screen rather than on the next
user question, and `list_models()` asks the endpoint's `GET /v1/models`
**server-side** so the model field can become a dropdown without the
browser ever holding the credentials. Endpoints that do not implement that
route are common; the response carries `supported: false` and the UI keeps
its free-text field rather than showing an error.
**One gate per endpoint, in front of every call** (`app/llm/gate.py`). A
self-hosted server has a fixed number of parallel slots; sending more than that
does not make it faster, it just moves the queue somewhere Pablan cannot see or
bound, where every waiting request still burns the HTTP timeout. Two colleagues
chatting while a reindex runs is enough. So `chat_stream`, `chat_json` and
`embed` each take a slot first, keyed by **base_url** rather than by role —
chat and utility usually share one server, and the server is what has the
slots. A caller waits at most `PABLAN_LLM_QUEUE_WAIT_SECONDS`, and past
`PABLAN_LLM_MAX_QUEUED` waiters the gate stops admitting at all; both refusals
come back as `llm_busy`, the same code a cloud provider's 429 produces, so the
frontend has one sentence to say. A streaming completion holds its slot until
the last token, because that is how long it occupies the server's. Two
consequences worth naming: a saturated EMBEDDING endpoint makes query mode fall
back to full-text retrieval (it already handles `LLMError` there) rather than
fail, and the query mode asks `endpoint_busy()` before it streams, so a waiting
turn says "the model is busy" instead of blinking a cursor. `probe()` and
`list_models()` deliberately bypass the gate: an admin has to be able to test an
endpoint precisely when it is saturated. `PABLAN_LLM_MAX_PARALLEL` should match
the server's own parallelism (llama.cpp `--parallel`).
**Admin-editable prompts** mirror this pattern with two simplifications. Every
shipped system prompt (query, no-sources, refinement persona/rules, grounding
framing, topic summary, title) has a CODE default in
`app/prompts/defaults.py`; an admin may override it in `prompt_settings`, applied
without a restart via a module cache (`app/prompts/overrides.py::get_prompt`,
refreshed on every write). The render functions read `get_prompt(key)`, so an
override wins and a missing row falls back to the default — the reset target is
the code default, since prompts have no `.env` layer, and there is no bootstrap.
Prompt-cache friendliness (D22) holds: a value only changes on an admin write, so
`query_system` stays byte-identical between a conversation's turns.
**Inspectable working context.** Behind a "?" in the chat, the context inspector
shows every passage retrieval surfaced for an answer, marking which grounded it
(`used`) versus which were retrieved but dropped as too weak — so a no-answer is
explained rather than silent. The editor shows the same for a refinement: the
grounding references it drew from, sent as a `grounding` SSE frame. Both surface
only material the requesting user may already read (rule 2) — titles/headings and
short excerpts, never new content.
### Ingestion queue
Sequence: [`diagrams/queue-sequence.svg`](diagrams/queue-sequence.svg)
A `jobs` table + an asyncio loop in the app lifespan, claiming jobs with
`SELECT … FOR UPDATE SKIP LOCKED`. The claim transaction stays open while
the handler runs: a crash rolls back claim and handler writes together, so
jobs are atomic and claimable again after a restart. Retry with exponential
backoff, `last_error` sanitized. Job types: `index_document`, `reindex_all`,
`retention_cleanup` (daily, reschedules itself).
Designed so the loop can later move into a separate worker container
(Variant B) without changing any imports.
**Constraint: exactly one uvicorn worker per deployment.** One process =
one queue loop = one in-process metrics registry. Scaling beyond this is
the Variant B worker split, NOT more uvicorn workers (the customer compose
pins `--workers 1`).
## Frontend structure (`frontend/src/`)
SvelteKit is pure UI — no database access, no auth logic beyond passing the
session cookie through. `hooks.server.ts` validates the session against
`GET /api/auth/me` and fills `locals.user`; the `(app)` route group redirects
to `/login` without it (sequence:
[`diagrams/auth-sequence.svg`](diagrams/auth-sequence.svg)). Chat state lives in Svelte 5 runes classes. Custom
components on top of Bits UI (headless behavior layer); design tokens in
`app.css` (see CLAUDE.md). API types are generated from FastAPI's OpenAPI
schema (`make types`).
The shell is a collapsible left sidebar (`lib/nav/Sidebar.svelte`): one
primary action on top, recent conversations in the middle,
documents/admin/account anchored at the bottom. Collapse state is
per-device (`localStorage`). The account entry opens a dialog
(`lib/nav/SettingsDialog.svelte`) rather than a menu — settings hold forms
(password, theme, language), which a menu cannot.
The conversation list is shared between sidebar and chat page through one
module-scope runes store (`lib/chat/conversations.svelte.ts`).
### One thing per screen
Every screen was gone over with two questions: what has to be seen without
looking for it, and what has to be findable without being in the way. The
rules that came out of it, applied everywhere:
- **One primary action, labelled.** The action a visitor most likely came for
is a button with a word on it. Everything else that has to be reachable goes
into `lib/components/Menu.svelte` — a labelled overflow menu, because a row
of bare icons makes the reader guess.
- **Only exceptions are marked.** A document list where every card says
"published · public · yours" says nothing; the one row that carries an
unanswered question disappears into the pattern. Badges are for the
abnormal: open questions, drafts, archived, built-in help.
- **Metadata is one quiet line, not a stack of rows.** The document page shows
status, who may read it, and when it changed in a single grey row above the
text, with the details a click behind the chip that states them
(`lib/documents/AccessPopover.svelte` — visibility and department sharing in
one place, since they are the same question).
- **Controls that are used monthly hide behind a toggle, and say when they are
on.** The document list is a search field and results; the six ways to narrow
it sit behind "Filter", and any filter that IS active stays visible as a
removable chip, because a quietly filtered list lies about what exists.
- **Unrelated jobs become tabs, not a longer scroll.** `/admin` is four
unrelated jobs (users and departments, templates, endpoints, prompts); as
tabs each is named up front and one click away.
- **A page with nothing to do is a page with something missing.** The landing
page volunteers the drafts nobody published and the checks waiting for this
person (`lib/documents/OpenWork.svelte`); the profile answers "what have I
written?".
The landing page itself is one input (focused on arrival), the two other
things anyone comes for, and then that open work.
A conversation lives at **`/chat/[id]`**, resolved in a server load so a
pasted link renders the right thing on first paint. The API is
owner-scoped, so an unknown id and someone else's id both come back 404 and
are indistinguishable from the outside — existence must not leak, same
semantics as the documents API. `/chat` is the empty composer and accepts
`?q=<text>` (ask straight away), consumed once.
Both routes render the same `lib/chat/ChatView.svelte` — the shell that owns
the route-driven conversation switch, the URL sync and the scroll follow, while
one reply is `lib/chat/AssistantTurn.svelte` (streaming, cited, uncovered, or
answered by a plain search) and the input is `lib/chat/Composer.svelte`. The
chat state
is a **module-level singleton** (`chatState` in `lib/chat/state.svelte.ts`)
rather than per-component. The first message on `/chat` creates the
conversation and moves the URL to `/chat/[id]` via `replaceState` — which
unmounts one page component and mounts another. Per-component state would
take the in-flight stream and the messages already on screen down with it.
Reacting to the route parameter rather than to clicks is also the single
place where switching conversations aborts the previous stream.
Chat is a split view with ONE right-hand slot for a cited document. Citations
render as labelled badges ("Sources:") that reveal the cited passage on hover
and open the document in the slot on click (`lib/chat/SourceBadge.svelte`,
`lib/chat/DocumentPanel.svelte`); a document whose title repeats as its first
heading is rendered without that heading (`bodyWithoutTitle`), because every
surface already shows the title above the text; below `lg` the panel stacks under the
conversation instead. When retrieval takes the low-confidence path, the answer
is followed by an invitation to capture the missing knowledge; it opens the
template picker, which creates a draft (`POST /api/documents`) and navigates to
the writing editor — the router never touches capture.
### Writing editor
Capture is a **writing-first editor**, not a chat panel. It starts at
`/documents/new` — a template picker that lists `GET /api/templates`, creates a
draft (`POST /api/documents {template_id}`) and navigates to the editor (the
landing "capture" chip and the chat no-answer/gap links all point here). The
chat links carry `?conversation=<id>`, which makes the picker
**retrieval-aware**: it calls `POST /api/documents/suggest-similar` and, under
the templates, offers existing documents that match the conversation ("matches
your conversation") so the user can **extend** one instead of starting fresh —
picking a match routes to that document's editor with the same
`?conversation=`. The matching runs over an **LLM topic summary** of the chat
(`app/authoring/context.py::summarize_conversation``chat_json`), not the raw
last message, then permission-filtered hybrid retrieval
(`rag/similarity.similar_documents`, help pages excluded). Creating a draft with
that `conversation_id` also stores the topic summary as `meta.context`, which
the refine prompt passes as background so refinement is chat-aware. Refinement
is also **retrieval-aware**: before it runs, the server searches the
permission-filtered knowledge base for related, already-published material the
author may read (`rag/similarity.similar_chunks`, the current document and help
pages excluded, and only once the section carries enough of its own text to
match on) and passes it to the prompt as grounding — so a suggestion stays
consistent with what the company already documented, while the prompt keeps it
a reference rather than a source of new facts. Query mode
uses the same topic summary too, but only as a **fallback** — it searches on the
raw message first and re-searches with a conversation topic summary only when the
raw search is low-confidence and there is earlier context (so a clear question
pays no extra latency; see `modes/query.py::_topic_transcript` and decision D21,
backed by `tests/evals/test_topic_retrieval_eval.py`, 4/4 vs. 1/4). The query
prompt is also structured for **prompt caching** (D22): a byte-identical static
system message, the conversation history, then this turn's excerpts + question
last (`modes/prompts.py::render_context_turn`), so the endpoint reuses the cached
system + history prefix and only reprocesses the volatile excerpts. The editor
lives at
`/documents/[id]/edit` (component
`lib/documents/WritingEditor.svelte`), loads the document in a server load
(same 404 semantics as chat), and is the SAME editor a document's detail page
opens for editing (`can_edit` → the edit button routes to `/edit`). It is built
on **CodeMirror 6** (`@codemirror/state`/`view`/`commands`/`language`/
`lang-markdown`), styled through the design tokens rather than a CodeMirror
theme.
- **A single full-width CodeMirror editor** over the Markdown, two-way synced to
the document string. It is not continuously autosaved (that would defeat the
review-before-save diff); instead a `beforeNavigate` guard keeps things tidy on
the way out: an abandoned, never-filled template draft (still only its skeleton)
is **discarded** so empty drafts do not pile up in the list, and an edited but
unsaved draft is **saved** (a draft is private and unindexed, so this is cheap)
so leaving never loses work.
- **Section refinement — shown INLINE at the section you are editing.** A typing
pause (debounced on document change) fires `POST /api/documents/{id}/refine`
through an SSE consumer (`lib/api/refine.ts`, the same `fetch` +
`ReadableStream` + `parseFrame` pattern as `stream.ts`). The suggestion streams
into a **CodeMirror block widget inserted right below the active section** (not
a separate pane), so it appears exactly where the cursor is; its body renders
through the sanitizing `$lib/markdown` renderer (rule 10) and it is aborted the
moment the user resumes typing (`AbortController`, like the chat stop button).
**Accept overwrites that section**: the exact `[start_line, end_line]` from the
server's `section` frame is replaced via a CodeMirror `dispatch`, and the widget
clears. The server owns that boundary; the client mirrors it
(`lib/documents/sections.ts`) only to place the widget. A short cooldown after
accept blocks the next refine until enough new editing has accumulated, and a
suggestion computed against a since-edited region is discarded rather than
applied.
- **Save shows the diff** — the editor's one action sits bottom right where
the writing ends: **Save** opens a `Dialog` with a read-only VSCode-style line
diff via `@codemirror/merge`'s `unifiedMergeView` (original = last saved
state, modified = the buffer), and asks again — save, cancel, or, for a draft,
**save and publish**. There is no autosave, because that look at the diff is
the point: you see what you are about to put your name on and can still back
out. (Leaving the page still persists an unsaved draft rather than losing
work; a never-filled template skeleton is discarded instead.) **The title is
edited in that dialog**, next to the diff: this is the moment you notice the
document is still called "Neuer Ablauf", and a ✨ button fills
the field from `POST /api/documents/{id}/suggest-title` when asked (a model
call on demand, not on every save). `lib/documents/SaveDialog.svelte`.
- **The draft says what state it is in.** Picking a documentation type creates
the document immediately (rule 6: the document IS the state) and leaving an
untouched skeleton deletes it again — both right, and both invisible, which
is what made "the document does not exist yet" confusing. The line under the
editor now says which of the three it is: a new draft that will be discarded
if nothing is written, a draft that exists and is private, or the time of the
last save.
- **A dead endpoint is knocked on once, not every two seconds.** After an
`llm_*` failure the suggestions go quiet, say so in one line, and wait before
trying again (two minutes for unreachable, five for misconfigured, thirty
seconds for busy) — with a "try again now" for the writer who knows the
endpoint is back. A successful suggestion clears it. Writing is untouched
either way: the assistant is the optional half of this editor.
- **The editor edits title and text, nothing else.** Who may READ a document is
a property of the document as it stands, not of the text being typed, so
visibility lives on the document page next to the department sharing it
belongs with (`lib/documents/AccessControls.svelte`) — and both are the
owner's decision, unlike the text itself.
- **Publishing is the author's own action.** One click, no queue: from the
save dialog, from the draft card on the document page, or from the drafts
card on the landing page. A reward modal (a check animation) confirms the
knowledge is captured, and offers "view it" or **"have it checked"**.
- **Review requests** — "please check this", addressed to one colleague,
optionally about something specific ("do the 14 holiday days still hold?").
Orthogonal to the status: it can sit on a draft the author is unsure about or
on a document published months ago, and it marks the document everywhere it
appears — the list card, the detail page, and the sources under a chat answer
(`review_pending`, warning-marked in `SourceBadge`, `ContextInspector`,
`FallbackResults` and the document panel). The author picks from
`GET /api/documents/{id}/reviewers` (candidates who can read the document, id
+ name only) and calls `POST /api/documents/{id}/reviews`
(`lib/documents/ReviewRequestForm.svelte`, shared by the detail page and the
publish reward). Being asked grants read AND edit until it is answered, so
the colleague can fix a wrong number instead of filing a second question;
they answer with **"that's correct"**
(`POST /api/documents/{id}/reviews/{review_id}/resolve`), and the author can
close a question that has become moot. The open questions sit above the
document text (`lib/documents/ReviewPanel.svelte`), because a reader has to
see them before trusting it; answered ones stay as a one-line record of who
checked what. A reviewer finds their queue through the `assigned_to_me`
filter — surfaced both as a list pill and, with their own unpublished drafts,
on the landing page (`lib/documents/OpenWork.svelte`, deep-linking to
`/documents?review=1` and `/documents?status=draft`).
The component set in `lib/components/` is deliberately small: `Button`,
`Input`, `FormField`, `Card`, `Badge`, `Markdown` (the only sanitized
`{@html}` site), plus the Bits UI wrappers `Popover`, `Tooltip`, `Dialog`
and `Tabs`. Tooltip content is plain text by contract, so untrusted strings
(document excerpts, model output) can never become markup.
## In-product help
`help/*.md` are the help pages users read inside Pablan (German product
content, YAML frontmatter with `key` and `title`). `app/help_import.py`
upserts them into `documents` on every start, keyed by `key`, flagged
`is_builtin`, published and public, with no author or department. Unchanged
files are skipped; a changed file is rewritten in place and re-indexed, so a
release always ships current help — and Pablan can answer questions about
itself from its own retrieval path. The API refuses to edit or delete them
(409 `builtin_readonly`), because the next deploy would overwrite the edit.
The **template catalog** is shipped content too, but works the other way
round: `templates/*.yaml` are blueprints that stay on disk until an admin
adds one, and what lands in the `templates` table is then the customer's,
editable and never re-imported (`app/template_catalog.py`). The dividing
line: content describing how *Pablan* works belongs to the product and stays
locked; content describing how *this company* works belongs to the customer.
`docs/authoring-templates.md` records the open questions around both.
Admins edit templates through a **form builder** (`lib/admin/TemplateBuilder`),
not raw YAML: fields for the persona, per-section heading + hint (add,
remove, reorder), title, language, visibility and model hints. The
skeleton the editor opens with is *derived* from the section headings (one
`## heading` each), so headings and hints never drift apart. The builder
posts the structured config to `POST /api/templates/build` — the frontend
has no YAML library, so it sends the `AuthoringTemplate` rather than
serializing it, and the backend validates the same schema a pasted YAML
goes through. Raw YAML stays one click away as an advanced escape hatch for
existing templates (`PUT /api/templates/{id}`), reachable and reversible
from the builder.
## EE plugin mechanism
- Core NEVER imports from `ee/` (enforced by import-linter in CI).
- `ee/backend` is an installable package `pablan_ee` exposing
`register(app, registry)`; activated only if installed AND a license key is
configured.
- `ee/frontend` fills named slots in `lib/ee/registry.ts` via a Vite alias;
without EE the alias points to an empty registry.
## Growth path
- **Variant B** (when needed): move the ingestion loop to a worker container;
optionally swap pgvector for Qdrant behind the retrieval interface.
- **Phase 2 — cross-department discovery**: entity/relation extraction during
ingestion into a simple edge table; a nightly job compares new knowledge
against other departments' documents and generates proactive hints. This is
the key market differentiator (see market research summary in README/docs)
but explicitly NOT part of the MVP.