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
Authoring templates
A template is not an interview guide. It is a Markdown skeleton — a starting document with headings the author fills in — plus a persona and optional per-section hints that steer the section-refinement model. Templates are declarative configuration, not code: a new document type (a machine write-up, a decision record, a process) is a new YAML file, not a new feature.
templates/ ships a catalog of blueprints that an admin adds from — see
"Catalog and lifecycle" below; the templates table holds only what a
customer actually uses, and every row in it is editable. The full template is
stored in templates.config (JSONB) and validated on load against
AuthoringTemplate (app/authoring/schema.py, schema version 1.0). The schema
is versioned (version field) and expected to evolve after real use.
Format
id: prozess
name: "Ablauf: wie wir das machen"
version: "1.0"
kind: authoring
locale: de
description: >
Ein wiederkehrender Ablauf, Schritt für Schritt — so, dass jemand anderes
ihn allein schafft.
model:
temperature: 0.4
min_class_hint: "12b" # UI warning if the endpoint is weaker
persona: |
Du bist ein präziser Fachredakteur für Arbeitsanweisungen. Du schreibst
sachlich, in vollständigen Sätzen, und machst aus einer Abfolge eine
nummerierte Liste. Zahlen, Fristen, Systemnamen und Zuständigkeiten
behältst du exakt bei und erfindest keine dazu.
title_template: "Neuer Ablauf"
skeleton: |
## Wann das gilt
## Schritt für Schritt
## Wenn es klemmt
## Wer zuständig ist
sections:
- heading: "Wann das gilt"
hint: >
Der Auslöser: in welcher Situation dieser Ablauf greift, und wo er
nicht gilt.
- heading: "Schritt für Schritt"
hint: >
Die Schritte in ihrer Reihenfolge, jeder als eine Handlung — mit den
Systemen, Formularen und Fristen, die dazugehören.
- heading: "Wenn es klemmt"
hint: >
Die Sonderfälle und die Stellen, an denen es erfahrungsgemäß hakt.
- heading: "Wer zuständig ist"
hint: >
Wer den Ablauf verantwortet und wen man bei Rückfragen anspricht.
metadata:
visibility: department
The headings are the questions a colleague actually asks. That is the
whole craft in a template: "Wann das gilt / Schritt für Schritt / Wenn es
klemmt" gets filled in, "Worum es geht / Details / Was andere wissen müssen"
does not — it is a blank page wearing a structure. An skeleton: "" with no
sections is a legitimate template (notiz), and better than headings that ask
for nothing in particular.
Fields
| Field | Meaning |
|---|---|
id |
Config id, stable across edits; the catalog and the picker key on it. Not the row UUID. |
name |
Human title in the picker. |
version |
Revision string, raised by hand when the skeleton or hints change. Read as a string even when the YAML looks like a float (1.0). |
kind |
Always authoring. Replaces the old mode: interview — it distinguishes an authoring template from any legacy config still shaped like an interview. |
locale |
The language this template's content is written in (de | en | null), not a UI string. The picker lists templates in the reader's language first. |
description |
One line shown in the picker and catalog. |
model.temperature |
Sampling temperature for the refinement call (default 0.4). |
model.min_class_hint |
Advisory model class (e.g. "12b"); the UI warns when the configured endpoint looks weaker. |
persona |
The editor voice — how the refinement model should write. Injected as the system persona for every section of this document. |
skeleton |
The Markdown the editor opens with: headings the author fills in. This IS the starting content, not a description of it. An empty skeleton yields a blank document. |
sections |
Per-heading hints: [{heading, hint}]. |
title_template |
Draft title, with {{user.name}} and {{date}} substituted at creation. |
metadata.visibility |
Default visibility of the resulting document (public | department | restricted), changeable in the editor before publishing. |
How a template drives the editor
- Creating a draft.
POST /api/documents {template_id}loads theAuthoringTemplate, renders the skeleton (render_skeleton, verbatim with a single trailing newline) and the title (render_title, with{{user.name}}/{{date}}substituted), and writes adraftdocument authored by the user (app/authoring/document.py). A draft is author-only and never indexed — seedata-model.md. - Writing. The editor opens on the skeleton. The author writes Markdown directly; Markdown is the source of truth (rule 1), so there is no derived render to watch.
- Section refinement. After a typing pause the client asks the model to
mature the section at the cursor (
POST /api/documents/{id}/refine, SSE). The whole document travels as prefix/suffix context, but the model regenerates only that section (FIM-style) so a large document is never re-emitted whole. The prompt (app/authoring/prompts.py, natural language — rule 7) ispersona+ the matching sectionhint+ a fixed rule block ("return only the refined section, keep the heading, invent no facts").
sections[].heading must match a skeleton heading exactly. The server finds
the section the cursor sits in (app/authoring/sections.py::active_section,
sharing the heading/fence logic with rag/chunking.py), reads the heading text
the section starts with, and looks up its hint by exact string match
(AuthoringTemplate.hint_for). A hint whose heading does not appear in the
skeleton simply never reaches the model. A section with no hint still gets
refined — persona and the rule block are enough.
Language policy
Shipped template content — persona, hints, descriptions, title templates,
skeleton headings — is German. Templates are product content for the German
market, exactly like the fixture corpus, NOT UI copy. Schema keys, structure
and section ids stay English. File naming is <id>.<locale>.yaml
(prozess.de.yaml); a file with no locale suffix belongs to the
default locale, so a customer can drop their own YAML in without learning the
convention.
The catalog
templates/ ships blueprints for common SME situations. A blueprint is inert:
it has no row in templates and cannot be used until an admin adds it.
| id | Purpose | Visibility of the result | Starter |
|---|---|---|---|
notiz |
Anything at all — no skeleton, an empty document | department | yes |
prozess |
How something is done here, step by step, with what snags | department | yes |
stoerung |
What broke, what caused it, what fixed it | department | yes |
person |
What someone does and what to ask them about | public | yes |
anlage |
A machine: running it, maintaining it, its quirks | department | |
entscheidung |
What was decided, why, and what follows | department | |
projekt-debrief |
What a project taught, in three questions | department |
Two of these deserve their reasoning written down:
notizhas no skeleton on purpose. Three generic headings ("What this is about / Details / What others need to know") are what a blank page looks like when it is trying to be helpful, and nobody writes a document that way. Someone reaching for the open template already knows what they want to say.personis written by the person themselves (the profile page starts it,api/account.py PERSONAL_BLUEPRINT) and ispublic: a directory that half the company cannot read answers nobody's "who knows about X?". It replaced an "onboarding" blueprint somebody else was supposed to fill in FOR a new colleague — nobody does that, and the colleague knows the answers.
A draft stays private to its author until they publish it, so anything sensitive can be removed first.
Catalog and lifecycle
A fresh instance seeds four starter blueprints — notiz, prozess,
stoerung, person (STARTER_TEMPLATE_IDS in app/template_catalog.py) —
and only while the templates table is empty. They are the four occasions on
which anyone actually writes something down: write it down, how we do this,
what broke, who you are. Everything more specific — a machine, a decision, a
project review — is a deliberate add from the catalog, because a picker of ten
options is a picker nobody reads. seed_starter_templates is the ONLY automatic
write to the table, and it runs exclusively against an empty table; a startup
upsert would silently discard an admin's edits.
Nothing in templates is read-only. A template describes how a company
documents its own knowledge, so the company owns it — including the seeded ones
and every blueprint added later. Admins can edit the YAML, duplicate (fork gets
<id>-kopie), and delete; deleting is safe because the catalog can always
supply the blueprint again, and documents created from the template are
independent and survive.
This is the opposite of the built-in help documents (app/help_import.py),
which are re-imported on every start and refuse edits (409 builtin_readonly).
The rule behind both: content that describes how Pablan works belongs to the
product; content that describes how this company works belongs to the customer.
Adding a blueprint twice is refused (409 already_added) rather than
overwriting — the second add would silently discard the admin's edits.
Versioning stays manual: the version string in the YAML identifies a revision,
and the editor reminds the admin to raise it when the skeleton or hints change.
There is no version history table — a template is content under the customer's
control, not an audit trail.
Validation approach
Cheap schema validation before any UI work: create a draft from the template,
write a rough section under each heading, and check that refinement matures the
prose without inventing facts, keeps the heading, and answers in the section's
language. A broken blueprint is logged and skipped on load rather than taking
the catalog down (catalog_invalid). The refinement prompt is covered by an
eval in tests/evals; extend it whenever the prompt or the rule block changes.
Open problem: where built-in content lives
Pablan ships two kinds of content it did not get from the customer: the help documents and the template catalog. The current split (help documents re-imported and locked, templates offered and owned) is a decision we are comfortable defending, but the surrounding questions are open:
- How much should a fresh instance start with? We seed four templates and a handful of help documents. Should the seed be larger (a demo document set that shows what a good captured document looks like), configurable at install time, or nothing at all?
- Where should the shipped content live? Today:
templates/andhelp/as files in the repo, imported at startup. Files-in-repo is right for two developers; it is not obviously right once the catalog has thirty entries in several languages. - How does catalog content reach existing instances? A blueprint improved in a later release does not reach anyone who already added it, by design. There is no "update available" signal — a diff view against the current blueprint is the obvious feature, and it is not built.
- Should deleting the last template be possible? It is today. A member who opens "Capture knowledge" in that state sees an empty picker.
This is deliberately recorded, not solved. Decide it with a real customer install in front of us rather than from first principles.