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
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.
/adminis 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
beforeNavigateguard 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}/refinethrough an SSE consumer (lib/api/refine.ts, the samefetch+ReadableStream+parseFramepattern asstream.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/markdownrenderer (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'ssectionframe is replaced via a CodeMirrordispatch, 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
Dialogwith a read-only VSCode-style line diff via@codemirror/merge'sunifiedMergeView(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 fromPOST /api/documents/{id}/suggest-titlewhen 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 inSourceBadge,ContextInspector,FallbackResultsand the document panel). The author picks fromGET /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 theassigned_to_mefilter — 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=1and/documents?status=draft).
- name only) and calls
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/backendis an installable packagepablan_eeexposingregister(app, registry); activated only if installed AND a license key is configured.ee/frontendfills named slots inlib/ee/registry.tsvia 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.