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
12 KiB
CLAUDE.md — Pablan
Self-hosted knowledge management for SMEs. Employees write knowledge documents in a split-screen Markdown editor while an LLM refines the section they are working on; the same LLM answers questions over the resulting documents via RAG. Everything is stored as Markdown and runs on the customer's infrastructure. Team of 2 developers — bias toward simplicity, no speculative abstractions.
Design docs live in docs/ (English). Read the relevant doc before larger changes.
Claude maintains the documentation: whenever a decision, schema, API, or
workflow changes, update the affected file in docs/ in the same change — stale
docs are treated as bugs. This includes the diagram sources in
docs/diagrams/ (hand-authored SVG, theme-aware; the SVG is the artifact,
no diagram toolchain).
docs/roadmap.md is the feature-based backlog: every feature area as an epic with
done ([x]) and open ([ ]) stories, ordered so it reads as a rebuild manual. It
is the only forward-looking doc — its open epics may describe not-yet-built work;
everything else in docs/ describes the as-is state only. docs/notes.md holds the
durable implementation learnings (calibrations, gotchas, why-it-is-this-way). When a
feature ships, tick its story in the roadmap and fold any lasting learning into
docs/notes.md, in the same change. The root README.md gives a short high-level overview
(what Pablan is, stack, quickstart) and links into docs/; it is also
Claude-maintained and must stay short and current.
help/ is in-product documentation and follows the same rule: those
Markdown files are the built-in help pages users read inside Pablan
(imported into documents on every start, is_builtin, not editable in
the UI). Any change to a user-facing workflow, screen or concept updates
the affected help page in the same change — an outdated help page is a bug
like a stale docs/ page, except users see this one. docs/ explains the
system to developers; help/ explains the product to its users, in German
(product content, like templates).
Stack (fixed decisions — do not substitute)
- Backend: Python 3.12+, FastAPI (async), managed with
uv. Lint+format:ruff. - DB: PostgreSQL + pgvector. ORM: SQLAlchemy 2.0 (async, typed). Migrations: Alembic. No dedicated vector DB, no Prisma, no Redis.
- Frontend: SvelteKit (Svelte 5 runes) + TypeScript + Tailwind. Prettier + ESLint. Headless behavior via Bits UI; we write our own styled components on top. No shadcn imports, no component library dependencies beyond Bits UI.
- LLM: OpenAI-compatible endpoints only (llama.cpp server locally, cloud APIs in prod).
No LangChain / LlamaIndex — thin custom client in
backend/app/llm/client.py. - Deployment: Docker Compose (postgres, backend, frontend, reverse proxy).
Reverse proxy config in
deploy/(Caddy, the only supported proxy for now).
Repo layout
backend/— FastAPI app (app/models,app/auth,app/api,app/modes,app/authoring,app/llm,app/rag,app/ingestion)frontend/— SvelteKit, pure UI / API client. No DB access, no auth logic beyond cookie passthrough inhooks.server.ts.templates/— built-in authoring templates: Markdown skeletons (YAML). Product content, not code.ee/— proprietary Enterprise modules. Core code must NEVER import from ee/. ee registers itself viaapp/ee_hooks.pyand the mode/frontend registries.docs/— English design documentation, kept in sync with code by Claude.- License: FSL-1.1 for core, separate proprietary license in
ee/LICENSE.
Commands
make dev— full dev stack with hot reload (postgres in docker, backend + frontend native)make down— stop the dev stackmake migrate— apply Alembic migrationsmake seed— seed dev datamake types— regeneratefrontend/src/lib/api/schema.d.tsfrom OpenAPI. Run after ANY backend API change.make lint— ruff + prettier + eslint + design-token contrast check (CI runs the same)make eval— LLM eval suite inbackend/tests/evalsagainst the configured endpointmake e2e— Playwright end-to-end tests against the dev stack
Architecture rules (invariants — enforce in every change)
- Markdown is the source of truth. Documents live as Markdown in Postgres. Chunks/embeddings are disposable derivatives; any pipeline change must allow full re-indexing from documents.
- Permissions filter BEFORE the LLM. All retrieval goes through
rag/retrieval.search(query, user=...)— there is no search without a user. Never pass chunks to a prompt that the requesting user could not read. - All LLM traffic goes through
llm/client.py(chat_stream,chat_json,embed) with model roleschat/utility/embedding, each independently configurable (base_url, api_key, model). Never call an LLM HTTP API elsewhere. Its failure vocabulary lives inllm/errors.py:LLMError.codeclassifies every endpoint failure once, and the frontend phrases it. - Structured outputs use
chat_jsonwith a Pydantic schema passed asresponse_format(JSON schema). Never parse free-form LLM text into data. - Modes implement the
Modeprotocol (modes/base.py) yieldingModeEvents; the conversations router converts events to SSE. Modes know no HTTP; routers know no mode logic. New modes register inmodes/registry.py. Query (RAG Q&A) is the only core mode; EE adds insight. Capture is NOT a mode — see rule 6. - Capture is writing-first, not a conversation. The user authors a
Documentdirectly (Markdown is the source of truth, rule 1); it starts indraftstatus — author-only (rag/permissions.readable_documents_filter) and never indexed until published (searchable requirespublished), so a draft never reaches another user or an LLM prompt (rule 2). Publishing is the author's own one-click action; the three statuses (draft, published, archived) say where a document stands, never whether its CONTENT is trusted. That is aReviewRequest— "please check this", which can hang on a draft or on a document published months ago, grants the person asked the right to edit until they answer, and marks the document everywhere it appears including chat sources. Section refinement (POST /api/documents/{id}/refine, packageapp/authoring/) regenerates ONLY the section at the cursor (FIM-style: the rest of the document is prefix/suffix context), so large documents stay cheap and small local models (Gemma-class) stay reliable. The active-section boundary is computed server-side, shared withrag/chunking. No hidden engine state — the document is the state. - Prompts are rendered natural language, never raw YAML/JSON dumps.
- Auth = server-side sessions (argon2 password hashes,
auth_sessionstable, httpOnly cookie). No JWT. Naming: chat threads areconversations, login sessions areauth_sessions— never mix these up. - Background work goes through the
jobstable (ingestion/queue.py,FOR UPDATE SKIP LOCKEDloop). No new queue infrastructure. - Frontend renders LLM/document Markdown only through the sanitizing renderer (DOMPurify). Treat all model output and document content as untrusted.
- API contract flows one way: FastAPI OpenAPI →
openapi-typescript→ typedopenapi-fetchclient. Never hand-write API response types. - NEVER log content — no prompts, no LLM responses, no user messages,
no document text. Log metadata only (model role, duration, token counts,
error codes, entity IDs, correlation id). This applies to every log line,
including exceptions (no content in error messages). Content debug
logging only behind
PABLAN_DEBUG_LOG_PROMPTS=true, documented as never-in-production. - Auth boundaries (login/logout) are full document navigations, never
client-side. Module-level client state (the conversation list, chat
state, the resolved locale — all runes singletons) is guaranteed dead at
the session boundary because the page is reloaded. Do NOT convert these
to client navigation (
goto/invalidateAll): a client nav keeps the previous user's singletons alive and leaks their data (conversation titles are an information disclosure). This is immune to stores added later; a per-store reset is not.
Conventions
- Code, comments, identifiers,
docs/, README, seed data, test strings: English. Template content and the test fixture corpus intests/fixtures/: German — product content for the German market. - UI copy goes through Paraglide messages (
frontend/messages/), source language de (informal "du"), en written in the same change. A hardcoded UI string is a bug; a missingenmessage and an em/en dash in any message both failmake lint(frontend/scripts/check-messages.py). Seedocs/i18n.mdfor the key naming convention and how the locale is resolved. - The backend never renders UI-language strings. API errors are
{detail, code}and the frontend translates bycode; SSEstateevents carry counts and markers, and the frontend phrases them. - Full type hints in Python;
ruffrules includeI(isort) andB(bugbear). - Svelte 5 runes only (
$state,$derived,$props) — no legacy stores for new code. - Keep the base component set small; reuse
lib/components/primitives. No one-off colors or spacing — see Design tokens below. - DB primary keys: UUID. Timestamps via the shared mixin in
models/base.py. docs/diagrams/data-model.svgis the ER diagram; any model or migration change updates the diagram and the prose indocs/data-model.mdin the same change.- Retention/privacy defaults matter (GDPR): users can delete their own conversations; retention cleanup runs as a scheduled job; insights features must only ever see aggregated data, never per-user raw messages.
Design tokens
- All colors are defined ONCE as semantic CSS variables in
frontend/src/app.cssand mapped into the Tailwind theme:--color-primary,--color-secondary,--color-accent, plus role tokens for surfaces, text, borders, and states (success/warning/danger), each with the shades needed for hover/muted variants. - Current palette values live ONLY in
frontend/src/app.css— CLAUDE.md never names colors. The palette must be swappable by editing only the token definitions — components reference tokens exclusively (bg-primary,text-accent), NEVER raw hex values or Tailwind default colors. - The accent token is for highlights, active states, and CTAs — never for
body text or large surfaces. Check WCAG AA contrast for any token pairing
(
frontend/scripts/contrast-check.py, enforced bymake lint); every token needs a working value in both light and dark mode. - Spacing, radii, and typography sizes also come from the theme scale — no
arbitrary values (
p-[13px]) in components.
Testing
- Fast unit tests colocated in
backend/tests/; retrieval logic gets SQL-level tests against a real Postgres (docker). tests/evals/holds the refinement/query eval set — extend it whenever prompt or engine behavior changes, and runmake evalwith both a local model and a cloud model before merging prompt changes.- E2E: Playwright in
frontend/e2e/against the dev stack (make e2e). Cover the critical paths: login, chat streaming, the writing editor (section refinement + accept), publishing, review requests, permission boundaries (user A must not see user B's restricted docs). - Visual verification: when changing UI, use the Playwright MCP server to open the affected pages, take screenshots (light AND dark mode), and inspect the result before considering the work done. Frontend changes are not finished on "it compiles".
Dev environment notes
- Local LLM: llama.cpp server (Gemma-class 12B/26B) via OpenAI-compatible API;
quality reference: Claude Sonnet via API. Both configured purely through
.env(PABLAN_CHAT_*,PABLAN_UTILITY_*,PABLAN_EMBEDDING_*). - Embeddings: multilingual model (e.g. bge-m3) — German retrieval quality is a
first-class requirement; hybrid search (pgvector + Postgres
germanfull-text with RRF) is the default, not an option.