# 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=` (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=`, 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.