Files
pablan/CLAUDE.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

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 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 ModeEvents; 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.