munin/AUSBAUSTUFEN.md
2026-09-15 20:36:26 +02:00

448 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Ausbaustufen — Munin
Was bewusst **nicht** gebaut wurde, warum, und woran man merkt, dass es Zeit dafür ist.
Der Sinn dieses Dokuments ist, dass niemand — auch nicht wir selbst in einem
halben Jahr — dieselben Fragen noch einmal von vorne durchdenken muss. Wo Zahlen
stehen, sind sie gemessen, nicht geschätzt; die Messumgebung ist jeweils genannt.
---
## Teil 1 — Entschieden und aufgeschoben
Diese Punkte sind durchgerechnet. Die Entscheidung lautet jeweils „später",
nicht „nie".
### 1.1 FTS5 statt eigenem Suchindex
**Stand heute:** Ein handgeschriebener BM25-Index im Arbeitsspeicher
(`internal/search`, 464 Zeilen plus 195 Zeilen Tests), einer je Projekt, beim
ersten Zugriff aufgebaut.
**Gemessen** (10.000 Einträge à 150 Wörter, Wortschatz 5.000, Zipf-verteilt):
| | eigener Index | FTS5 |
|---|---|---|
| Arbeitsspeicher | 65 MB | 0 |
| Platte | 0 | 54 MB |
| Aufbau | 171 ms | 280 ms |
| Suche | 12 µs | 208 µs |
FTS5 ist im reinen Go-Treiber verfügbar (`ENABLE_FTS5`), der statische Build
bliebe also erhalten.
**Warum aufgeschoben:**
- Der Suchindex ist **abgeleitete Daten**. Er lässt sich jederzeit aus `entries`
neu aufbauen — eine Umstellung später bedeutet keine Datenmigration. Der übliche
Grund, so etwas früh zu erledigen, greift hier nicht.
- Es löst nicht den eigentlichen Engpass. Der ist die semantische Trefferqualität
(siehe 2.2), und die kann FTS5 so wenig wie BM25 von Hand.
- FTS5 gibt es nur in SQLite. Der eigene Index ist von der Datenbank unabhängig
und hält damit den Weg zu PostgreSQL offen.
**Auslöser für die Umstellung:**
- Ein Projekt jenseits von grob 50.000 Einträgen (~300 MB Arbeitsspeicher allein
für dessen Index)
- Der Start dauert spürbar, weil viele große Projekte gleichzeitig angefragt werden
- Phrasensuche, `NEAR` oder boolesche Operatoren werden gebraucht — die bringt
FTS5 mit, der eigene Index nicht
**Aufwand:** ein bis zwei Tage. Virtuelle Tabelle mit `content=`-Verweis auf
`entries`, Befüllung aus dem Bestand, `internal/search` entfällt.
### 1.2 Eine SQLite-Datei je Projekt
**Stand heute:** Eine Datei für alles, zwei Verbindungspools — acht Verbindungen
zum Lesen, eine zum Schreiben.
**Gemessen:**
| | |
|---|---|
| Schreibvorgänge über die eine Schreibverbindung | 8.656/s |
| Lesen in Projekt A, ungestört | 82 µs |
| Lesen in Projekt A, während drei Projekte im Dauerlauf schreiben | 121 µs |
Hundert Agenten, die jede Sekunde einen Eintrag ablegen, sind 100
Schreibvorgänge/s — etwa **1 % der Kapazität**.
**Warum aufgeschoben:** Es bräuchte trotzdem eine zentrale Datenbank, denn drei
Dinge braucht man, *bevor* man das Projekt kennt: `ResolveKeyHash` (Key →
Projekt), `ListProjects` und `EnsureWorkspace`. Daraus folgt:
- Globales Protokoll und „Projekte mit Eintragszahl" werden aus einer Abfrage
N Abfragen plus Zusammenführen in Go
- Migrationen laufen über jede Datei, auch beim Anlegen eines Projekts
- Verbindungsverwaltung mit Verdrängung nötig (50 Projekte × 9 Verbindungen)
- Kein gemeinsamer Commit über zentrale und Projektdatenbank — „Projekt anlegen
samt erstem Key" wäre nicht mehr atomar
**Auslöser:**
- Anhaltend über ~1.000 Schreibvorgänge/s (10 % der Kapazität)
- Ein Projekt jenseits weniger GB, sodass Sicherung und `VACUUM` unangenehm werden
- Verbindliche Löschzusagen je Projekt, bei denen „Zeilen gelöscht" nicht reicht
**Billigere Schritte davor:** mehrere Einträge in einer Transaktion bündeln,
WAL-Prüfpunkte seltener setzen.
### 1.3 Zeichenketten im Suchindex teilen
`internal/search` hält in `docTerms` je Dokument eine Liste **aller seiner
verschiedenen Wörter** als eigene Zeichenketten. Bei 10.000 Einträgen sind das
über eine Million einzeln abgelegte Zeichenketten, obwohl im Projekt vielleicht
nur 20.000 verschiedene Wörter vorkommen.
Die Liste ist nötig: ohne sie müsste beim Ändern oder Löschen der komplette Index
durchsucht werden. Sie ließe sich aber verkleinern, indem gleiche Wörter dieselbe
Zeichenkette teilen. **Geschätzt ein Drittel bis die Hälfte** des Speichers.
**Warum aufgeschoben:** Löst dasselbe Problem wie 1.1, nur halb. Wenn der
Speicher wirklich drückt, ist FTS5 der gründlichere Schritt.
### 1.4 sqlc für die Datenschicht
War empfohlen, dann verworfen: sqlc ist nicht installiert, und ein zusätzlicher
Codegenerator bedeutet, dass `go build` allein nicht mehr reicht. Handgeschriebenes
`database/sql` ist außerdem besser lesbar für jemanden, der Go lernt.
**Nachrüstbar,** ohne eine Zeile SQL zu ändern — das SQL ist bereits so
geschrieben, wie sqlc es erwartet.
---
## Teil 2 — Trefferqualität
**Hier liegt der eigentliche Engpass.** Alles andere in diesem Dokument ist
Ressourcenoptimierung; das hier entscheidet darüber, ob ein Agent findet, was er
sucht.
### 2.1 Umlaute im Tokenizer falten — ✅ umgesetzt
`tokenize` bildet `ä→a`, `ö→o`, `ü→u` und `ß→ss` ab, beim Indizieren wie beim
Suchen (siehe `faltUmlaut` in `internal/search/index.go`).
```
prüfung ↔ prufung ✓
größe ↔ grosse ✓
prüfung ↔ pruefung ✗
```
**Korrektur zur früheren Fassung dieses Abschnitts:** Dort stand, die Umschreibung
mit e (`ue`, `oe`, `ae`) werde mit abgedeckt. Das stimmt nicht. Sie ließe sich nur
abdecken, indem man auch `ue` zu `u` zusammenzieht — dann würde aber aus „neue"
ein „nu" und aus „Steuer" ein „Stur". Der Schaden wäre größer als der Nutzen.
Die Punkte wegzulassen ist ohnehin die weit häufigere Schreibweise.
Gegenüber FTS5 mit `remove_diacritics` ein Vorteil: Das faltet zwar Umlaute, aber
kein `ß` — dort findet „grosse" die „Größe" nicht.
### 2.2 Semantische Suche
**Problem:** BM25 vergleicht Wörter. Fragt ein Agent „wie deployen wir", findet er
einen Eintrag namens „Release-Prozess" nicht — kein Wort passt.
**Lösung:** Einbettungen (Embeddings) plus Vektorsuche, kombiniert mit BM25
(„hybride Suche"): BM25 ist stark bei seltenen, exakten Begriffen — Fehlercodes,
Eigennamen —, die Vektorsuche bei Umschreibungen.
**Bauform:** ein eigener kleiner Dienst, angebunden über HTTP. Python hat dafür
das deutlich reichere Ökosystem; die Go-Anwendung bliebe ein Binary.
**Aufwand:** mehrere Tage, plus laufender Betrieb eines zweiten Dienstes und
eines Einbettungsmodells.
**Vorher versuchen:** die `instructions` an echten Anfragen schärfen (siehe 3.1).
Erfahrungsgemäß holt das einen guten Teil des Unterschieds, kostet aber nichts.
### 2.3 Lange Einträge zerlegen
`get` liefert einen Eintrag ganz oder gar nicht. Ein Eintrag mit 3.000 Wörtern
belegt beim Agenten Kontext, von dem er zwei Absätze braucht.
**Lösung:** Einträge in Abschnitte zerlegen (an Überschriften) und abschnittsweise
zurückgeben, mit Verweis auf den vollständigen Eintrag.
**Vorher prüfen:** ob überhaupt so lange Einträge entstehen. Bei einem
Agentengedächtnis sind die meisten Einträge kurz — dann wäre das eine Lösung ohne
Problem.
### 2.4 Stoppwörter und Wortstämme
Häufige Wörter („und", „der", „ist") blähen den Index auf, ohne zu unterscheiden.
Und „Deployment" findet „Deployments" nicht.
Beides ist Standardwerkzeug jeder Suchmaschine, kostet aber Genauigkeit bei
Fachbegriffen. **Erst messen, ob es im Alltag stört** — BM25 gewichtet häufige
Wörter ohnehin bereits herunter.
---
## Teil 3 — Funktionen für Agenten
### 3.1 Die `instructions` an echten Anfragen schärfen
Der Text in `internal/mcpserver/server.go` steht bei jedem Client im Kontext und
entscheidet darüber, ob ein Agent vor dem Schreiben sucht oder blind Dubletten
anlegt.
**Vorgehen:** Nach ein paar Wochen Betrieb ins Protokoll schauen — wie viele
`remember`-Aufrufe legen neu an, wie viele aktualisieren? Ein hoher Anteil
Neuanlagen bei ähnlichen Themen heißt: Die Anleitung greift nicht.
**Kostet nichts und wirkt sofort.** Deshalb steht es hier ganz oben.
### 3.2 Importer als Gegenstück zum Export
Der Markdown-Export ist heute eine Einbahnstraße. Das Dateiformat trägt alle
Felder im Kopf, ein Einlesen wäre also möglich.
**Wozu:** Umzug auf einen anderen Server, Wiederherstellung aus der Sicherung,
Erstbefüllung aus vorhandener Dokumentation.
**Aufwand:** ein halber Tag. Zu klären wäre, was bei einem Zusammenstoß passiert
— überschreiben, überspringen oder abbrechen.
### 3.3 Dubletten über Ähnlichkeit erkennen
Heute verhindert nur der gleiche Bezeichner eine Dublette. Wählt ein Agent einen
neuen Slug für dasselbe Thema, steht die Erkenntnis zweimal da.
**Lösung:** Bei `remember` mit neuem Bezeichner den vorhandenen Suchindex über
Titel und Inhalt laufen lassen. Liegt ein Treffer über einer Schwelle, den
Eintrag **nicht** anlegen, sondern die Kandidaten zurückgeben und den Agenten
entscheiden lassen:
```json
{
"created": false,
"judgment_required": true,
"candidates": [
{ "slug": "deployment-prozess", "title": "Deployment-Prozess", "score": 0.81 }
],
"hint": "Passt einer davon? Dann mit dessen slug erneut remember aufrufen.
Sonst mit force: true anlegen."
}
```
Der entscheidende Punkt ist, die Entscheidung **zurückzugeben** statt sie zu
treffen. Ein Agent, der einen Hinweis bekommt, korrigiert sich; einer, dem der
Aufruf stillschweigend abgelehnt wird, versucht es mit einem noch schlechteren
Bezeichner.
Braucht keine Einbettungen — der BM25-Index liegt bereits vor.
**Vorbild:** [Engram](https://github.com/Gentleman-Programming/engram) macht genau
das über FTS5 (`candidates[]` plus `judgment_required` bei `mem_save`). Das Muster
ist gut und ließe sich mit unserem Index eins zu eins übernehmen.
### 3.4 Bezeichner vorschlagen lassen
Der slug ist der Schlüssel gegen Dubletten (siehe 3.3), aber der Agent muss ihn
raten. Ein Werkzeug `suggest_slug(thema)` würde ihm die Arbeit abnehmen:
- gibt es zum Thema bereits einen Eintrag → dessen slug zurückgeben
- sonst einen neuen vorschlagen, der zu den vorhandenen passt
Damit wird aus „hoffentlich wählt das Modell einen guten Bezeichner" ein
Nachschlagevorgang. **Aufwand gering**, die Bausteine (Suche, `Slugify`) liegen vor.
**Vorbild:** Engrams `mem_suggest_topic_key`.
### 3.5 Aktualität von Wissen
Ein Agentengedächtnis veraltet still. Ein Eintrag über den Deployment-Prozess von
vor zwei Jahren sieht genauso aus wie einer von gestern — beide kommen mit
demselben Gewicht aus `recall` zurück.
**Kleiner Schritt zuerst:** Das Änderungsdatum steht bereits in jeder Antwort,
wird aber nicht betont. Einträge jenseits einer Frist zusätzlich mit einem
sichtbaren Vermerk versehen:
```json
{ "slug": "deployment-prozess", "updated_at": "2024-03-11",
"stale": true, "hint": "Seit 18 Monaten unverändert — vor Verwendung prüfen." }
```
**Größerer Schritt:** eine Prüffrist je Eintrag, die nach Art unterschiedlich
schnell abläuft — ein Zugangsverfahren altert schneller als eine
Grundsatzentscheidung. Dazu eine Ansicht „fällige Einträge" für Menschen und ein
Bestätigen, das die Frist zurücksetzt.
**Vorbild:** Engram führt dafür `review_after` und `expires_at` je Beobachtung,
mit einer Verfallslogik je Typ, sowie ein `mem_review` zum Abarbeiten. Deutlich
ausgebauter als das, was wir bräuchten — aber die Richtung stimmt.
### 3.6 Änderungshistorie
`entries.version` zählt hoch, aber alte Fassungen werden nicht aufgehoben.
Überschreibt ein Agent etwas Richtiges mit etwas Falschem, ist das Richtige weg.
**Lösung:** Tabelle `entry_versions` mit dem vorherigen Inhalt bei jedem
Schreibvorgang. Kostet Platz, gibt aber ein Zurück — und macht die Frage „wer hat
das wann geändert" beantwortbar.
**Alternative ohne Aufwand:** der Markdown-Export in einem Git-Repository. Dann
liegt die Historie dort, mit `git log` und `git diff`.
### 3.7 Verwandte Einträge
Der Verweisgraph (`entry_links`) wird bisher nur für Rückverweise genutzt. Ein
Werkzeug `related(slug)`, das Nachbarn im Graphen liefert, wäre für einen Agenten
ein zweiter Weg zum Kontext — neben der Suche.
**Aufwand:** gering, die Daten liegen vor.
### 3.8 Obergrenze gegen durchdrehende Agenten — ✅ umgesetzt
Ein gültiger Key umgeht das Rate-Limit bewusst (siehe `APIAuth` in
`internal/web/middleware.go`) — richtig für Menschen hinter einer gemeinsamen
Adresse, aber eine Schleife im Agenten hatte damit kein Limit.
**Umgesetzt als `auth.WriteBudget`:** ein Fixed-Window-Zähler über schreibende
MCP-Aufrufe, Vorgabe **1.000 je Stunde** (`WRITE_LIMIT_HOURLY`), durchgesetzt in
`Handler.requireWrite` in `internal/mcpserver/tools.go`. Lesen bleibt
unbegrenzt.
**Korrektur zum ursprünglichen Vorschlag:** Dort stand „je Key und Stunde". Das
Budget hängt jetzt am **Projekt**. Der Grund: Ein Limit je Key lässt sich
umgehen, indem man einen zweiten Key anlegt — und das darf jeder Projekt-Admin
selbst. Es hätte also nur vor dem Versehen geschützt, nicht vor dem Verbrauch.
Einzelne Projekte bekommen unter `/admin/projects` eine eigene Obergrenze; setzen
darf sie nur der Plattform-Admin, aus demselben Grund.
**Drei Dinge, die beim Bauen auffielen:**
1. **Die Obergrenze darf nicht zwischengespeichert werden.** Die MCP-Server je
Projekt sind Closures über eine Projektzeile, die beim ersten Zugriff gelesen
wurde — eine Änderung in der Verwaltung wäre dort nie angekommen. Genommen
wird deshalb `sess.Project.WriteLimitHourly` aus der Sitzung: Die hat
`APIAuth` für genau diesen Request frisch aus der Datenbank geholt. Aktuell,
und ohne zusätzliche Abfrage im Schreibpfad.
2. **Jede Ablehnung zu protokollieren hätte das Problem nur verschoben.** Ein
Agent in einer Schleife hätte statt der Einträge das Protokoll vollgeschrieben.
Deshalb gibt `Consume` als dritten Wert zurück, der wievielte Aufruf über der
Grenze das war — vermerkt wird nur der erste je Fenster.
3. **Die Fehlermeldung geht an ein Sprachmodell, nicht an einen Menschen.** Ein
nackter Fehler führt dazu, dass es sofort wieder versucht oder auf ein anderes
Werkzeug ausweicht. Die Meldung sagt darum ausdrücklich, was zu tun ist:
abbrechen und das Limit melden.
**Was das Budget nicht ist:** Der Zählerstand liegt im Arbeitsspeicher und ist
nach einem Neustart weg. Als Netz gegen eine Schleife reicht das. Sobald eine
verbindliche Zusage gebraucht wird — oder mehr als ein Prozess läuft —, gehört
der Zähler in die Datenbank.
---
## Teil 4 — Betrieb
### 4.1 Zugriffsprotokoll — ✅ umgesetzt
`LogRequests` in `internal/web/middleware.go` schreibt je Anfrage eine Zeile:
```
200 POST /mcp 1.475ms claude-desktop
202 POST /mcp 149µs claude-desktop
401 POST /mcp 193µs —
```
Statische Dateien und der Healthcheck bleiben draußen (Rauschen), die
Query-Zeichenkette ebenfalls — dort stehen Suchbegriffe, und die sind Inhalt,
kein Betriebsdatum.
**Zwei Fallstricke, die beim Bauen auffielen:**
1. Der Mantel um den `ResponseWriter` verdeckte `http.Flusher`. Der MCP-Transport
kommt über `http.ResponseController` und damit über `Unwrap()` heran, die
verbreitetere direkte Typzusicherung `w.(http.Flusher)` hätte aber lautlos
fehlgeschlagen — der Aufrufer nimmt dann an, Leeren werde nicht unterstützt.
Deshalb ist `Flush()` ausdrücklich implementiert.
2. Der Zugang stand nicht in der Zeile. Das Protokoll liegt ganz außen, damit es
auch abgewiesene Anfragen erfasst — zu diesem Zeitpunkt ist aber noch niemand
angemeldet, und die Sitzung landet später in einer abgeleiteten Kopie des
Requests. Gelöst über einen veränderbaren Halter im Context (`aktorHalter`),
den die Anmelde-Middleware befüllt.
### 4.2 Automatisierte Tests bei jeder Änderung — ✅ umgesetzt
`.github/workflows/test.yml` prüft bei jedem Push und jedem Pull Request:
Formatierung, `go vet`, `go test -race` und einen Bau mit `CGO_ENABLED=0`.
`-race` ist hier kein Beiwerk: Suchindex, Rate-Limiter und die Datenbankpools
werden von vielen Anfragen gleichzeitig benutzt. Der Bau ohne CGO schlägt fehl,
sobald jemand eine Abhängigkeit hinzufügt, die C braucht — das statische Binary
ist die Grundlage des ganzen Deployments.
### 4.3 Keycloak statt Key-Anmeldung
Vorgesehen und im Schema bereits berücksichtigt: Das Protokoll verweist auf
`api_key_id`, nicht auf Klarnamen. Kommt eine Nutzerverwaltung dazu, lässt sich
`user_id` danebenstellen, ohne die Historie zu verlieren.
**Zu ergänzen:** Tabellen `users` und `memberships`, OIDC-Anmeldung, Rollen an
Personen statt an Keys.
### 4.4 Mehrere Workspaces
Die Spalte `workspace_id` steckt von Anfang an in allen Tabellen, es gibt aber nur
einen und keine Oberfläche dafür. Nötig, sobald Abteilungen sich gegenseitig
nicht sehen sollen.
### 4.5 Kennzahlen
Ein `/metrics`-Endpunkt mit Anfragen, Dauern, Eintragszahlen. Sinnvoll, sobald es
ein Überwachungssystem gibt, in das es hineinfließt — vorher nicht.
---
## Teil 5 — Oberfläche
| | Nutzen | Aufwand |
|---|---|---|
| Vorschau beim Bearbeiten | Markdown sofort sehen | mittel (braucht JavaScript) |
| Autovervollständigung für `[[…]]` | Verweise ohne Abtippen | mittel |
| Graph-Ansicht der Verweise | Zusammenhänge sehen | hoch |
| Vergleich zweier Fassungen | setzt 3.6 voraus | mittel |
| Einträge in großer Zahl bearbeiten | Schlagworte nachziehen | gering |
Nichts davon ist dringend: Die Oberfläche ist bewusst der zweite Zugang, MCP ist
der erste.
---
## Empfohlene Reihenfolge
Wenn es demnächst weitergehen soll, in dieser Folge — jeweils kleiner Aufwand,
sichtbarer Gewinn:
~~1. Zugriffsprotokoll (4.1)~~ · ~~2. Umlaute falten (2.1)~~ ·
~~3. Automatisierte Tests (4.2)~~ — erledigt.
~~4. Obergrenze je Projekt (3.8)~~ — erledigt.
Als Nächstes:
1. **`instructions` schärfen** (3.1) — braucht ein paar Wochen echter Nutzung.
Das Zugriffsprotokoll und `/p/<projekt>/audit` liefern jetzt die Daten dafür:
Wie viele `remember`-Aufrufe legen neu an, wie viele aktualisieren?
2. **Kandidaten bei Dubletten** (3.3) und **Bezeichner vorschlagen** (3.4) —
beide ändern den MCP-Vertrag und sollten bewusst entschieden werden, nicht
nebenbei.
Nebenbei zu beobachten: ob 1.000 Aufrufe je Stunde die richtige Zahl sind. Die
Vermutung ist, dass echte Projekte weit darunter bleiben — nachsehen lässt es
sich in der Spalte „Schreibbudget" unter `/admin/projects`. Wenn dort nie etwas
knapp wird, ist die Zahl gut gewählt; wird sie regelmäßig erreicht, ist entweder
das Limit zu eng oder ein Agent tut etwas Unsinniges.
Alles darüber hinaus erst, wenn der Betrieb zeigt, dass es gebraucht wird.
**Was ausdrücklich nicht auf dieser Liste steht:** FTS5, getrennte Datenbanken,
semantische Suche. Alle drei sind gute Ideen für den Fall, dass ihre Auslöser
eintreten — und drei gute Wege, Zeit in Probleme zu stecken, die es noch nicht gibt.