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
29 KiB
Roadmap
The feature backlog for Pablan, organized by feature area rather than by delivery date. It doubles as a rebuild manual: follow the done epics top to bottom and you arrive at the system as it stands today; the open epics are the road ahead.
How to read it. Each epic is a feature area with a one-line intent and a
checklist of stories — [x] shipped, [ ] not yet. Epics are ordered as a
build sequence (foundation upward). File pointers name where a capability lives,
so a rebuilder can find the pattern to follow. Implementation learnings that
survive across features live in notes.md; the as-is contract lives
in architecture.md, data-model.md and
api-protocol.md.
1. Platform foundation (done)
The backend skeleton every other feature stands on.
- Config via
pydantic-settings, allPABLAN_*env vars; per-role LLM config (base_url / api_key / model for chat, utility, embedding). - Async SQLAlchemy 2.0 (typed) + Alembic migrations on PostgreSQL + pgvector;
UUID primary keys and a shared timestamp mixin (
models/base.py). - The table set — see data-model.md and diagrams/data-model.svg.
- pytest harness against a real Postgres (docker); retrieval logic gets SQL-level tests.
- Seed script with a production guard; a shared German fixture corpus
(
tests/fixtures/corpus/+golden_queries.yaml) that seeds and tests draw from.
2. Authentication & sessions (done)
Server-side sessions, no JWT, and an auth boundary that cannot leak one user's state to the next.
- argon2 password hashing;
auth_sessionstable; httpOnlypablan_sessioncookie (14-day TTL);{detail, code}error shape. - login / logout / me; enumeration-resistant login.
- Session revocation (
auth/sessions.py::revoke_user_sessions) — a password change logs out every other session. - Full-page auth boundary: login and logout are document navigations,
never client
goto, so module-singleton client state (conversation list, chat state, resolved locale) is guaranteed dead at the boundary and cannot leak the previous user's data.
3. Frontend foundation (done)
The SvelteKit shell, the design system, the typed API contract, and i18n.
- Semantic design tokens as CSS variables mapped into Tailwind; the palette
is swappable by editing tokens only; a WCAG-AA contrast gate
(
frontend/scripts/contrast-check.py) runs inmake lint. - A small styled component set on Bits UI (Button, Input, Select, Dialog, Card, Badge, Tabs, Tooltip, Popover, FormField) — no component-library deps.
- Typed API client: FastAPI OpenAPI → openapi-typescript → openapi-fetch;
make typesregenerates it after any backend API change. (app)route group +hooks.server.tssession validation (absolute backend URL, since a relativeevent.fetchnever hits the vite proxy).- The sanitizing Markdown renderer (
components/Markdown.svelte, DOMPurify) — all model and document output renders only through it. - Paraglide i18n: de source, en written in the same change; two lint
rules (missing
en, em/en dashes) incheck-messages.py; the backend never renders UI strings — it returns{detail, code}and the frontend translates. - Theme per device (localStorage, applied pre-paint by a boot script) and
language per account (
users.locale).
4. LLM gateway & background jobs (done)
One place that talks to models, and a Postgres-only work queue.
llm/client.pyis the ONLY code that calls LLM endpoints:chat_stream,chat_json(structured output via a Pydanticresponse_format),embed; roles chat / utility / embedding, each independently configured;extra_bodypassthrough (e.g.enable_thinking:false).- In-app LLM config: bootstrap each field from env, then the DB, with
per-field provenance ("from .env" / "changed here");
llm/testpings the three roles; model discovery is server-side. - Postgres job queue (
jobstable,FOR UPDATE SKIP LOCKED, backoff 30s·2^n over 5 attempts); the handler runs inside the open claim transaction; exactly one uvicorn worker (queue and metrics are per-process). - Scheduled retention cleanup (reschedules itself); an in-process metrics registry.
- Content-safe logging: metadata only, never prompts / responses / user text;
LLMErrorraisedfrom None; an openai-SDK DEBUG-log guard (PABLAN_DEBUG_LOG_PROMPTS, never in production). - A failing endpoint says why.
LLMError.codeclassifies every failure once (llm_unreachable/llm_busy/llm_misconfigured/llm_failed); every surface reports it unchanged and the frontend phrases it (lib/api/errors.ts). The conversations router catchesLLMErrorcentrally, so a failure during retrieval (which embeds before the model is called) ends the turn with the reason instead of a truncated stream. A busy endpoint queues rather than refuses, so the chat also says so after 12s of silence.
5. Documents & the knowledge model (done)
Markdown documents with a permissioned lifecycle; the single source of truth.
- Documents stored as Markdown (
content_md); chunks and embeddings are disposable derivatives, always re-indexable from the document. - Lifecycle draft → published → archived;
visibility(public / department / restricted). - Publishing is the author's own action (
POST /documents/{id}/publish), plus archive/republish. Whether the CONTENT is trusted is a review request instead, never a status (epic 16). - The single permission filter (
rag/permissions.py) shared by the API and retrieval, evaluated live against the table (never on stale chunk meta):readableadds a user's own drafts and the documents someone asked this user to check;searchableis published-only, so a draft never reaches another user or an LLM prompt. - ZIP export of the readable knowledge base (Markdown + YAML frontmatter),
permission-filtered, help pages excluded, stdlib
zipfile/ioonly. - Built-in help pages (
help/*.md) imported intodocumentson start (is_builtin, read-only, not deletable). - Admin CRUD guardrails (no self-delete / self-demote, 409
self_modification); GDPR surfaces (delete-own-conversation, the retention job).
6. RAG retrieval (done)
Permission-filtered hybrid search, German-first.
- Heading-aware chunking (the active-section boundary is shared with the
editor); index / reindex jobs; bge-m3 multilingual embeddings
(
EMBEDDING_DIM=1024). - Hybrid search: a permission CTE → pgvector top-20 + Postgres
germanFTS top-20 → Reciprocal Rank Fusion (k=60), all in one SQL statement. - Permission filter before the LLM: there is no search without a user; an unauthorized chunk is structurally impossible to retrieve.
- Similarity path (
similar_chunks/similar_documents): pure cosine, permission-filtered, threshold applied in Python afterORDER BY … LIMIT(a WHERE predicate fights the HNSW index); calibrated constants (no-answer ≥ 0.45, capture-context 0.45). - Eval suite (
make eval): recall@5 baseline against the fixture corpus and the no-answer threshold. - The knowledge base stays searchable without any model.
text_search()is the full-text half alone (thegermantsvector GIN index, no embedding call), same permission CTE; query mode uses it when the embedding endpoint is gone, and when no model answers at all the turn ends as a plain hit list the user opens themselves, labelled as a search without a model.
7. Query chat (done)
Ask the knowledge base a question, get a streamed, cited answer.
- The
Modeprotocol + registry: a mode yieldsModeEvents and the router converts them to SSE; Query is the only core mode (EE registers insight). - Conversations + messages; the SSE streaming turn; a stop button (a client
abort persists the partial message via
asyncio.shield). - Citations snapshotted into
messages.meta(chunks are disposable) + the source side panel; one badge per document, not per chunk. - No-answer handling lives in retrieval (the ≥0.45 threshold), not the prompt; a no-answer offers a "capture this knowledge" hand-off into the editor.
- Topic-summary retrieval fallback: on a low-confidence hit with prior context, re-search on an LLM topic summary of the conversation.
- A prompt-cache-friendly shape: a stable system prompt + history, with the volatile excerpts + question last.
8. Writing-first capture (done)
The centerpiece: employees write knowledge; the model matures the section at the cursor. (This replaced an earlier dialogue-driven approach — see notes.md History.)
- A CodeMirror 6 split editor at
/documents/{id}/edit(also the edit surface for any existing document); the caret is visible (drawSelection+ accent caret color). - Section refinement
POST /documents/{id}/refine(SSE): the server owns the active-section boundary (shared with the chunker), sends a FIM-style natural- language prompt, and streams a matured version of only that section into the right pane;enable_thinking:falsekeeps it ~1s; onlydelta.contentis read. - Retrieval-aware grounding: before refining, the server searches the
permission-filtered knowledge base for related published material the author
may read (
_groundingviasimilar_chunks, the current doc and help pages excluded) and passes it to the prompt as a reference, not a fact source. - A staleness guard and a post-accept cooldown on "Übernehmen"; a suggestion computed against a since-edited region is discarded rather than applied.
- A pre-save diff:
@codemirror/mergeunifiedMergeView(VSCode-style). Save sits bottom right where the writing ends, shows what changed, and asks again — save, cancel, or (for a draft) save and publish. - Authoring templates (
AuthoringTemplate): a Markdown skeleton + persona + per-section hints — declarative config, not a Mode. A shipped catalog plus a form template builder (POST /templates/build; the skeleton is derived from the section headings), with raw YAML behind an advanced toggle. - Capture from a chat: carries the conversation as background context
(
meta.context) and shows a matching-documents picker (topic summary →similar_documents) to extend an existing document instead. - A reward flow after publishing (a check animation → View / Have it checked), a title suggestion in the save dialog, and a streaming token fade-in.
9. Insight & growth (open)
Aggregate signals about the knowledge base — never per-user data.
- A growth surface, in a form that earns its place. The first version (a
/dashboardpage with bar charts offGET /documents/dashboard) was removed again: numbers nobody acts on are noise, and the charts said nothing the document list does not. Rebuild only around a question someone actually asks. GET /documents/stats— company-wide counts, read by the landing page's first-run guide.- The document detail page shows created / last-changed timestamps.
10. Product content & docs (done)
The content that ships with the product, and the docs that describe it.
- In-product help (
help/*.md, German) imported as read-only documents, kept current in the same change as the workflow it documents. - The shipped template catalog: blueprints on disk, inert until an admin adds one; an added template becomes the customer's, fully editable.
- Claude-maintained developer docs (
docs/), hand-authored SVG diagrams (docs/diagrams/, theme-aware, no toolchain), this roadmap, and notes.md.
11. Change history & review audit (done)
A git-like record of who changed what, when, and who checked it — including who confirmed the content, which used to be stored nowhere.
- A
document_eventstable:document_id,actor_id,action(created / edited / published / archived / visibility_changed / review_requested / review_resolved),created_at, and — for content-bearing events — a snapshot ofcontent_md, title, visibility and meta. Snapshots the Markdown (the source of truth), never chunks;actor_idis SET NULL and events CASCADE with the document. - An event is recorded at every transition: the content branch of
update_document, and create / publish / archive plus asking and answering a review inapi/documents/(helperauthoring/history.py::record_event).review_resolvedcaptures who confirmed the content as the event actor. GET /api/documents/{id}/history→ the events newest-first (actor name, action, timestamp,has_snapshot), plusGET /api/documents/{id}/versions/{event_id}to fetch a past version's content for viewing or diffing. Both behind the document's own read gate.- A history timeline on the document detail page
(
lib/documents/DocumentHistory.svelte) and a version diff reusing theunifiedMergeView(shared theme extracted tolib/documents/editorTheme.ts), with restore of a previous version. - An entry shows its OWN change. A snapshot is written after its event, so
diffing it against the live document made every row show the next row's edit
(the newest edit showed nothing at all).
GET /versions/{event_id}now also returnsprevious_content_md, the closest earlier snapshot, and the dialog diffs the pair. - The review queue is discoverable from the landing page, deep-linking to
/documents?review=1(see epic 16 for the review model itself).
12. People & profiles (done)
A directory colleagues can browse, and ONE way to describe a person: a document like any other.
- A member-visible directory:
GET /api/people(+/api/people/{id}) → colleagues'{id, name, role, department}for any authenticated user (no password/email leakage; mirrors the permission-scoped shape ofReviewerCandidate), with a directory page (/people) and a person page (/people/{id}) wired into the nav. - The profile page is the document about you.
GET /api/account/documentanswers whether the caller wrote one (from the person blueprint) and what to start it from otherwise; the page opens it for editing or offers to create it. The blueprint id lives in the backend, so the frontend knows no ids. - The self-written bio was removed again. A short profile blurb and a
document about yourself are the same thing said twice, and only one of them
is findable by the search, versioned and approvable. The
users.biocolumn, both endpoints, the editor and its two prompts are gone.
13. Access control & sharing (done)
Let a document reach more than one department, and stop anyone from accidentally locking themselves out of it.
- Multi-department documents.
documents.department_idstays the owning department; extra departments are shared through the existingdoc_permissionsjoin (thesearchable_documents_filterEXISTS doc_permissionbranch already unions them in — this was management, not permission logic).PUT /api/documents/{id}/departmentsreplaces the set; a detail-page multi-select (lib/documents/DepartmentSharing.svelte) manages it; and several departments are reflected inshared_departments, the?department=filter and the export frontmatter. - Self-lockout guard. Before committing a visibility or grant change, the
proposed state is evaluated against the read rules (
_guard_self_lockout, a Python mirror ofreadable_documents_filter). An author keeps access as author, so this only bites an admin editing a document they do not own: 409self_lockout_warningunless resent withconfirm_lockout(surfaced as an inline "save anyway" in the UI). - Close the adjacent admin lockout vectors.
delete_departmentnow refuses to silently delete a department that still has members, owned documents or grants (409department_in_useunless?confirm=true) — itsdoc_permissionsgrants CASCADE away invisibly otherwise. Thedelete_user/update_userdepartment-nulling is deliberate orphaning (documents and users survive authorless/departmentless,notes.md), not a silent lockout, and is documented indata-model.md.
14. Prompt & context transparency (done)
Give admins control over the model's instructions, and make what the model is working from visible to the user.
- Admin-editable system prompts. Every system prompt (query + no-sources,
refinement persona/rules, grounding framing, topic summary, title) is
editable on the admin UI, stored in
prompt_settingsand applied without a restart — mirroring the per-role LLM-settings pattern (app/prompts/overrides.py+api/admin/, code defaults inapp/prompts/defaults.py), with a reset-to-default per prompt (lib/admin/PromptSettings.svelte). - Always-inspectable working context. A "?" inspector in the chat
(
lib/chat/ContextInspector.svelte) shows every retrieved passage, marking which grounded the answer (used) versus which were too weak — so a no-answer is explained, not silent. The editor shows the grounding references a refinement drew from, sent as agroundingSSE frame.
16. Publish and review (done)
Separating "where does this document stand" from "is what it says right" — see decisions.md D24.
- Statuses are draft / published / archived;
pending_approvalis gone and publishing is the author's own one-click action (from the save dialog, the document page, or the landing page). - A
review_requeststable: one question, addressed to one colleague, open until answered (resolved_at+resolved_by_id). Orthogonal to the status — it can sit on a draft or on a document published months ago. - Being asked grants read AND edit until the answer
(
readable_documents_filter,can_edit), so a reviewer fixes a wrong number instead of filing a second question. Owner-only decisions (delete, sharing, handing out a request) stay with the author or an admin. - An open question marks the document everywhere: list card, detail page, the
document panel, and the sources under a chat answer (
review_pendingon every search hit, snapshotted with the citation). - The landing page surfaces open work: your unpublished drafts (with publish)
and the documents waiting for your check (
lib/documents/OpenWork.svelte). - Follow-through from use:
AccessReason.reviewnames the access a request grants, the document page thanks a reviewer instead of 404ing when their answer ends it, visibility moved out of the editor to the document page next to department sharing (both owner-only, like publishing), and the title is edited in the save dialog with a ✨ suggestion on demand. - Seed data is built through the real process — per-section edit history, publish events, drafts and review requests — so every history and diff surface has something true to show.
17. Interface pass (done)
One question asked of every screen: does the important thing catch the eye, and
is everything else still findable? See architecture.md "One thing per screen".
- Ranked actions: one labelled primary button per screen, the rest in a
labelled overflow menu (
lib/components/Menu.svelte) instead of a row of bare icons. - Badges mark exceptions only — an open question, an overdue check, a draft, an archived or built-in document. "Published / public / yours" is the normal case and says nothing on every card.
- The document page is the document: one quiet metadata line above the
text, visibility and sharing behind the chip that states them
(
AccessPopover), history collapsed to the recent entries, and the leading# Titledropped where it repeats the title. - The document list is a search field and results; filters live behind one toggle and stay visible as removable chips while they are on.
/adminbecame four tabs;/peoplegroups by department and filters; the profile lists what you wrote; the landing page focuses its input.
18. A template set people actually use (done)
- The catalog is four starter blueprints and three additions, replacing
eight that read like a taxonomy:
notiz(no skeleton at all),prozess,stoerung,person, plusanlage,entscheidung,projekt-debrief. Headings are the questions a colleague asks ("Wann das gilt / Schritt für Schritt / Wenn es klemmt"), not shapes of a document ("Worum es geht / Details"). onboarding-basisbecameperson, written by the person themselves and public — the profile page starts it.offboarding,kundenterminandlieferantenwissenare gone.- The picker is a list, not a grid: a handful of choices read top to bottom, where the description that decides between them is legible.
19. Decided (open)
Decisions taken on 2026-08-27. The three removals landed on 2026-08-30 (D25); what is left needs either a screen size or an endpoint to work against.
- Remove the verification workflow.
documents.verified_until, thedocument_review_dayssetting,POST /{id}/reverify, thereverifiedevent and every "Verifizierung fällig" surface are gone. It marked documents and reminded nobody; a review request is the mechanism that actually asks a person something. - Remove tags.
meta.tags, the?tag=filter, the chunk-meta copy,metadata.tagsin the template schema and the fixture frontmatter are gone; they had been write-only since M8. - Remove
source_type/ the upload half. Nothing can be uploaded, anduploadwas the value nobody wrote. The idea comes back with an import (below), which is when a provenance column earns its place again. - Mobile. Desktop is the main target, but the app has to be usable on a
phone: sidebar, the chat split view, the CodeMirror editor and the new
overflow menus are unverified below
lg. - Import, as the counterpart to the export. A self-hosted system has to be able to read its own ZIP back in (Markdown + YAML frontmatter): restore after a move, and the first honest answer to "we already have documentation somewhere else".
- Carry the chat a capture started from. A draft started out of a chat
keeps its subject as
meta.context; extending an EXISTING document out of the same chat now does too (PATCH /api/documents/{id} {conversation_id}), which was the one path that dropped it. - Show the search query the model actually wrote — the topic-summary retry rewrites the question on the low-confidence path, and the context inspector still shows only what came back, not what was asked.
Polish & known issues (open)
Smaller fixes and rough edges to pick up opportunistically.
-
Refinement suggestions appear inline at the section you are editing, as a CodeMirror block widget right below it, instead of in a disconnected right-hand pane (
WritingEditor.svelte). -
The editor no longer loses work or leaves clutter. A
beforeNavigateguard saves an edited-but-unsaved draft on the way out (no autosave, so the review-before-save diff is preserved) and discards an abandoned, never-filled template draft so empty drafts do not pile up. The document list also has its own "capture" entry, not only the landing page. -
The chat input stays focused after the first message is sent: the
/chat→/chat/[id]navigation swaps the page component, soChatViewre-focuses the composer inafterNavigate. -
Destructive confirmations use the app's modal, not the browser's
confirm():lib/components/ConfirmDialog.sveltebacks delete-user, delete-department, delete-template, delete-document and delete-conversation. -
The streaming reply no longer snaps. The assistant turn renders as sanitized Markdown LIVE as it streams (with a blinking cursor) instead of fading in plain text and re-rendering to Markdown on completion.
-
Model LaTeX/math renders as math. The sanitizing renderer runs
marked-katex-extension+ KaTeX before DOMPurify ($lib/markdown.ts), with KaTeX styles/fonts bundled locally (no CDN) — the local model emits real$...$/$$...$$LaTeX, now typeset. -
The classic-search fallback answers a whole question. Its terms are ORed rather than ANDed (
_any_term_tsquery), so a typed-out sentence still finds something when no model is reachable; ranking staysts_rank_cd. The hybrid path keeps the AND, wherefts_matchhas to mean "the words are really in there". Open: the fallback still needs chunks, so a document published while the embedding endpoint was down is not findable at all (notes.md). -
[~] Retrieval quality on statements. Investigated: the hybrid path (query + search) retrieves the same top document for a declarative statement as for the question, because the German full-text component catches the keywords regardless of phrasing — verified and now guarded by declarative-statement cases in the golden query eval. The pure-cosine similarity path (capture picker / grounding) is more phrasing-sensitive by design (it needs a comparable distance for its threshold,
notes.md); switching it to hybrid is left open.
15. Hardening & release readiness (open)
Get to "a stranger can deploy from the README alone", with the EE boundary in place and every quality gate green.
- EE boundary:
app/ee_hooks.py(optionalpablan_eeimport +register),lib/ee/registry.ts(empty frontend slot registry + Vite alias), and an import-linter contract (core never importsee/) wired intomake lint. - Minimal login throttling: in-process, per-user+IP backoff on failed logins (no new dependency, no Redis).
- A Prometheus text-format exporter at
/metricsfor the metrics registry, documented for customer IT. - An endpoint concurrency gate (
llm/gate.py): one semaphore per base_url in front ofchat_stream/chat_json/embed, a bounded wait and a bounded queue, both refusals reported asllm_busy, wait time and queue depth metered, and aqueuedchat phase so a waiting turn says so. - SDK-retry visibility: meter actual HTTP attempts, or document the tradeoff (SDK-internal retries are invisible to our metrics) in the ops docs.
- Customer ops docs: backup/restore (pg_dump, rehearsed once), the upgrade
path (
alembic upgrade), the/metricsprotection note, and the parkedidle_in_transaction_session_timeoutnote for managed Postgres. - The full e2e suite green: login, chat streaming, the writing editor, document approval, permission boundaries.
- First cloud-model eval validation of every prompt shipped so far — the
writing-first refinement, grounding, topic-summary and query prompts have
only run against a local Gemma-class model. Add a
PABLAN_EVAL_CLOUD_*role to.env.example; the eval picks it up when set and skips it otherwise. Budget time to fix prompt behaviour a 26B model tolerated. - Two anti-scaffolding assertions in the refinement eval: across the eval set the replies must not share one opening prefix, and at most a third may share their first word. A model that scaffolds every answer the same way reads as a form letter, and nothing catches that today.
- Clean-clone verification: quickstart (dev) and customer deploy (compose +
Caddy) from the README on a fresh checkout; a final pass so every
docs/*.mdmatches the implementation.
Non-goals
Deliberately out of scope; revisit only with a concrete need.
- EE insights implementation — only extension-point stubs in the core.
- OIDC / Entra ID SSO.
- Cross-department knowledge discovery as a distinct feature (the capture-time "matching documents" panel is a deliberate precursor; this is different from epic 13, which shares one document across departments).
- CI pipeline setup.
- File uploads / attachments — Markdown is the source of truth; binary storage, previews and scanning add complexity without serving the capture → RAG loop.
- Comments / in-tool collaboration — knowledge flows through conversations with Pablan, not discussion threads inside the tool.
- Real-time / concurrent editing — no use case; SSE streaming is all the liveness the product needs.
- Email notifications — needs SMTP and deliverability support on customer infrastructure; a candidate for EE later.
(Document version history was previously a non-goal; it is now planned as epic 11.)