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
466 lines
27 KiB
Markdown
466 lines
27 KiB
Markdown
# Architecture
|
|
|
|
Pablan is a self-hosted knowledge management application for SMEs. Employees
|
|
capture knowledge by **writing Markdown directly** into a document in an
|
|
editor that matures the section at the cursor with the LLM (a writing-first
|
|
flow, not a chatbot interview — D19); the results are Markdown documents. It
|
|
answers questions about the knowledge base via RAG, and (as a paid feature)
|
|
generates aggregated insights for management.
|
|
|
|
Core pain points addressed: knowledge leaves the company when employees do
|
|
("tacit knowledge" is never written down), and departments solve problems in
|
|
isolation without learning from each other.
|
|
|
|
## Deployment model (Variant A — the pragmatic monolith)
|
|
|
|
Everything runs on the customer's infrastructure via Docker Compose:
|
|
|
|
```
|
|
[Browser] ⇄ [Reverse proxy :443]
|
|
├── /api/* → backend (FastAPI, :8000)
|
|
└── /* → frontend (SvelteKit node adapter, :3000)
|
|
[backend] ⇄ [PostgreSQL + pgvector]
|
|
[backend] ⇄ [LLM endpoints] (OpenAI-compatible: llama.cpp local OR cloud API)
|
|
```
|
|
|
|
- **One origin.** The reverse proxy makes frontend and API same-origin: no
|
|
CORS, httpOnly session cookies just work, SSE streams flow directly from
|
|
FastAPI to the browser without passing through Node.
|
|
- **One database.** PostgreSQL holds relational data, documents, embeddings
|
|
(pgvector), full-text indexes, background jobs, and auth sessions. No
|
|
dedicated vector DB, no Redis, no message broker.
|
|
- **Reverse proxy** is a reference config in `deploy/`, not part of the app.
|
|
Caddy is the supported customer setup (5-line config, automatic TLS).
|
|
|
|
Component overview (built vs. planned):
|
|
[`diagrams/components.svg`](diagrams/components.svg)
|
|
|
|
## Backend structure (`backend/app/`)
|
|
|
|
| Module | Responsibility |
|
|
|---|---|
|
|
| `models/` | SQLAlchemy 2.0 models — see `data-model.md` |
|
|
| `auth/` | Server-side sessions, argon2, permission dependencies |
|
|
| `api/` | Thin HTTP routers; no business logic. The big ones (`documents/`, `admin/`, `conversations/`, `authoring/`, `templates/`) are packages split by what a caller is doing |
|
|
| `modes/` | Mode engine: query (+ ee: insights) |
|
|
| `authoring/` | Capture: authoring-template schema + document/section refinement for the writing editor |
|
|
| `llm/` | The ONLY code speaking to LLM endpoints |
|
|
| `rag/` | Chunking, indexing, hybrid retrieval with permission filter |
|
|
| `ingestion/` | Background jobs via a Postgres `jobs` table |
|
|
| `log.py` | Structured JSON logging, correlation ids; content-free by policy (rule 12) |
|
|
| `metrics.py` | In-process metrics registry, exposed via `GET /api/admin/metrics` |
|
|
| `ee_hooks.py` | Optional import of `pablan_ee`, registers EE modules |
|
|
|
|
### How an API package is cut
|
|
|
|
By what a caller is doing, not by HTTP verb: `documents/` has `browse`, `crud`,
|
|
`history`, `workflow` and `sharing`, with the gates in `access.py` and the
|
|
response shapes in `view.py`, so a rule like "an author keeps access to their
|
|
own document" exists once. Each module builds its router through the package's
|
|
`routing.py`, which is also where a shared dependency lives — the admin gate
|
|
sits on the router, so a new admin endpoint is gated by where it is added
|
|
rather than by remembering a decorator.
|
|
|
|
**Route order matters**: FastAPI matches in registration order, so the module
|
|
with the static paths (`/search`, `/export`, `/catalog`) is included before the
|
|
one with `/{id}`.
|
|
|
|
### Mode engine
|
|
|
|
Every interaction type implements the `Mode` protocol:
|
|
|
|
```python
|
|
class Mode(Protocol):
|
|
name: str
|
|
async def handle_turn(conv, user_message, db) -> AsyncIterator[ModeEvent]
|
|
```
|
|
|
|
`ModeEvent` = `Token | Sources | StateChanged | Done | Error | Degraded`
|
|
(`StateChanged` carries a `phase` and an optional `count` — metadata only,
|
|
rule 12; `Degraded` says no model could be reached, so the accompanying
|
|
`Sources` are a plain full-text result list rather than citations). The
|
|
conversations router converts events 1:1 into SSE events (see
|
|
`api-protocol.md`). Modes know nothing about HTTP; routers know nothing about
|
|
mode logic. Modes register in `modes/registry.py` — this is also the EE
|
|
extension point (the insights mode lives entirely in `ee/backend`).
|
|
|
|
**`query` (RAG Q&A) is the only core mode.** Capture is deliberately NOT a
|
|
mode: it is writing into a Document directly (`app/authoring/`), not a
|
|
conversation, so it never touches `handle_turn` or the SSE turn loop. That
|
|
keeps rule 5 clean — `modes/` stays the home of the turn protocol.
|
|
|
|
### LLM abstraction
|
|
|
|
Three independently configured model roles, each just `base_url` +
|
|
`api_key` + `model` (env vars `PABLAN_CHAT_*`, `PABLAN_UTILITY_*`,
|
|
`PABLAN_EMBEDDING_*`):
|
|
|
|
- **chat** — conversation turns (quality matters; Gemma-class 12B+ locally,
|
|
Claude/cloud in production)
|
|
- **utility** — summarization, entity extraction (cheap + fast)
|
|
- **embedding** — multilingual model (e.g. bge-m3); German quality is a
|
|
first-class requirement
|
|
|
|
The client exposes exactly three functions: `chat_stream`, `chat_json`
|
|
(structured output enforced via `response_format` JSON schema + Pydantic
|
|
validation + one retry), and `embed`. llama.cpp's server implements all
|
|
required endpoints including grammar-constrained JSON. `chat_stream` takes an
|
|
optional `extra_body` passed through to the endpoint verbatim — used only by
|
|
section refinement to send `chat_template_kwargs.enable_thinking = false`,
|
|
which turns off a reasoning model's hidden thinking so the refine call returns
|
|
in ~1s instead of ~10s (a mechanical rewrite needs no chain of thought).
|
|
|
|
**Configuration: bootstrap from env, then the DB owns it.** At first start
|
|
`bootstrap_llm_settings()` copies the `PABLAN_CHAT_*` / `PABLAN_UTILITY_*` /
|
|
`PABLAN_EMBEDDING_*` values into `llm_settings`, one row per role. From then
|
|
on **the table is authoritative and later `.env` edits are ignored** — a
|
|
configuration the admin can change in the UI and the deployment can change
|
|
underneath them cannot both be the truth, and silently losing a UI change
|
|
on the next restart is the worse of the two failures.
|
|
|
|
The environment stays reachable as the value a field can be *reset* to.
|
|
Each field carries a `*_from_env` flag recording where its current value
|
|
came from, so the UI labels every field "from .env" or "changed here" and
|
|
offers a per-field reset that writes back what `.env` currently says.
|
|
Provenance is tracked explicitly rather than inferred by comparing against
|
|
the environment, which would mislabel every field the moment someone edits
|
|
`.env` after install.
|
|
|
|
`_role_config()` reads a module-level cache (`app/llm/overrides.py`)
|
|
because it is synchronous and hot; the cache is filled at startup and
|
|
refreshed on every admin write, which also calls `rebuild_clients()` — the
|
|
cached `AsyncOpenAI` instances hold the old URL and key, so changes apply
|
|
without a restart. This works because the deployment pins one uvicorn
|
|
worker: exactly one cache to refresh. A multi-worker setup would need a
|
|
notification channel.
|
|
|
|
The `api_key` is stored in plaintext — it must be replayed to the endpoint,
|
|
so there is nothing to compare a hash against. It is never returned by the
|
|
API (only `api_key_set` / `api_key_from_env` booleans) and never logged
|
|
(rule 12). Admins validate a candidate endpoint with `probe()` before
|
|
saving, so a wrong URL fails in the settings screen rather than on the next
|
|
user question, and `list_models()` asks the endpoint's `GET /v1/models`
|
|
**server-side** so the model field can become a dropdown without the
|
|
browser ever holding the credentials. Endpoints that do not implement that
|
|
route are common; the response carries `supported: false` and the UI keeps
|
|
its free-text field rather than showing an error.
|
|
|
|
**One gate per endpoint, in front of every call** (`app/llm/gate.py`). A
|
|
self-hosted server has a fixed number of parallel slots; sending more than that
|
|
does not make it faster, it just moves the queue somewhere Pablan cannot see or
|
|
bound, where every waiting request still burns the HTTP timeout. Two colleagues
|
|
chatting while a reindex runs is enough. So `chat_stream`, `chat_json` and
|
|
`embed` each take a slot first, keyed by **base_url** rather than by role —
|
|
chat and utility usually share one server, and the server is what has the
|
|
slots. A caller waits at most `PABLAN_LLM_QUEUE_WAIT_SECONDS`, and past
|
|
`PABLAN_LLM_MAX_QUEUED` waiters the gate stops admitting at all; both refusals
|
|
come back as `llm_busy`, the same code a cloud provider's 429 produces, so the
|
|
frontend has one sentence to say. A streaming completion holds its slot until
|
|
the last token, because that is how long it occupies the server's. Two
|
|
consequences worth naming: a saturated EMBEDDING endpoint makes query mode fall
|
|
back to full-text retrieval (it already handles `LLMError` there) rather than
|
|
fail, and the query mode asks `endpoint_busy()` before it streams, so a waiting
|
|
turn says "the model is busy" instead of blinking a cursor. `probe()` and
|
|
`list_models()` deliberately bypass the gate: an admin has to be able to test an
|
|
endpoint precisely when it is saturated. `PABLAN_LLM_MAX_PARALLEL` should match
|
|
the server's own parallelism (llama.cpp `--parallel`).
|
|
|
|
**Admin-editable prompts** mirror this pattern with two simplifications. Every
|
|
shipped system prompt (query, no-sources, refinement persona/rules, grounding
|
|
framing, topic summary, title) has a CODE default in
|
|
`app/prompts/defaults.py`; an admin may override it in `prompt_settings`, applied
|
|
without a restart via a module cache (`app/prompts/overrides.py::get_prompt`,
|
|
refreshed on every write). The render functions read `get_prompt(key)`, so an
|
|
override wins and a missing row falls back to the default — the reset target is
|
|
the code default, since prompts have no `.env` layer, and there is no bootstrap.
|
|
Prompt-cache friendliness (D22) holds: a value only changes on an admin write, so
|
|
`query_system` stays byte-identical between a conversation's turns.
|
|
|
|
**Inspectable working context.** Behind a "?" in the chat, the context inspector
|
|
shows every passage retrieval surfaced for an answer, marking which grounded it
|
|
(`used`) versus which were retrieved but dropped as too weak — so a no-answer is
|
|
explained rather than silent. The editor shows the same for a refinement: the
|
|
grounding references it drew from, sent as a `grounding` SSE frame. Both surface
|
|
only material the requesting user may already read (rule 2) — titles/headings and
|
|
short excerpts, never new content.
|
|
|
|
### Ingestion queue
|
|
|
|
Sequence: [`diagrams/queue-sequence.svg`](diagrams/queue-sequence.svg)
|
|
|
|
A `jobs` table + an asyncio loop in the app lifespan, claiming jobs with
|
|
`SELECT … FOR UPDATE SKIP LOCKED`. The claim transaction stays open while
|
|
the handler runs: a crash rolls back claim and handler writes together, so
|
|
jobs are atomic and claimable again after a restart. Retry with exponential
|
|
backoff, `last_error` sanitized. Job types: `index_document`, `reindex_all`,
|
|
`retention_cleanup` (daily, reschedules itself).
|
|
Designed so the loop can later move into a separate worker container
|
|
(Variant B) without changing any imports.
|
|
|
|
**Constraint: exactly one uvicorn worker per deployment.** One process =
|
|
one queue loop = one in-process metrics registry. Scaling beyond this is
|
|
the Variant B worker split, NOT more uvicorn workers (the customer compose
|
|
pins `--workers 1`).
|
|
|
|
## Frontend structure (`frontend/src/`)
|
|
|
|
SvelteKit is pure UI — no database access, no auth logic beyond passing the
|
|
session cookie through. `hooks.server.ts` validates the session against
|
|
`GET /api/auth/me` and fills `locals.user`; the `(app)` route group redirects
|
|
to `/login` without it (sequence:
|
|
[`diagrams/auth-sequence.svg`](diagrams/auth-sequence.svg)). Chat state lives in Svelte 5 runes classes. Custom
|
|
components on top of Bits UI (headless behavior layer); design tokens in
|
|
`app.css` (see CLAUDE.md). API types are generated from FastAPI's OpenAPI
|
|
schema (`make types`).
|
|
|
|
The shell is a collapsible left sidebar (`lib/nav/Sidebar.svelte`): one
|
|
primary action on top, recent conversations in the middle,
|
|
documents/admin/account anchored at the bottom. Collapse state is
|
|
per-device (`localStorage`). The account entry opens a dialog
|
|
(`lib/nav/SettingsDialog.svelte`) rather than a menu — settings hold forms
|
|
(password, theme, language), which a menu cannot.
|
|
|
|
The conversation list is shared between sidebar and chat page through one
|
|
module-scope runes store (`lib/chat/conversations.svelte.ts`).
|
|
|
|
### One thing per screen
|
|
|
|
Every screen was gone over with two questions: what has to be seen without
|
|
looking for it, and what has to be findable without being in the way. The
|
|
rules that came out of it, applied everywhere:
|
|
|
|
- **One primary action, labelled.** The action a visitor most likely came for
|
|
is a button with a word on it. Everything else that has to be reachable goes
|
|
into `lib/components/Menu.svelte` — a labelled overflow menu, because a row
|
|
of bare icons makes the reader guess.
|
|
- **Only exceptions are marked.** A document list where every card says
|
|
"published · public · yours" says nothing; the one row that carries an
|
|
unanswered question disappears into the pattern. Badges are for the
|
|
abnormal: open questions, drafts, archived, built-in help.
|
|
- **Metadata is one quiet line, not a stack of rows.** The document page shows
|
|
status, who may read it, and when it changed in a single grey row above the
|
|
text, with the details a click behind the chip that states them
|
|
(`lib/documents/AccessPopover.svelte` — visibility and department sharing in
|
|
one place, since they are the same question).
|
|
- **Controls that are used monthly hide behind a toggle, and say when they are
|
|
on.** The document list is a search field and results; the six ways to narrow
|
|
it sit behind "Filter", and any filter that IS active stays visible as a
|
|
removable chip, because a quietly filtered list lies about what exists.
|
|
- **Unrelated jobs become tabs, not a longer scroll.** `/admin` is four
|
|
unrelated jobs (users and departments, templates, endpoints, prompts); as
|
|
tabs each is named up front and one click away.
|
|
- **A page with nothing to do is a page with something missing.** The landing
|
|
page volunteers the drafts nobody published and the checks waiting for this
|
|
person (`lib/documents/OpenWork.svelte`); the profile answers "what have I
|
|
written?".
|
|
|
|
The landing page itself is one input (focused on arrival), the two other
|
|
things anyone comes for, and then that open work.
|
|
|
|
A conversation lives at **`/chat/[id]`**, resolved in a server load so a
|
|
pasted link renders the right thing on first paint. The API is
|
|
owner-scoped, so an unknown id and someone else's id both come back 404 and
|
|
are indistinguishable from the outside — existence must not leak, same
|
|
semantics as the documents API. `/chat` is the empty composer and accepts
|
|
`?q=<text>` (ask straight away), consumed once.
|
|
|
|
Both routes render the same `lib/chat/ChatView.svelte` — the shell that owns
|
|
the route-driven conversation switch, the URL sync and the scroll follow, while
|
|
one reply is `lib/chat/AssistantTurn.svelte` (streaming, cited, uncovered, or
|
|
answered by a plain search) and the input is `lib/chat/Composer.svelte`. The
|
|
chat state
|
|
is a **module-level singleton** (`chatState` in `lib/chat/state.svelte.ts`)
|
|
rather than per-component. The first message on `/chat` creates the
|
|
conversation and moves the URL to `/chat/[id]` via `replaceState` — which
|
|
unmounts one page component and mounts another. Per-component state would
|
|
take the in-flight stream and the messages already on screen down with it.
|
|
Reacting to the route parameter rather than to clicks is also the single
|
|
place where switching conversations aborts the previous stream.
|
|
|
|
Chat is a split view with ONE right-hand slot for a cited document. Citations
|
|
render as labelled badges ("Sources:") that reveal the cited passage on hover
|
|
and open the document in the slot on click (`lib/chat/SourceBadge.svelte`,
|
|
`lib/chat/DocumentPanel.svelte`); a document whose title repeats as its first
|
|
heading is rendered without that heading (`bodyWithoutTitle`), because every
|
|
surface already shows the title above the text; below `lg` the panel stacks under the
|
|
conversation instead. When retrieval takes the low-confidence path, the answer
|
|
is followed by an invitation to capture the missing knowledge; it opens the
|
|
template picker, which creates a draft (`POST /api/documents`) and navigates to
|
|
the writing editor — the router never touches capture.
|
|
|
|
### Writing editor
|
|
|
|
Capture is a **writing-first editor**, not a chat panel. It starts at
|
|
`/documents/new` — a template picker that lists `GET /api/templates`, creates a
|
|
draft (`POST /api/documents {template_id}`) and navigates to the editor (the
|
|
landing "capture" chip and the chat no-answer/gap links all point here). The
|
|
chat links carry `?conversation=<id>`, which makes the picker
|
|
**retrieval-aware**: it calls `POST /api/documents/suggest-similar` and, under
|
|
the templates, offers existing documents that match the conversation ("matches
|
|
your conversation") so the user can **extend** one instead of starting fresh —
|
|
picking a match routes to that document's editor with the same
|
|
`?conversation=`. The matching runs over an **LLM topic summary** of the chat
|
|
(`app/authoring/context.py::summarize_conversation` → `chat_json`), not the raw
|
|
last message, then permission-filtered hybrid retrieval
|
|
(`rag/similarity.similar_documents`, help pages excluded). Creating a draft with
|
|
that `conversation_id` also stores the topic summary as `meta.context`, which
|
|
the refine prompt passes as background so refinement is chat-aware. Refinement
|
|
is also **retrieval-aware**: before it runs, the server searches the
|
|
permission-filtered knowledge base for related, already-published material the
|
|
author may read (`rag/similarity.similar_chunks`, the current document and help
|
|
pages excluded, and only once the section carries enough of its own text to
|
|
match on) and passes it to the prompt as grounding — so a suggestion stays
|
|
consistent with what the company already documented, while the prompt keeps it
|
|
a reference rather than a source of new facts. Query mode
|
|
uses the same topic summary too, but only as a **fallback** — it searches on the
|
|
raw message first and re-searches with a conversation topic summary only when the
|
|
raw search is low-confidence and there is earlier context (so a clear question
|
|
pays no extra latency; see `modes/query.py::_topic_transcript` and decision D21,
|
|
backed by `tests/evals/test_topic_retrieval_eval.py`, 4/4 vs. 1/4). The query
|
|
prompt is also structured for **prompt caching** (D22): a byte-identical static
|
|
system message, the conversation history, then this turn's excerpts + question
|
|
last (`modes/prompts.py::render_context_turn`), so the endpoint reuses the cached
|
|
system + history prefix and only reprocesses the volatile excerpts. The editor
|
|
lives at
|
|
`/documents/[id]/edit` (component
|
|
`lib/documents/WritingEditor.svelte`), loads the document in a server load
|
|
(same 404 semantics as chat), and is the SAME editor a document's detail page
|
|
opens for editing (`can_edit` → the edit button routes to `/edit`). It is built
|
|
on **CodeMirror 6** (`@codemirror/state`/`view`/`commands`/`language`/
|
|
`lang-markdown`), styled through the design tokens rather than a CodeMirror
|
|
theme.
|
|
|
|
- **A single full-width CodeMirror editor** over the Markdown, two-way synced to
|
|
the document string. It is not continuously autosaved (that would defeat the
|
|
review-before-save diff); instead a `beforeNavigate` guard keeps things tidy on
|
|
the way out: an abandoned, never-filled template draft (still only its skeleton)
|
|
is **discarded** so empty drafts do not pile up in the list, and an edited but
|
|
unsaved draft is **saved** (a draft is private and unindexed, so this is cheap)
|
|
so leaving never loses work.
|
|
- **Section refinement — shown INLINE at the section you are editing.** A typing
|
|
pause (debounced on document change) fires `POST /api/documents/{id}/refine`
|
|
through an SSE consumer (`lib/api/refine.ts`, the same `fetch` +
|
|
`ReadableStream` + `parseFrame` pattern as `stream.ts`). The suggestion streams
|
|
into a **CodeMirror block widget inserted right below the active section** (not
|
|
a separate pane), so it appears exactly where the cursor is; its body renders
|
|
through the sanitizing `$lib/markdown` renderer (rule 10) and it is aborted the
|
|
moment the user resumes typing (`AbortController`, like the chat stop button).
|
|
**Accept overwrites that section**: the exact `[start_line, end_line]` from the
|
|
server's `section` frame is replaced via a CodeMirror `dispatch`, and the widget
|
|
clears. The server owns that boundary; the client mirrors it
|
|
(`lib/documents/sections.ts`) only to place the widget. A short cooldown after
|
|
accept blocks the next refine until enough new editing has accumulated, and a
|
|
suggestion computed against a since-edited region is discarded rather than
|
|
applied.
|
|
- **Save shows the diff** — the editor's one action sits bottom right where
|
|
the writing ends: **Save** opens a `Dialog` with a read-only VSCode-style line
|
|
diff via `@codemirror/merge`'s `unifiedMergeView` (original = last saved
|
|
state, modified = the buffer), and asks again — save, cancel, or, for a draft,
|
|
**save and publish**. There is no autosave, because that look at the diff is
|
|
the point: you see what you are about to put your name on and can still back
|
|
out. (Leaving the page still persists an unsaved draft rather than losing
|
|
work; a never-filled template skeleton is discarded instead.) **The title is
|
|
edited in that dialog**, next to the diff: this is the moment you notice the
|
|
document is still called "Neuer Ablauf", and a ✨ button fills
|
|
the field from `POST /api/documents/{id}/suggest-title` when asked (a model
|
|
call on demand, not on every save). `lib/documents/SaveDialog.svelte`.
|
|
- **The draft says what state it is in.** Picking a documentation type creates
|
|
the document immediately (rule 6: the document IS the state) and leaving an
|
|
untouched skeleton deletes it again — both right, and both invisible, which
|
|
is what made "the document does not exist yet" confusing. The line under the
|
|
editor now says which of the three it is: a new draft that will be discarded
|
|
if nothing is written, a draft that exists and is private, or the time of the
|
|
last save.
|
|
- **A dead endpoint is knocked on once, not every two seconds.** After an
|
|
`llm_*` failure the suggestions go quiet, say so in one line, and wait before
|
|
trying again (two minutes for unreachable, five for misconfigured, thirty
|
|
seconds for busy) — with a "try again now" for the writer who knows the
|
|
endpoint is back. A successful suggestion clears it. Writing is untouched
|
|
either way: the assistant is the optional half of this editor.
|
|
- **The editor edits title and text, nothing else.** Who may READ a document is
|
|
a property of the document as it stands, not of the text being typed, so
|
|
visibility lives on the document page next to the department sharing it
|
|
belongs with (`lib/documents/AccessControls.svelte`) — and both are the
|
|
owner's decision, unlike the text itself.
|
|
- **Publishing is the author's own action.** One click, no queue: from the
|
|
save dialog, from the draft card on the document page, or from the drafts
|
|
card on the landing page. A reward modal (a check animation) confirms the
|
|
knowledge is captured, and offers "view it" or **"have it checked"**.
|
|
- **Review requests** — "please check this", addressed to one colleague,
|
|
optionally about something specific ("do the 14 holiday days still hold?").
|
|
Orthogonal to the status: it can sit on a draft the author is unsure about or
|
|
on a document published months ago, and it marks the document everywhere it
|
|
appears — the list card, the detail page, and the sources under a chat answer
|
|
(`review_pending`, warning-marked in `SourceBadge`, `ContextInspector`,
|
|
`FallbackResults` and the document panel). The author picks from
|
|
`GET /api/documents/{id}/reviewers` (candidates who can read the document, id
|
|
+ name only) and calls `POST /api/documents/{id}/reviews`
|
|
(`lib/documents/ReviewRequestForm.svelte`, shared by the detail page and the
|
|
publish reward). Being asked grants read AND edit until it is answered, so
|
|
the colleague can fix a wrong number instead of filing a second question;
|
|
they answer with **"that's correct"**
|
|
(`POST /api/documents/{id}/reviews/{review_id}/resolve`), and the author can
|
|
close a question that has become moot. The open questions sit above the
|
|
document text (`lib/documents/ReviewPanel.svelte`), because a reader has to
|
|
see them before trusting it; answered ones stay as a one-line record of who
|
|
checked what. A reviewer finds their queue through the `assigned_to_me`
|
|
filter — surfaced both as a list pill and, with their own unpublished drafts,
|
|
on the landing page (`lib/documents/OpenWork.svelte`, deep-linking to
|
|
`/documents?review=1` and `/documents?status=draft`).
|
|
|
|
The component set in `lib/components/` is deliberately small: `Button`,
|
|
`Input`, `FormField`, `Card`, `Badge`, `Markdown` (the only sanitized
|
|
`{@html}` site), plus the Bits UI wrappers `Popover`, `Tooltip`, `Dialog`
|
|
and `Tabs`. Tooltip content is plain text by contract, so untrusted strings
|
|
(document excerpts, model output) can never become markup.
|
|
|
|
## In-product help
|
|
|
|
`help/*.md` are the help pages users read inside Pablan (German product
|
|
content, YAML frontmatter with `key` and `title`). `app/help_import.py`
|
|
upserts them into `documents` on every start, keyed by `key`, flagged
|
|
`is_builtin`, published and public, with no author or department. Unchanged
|
|
files are skipped; a changed file is rewritten in place and re-indexed, so a
|
|
release always ships current help — and Pablan can answer questions about
|
|
itself from its own retrieval path. The API refuses to edit or delete them
|
|
(409 `builtin_readonly`), because the next deploy would overwrite the edit.
|
|
|
|
The **template catalog** is shipped content too, but works the other way
|
|
round: `templates/*.yaml` are blueprints that stay on disk until an admin
|
|
adds one, and what lands in the `templates` table is then the customer's,
|
|
editable and never re-imported (`app/template_catalog.py`). The dividing
|
|
line: content describing how *Pablan* works belongs to the product and stays
|
|
locked; content describing how *this company* works belongs to the customer.
|
|
`docs/authoring-templates.md` records the open questions around both.
|
|
|
|
Admins edit templates through a **form builder** (`lib/admin/TemplateBuilder`),
|
|
not raw YAML: fields for the persona, per-section heading + hint (add,
|
|
remove, reorder), title, language, visibility and model hints. The
|
|
skeleton the editor opens with is *derived* from the section headings (one
|
|
`## heading` each), so headings and hints never drift apart. The builder
|
|
posts the structured config to `POST /api/templates/build` — the frontend
|
|
has no YAML library, so it sends the `AuthoringTemplate` rather than
|
|
serializing it, and the backend validates the same schema a pasted YAML
|
|
goes through. Raw YAML stays one click away as an advanced escape hatch for
|
|
existing templates (`PUT /api/templates/{id}`), reachable and reversible
|
|
from the builder.
|
|
|
|
## EE plugin mechanism
|
|
|
|
- Core NEVER imports from `ee/` (enforced by import-linter in CI).
|
|
- `ee/backend` is an installable package `pablan_ee` exposing
|
|
`register(app, registry)`; activated only if installed AND a license key is
|
|
configured.
|
|
- `ee/frontend` fills named slots in `lib/ee/registry.ts` via a Vite alias;
|
|
without EE the alias points to an empty registry.
|
|
|
|
## Growth path
|
|
|
|
- **Variant B** (when needed): move the ingestion loop to a worker container;
|
|
optionally swap pgvector for Qdrant behind the retrieval interface.
|
|
- **Phase 2 — cross-department discovery**: entity/relation extraction during
|
|
ingestion into a simple edge table; a nightly job compares new knowledge
|
|
against other departments' documents and generates proactive hints. This is
|
|
the key market differentiator (see market research summary in README/docs)
|
|
but explicitly NOT part of the MVP.
|