- 3.7: MCP-Werkzeug "related" liefert Nachbarn im Verweisgraphen in beide
Richtungen, bis zur zweiten Reihe (store.RelatedEntries).
- 3.5: Einträge, die seit über einem Jahr unverändert sind, tragen in
recall/get/list/related "stale": true; get und recall geben einen
Hinweis, die Eintragsseite eine Marke. instructions erklären das Feld.
- 3.2: Import von Markdown-Dateien als ZIP unter /p/{projekt}/import
(Rolle admin), mit Vorschau, überspringen/überschreiben, Erkennung von
Unverändertem und Papierkorb. Neue Einträge behalten Autor, Herkunft,
Prüfvermerk und Zeitstempel aus dem Export.
Nebenbei behoben: Das 1-MB-Body-Limit für Formulare lag über dem ganzen
Mux und begrenzte damit auch /mcp auf 1 MB statt MAX_API_BODY_BYTES.
LimitBodies wählt die Grenze jetzt einmal außen je Route.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
978 lines
50 KiB
Markdown
978 lines
50 KiB
Markdown
# 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
|
||
|
||
> **Nachtrag:** Mit 4.4 gibt es inzwischen eine Datei je *Workspace* und eine
|
||
> zentrale Kontroll-Datenbank für die Zuordnung Key → Workspace. Die hier
|
||
> besprochene Aufteilung je *Projekt* bleibt aufgeschoben. Die Messwerte unten
|
||
> stammen aus der Zeit mit nur einer Datei und gelten jetzt je Workspace-Datei.
|
||
|
||
**Stand damals:** 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 — ✅ umgesetzt
|
||
|
||
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.
|
||
|
||
**✅ Umgesetzt 2026-09-25:**
|
||
|
||
- **Weg:** ZIP-Upload unter `/p/{projekt}/import`, Rolle `admin`. Bewusst kein
|
||
Kommandozeilenbefehl: Der Suchindex liegt im Speicher des laufenden
|
||
Servers, ein zweiter Prozess könnte ihn nicht nachziehen. Nebenbei können
|
||
Projekt-Admins so selbst importieren.
|
||
- **Zusammenstoß:** *überspringen* (Standard) oder *überschreiben*. Beim
|
||
Überschreiben wandert der alte Stand in den Verlauf, der Autor ist dann der
|
||
importierende Zugang. Ein *Abbrechen* gibt es nicht, dafür eine Vorschau,
|
||
die beim ersten Hochladen angehakt ist. Dateien, die nichts ändern würden,
|
||
werden nie geschrieben. Ein wiederholter Import ist damit harmlos.
|
||
- **Papierkorb:** Im Modus *überspringen* holt ein Import keinen gelöschten
|
||
Eintrag zurück. Das war die Falle, weil `AllEntries` den Papierkorb nicht
|
||
sieht.
|
||
- **Felder:** Beim Anlegen bleiben Autor, Herkunft, Prüfvermerk und beide
|
||
Zeitstempel erhalten (neu: `EntryWrite.CreatedAt/UpdatedAt`). Sonst sähe
|
||
nach einer Wiederherstellung alles frisch aus, und 3.5 liefe ins Leere. Der
|
||
Verlauf wird nicht übernommen, der Export enthält ihn nicht.
|
||
- **Fremde Dateien:** Ohne Kopf gilt der Dateiname als Bezeichner und die
|
||
erste Überschrift als Titel. Fremde Köpfe (Hugo, Obsidian) werden gelesen,
|
||
soweit sie `title` und `tags` betreffen. Kein YAML-Paket, ein kleiner
|
||
eigener Leser in `internal/importer/parse.go`.
|
||
- **Grenzen:** 20 MB Upload, 5.000 Dateien, 1 MB je Datei, 100 MB ausgepackt.
|
||
Gezählt wird, was tatsächlich ausgepackt wird, nicht was das Archiv
|
||
behauptet.
|
||
- **Protokoll:** ein Eintrag `entry_import` je Import mit Zusammenfassung,
|
||
nicht einer je Datei. So verzerrt ein einmaliger Import die
|
||
Nutzungsübersicht (4.6) nicht.
|
||
- **Nebenbei behoben:** Das 1-MB-Limit für Formulare lag über dem ganzen Mux,
|
||
auch über `/mcp`. Zwei geschachtelte `MaxBytesReader` begrenzen auf den
|
||
kleineren Wert, MCP war also nie bei den dokumentierten 4 MB, und
|
||
`MAX_API_BODY_BYTES` wirkte nur nach unten. Jetzt wählt `LimitBodies` die
|
||
Grenze einmal außen je Route.
|
||
|
||
### 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 — kleiner Schritt ✅ umgesetzt
|
||
|
||
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.
|
||
|
||
**✅ Kleiner Schritt umgesetzt 2026-09-25:**
|
||
|
||
- **Regel:** `Entry.Stale` in `internal/store/models.go`. Ein Eintrag, der seit
|
||
über einem Jahr (`StaleAfter`) nicht geändert wurde, gilt als veraltet. Das
|
||
gilt auch für geprüfte Einträge: Das Häkchen sagt nicht, *wann* geprüft
|
||
wurde. Ein Jahr ist bewusst eher lang. Ein Vermerk an jedem zweiten Eintrag
|
||
liest bald niemand mehr.
|
||
- **MCP:** `recall`, `list` und `related` tragen `"stale": true`, `get`
|
||
zusätzlich einen `hint` mit Handlungsanweisung. `recall` nennt im Hinweis,
|
||
wie viele Treffer alt sind. Alte Treffer werden weder ausgeblendet noch
|
||
nach hinten sortiert, denn ein alter Eintrag kann der einzige zum Thema
|
||
sein. Die `instructions` erklären das Feld.
|
||
- **Oberfläche:** Auf der Eintragsseite steht die Marke *über ein Jahr alt*.
|
||
- **Offen:** der größere Schritt (Frist je Art, „fällige Einträge“,
|
||
Bestätigen).
|
||
|
||
**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 — ✅ umgesetzt
|
||
|
||
`entries.version` zählt hoch, aber alte Fassungen wurden nicht aufgehoben.
|
||
Überschreibt ein Agent etwas Richtiges mit etwas Falschem, war das Richtige weg.
|
||
|
||
**Umgesetzt:** Tabelle `entry_versions`
|
||
(`migrations/004_aenderungshistorie.sql`). `entries` bleibt die aktuelle
|
||
Fassung; bei jedem Schreibvorgang, der eine bestehende Zeile ändert (nicht
|
||
beim erstmaligen Anlegen), sichert `snapshotEntryVersion` den bisherigen
|
||
Stand — Titel, Inhalt, Schlagworte, Autor —, bevor er überschrieben wird.
|
||
Läuft in derselben Transaktion wie die Änderung selbst, sowohl beim normalen
|
||
Schreiben (`PutEntry`) als auch bei der Sammelbearbeitung
|
||
(`BulkUpdateTags`, siehe 5). Löschen eines Eintrags nimmt seine Historie über
|
||
`ON DELETE CASCADE` mit — konsistent mit Tags und Verweisen.
|
||
|
||
Die Alternative "Markdown-Export in einem Git-Repository" bleibt weiterhin
|
||
möglich, ersetzt das hier aber nicht: Der Export läuft nur, wenn `EXPORT_DIR`
|
||
gesetzt ist, während die Historie in der Datenbank immer mitläuft.
|
||
|
||
### 3.7 Verwandte Einträge — ✅ umgesetzt
|
||
|
||
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.
|
||
|
||
**✅ Umgesetzt 2026-09-25:** MCP-Werkzeug `related(slug, distance?, limit?)`.
|
||
Liefert Nachbarn in beide Richtungen (wer verweist auf mich, worauf verweise
|
||
ich), standardmäßig bis zur zweiten Reihe, höchstens 50. Jeder Treffer nennt
|
||
Abstand, den Eintrag, über den er gefunden wurde (`via`), die Richtung und
|
||
`stale`. Verweise ins Leere und Einträge im Papierkorb fallen heraus, auch als
|
||
Brücke in die zweite Reihe. Mehr als zwei Schritte gibt es bewusst nicht: In
|
||
einem gut verknüpften Projekt hängt in der dritten Reihe fast alles mit allem
|
||
zusammen. `get` liefert weiterhin `links_to`/`linked_by` als nackte
|
||
Bezeichner. `related` ergänzt Titel, Datum und die zweite Reihe.
|
||
|
||
### 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.
|
||
|
||
### 3.9 Papierkorb statt endgültigem Löschen — ✅ umgesetzt
|
||
|
||
`forget` löscht heute sofort und endgültig, samt aller Fassungen (3.6,
|
||
`ON DELETE CASCADE`). Das gilt auch, wenn ein Agent es aufruft. Überschreiben
|
||
lässt sich über den Verlauf reparieren, Löschen nicht. Damit ist Löschen der
|
||
schlimmste Fall, den ein fehlgeleiteter Agent heute anrichten kann.
|
||
|
||
**Lösung:** Löschen setzt nur eine Markierung (`deleted_at`, `deleted_by`).
|
||
Markierte Einträge verschwinden aus Suche, Liste, Graph und MCP-Antworten,
|
||
bleiben aber samt Verlauf in der Datenbank. Eine Seite *Papierkorb* je Projekt
|
||
zeigt sie. Menschen mit Schreibrecht können sie dort wiederherstellen oder
|
||
endgültig löschen. Nach einer Frist (Vorschlag 30 Tage) räumt die tägliche
|
||
Aufgabe sie endgültig weg, im selben Lauf wie das Protokoll (siehe 4.4,
|
||
WP12).
|
||
|
||
**Zu bedenken:**
|
||
- Der Bezeichner bleibt belegt, solange der Eintrag im Papierkorb liegt.
|
||
Schreibt jemand mit demselben Bezeichner neu, muss klar sein, was passiert.
|
||
Vorschlag: Das stellt den Eintrag wieder her und überschreibt ihn, wobei der
|
||
alte Stand wie gewohnt in den Verlauf wandert.
|
||
- Jede Abfrage auf `entries` braucht die Bedingung `deleted_at IS NULL`. Das ist
|
||
dieselbe Art Risiko, die 4.4 für Workspaces bewusst vermeiden wollte. Hier ist
|
||
es vertretbar, weil ein vergessenes `WHERE` nur einen gelöschten Eintrag des
|
||
*eigenen* Projekts zeigt. Trotzdem gehört ein Test dazu, der jeden lesenden
|
||
Weg (Suche, Liste, `get`, `recall`, Graph, Rückverweise, Export) prüft.
|
||
|
||
**Aufwand:** mittel.
|
||
|
||
**✅ Umgesetzt 2026-09-24:**
|
||
|
||
- **Schema:** `migrations/005_papierkorb.sql`: `deleted_at` (NULL = normal),
|
||
`deleted_by` und ein Teilindex nur über den Papierkorb.
|
||
- **Store:** `internal/store/papierkorb.go`. `DeleteEntry` gibt es nicht mehr,
|
||
an seine Stelle tritt `MoveEntryToTrash`. Der neue Name sorgt dafür, dass
|
||
jeder alte Aufrufer beim Kompilieren auffällt. Endgültig löschen können nur
|
||
`PurgeFromTrash` und `PurgeTrash`, und beide fassen nur an, was schon im
|
||
Papierkorb liegt.
|
||
- **Lesewege:** Alle tragen `nichtImPapierkorb`. `ListTags` schließt die
|
||
Schlagworte gelöschter Einträge über eine Unterabfrage auf den Teilindex
|
||
aus; die Schlagworte selbst bleiben für das Wiederherstellen stehen.
|
||
`TestPapierkorbUnsichtbar` prüft jeden Weg einzeln, `TestForgetMovesToTrash`
|
||
dasselbe aus Sicht eines Agenten (`get`, `recall`, `list`).
|
||
- **Gleicher Bezeichner:** Wie vorgeschlagen holt `PutEntry` den Eintrag
|
||
zurück und überschreibt ihn, der gelöschte Stand wandert in den Verlauf
|
||
(`Entry.RestoredFromTrash`). Web und `remember` sagen das dem Schreiber, das
|
||
Protokoll vermerkt `entry_restore`. Ausnahme: Wer eine gelesene Fassung
|
||
mitschickt (`ExpectedVersion`), bekommt einen Konflikt. Das ist der Fall
|
||
„Formular offen, währenddessen hat ein Agent gelöscht“, und eine Löschung
|
||
soll nicht unbemerkt rückgängig werden.
|
||
- **MCP:** `forget` legt in den Papierkorb und sagt das in Beschreibung und
|
||
Antwort. Wiederherstellen oder endgültig löschen gibt es über MCP bewusst
|
||
nicht. `remember` erkennt „angelegt“ jetzt an der Fassung nach dem Schreiben
|
||
statt über eine Nachfrage vorher, die den Papierkorb nicht gesehen hätte.
|
||
- **Oberfläche:** `/p/{project}/trash`, nur mit Schreibrecht, auch das Ansehen:
|
||
Gelöscht wird oft, was nicht jeder sehen soll. Es gibt Wiederherstellen und
|
||
endgültiges Löschen je Zeile oder für eine Auswahl, dazu einen Filter nach
|
||
löschendem Zugang mit *Alle … wiederherstellen* für den Fall „Agent hat zu
|
||
gründlich aufgeräumt“. `confirm.js` liest die Rückfrage jetzt auch am
|
||
gedrückten Knopf (`event.submitter`).
|
||
- **Frist:** `store.TrashRetention` = 30 Tage. `papierkorbLeerenAlle` in
|
||
`main.go` läuft im selben täglichen Takt wie das Protokoll-Aufräumen und
|
||
schreibt je Eintrag eine Protokollzeile mit Herkunft `system`.
|
||
Die Hilfeseiten nennen „30 Tage“ als Text. Wer die Frist ändert, muss sie
|
||
dort mitziehen.
|
||
- **Protokoll:** Neue Aktionen `entry_restore` und `entry_purge`.
|
||
`entry_delete` bedeutet jetzt „in den Papierkorb“ (Detail sagt es).
|
||
|
||
---
|
||
|
||
## 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 — ✅ umgesetzt
|
||
|
||
**Ausgangslage:** Die Spalte `workspace_id` stand von Anfang an in allen Tabellen,
|
||
es gab aber nur einen Workspace und keine Oberfläche dafür. Nötig, sobald
|
||
Abteilungen sich gegenseitig nicht sehen sollen.
|
||
|
||
**Entscheidung:** Abteilungen dürfen sich strukturell nie sehen — nicht nur "in
|
||
der Praxis meistens nicht". Eine gemeinsame Tabelle mit Filterung über
|
||
`WHERE workspace_id = ?` hängt davon ab, dass diese Bedingung in jeder
|
||
gegenwärtigen und jeder künftigen Abfrage korrekt steht — bei einem einzelnen
|
||
Entwickler ohne Review durch Dritte ein zu hohes Risiko für ein einziges
|
||
vergessenes `WHERE`.
|
||
|
||
Gleichzeitig muss der Betrieb zentral bleiben: ein Deployment für
|
||
anfangs 10, später bis zu 40 Teams (1–5 Projekte je Team) statt eines eigenen
|
||
Deployments pro Abteilung, das niemand von Hand pflegen kann. Eigene Backups je
|
||
Team sind Pflicht.
|
||
|
||
**Konzept:** eine dünne Kontroll-Datenbank (Workspaces, Key-Hash →
|
||
Workspace-Routing) plus eine eigene SQLite-Datei je Workspace mit dem heutigen,
|
||
unveränderten Schema. Trennung ist damit strukturell (eigene Datei), nicht durch
|
||
Disziplin in jeder Abfrage erzwungen. Backups werden dadurch nebenbei
|
||
trivial — eine Datei je Team lässt sich einzeln sichern.
|
||
|
||
Bei 10–40 Workspaces ist das unproblematisch — deutlich unterhalb der in 1.2
|
||
genannten Schwelle, ab der Verbindungsverwaltung mit Verdrängung nötig würde
|
||
(die galt für *Projekte*, nicht für Workspaces, und für eine viel höhere Zahl).
|
||
|
||
**Arbeitspakete:**
|
||
|
||
1. ✅ Kontroll-Schema: `workspaces` + `key_index` (hash → workspace_id) —
|
||
`internal/store/migrations_control/001_init.sql`
|
||
2. ✅ `ControlStore`: kleines Store-Objekt für die Kontroll-DB —
|
||
`internal/store/control.go`
|
||
3. ✅ Key-Routing zweistufig: `ControlStore.ResolveKeyWorkspace` (hash →
|
||
workspace_id) davor, danach die heutige `ResolveKeyHash`-Logik unverändert
|
||
auf der passenden Workspace-Datei — sowohl beim Web-Login
|
||
(`handlers_auth.go`) als auch bei MCP-Aufrufen (`APIAuth` in
|
||
`middleware.go`). `CreateAPIKey`/`RevokeAPIKey` pflegen `key_index` im
|
||
selben Zug.
|
||
4. ✅ Verbindungscache je Workspace: `internal/workspace.Manager` öffnet
|
||
Workspace-Dateien bei Bedarf und hält sie offen (`map[workspaceID]*Workspace`,
|
||
ein `sync.Mutex`). Bei 10–40 Workspaces keine Verdrängung nötig.
|
||
5. ✅ Workspace-Auflösung pro Request ersetzt das feste `WorkspaceID` in
|
||
`web.Config`: Das Session-Subject trägt die Workspace-ID direkt
|
||
(`key:<workspace-id>:<key-id>` bzw. `platform:<workspace-id>`, siehe
|
||
`sessionFromSubject` in `middleware.go`) — signiert, also fälschungssicher,
|
||
und ohne Umweg über die Kontroll-DB bei jedem Request. Für den
|
||
Plattform-Admin (ein globales `ADMIN_TOKEN`, siehe Entscheidung oben) kommt
|
||
die Workspace-Wahl über `/admin/workspaces` dazu
|
||
(`handlers_workspaces.go`) — eine frische Admin-Sitzung ohne gewählten
|
||
Workspace kommt an keine Projektseite, `requireWorkspace` in
|
||
`middleware.go` erzwingt das. MCP-Werkzeuge lesen ihren Workspace aus dem
|
||
Context (`web.WorkspaceFromContext`, siehe `mcpserver/tools.go`) statt aus
|
||
einer festen Handler-Instanz — ein `mcpserver.Handler` bedient dadurch
|
||
beliebig viele Workspaces gleichzeitig.
|
||
6. ✅ Migrationen laufen für die Kontroll-DB und beim Öffnen jeder
|
||
Workspace-Datei (`store.Open`/`store.OpenControl`, `main.go` legt
|
||
`DATA_DIR/control.db` und `DATA_DIR/workspaces/<id>.db` an)
|
||
7. Datenmigration für eine bereits befüllte Bestandsinstallation (einmaliges
|
||
Skript) — bislang nicht nötig, es läuft noch keine produktive Instanz
|
||
8. ✅ Workspace-Verwaltung, vollständig: `/admin/workspaces` zeigt alle
|
||
Workspaces (neueste zuerst, mit Anlegedatum und einer
|
||
"Aktuell"-Markierung), legt neue an (springt danach direkt zur
|
||
Projektverwaltung, kein Umweg über die Auswahl) und verlinkt je Zeile auf
|
||
`/admin/workspaces/{id}/edit` zum Umbenennen. Dort auch die Gefahrenzone:
|
||
Löschen entfernt erst die Kontroll-Zeile (samt `key_index` über
|
||
`ON DELETE CASCADE`), dann die Workspace-Datei von der Platte
|
||
(`workspace.Manager.Delete`) — mit Bestätigung durch Eintippen des Namens,
|
||
wie beim Löschen eines Projekts. Betrifft das den Workspace der eigenen
|
||
Sitzung, wird die Sitzung auf "kein Workspace gewählt" zurückgesetzt statt
|
||
den Admin auszusperren. Dazu ein Workspace-Umschalter in der Kopfleiste
|
||
(nicht nur in der Seitenleiste): zeigt den Namen des aktuellen Workspace
|
||
auf jeder Seite, Klick führt zur Auswahl/Verwaltung — Vorbild sind die
|
||
Workspace-Umschalter von Slack/Linear/Notion (`templates/partials.html`,
|
||
`.arbeitsbereich*` in `static/style.css`).
|
||
9. ✅ `scripts/backup.sh`: sichert Kontroll-DB und jede Workspace-Datei
|
||
einzeln über `sqlite3 .backup` in einen zeitgestempelten Ordner,
|
||
optionale Aufräumfrist über `RETENTION_DAYS`. README verlinkt es
|
||
samt Cron-Beispiel.
|
||
10. ✅ Tests: `internal/store/control_test.go` (`ControlStore`/Routing),
|
||
`internal/workspace/workspace_test.go` (Manager cached, trennt Dateien,
|
||
schließt sauber), `internal/web/web_test.go`
|
||
(`TestWorkspaceIsolation` — zwei gleichzeitig offene Workspace-Dateien im
|
||
selben Prozess kommen sich nicht in die Quere)
|
||
11. ✅ README, `.env.example`, `Dockerfile`: `DB_PATH` → `DATA_DIR`
|
||
|
||
**Stand:** Alle Arbeitspakete umgesetzt bis auf eines: eine Datenmigration für
|
||
eine bereits befüllte Bestandsinstallation, falls die je nötig wird (7,
|
||
bislang läuft keine produktive Instanz). Mehrere Workspaces gleichzeitig in
|
||
einem Prozess, strukturell getrennt, mit Oberfläche zum Anlegen und Wechseln
|
||
und eigenem Backup-Skript — fertig und end-to-end geprüft (Tests plus
|
||
manueller Durchlauf gegen den laufenden Server bzw. das Skript).
|
||
|
||
**Nachgezogen** (beim Abgleich der Doku mit dem Code am 2026-09-24 gefunden
|
||
und am selben Tag behoben):
|
||
|
||
12. ✅ Wiederkehrende Aufgaben über alle Workspaces. Vorher bekamen
|
||
`pruneAuditRegularly` und `exportRegularly` nur den ältesten Workspace.
|
||
Das Protokoll aller anderen wurde nie aufgeräumt, die 90-Tage-Frist (4.1)
|
||
griff dort nicht. Jetzt gehen beide in jedem Durchgang über
|
||
`fuerJedenWorkspace` in `main.go`: Die Liste kommt jedes Mal frisch aus
|
||
der Kontroll-Datenbank, also sind auch nach dem Start angelegte Workspaces
|
||
dabei. Ein Fehler in einem Workspace hält die übrigen nicht auf, wird aber
|
||
gesammelt gemeldet (`errors.Join`). Tests: `main_test.go`.
|
||
13. ✅ Export je Workspace. Vorher war der Exporter beim Start fest an den Store
|
||
des ältesten Workspace gebunden (*Jetzt exportieren* in einem anderen
|
||
Workspace schrieb nichts), und die Zielordner hießen nur
|
||
`EXPORT_DIR/<projekt>`, sodass sich Workspaces mit gleichem
|
||
Projekt-Bezeichner gegenseitig überschrieben und Dateien wegräumten. Jetzt
|
||
kennt `export.Exporter` keine Datenbank mehr, der Aufrufer gibt den Store
|
||
je Lauf mit (`ExportWorkspace`). Die Ordner heißen
|
||
`EXPORT_DIR/<workspace-id>/<projekt>/`, dazu `_workspace.md` mit dem Namen.
|
||
Die ID statt des Namens, damit ein Umbenennen keinen zweiten Ordner
|
||
erzeugt. `./munin -export` exportiert alle Workspaces und endet mit
|
||
Fehlercode, wenn einer scheitert. Tests: `TestExportTrenntWorkspaces`,
|
||
`TestExportKnopfImZweitenWorkspace`, `main_test.go`.
|
||
|
||
Alte Exportordner direkt unter `EXPORT_DIR/` fasst der neue Export nicht mehr
|
||
an. Sie können nach dem ersten erfolgreichen Lauf von Hand gelöscht werden.
|
||
|
||
### 4.5 Kennzahlen
|
||
|
||
Ein `/metrics`-Endpunkt mit Anfragen, Dauern, Eintragszahlen. Sinnvoll, sobald es
|
||
ein Überwachungssystem gibt, in das es hineinfließt — vorher nicht.
|
||
|
||
### 4.6 Nutzungsübersicht je Projekt — ✅ umgesetzt
|
||
|
||
Anders als 4.5 nicht für ein Überwachungssystem, sondern für Menschen in der
|
||
Oberfläche. Die Hilfe (*Mit Agenten arbeiten*) rät, zu prüfen, ob
|
||
Aktualisierungen gegenüber Neuanlagen zunehmen. Das lässt sich heute nur
|
||
mühsam aus dem Protokoll ablesen.
|
||
|
||
**Inhalt einer kleinen Übersichtsseite je Projekt:**
|
||
- Schreibvorgänge der letzten 30 Tage, aufgeteilt in Neuanlagen und
|
||
Aktualisierungen, getrennt nach Mensch und Agent
|
||
- Anteil kuratierter Einträge
|
||
- Einträge ohne Schlagwort, Verweise auf nicht vorhandene Einträge
|
||
- zuletzt aktive Zugänge
|
||
|
||
Für den Plattform-Admin dieselben Zahlen als Spalten in `/admin/projects`. So
|
||
fällt auf, welches Team Munin gar nicht benutzt.
|
||
|
||
**Voraussetzung — ✅ erledigt (2026-09-24):** Das Protokoll vermerkt bei
|
||
`entry_write` im Feld `detail`, ob der Eintrag `angelegt` oder `aktualisiert`
|
||
wurde (`store.EntryWriteDetail`, Konstanten `DetailEntryCreated` und
|
||
`DetailEntryUpdated` in `internal/store/audit.go`), sowohl bei `remember` als
|
||
auch in der Weboberfläche. Maßgeblich ist die Fassung nach dem Schreiben
|
||
(1 = angelegt), das stimmt auch bei gleichzeitigem Schreiben. Die
|
||
Sammelbearbeitung schreibt weiterhin ihren eigenen Text und zählt als
|
||
Aktualisierung. Schreibvorgänge vor diesem Datum lassen sich nicht
|
||
unterscheiden.
|
||
|
||
**Aufwand:** gering bis mittel, die Daten liegen im Protokoll und in `entries`.
|
||
|
||
**✅ Umgesetzt 2026-09-24:**
|
||
|
||
- **Seite:** `/p/{project}/usage` (`handlers_nutzung.go`), für jede Rolle.
|
||
Die Zahlen und Zugangsnamen stehen ohnehin schon im Protokoll, das jede Rolle
|
||
sieht.
|
||
- **Zahlen:** `store.UsageSummaries` (`internal/store/nutzung.go`) liefert
|
||
Schreibvorgänge der letzten 30 Tage (`UsageWindow`) aus dem Protokoll und den
|
||
Bestand aus `entries`, für ein Projekt oder alle, in zwei Abfragen. Es gibt
|
||
keine eigene Zähltabelle, die auseinanderlaufen könnte.
|
||
- **Mensch oder Agent:** Unterschieden wird nach Weg: Web = Mensch, MCP =
|
||
Agent. Ein Mensch, der selbst über MCP schreibt, zählt als Agent.
|
||
- **Neu oder aktualisiert:** Maßgeblich ist `detail`. `angelegt` ist neu;
|
||
alles andere außer leer ist eine Aktualisierung („aktualisiert“,
|
||
„wiederhergestellt: …“, „Sammelbearbeitung: …“). Leer heißt „ohne Angabe“:
|
||
Das sind Zeilen aus der Zeit vor der Unterscheidung, die nach 30 Tagen von
|
||
selbst herausfallen.
|
||
- **Der entscheidende Wert:** der Anteil der Aktualisierungen, mit einem
|
||
Hinweis zur Deutung. Eine feste Schwelle „gut/schlecht“ gibt es bewusst
|
||
nicht, denn ein junges Projekt legt naturgemäß vor allem an.
|
||
- **Weitere Abschnitte:** Verweise ins Leere (meistgenannte zuerst, mit
|
||
Schreibrecht „anlegen“ über `/new?slug=…`), Einträge ohne Schlagwort, die
|
||
fünf zuletzt aktiven Zugänge.
|
||
- **Admin:** `/admin/projects` hat die Spalten *Schreibvorgänge* (30 Tage, mit
|
||
„keine“ als deutlichem Signal) und *Zuletzt geändert*. Die Eintragszahl
|
||
kommt jetzt aus derselben Abfrage statt aus einem `CountEntries` je Projekt.
|
||
|
||
### 4.7 Ungenutzte Zugänge sichtbar machen — Stufe 1 ✅ umgesetzt
|
||
|
||
`last_used_at` wird schon erfasst. Bei 10–40 Teams sammeln sich trotzdem
|
||
Keys an, die niemand mehr benutzt, etwa von Personen, die gewechselt haben,
|
||
oder von abgeschalteten Pipelines. Jeder davon ist ein offener Zugang.
|
||
|
||
**Lösung, in zwei Stufen:**
|
||
1. Unter *Zugänge* Keys markieren, die seit mehr als 90 Tagen (oder nie)
|
||
benutzt wurden, mit einem Knopf zum Zurückziehen. Gering.
|
||
2. Optional ein Ablaufdatum beim Anlegen. Abgelaufene Keys gelten wie
|
||
zurückgezogene. Braucht eine Migration (`expires_at`) und eine Prüfung in
|
||
`ResolveKeyHash`. Mittel.
|
||
|
||
**✅ Stufe 1 umgesetzt 2026-09-25:**
|
||
|
||
- **Regel:** `APIKey.Unused` in `internal/store/models.go`. Ein aktiver Key
|
||
gilt als ungenutzt, wenn er seit mehr als 90 Tagen (`UnusedAfter`) nicht
|
||
benutzt wurde. Wurde er noch nie benutzt, zählt das Anlegedatum. Sonst stünde
|
||
jeder frische Key sofort als ungenutzt da. Zurückgezogene Keys sind nie
|
||
ungenutzt.
|
||
- **Oberfläche:** Unter *Zugänge* trägt so ein Key die Marke *ungenutzt*, über
|
||
der Tabelle steht ein Hinweis mit der Anzahl. Den Knopf zum Zurückziehen gab
|
||
es schon. Keine Migration, `last_used_at` reicht.
|
||
- **Unschärfe:** `TouchAPIKey` schreibt höchstens einmal pro Stunde. Für eine
|
||
Grenze von 90 Tagen spielt das keine Rolle.
|
||
- **Tests:** `TestAPIKeyUnused` (Grenzfälle), `TestKeyListFreshKeyNotUnused`.
|
||
- **Offen:** Stufe 2 (Ablaufdatum).
|
||
|
||
### 4.8 Admin je Workspace
|
||
|
||
Heute legt nur der Plattform-Admin (`ADMIN_TOKEN`) Projekte an. Bei 10–40
|
||
Teams wird er damit zum Nadelöhr, und ein gemeinsames Token für alle
|
||
Workspaces widerspricht dem Gedanken, dass Abteilungen sich nie sehen.
|
||
|
||
**Lösung:** Eine Rolle „Workspace-Admin“, die im eigenen Workspace Projekte
|
||
anlegen, umbenennen und löschen darf, aber weder andere Workspaces sieht noch
|
||
das Schreibbudget ändert (siehe 3.8, warum das beim Plattform-Admin bleibt).
|
||
|
||
**Abhängigkeit:** Hängt eng mit 4.3 (Keycloak) zusammen. Mit Personen statt Keys
|
||
wäre „Workspace-Admin“ einfach eine Gruppenmitgliedschaft. Ohne 4.3 bräuchte es
|
||
eine eigene Key-Art ohne Projektbindung. Beides zusammen entscheiden, nicht
|
||
4.8 vorziehen und später umbauen.
|
||
|
||
**Aufwand:** hoch.
|
||
|
||
---
|
||
|
||
## 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 |
|
||
| ✅ Eingebaute Hilfe unter `/hilfe` | Onboarding ohne Umweg über das Repository | mittel |
|
||
| ✅ Fassung wiederherstellen (5.1) | Fehler eines Agenten mit einem Klick zurücknehmen | gering |
|
||
| ✅ Echte Server-Adresse auf der Zugangsseite (5.2) | Konfiguration direkt kopierbar | gering |
|
||
| ✅ Mehr Markdown in Einträgen (5.3) | Tabellen, Checklisten, Durchstreichen | gering |
|
||
| ✅ Hilfe an Ort und Stelle verlinken (5.4) | Hilfe dort, wo man hängt | gering |
|
||
|
||
Nichts davon ist dringend: Die Oberfläche ist bewusst der zweite Zugang, MCP ist
|
||
der erste.
|
||
|
||
### 5.1 Fassung wiederherstellen — ✅ umgesetzt
|
||
|
||
Der Verlauf (3.6) zeigt alte Fassungen, aber zurückholen heißt heute: Text aus
|
||
dem Vergleich kopieren und von Hand einfügen. **Lösung:** Knopf *Diese Fassung
|
||
wiederherstellen* in der Versionsansicht (nur mit Schreibrecht). Technisch ist
|
||
das ein gewöhnliches `PutEntry` mit Titel, Inhalt und Schlagworten der alten
|
||
Fassung und `ExpectedVersion` der aktuellen. Der aktuelle Stand wandert dabei
|
||
selbst in den Verlauf, es geht nichts verloren. Das Protokoll sollte vermerken,
|
||
dass es eine Wiederherstellung war und von welcher Fassung.
|
||
|
||
**✅ Umgesetzt 2026-09-24:** `POST /p/{project}/e/{slug}/versions/{version}/restore`
|
||
(`handleEntryRestore`, `internal/web/handlers_entries.go`), Knopf auf der
|
||
Vergleichsseite und je Zeile im Verlauf, nur mit Schreibrecht. Das Formular
|
||
schickt die beim Anzeigen aktuelle Fassung als `current_version` mit; fehlt sie,
|
||
wird abgelehnt (400) statt ohne Konfliktprüfung zu schreiben, bei einem Konflikt
|
||
kommt die Verlaufsseite mit Hinweis (409). Die Markierung *kuratiert* bleibt,
|
||
wie sie ist — sie beschreibt den Eintrag, nicht einen Textstand. Im Protokoll
|
||
steht `wiederhergestellt: Fassung n` (`store.EntryRestoreDetail`); für die
|
||
Zählung in 4.6 zählt das als Aktualisierung (`detail LIKE 'wiederhergestellt%'`).
|
||
|
||
### 5.2 Echte Server-Adresse auf der Zugangsseite — ✅ umgesetzt
|
||
|
||
`keys.html` zeigt in den Konfigurationsblöcken noch `https://DEIN-SERVER/mcp`.
|
||
Die Hilfe setzt die Adresse schon ein (`serverURL` in
|
||
`internal/web/handlers_hilfe.go`). Dieselbe Funktion für `keyListData`
|
||
verwenden. Gerade hier zählt es, denn der Key ist nur dieses eine Mal zu sehen.
|
||
|
||
**✅ Umgesetzt 2026-09-24:** `keyListData.ServerURL`, gesetzt in
|
||
`renderKeyList`; `keys.html` nutzt es in allen drei Konfigurationsblöcken.
|
||
|
||
### 5.3 Mehr Markdown in Einträgen — ✅ umgesetzt
|
||
|
||
`entry.RenderHTML` nutzt goldmark ohne Erweiterungen. Tabellen erscheinen in
|
||
Einträgen deshalb als Text, in der Hilfe (`internal/hilfe`) dagegen als
|
||
Tabelle. **Lösung:** `extension.GFM` einschalten (Tabellen, Durchstreichen,
|
||
Checklisten, automatische Links). Roh-HTML bleibt gesperrt, die
|
||
Sicherheitsannahme in `RenderHTML` ändert sich nicht. Danach den Hinweis auf
|
||
der Hilfeseite *Weboberfläche* entfernen und Hilfe und Einträge über denselben
|
||
Renderer laufen lassen.
|
||
|
||
**✅ Umgesetzt 2026-09-24:** Neues Paket `internal/markdown` — ein
|
||
goldmark-Renderer mit `extension.GFM` für beide. `entry.RenderHTML` und
|
||
`hilfe.Render` rufen nur noch dort hinein. Einziger Unterschied:
|
||
`RenderMitAnkern` (Hilfe) vergibt Überschrift-ids, `Render` (Einträge) nicht —
|
||
Eintragstext stammt von Menschen und Agenten, und eine Überschrift „## suche"
|
||
soll nicht mit einer id der Seite selbst zusammenstoßen können.
|
||
`markdown_test.go` prüft, dass auch mit GFM weder Roh-HTML noch
|
||
`javascript:`-Links durchkommen. Die Hilfe beschreibt jetzt Tabellen,
|
||
Checklisten, Durchstreichen und automatische Links.
|
||
|
||
### 5.4 Hilfe an Ort und Stelle verlinken — ✅ umgesetzt
|
||
|
||
Kleine „?“-Links von einer Seite zur passenden Hilfe, zum Beispiel *Zugänge* →
|
||
`/hilfe/zugaenge`, Editor → Markdown-Abschnitt, leere Eintragsliste →
|
||
*Agent anbinden*. Dazu Überschrift-Anker in den Hilfeseiten
|
||
(`parser.WithAutoHeadingID`), damit Links auf einen Abschnitt zeigen können.
|
||
|
||
**✅ Umgesetzt 2026-09-24:** Partial `hilfelink` in `partials.html`, ein
|
||
kleines „?" neben Überschriften und dem Inhaltsfeld des Editors: Einträge,
|
||
Editor (Markdown-Abschnitt), Verlauf, Vergleich, Zugänge, Protokoll (je nach
|
||
Ansicht Projekt- oder Admin-Hilfe), Workspaces, Projekte. Die leere
|
||
Eintragsliste verlinkt auf *Agent anbinden* und *Mit Agenten arbeiten*. Die
|
||
Hilfe öffnet in einem neuen Tab, damit beim Nachschlagen im Editor kein
|
||
ungespeicherter Text verloren geht.
|
||
|
||
Die Anker erzeugt nicht goldmarks eingebauter Generator — der lässt Umlaute
|
||
einfach fallen („zugnge-verwalten") —, sondern `ankerIDs` in
|
||
`internal/markdown` über `store.Slugify` („zugaenge-verwalten"), dieselbe
|
||
Regel wie für Eintragsbezeichner. `TestHilfelinksZeigenAufVorhandeneAbschnitte`
|
||
liest alle Templates, sucht jedes Hilfe-Ziel und prüft, dass Seite und
|
||
Abschnitt existieren. Wer eine Überschrift in der Hilfe umbenennt, erfährt so
|
||
im Test, welcher Link dadurch ins Leere zeigen würde.
|
||
|
||
**✅ Sammelbearbeitung umgesetzt:** `entries.html` zeigt bei Schreibrecht ein
|
||
Auswahlkästchen je Eintrag plus eine Sammelleiste (Schlagworte hinzufügen/
|
||
entfernen, kommagetrennt). `Store.BulkUpdateTags` (`internal/store/entries.go`)
|
||
prüft je ID, dass sie wirklich zum aufrufenden Projekt gehört — eine
|
||
untergeschobene ID aus einem fremden Projekt wird stillschweigend übersprungen,
|
||
nicht verändert. Nur tatsächlich geänderte Einträge zählen als Treffer und
|
||
zählen ihre `version` hoch; der Suchindex wird für sie nachgezogen. Kein
|
||
JavaScript nötig — ein einziges Formular um die ganze Liste.
|
||
|
||
**✅ Fassungsvergleich umgesetzt:** Baut auf 3.6 auf. `/p/{project}/e/{slug}/versions`
|
||
zeigt den Verlauf, `/versions/{version}` vergleicht eine abgelöste Fassung mit
|
||
ihrer Nachfolgerin — entweder der nächsten abgelösten oder, wenn es keine mehr
|
||
gibt, der aktuell gültigen. Der Zeilenvergleich selbst
|
||
(`internal/entry/diff.go`, `DiffLines`) ist eine klassische
|
||
LCS-Implementierung ohne externe Abhängigkeit, mit einer Notbremse für
|
||
außergewöhnlich lange Einträge (`grobDiff` oberhalb von vier Millionen
|
||
Vergleichszellen). Auch das kein JavaScript — reines serverseitiges Rendern.
|
||
|
||
**✅ Vorschau beim Bearbeiten umgesetzt:** Das erste nennenswerte
|
||
JavaScript im Projekt (`static/preview.js`, ~90 Zeilen) neben `confirm.js`.
|
||
Reiter "Schreiben"/"Vorschau" über dem Inhaltsfeld in `edit.html`; ein Klick
|
||
auf "Vorschau" schickt den aktuellen Textarea-Inhalt per `fetch` an
|
||
`POST /p/{project}/preview` (`handleEntryPreview`,
|
||
`internal/web/handlers_entries.go`) und zeigt das Ergebnis an. Bewusst kein
|
||
Markdown-Parsing im Browser: Der Endpunkt ruft denselben `entry.RenderHTML`
|
||
auf wie die Detailansicht, Vorschau und späteres Ergebnis können also nicht
|
||
auseinanderlaufen — und es ist keine externe JS-Bibliothek nötig, die an der
|
||
Content-Security-Policy (`script-src 'self'`) vorbeigemusst hätte. Ohne
|
||
JavaScript bleibt einfach der Reiter "Schreiben" aktiv, das Formular
|
||
funktioniert unverändert. Kein Live-Rendern beim Tippen, nur auf Klick —
|
||
hält die Zahl der Aufrufe klein.
|
||
|
||
**✅ Autovervollständigung umgesetzt:** `static/autocomplete.js` erkennt
|
||
beim Tippen ein noch nicht geschlossenes `[[`, fragt
|
||
`GET /p/{project}/entries/suggest?q=…` (`handleEntrySuggest`,
|
||
`internal/web/handlers_entries.go`) und zeigt die Treffer als Liste unter dem
|
||
Feld — bewusst nicht an der Schreibmarke ausgerichtet, das bräuchte einen
|
||
Spiegel-Trick zur Pixel-Positionierung im Textfeld, den die Liste unter dem
|
||
Feld erspart. Tastatur (Pfeiltasten, Enter/Tab, Escape) und Maus bedienbar.
|
||
Die Suche selbst dupliziert keine Logik: Mit Suchbegriff läuft dieselbe
|
||
BM25-Volltextsuche wie bei `recall` (MCP) und der Projektsuche
|
||
(`search.Registry`), ohne Suchbegriff kommen die zuletzt geänderten
|
||
Einträge. Ausgewählt wird der Bezeichner (slug), nicht der Titel — das ist,
|
||
was `[[…]]` erwartet.
|
||
|
||
**✅ Graph-Ansicht umgesetzt:** `/p/{project}/graph` (Seite) und
|
||
`/p/{project}/graph/data` (JSON, `handleEntryGraphData`). Datenseitig neu ist
|
||
nur `Store.LinkGraph` (`internal/store/entries.go`) — eine Abfrage über den
|
||
bereits vorhandenen, indizierten `entry_links`. Verweise auf einen
|
||
Bezeichner ohne Eintrag erzeugen einen eigenen, gestrichelten Knoten
|
||
(`Exists: false`) statt stillschweigend zu verschwinden — sichtbar macht,
|
||
wohin schon verwiesen wird, was aber noch fehlt.
|
||
|
||
Begrenzung wie in der Aufwands-/Risikoabschätzung besprochen:
|
||
`graphNodeLimit = 150` — bei mehr Einträgen bleiben die am stärksten
|
||
verknüpften übrig (nach Grad sortiert), die Antwort trägt `truncated: true`
|
||
und die Gesamtzahl, die Seite zeigt einen Hinweis. Das hält sowohl die
|
||
Kräftesimulation (`static/graph.js`, O(Knoten²) je Durchlauf) als auch die
|
||
SVG-Darstellung in einem Bereich, der im Browser flüssig bleibt.
|
||
|
||
Kein externes Diagramm-Werkzeug: Die Kräftesimulation (Abstoßung zwischen
|
||
allen Knotenpaaren, Anziehung entlang der Kanten, schwache Zentrierung) ist
|
||
selbst geschrieben, läuft einmal beim Laden durch und rendert dann reines
|
||
SVG. Texte werden ausschließlich über `textContent` gesetzt, nie
|
||
`innerHTML` — Eintragstitel kommen von Menschen und Agenten und sind damit
|
||
nicht vertrauenswürdig. Zoom (Mausrad) und Pan (Ziehen) über eine simple
|
||
Transform-Gruppe, keine Abhängigkeit auf eine Bibliothek.
|
||
|
||
---
|
||
|
||
## 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:
|
||
|
||
*Stand der Priorisierung: 2026-09-24.*
|
||
|
||
**Vor dem zweiten Workspace**
|
||
|
||
1. ~~**Offene Reste aus 4.4** (WP12 und WP13): Protokoll aufräumen und Export
|
||
für alle Workspaces.~~ — erledigt 2026-09-24, zusammen mit der
|
||
Unterscheidung angelegt/aktualisiert im Protokoll (Voraussetzung für 4.6).
|
||
|
||
**Klein, sofort spürbar für die Teams**
|
||
|
||
2. ~~**Fassung wiederherstellen** (5.1) und **Server-Adresse auf der
|
||
Zugangsseite** (5.2). Zusammen ein kleines Paket.~~ — erledigt 2026-09-24.
|
||
3. ~~**Mehr Markdown** (5.3) und **Hilfe an Ort und Stelle** (5.4).~~ —
|
||
erledigt 2026-09-24.
|
||
|
||
**Mittel, wichtig bei vielen Teams**
|
||
|
||
4. ~~**Papierkorb** (3.9): schließt den einzigen Schaden, der sich heute nicht
|
||
rückgängig machen lässt.~~ — erledigt 2026-09-24.
|
||
5. ~~**Nutzungsübersicht** (4.6): zeigt, ob die Teams Munin wirklich benutzen,
|
||
und liefert die Zahlen für Punkt 7.~~ — erledigt 2026-09-24.
|
||
6. ~~**Ungenutzte Zugänge** (4.7), zunächst nur Stufe 1.~~ — erledigt
|
||
2026-09-25. Stufe 2 (Ablaufdatum) erst, wenn Stufe 1 nicht reicht.
|
||
|
||
**Bewusst entscheiden, nicht nebenbei**
|
||
|
||
7. **`instructions` schärfen** (3.1) — braucht ein paar Wochen echter Nutzung.
|
||
Das Zugriffsprotokoll und 4.6 liefern die Daten dafür: Wie viele
|
||
`remember`-Aufrufe legen neu an, wie viele aktualisieren?
|
||
8. **Kandidaten bei Dubletten** (3.3) und **Bezeichner vorschlagen** (3.4) —
|
||
beide ändern den MCP-Vertrag.
|
||
9. **Admin je Workspace** (4.8) zusammen mit **Keycloak** (4.3). Wird dringend,
|
||
sobald das Anlegen von Projekten spürbar auf den Plattform-Admin wartet.
|
||
|
||
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.
|
||
|
||
Zusätzlich vorgezogen und erledigt 2026-09-25: **Verwandte Einträge** (3.7),
|
||
**Aktualität, kleiner Schritt** (3.5) und **Importer** (3.2).
|
||
|
||
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.
|