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
227 lines
12 KiB
Markdown
227 lines
12 KiB
Markdown
# 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
|
|
|
|
```yaml
|
|
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
|
|
|
|
1. **Creating a draft.** `POST /api/documents {template_id}` loads the
|
|
`AuthoringTemplate`, renders the skeleton (`render_skeleton`, verbatim with
|
|
a single trailing newline) and the title (`render_title`, with `{{user.name}}`
|
|
/ `{{date}}` substituted), and writes a `draft` document authored by the user
|
|
(`app/authoring/document.py`). A draft is author-only and never indexed — see
|
|
[`data-model.md`](data-model.md).
|
|
2. **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.
|
|
3. **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) is `persona` + the matching section `hint` + 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:
|
|
|
|
- **`notiz` has 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.
|
|
- **`person` is written by the person themselves** (the profile page starts
|
|
it, `api/account.py PERSONAL_BLUEPRINT`) and is `public`: 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/` and `help/` 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.
|