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
This commit is contained in:
@@ -0,0 +1,311 @@
|
||||
# Decision log
|
||||
|
||||
Short ADR-style records. Context for all: team of 2 developers + 1
|
||||
legal/marketing co-founder; product must be self-hostable by SME IT admins;
|
||||
bias toward boring, simple, understandable technology.
|
||||
|
||||
## D1 — PostgreSQL + pgvector, no dedicated vector DB
|
||||
One database for relational data, documents, vectors, full-text, jobs and
|
||||
sessions. `docker compose up` starts 3 containers; nothing extra to operate,
|
||||
patch and back up. HNSW handles our scale (SMEs, hundreds of thousands of
|
||||
chunks) easily. Retrieval is capsuled behind `rag/retrieval.py`, so swapping
|
||||
in Qdrant later (Variant B) is a contained refactor. Bonus: Postgres
|
||||
full-text (`german` config) enables hybrid search with zero extra
|
||||
infrastructure.
|
||||
|
||||
## D2 — SQLAlchemy 2.0 + Alembic, not Prisma
|
||||
Prisma is TypeScript-first; the DB belongs to the FastAPI backend (the
|
||||
Python client is a community project — not production-grade). Prisma treats
|
||||
pgvector as `Unsupported("vector")`, forcing raw SQL anyway. The official
|
||||
pgvector package ships a first-class SQLAlchemy type.
|
||||
|
||||
## D3 — No LangChain / LlamaIndex
|
||||
Thin custom LLM client (3 functions) + our own mode engine. For a focused
|
||||
product the framework abstraction costs more than it saves; prompts and
|
||||
retrieval are our core competence and must stay fully controllable.
|
||||
|
||||
## D4 — OpenAI-compatible API as the only LLM contract
|
||||
llama.cpp server, vLLM, Ollama and all major clouds speak it. Config is just
|
||||
base_url/api_key/model — per model role (chat / utility / embedding).
|
||||
The serving layer applies each model's chat template; we never hand-build
|
||||
prompt formats.
|
||||
|
||||
## D5 — Server-side sessions, no JWT; auth in FastAPI, not Auth.js
|
||||
Auth.js would put identity in the SvelteKit server while enforcement
|
||||
(document permissions, retrieval filtering) lives in FastAPI — two systems
|
||||
to keep in sync. Server-side sessions are instantly revocable ("employee
|
||||
leaves" is our core use case), trivial to reason about, and have zero
|
||||
drawbacks for single-backend self-hosted deployments.
|
||||
|
||||
## D6 — Sessions naming
|
||||
`conversations` (chat threads) vs `auth_sessions` (logins). Never "session"
|
||||
unqualified.
|
||||
|
||||
## D7 — Background jobs via Postgres table, no Redis/Celery
|
||||
`jobs` table + asyncio loop with `FOR UPDATE SKIP LOCKED`. ~80 lines,
|
||||
survives restarts, built-in retry, no new infrastructure for customers.
|
||||
Moves unchanged into a worker container when scale demands it.
|
||||
|
||||
## D8 — SvelteKit is pure UI
|
||||
All `/api` traffic goes through the reverse proxy directly to FastAPI (same
|
||||
origin: no CORS, cookies and SSE just work, no stream buffering through
|
||||
Node). SvelteKit renders pages and gates routes via `hooks.server.ts`.
|
||||
|
||||
## D9 — Own components on Bits UI, no component library imports
|
||||
Bits UI provides the hard invisible parts (focus traps, keyboard
|
||||
conventions, ARIA state, portal/positioning, outside-click) as a headless
|
||||
layer; we own 100% of markup and styling. shadcn-svelte was considered (it
|
||||
copies source rather than importing) but we skip its styling layer entirely.
|
||||
Discipline required: small base component set, everything from design
|
||||
tokens.
|
||||
|
||||
## D10 — Caddy as the only supported reverse proxy (for now)
|
||||
Argument is config ergonomics for foreign IT admins (5-line Caddyfile,
|
||||
automatic TLS) — not capability. The app is proxy-agnostic (one origin,
|
||||
`/api/*` to the backend, rest to the frontend), so other proxies can be
|
||||
added to `deploy/` when a customer actually needs one; until then we
|
||||
maintain exactly one config.
|
||||
|
||||
## D11 — uv + ruff (lint AND format), pre-commit, Makefile as task runner
|
||||
One Python toolchain, no Black. `make` because it is universally installed
|
||||
and gives CI and both developers identical commands.
|
||||
|
||||
## D12 — Markdown as canonical knowledge format
|
||||
Human-readable, versionable, exportable — no data prison (sales argument).
|
||||
Chunks/embeddings are disposable derivatives; re-indexing from documents
|
||||
must always be possible (e.g. embedding model switch).
|
||||
|
||||
## D13 — Step-wise prompt injection for interviews (SUPERSEDED by D19)
|
||||
Only the current interview point enters the prompt; a separate `chat_json`
|
||||
bookkeeper call tracks the checklist. Keeps Gemma-class 12B models reliable
|
||||
and shrinks the local-vs-cloud quality gap. **Superseded:** capture is no
|
||||
longer a dialog — the interview engine and bookkeeper were removed. The
|
||||
successor keeps small models fast a different way: FIM section refinement (D19).
|
||||
|
||||
## D14 — Fair Source (FSL) + proprietary `ee/`
|
||||
See `licensing.md`.
|
||||
|
||||
## D15 — Privacy defaults are product features
|
||||
Approval-gated publishing, user-deletable conversations, retention job,
|
||||
aggregation-only insights. Designed for the German works-council/GDPR
|
||||
conversation from day one — retrofitting this would be painful; leading with
|
||||
it is a sales argument.
|
||||
|
||||
## D16 — Hybrid dev stack: Postgres in Docker, app processes native
|
||||
`make dev` starts only Postgres via `docker-compose.dev.yml` (own compose
|
||||
project name `pablan-dev`) and runs uvicorn `--reload` and vite natively on
|
||||
the host — fast reloads, IDE-friendly `.venv`/`node_modules`, no bind-mount
|
||||
quirks. The fully containerized `docker-compose.yml` is the customer
|
||||
deployment and doubles as the pre-release check (`docker compose up -d
|
||||
--build` with `PABLAN_DOMAIN=localhost`); separate compose project names let
|
||||
both run on one machine.
|
||||
|
||||
## D17 — Display rename "Guided documentation" without id migration (SUPERSEDED by D19)
|
||||
The feature formerly presented as "Interview" was renamed **"Guided
|
||||
documentation"** in user-facing copy (M8), keeping the `interview` mode id and
|
||||
module. **Superseded:** the interview mode itself was removed — capture is now
|
||||
a writing editor (D19), so there is no interview engine to name.
|
||||
|
||||
## D18 — LLM endpoint configuration in the DB, env as fallback
|
||||
Endpoints move during a pilot (local llama.cpp → cloud, or a new model),
|
||||
and asking a customer to edit `.env` and restart for that is a support
|
||||
call. Admins now override `base_url` / `model` / `api_key` per role in the
|
||||
UI; the environment stays the bootstrap and the per-field fallback, so a
|
||||
fresh deployment works with no database rows at all. Changes apply without
|
||||
a restart by rebuilding the cached clients. The key is stored in plaintext
|
||||
because it must be replayed to the endpoint — it is never returned by the
|
||||
API and never logged, and the database is the customer's own.
|
||||
|
||||
## D19 — Capture inverted to a writing-first editor
|
||||
Capture was a dialog: an interviewer asked, a bookkeeper extracted notes, and
|
||||
the document was a render of those notes — the user never wrote it. That reads
|
||||
like an interrogation and fights the reality that someone *has* something to
|
||||
write down. Markdown is already the source of truth (D12/rule 1), so the user
|
||||
should edit that source directly, not watch a derived render. Capture is now a
|
||||
**writing editor**: the user writes Markdown into a `Document` (`app/authoring/`),
|
||||
and after a typing pause the model matures only the section at the cursor.
|
||||
Consequences that make this cheap and safe on small local models:
|
||||
- **Drafts are author-only and never indexed.** A captured document starts as
|
||||
a `draft`, private to its author and outside retrieval, so it can never reach
|
||||
another user or an LLM prompt (rule 2) before its author publishes it.
|
||||
- **FIM section refinement.** The whole document is prefix/suffix context but
|
||||
the model regenerates ONLY the active section, so a large document is never
|
||||
re-emitted whole. With the reasoning model's thinking disabled
|
||||
(`enable_thinking = false`) the call returns in ~1s — fast enough for a
|
||||
"after pause" trigger and reliable on Gemma-class 12B.
|
||||
- **One editor for capture and edit, with a pre-save diff.** The same editor
|
||||
writes new drafts and revises existing documents, always showing a VSCode-style
|
||||
line diff before saving.
|
||||
- **CodeMirror 6 + `@codemirror/merge` as a deliberate dependency.** CodeMirror
|
||||
is an editor/diff engine (like marked/dompurify), not a UI component library,
|
||||
so it is compatible with D9; `@codemirror/merge`'s `unifiedMergeView` provides
|
||||
the pre-save diff, which is why no separate diff library (jsdiff) is pulled in.
|
||||
|
||||
This removed the interview mode, bookkeeper, checklist, the `Draft`/`Navigable`
|
||||
protocols, and the conversation capture endpoints; `query` is the only core
|
||||
mode left. See `architecture.md` and `authoring-templates.md`.
|
||||
|
||||
## D20 — Reviewer access as a read clause, not a new permission layer
|
||||
An author can ask a colleague to check a document, and that colleague must be
|
||||
able to read it even while it is an unpublished draft — without anything
|
||||
leaking into chat/search. So the grant is a single clause (an open
|
||||
`review_requests` row addressed to the user) added to
|
||||
`readable_documents_filter` **only** — never to `searchable_documents_filter` —
|
||||
reusing the existing filter split rather than introducing a status or a grant
|
||||
table. Reviewer candidates are derived department-granular from the document's
|
||||
would-be read set (author + public/department/`doc_permissions`) via a
|
||||
dedicated non-admin, permission-safe query returning id + name only — not by
|
||||
reusing `/admin/users`. (Superseded in part by D24: the clause is now the open
|
||||
request, not a `documents.reviewer_id` column, and being asked also grants the
|
||||
right to EDIT.)
|
||||
|
||||
## D21 — Topic-summary retrieval, in the capture picker only (for now)
|
||||
When a capture starts from a chat, the picker suggests existing documents to
|
||||
extend. Retrieving over the raw last chat message is unreliable (the last turn is
|
||||
often a topic-less follow-up like "thanks"), so the match runs over a short **LLM
|
||||
topic summary** of the conversation (`chat_json`, utility) instead. An eval
|
||||
(`tests/evals/test_topic_retrieval_eval.py`) compares topic-summary vs.
|
||||
raw-message recall on a fixture; measured 4/4 vs. 1/4. On that evidence, query
|
||||
mode now uses it too, but only as a **fallback**: it searches on the raw message
|
||||
first and re-searches with a topic summary of the conversation ONLY when the raw
|
||||
search is low-confidence and there is earlier context (`query.py::_topic_transcript`
|
||||
returns "" for a first message). So a clear question pays no extra latency and a
|
||||
first-message miss stays a genuine no-answer, while a topic-losing follow-up
|
||||
recovers the subject. The same topic summary is stored as `meta.context` on a
|
||||
chat-originated draft and passed to the refine prompt as background, so refinement
|
||||
is chat-aware without re-summarizing per call.
|
||||
|
||||
## D22 — Cache-friendly query prompt: static system, volatile excerpts last
|
||||
Local llama.cpp and cloud OpenAI-compatible endpoints both reuse a request's
|
||||
longest identical prefix (prompt/KV caching). The old query prompt injected the
|
||||
per-question excerpts INTO the system message, so everything after the static
|
||||
rules — including the conversation history — was reprocessed every turn. The
|
||||
message order is now `[static system rules] [conversation history] [this turn's
|
||||
excerpts + question]` (`modes/prompts.py::render_context_turn`): the system rules
|
||||
are byte-identical for every question (cached across all conversations and users)
|
||||
and the history is byte-identical across a conversation's turns (cached within
|
||||
it), so only the volatile excerpts + question are new work. The excerpts are
|
||||
inherently per-question and never cacheable; keeping them out of the shared
|
||||
prefix is what makes the rest cacheable. No endpoint config is needed — the win
|
||||
is purely in prompt structure.
|
||||
|
||||
|
||||
## D23 — API modules are packages cut by caller intent, with the gate on the router
|
||||
`api/documents.py` had grown to 1137 lines and `api/admin.py` to 615, which is
|
||||
what happens when "one module per resource" meets a resource with five different
|
||||
jobs. They are packages now, cut by what a caller is DOING — browse, the life of
|
||||
one document, its audit trail, the publish/review workflow, sharing — rather
|
||||
than by HTTP verb or by schema-vs-route. Two consequences are the actual point:
|
||||
|
||||
- **Shared rules exist once, and are stated.** `documents/access.py` holds every
|
||||
gate (including the Python mirror of the read filter used to judge a change
|
||||
before it commits) and `documents/view.py` every response shape. Modules that
|
||||
used to reach into another module's private helpers (`_readable_document`,
|
||||
`_role_config`, `_excerpt`, `_HEADING_RE`) now import a named interface.
|
||||
- **A dependency belongs on the router, not on each endpoint.** Each package has
|
||||
a `routing.py` that builds its `APIRouter`; `admin` puts `require_admin`
|
||||
there, and `templates` has two constructors because reading is for everyone
|
||||
and writing is not. A new endpoint is gated by the router it is added to,
|
||||
which is not something an author can forget.
|
||||
|
||||
The cost is that route order becomes explicit: FastAPI matches in registration
|
||||
order, so the module with static paths is included before the one with
|
||||
`/{id}`. That is stated in each package's `__init__` rather than discovered.
|
||||
|
||||
|
||||
|
||||
## D24 — Review is a question about a document, not a status of it
|
||||
Publishing used to run through an approval queue: `draft → pending_approval →
|
||||
published`, with a delegated reviewer who could approve but not edit. Two things
|
||||
were wrong with it. It made every author wait for someone else before their
|
||||
knowledge was findable at all — the opposite of "writing it down must be
|
||||
cheap" — and it could only express doubt BEFORE publishing, while the doubt that
|
||||
actually matters ("do the 14 holiday days still hold?") turns up on a document
|
||||
that has been published for months.
|
||||
|
||||
So the two ideas were separated:
|
||||
|
||||
- **Status says where a document stands**: `draft` (being written, author-only,
|
||||
unindexed), `published`, `archived`. Publishing is the author's own one-click
|
||||
action; `pending_approval` is gone.
|
||||
- **A `ReviewRequest` says what is unsettled about its CONTENT** — one question,
|
||||
addressed to one colleague, open until answered. It can sit on a draft or on a
|
||||
document published months ago, and it is orthogonal to the status.
|
||||
|
||||
Consequences that make it work:
|
||||
|
||||
- **An open request marks the document everywhere**, including the sources under
|
||||
a chat answer (`review_pending` on every search hit, snapshotted with the
|
||||
citation). A reader who is handed an answer sees that the passage behind it is
|
||||
not confirmed — which is the case the whole feature exists for.
|
||||
- **Being asked grants the right to edit** (`can_edit` = author, admin, or open
|
||||
reviewer), not just to read: a reviewer who spots a wrong number should fix it
|
||||
rather than file a second question about it. The grant ends with the answer,
|
||||
and for a draft the read access ends with it too. The owner-only decisions
|
||||
(delete, sharing, handing out a request) stay with the author or an admin.
|
||||
- **The audit trail keeps who checked what**: `review_requested` /
|
||||
`review_resolved` events, with the answering colleague as `actor_id`. The
|
||||
`review_requests` row alone would only tell you that nothing is open.
|
||||
|
||||
The migration maps `pending_approval` → `draft`, `approved` events → `published`,
|
||||
drops `submitted` events and `documents.reviewer_id`.
|
||||
|
||||
|
||||
|
||||
## D25 — Three fields removed rather than kept "for later"
|
||||
`documents.verified_until`, `meta.tags` and `documents.source_type` all
|
||||
existed, were all written on every publish, and none of them did anything.
|
||||
They were removed together.
|
||||
|
||||
- **`verified_until`** was a freshness horizon: 180 days after publishing, the
|
||||
UI put "Verifizierung fällig" on the document and offered a Re-verify button.
|
||||
It marked documents and reminded nobody — no mail, no queue, no name attached
|
||||
— so the badge said only "time has passed", which the `updated_at` in the
|
||||
same line already said. A `ReviewRequest` (D24) is the thing that actually
|
||||
asks: it names a person, carries a question, and can sit on a document
|
||||
published months ago.
|
||||
- **`tags`** had been write-only since M8: free text with no way to see which
|
||||
tags already existed, so the values that arrived were too inconsistent to
|
||||
filter by, and hybrid search covers finding things anyway. Keeping the column
|
||||
"because the data is real" only kept the template form asking for it.
|
||||
- **`source_type`** had two members and one of them (`upload`) was unreachable:
|
||||
nothing can be uploaded. Everything in the table was written in Pablan, so
|
||||
the column classified nothing.
|
||||
|
||||
The general rule this records: a field nothing reads is not neutral. It costs a
|
||||
form row, a table column, a line in the export frontmatter, an entry in the ER
|
||||
diagram, and a paragraph of documentation explaining why it is there. Import
|
||||
(roadmap epic 19) will want provenance, and can reintroduce a column that
|
||||
distinguishes values it actually produces.
|
||||
|
||||
|
||||
|
||||
## D26 — The queue in front of the endpoint is ours, not the server's
|
||||
A self-hosted llama.cpp server takes every request it is offered and serves
|
||||
`--parallel` of them at a time; the rest wait inside it. That is a queue Pablan
|
||||
can neither see, bound, nor explain — and every request waiting in it is still
|
||||
burning the client's HTTP timeout. Two colleagues chatting while a reindex runs
|
||||
is enough to turn "slow" into "everything times out at once".
|
||||
|
||||
So the waiting happens in `app/llm/gate.py`, in front of the endpoint, and it
|
||||
is bounded twice: a caller waits at most `PABLAN_LLM_QUEUE_WAIT_SECONDS` for a
|
||||
slot, and past `PABLAN_LLM_MAX_QUEUED` waiters the gate stops admitting at all.
|
||||
An unbounded wait would be the same failure as no gate, just quieter.
|
||||
|
||||
Three details that make it work:
|
||||
|
||||
- **Keyed by base_url, not by role.** chat and utility point at the same server
|
||||
in the shipped configuration; a per-role limit would let one server be given
|
||||
twice its slots.
|
||||
- **Both refusals are `llm_busy`.** A queue timeout and a provider's 429 mean
|
||||
the same thing to the person waiting, so they get the same sentence.
|
||||
- **A stream holds its slot to the last token**, because that is how long it
|
||||
occupies the server's slot.
|
||||
|
||||
What it buys beyond fairness: a saturated EMBEDDING endpoint now degrades query
|
||||
mode to full-text retrieval (that path already existed for an unreachable
|
||||
endpoint) instead of failing, and the chat can say "the model is busy" as a
|
||||
`queued` phase rather than blinking a cursor for twenty seconds.
|
||||
|
||||
`probe()` and `list_models()` stay outside the gate: an admin has to be able to
|
||||
test an endpoint precisely when it is saturated.
|
||||
Reference in New Issue
Block a user