# 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 in `hooks.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 via `app/ee_hooks.py` and 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 stack - `make migrate` — apply Alembic migrations - `make seed` — seed dev data - `make types` — regenerate `frontend/src/lib/api/schema.d.ts` from 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 in `backend/tests/evals` against the configured endpoint - `make e2e` — Playwright end-to-end tests against the dev stack ## Architecture rules (invariants — enforce in every change) 1. **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. 2. **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. 3. **All LLM traffic goes through `llm/client.py`** (`chat_stream`, `chat_json`, `embed`) with model roles `chat` / `utility` / `embedding`, each independently configurable (base_url, api_key, model). Never call an LLM HTTP API elsewhere. Its failure vocabulary lives in `llm/errors.py`: `LLMError.code` classifies every endpoint failure once, and the frontend phrases it. 4. **Structured outputs use `chat_json`** with a Pydantic schema passed as `response_format` (JSON schema). Never parse free-form LLM text into data. 5. **Modes implement the `Mode` protocol** (`modes/base.py`) yielding `ModeEvent`s; the conversations router converts events to SSE. Modes know no HTTP; routers know no mode logic. New modes register in `modes/registry.py`. Query (RAG Q&A) is the only core mode; EE adds insight. Capture is NOT a mode — see rule 6. 6. **Capture is writing-first, not a conversation.** The user authors a `Document` directly (Markdown is the source of truth, rule 1); it starts in `draft` status — author-only (`rag/permissions.readable_documents_filter`) and never indexed until published (searchable requires `published`), 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 a `ReviewRequest` — "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`, package `app/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 with `rag/chunking`. No hidden engine state — the document is the state. 7. **Prompts are rendered natural language**, never raw YAML/JSON dumps. 8. **Auth = server-side sessions** (argon2 password hashes, `auth_sessions` table, httpOnly cookie). No JWT. Naming: chat threads are `conversations`, login sessions are `auth_sessions` — never mix these up. 9. **Background work goes through the `jobs` table** (`ingestion/queue.py`, `FOR UPDATE SKIP LOCKED` loop). No new queue infrastructure. 10. **Frontend renders LLM/document Markdown only through the sanitizing renderer** (DOMPurify). Treat all model output and document content as untrusted. 11. **API contract flows one way**: FastAPI OpenAPI → `openapi-typescript` → typed `openapi-fetch` client. Never hand-write API response types. 12. **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. 13. **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 in `tests/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 missing `en` message and an em/en dash in any message both fail `make lint` (`frontend/scripts/check-messages.py`). See `docs/i18n.md` for 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 by `code`; SSE `state` events carry counts and markers, and the frontend phrases them. - Full type hints in Python; `ruff` rules include `I` (isort) and `B` (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.svg` is the ER diagram; any model or migration change updates the diagram and the prose in `docs/data-model.md` in 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.css` and 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 by `make 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 run `make eval` with 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 `german` full-text with RRF) is the default, not an option.