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

27 KiB

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

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:

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

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). 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_conversationchat_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.