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:
ProfessorNova
2026-09-04 08:36:17 +02:00
co-authored by Claude Opus 5
commit 97dbff309c
346 changed files with 43430 additions and 0 deletions
+226
View File
@@ -0,0 +1,226 @@
# 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.