Ausbaustufe mit Suchoptimierung, Bugfixes und UI Anpassungen

This commit is contained in:
Thomas Adamek 2026-10-14 23:36:24 +02:00
parent 71c7e9bf5b
commit 1d07485e72
42 changed files with 3072 additions and 160 deletions

View file

@ -34,11 +34,11 @@ und im Protokoll.
| Werkzeug | Parameter | Rolle |
|---|---|---|
| `recall` | `query`, `tags?`, `limit?` | read |
| `recall` | `query`, `synonyms?`, `tags?`, `limit?` | read |
| `get` | `slug` | read |
| `list` | `tag?`, `curated?`, `limit?` | read |
| `related` | `slug`, `distance?`, `limit?` | read |
| `remember` | `title`, `content`, `slug?`, `tags?`, `expected_version?` | write |
| `remember` | `title`, `content`, `slug?`, `tags?`, `expected_version?`, `force?`, `override_curated?`, `reason?` | write |
| `forget` | `slug` | write |
---
@ -56,8 +56,28 @@ Deshalb der folgende Block.
## 4. Projektprompt
In `CLAUDE.md`, `.clinerules`, „Custom Instructions" oder den Systemprompt der
eigenen Anwendung:
### Wohin der Block gehört
| Werkzeug | im Projekt | persönlich, für alle Projekte |
|---|---|---|
| OpenCode | `AGENTS.md` (ersatzweise `CLAUDE.md`) | `~/.config/opencode/AGENTS.md` |
| Cline | `AGENTS.md` oder `.clinerules/` | `~/Documents/Cline/Rules/` |
| Claude Code | `CLAUDE.md` | `~/.claude/CLAUDE.md` |
| eigene Anwendung | Systemprompt | |
**Mehrere Werkzeuge im Team:** Den Block in **eine** `AGENTS.md` im
Projektverzeichnis schreiben. OpenCode und Cline lesen sie direkt. Claude Code
liest `AGENTS.md` nur, wenn es keine `CLAUDE.md` gibt (und erst in neueren
Fassungen). Sicher ist deshalb: in die `CLAUDE.md` die Zeile `@AGENTS.md`
schreiben, gegebenenfalls als einzige Zeile. So steht der Block an genau einer
Stelle und läuft nicht auseinander.
**Persönlich statt im Projekt:** Ist Munin global eingerichtet (Claude Code mit
`--scope user`, OpenCode in `~/.config/opencode/opencode.json`), passt der Block
auch in die persönliche Datei. Dann gilt er überall, ohne dass jedes Repository
ihn braucht.
### Der Block
```markdown
## Munin
@ -66,7 +86,10 @@ Für dieses Projekt gibt es einen Munin über MCP (Werkzeuge: recall,
get, list, related, remember, forget). Er ist das gemeinsame Gedächtnis des Teams.
LESEN — vor jeder Antwort zu projektspezifischen Fragen:
- Zuerst `recall` mit der Frage aufrufen, nicht mit Stichworten.
- Zuerst `recall` mit der Frage aufrufen, nicht mit Stichworten. Unter
`synonyms` weitere Namen für das Thema mitgeben (Synonyme, Übersetzung,
Abkürzungen) — die Suche vergleicht Wörter, keine Bedeutungen.
- Findet `recall` nichts, mit anderen Begriffen erneut suchen.
- Passenden Treffer mit `get` vollständig laden.
- Für mehr Kontext mit `related` die verknüpften Einträge ansehen.
- Einträge mit `"stale": true` sind über ein Jahr alt: vor Verwendung prüfen
@ -79,6 +102,11 @@ SCHREIBEN — sobald etwas festgehalten werden sollte:
- IMMER zuerst `recall`, ob es zum Thema schon einen Eintrag gibt.
- Gibt es einen: mit DESSEN slug `remember` aufrufen. Das aktualisiert ihn.
- Nur wenn wirklich nichts passt, einen neuen slug wählen.
- Meldet `remember` `judgment_required`, die `candidates` prüfen: Passt einer,
ihn fortschreiben. Nur bei wirklich anderem Thema mit `force: true` anlegen.
- Einträge mit `"curated": true` hat ein Mensch geprüft: Sie haben Vorrang.
Nur ändern, wenn sie nachweislich nicht mehr stimmen — dann mit
`override_curated: true` und einer Begründung unter `reason`.
- Vor dem Aktualisieren `get` aufrufen und die gelieferte `version` als
`expected_version` mitgeben.
@ -91,6 +119,8 @@ WAS festgehalten wird:
WIE festgehalten wird:
- Destilliert, nicht als Protokoll. Ein Eintrag beantwortet eine Frage.
- Verwandtes mit [[bezeichner]] verknüpfen.
- Am Ende eine Zeile `Auch gesucht als: …` mit 2–5 Begriffen, die nicht im
Text stehen, unter denen man den Eintrag aber suchen würde.
- Schlagworte sparsam: 1–3 aus dem bestehenden Bestand (`list` zeigt sie).
```
@ -158,6 +188,11 @@ schreibe zurück — mit expected_version aus dem get.
| Neuer slug für vorhandenes Thema | slug-Regeln oben; `recall` liefert den vorhandenen |
| Zeitstempel im slug | verbieten — Fassungen zählt das System |
| Jede Erkenntnis als eigener Eintrag | „Ein Eintrag beantwortet eine Frage" |
| Thema unter anderem Namen gesucht, nichts gefunden | `synonyms` bei `recall`, „Auch gesucht als" im Eintrag |
Als letzte Hürde prüft `remember` beim Anlegen, ob es schon einen Eintrag mit
ähnlichem Titel gibt, und fragt dann nach (`judgment_required` mit
`candidates`). Erst `force: true` legt trotzdem an.
---
@ -165,14 +200,27 @@ schreibe zurück — mit expected_version aus dem get.
**Protokoll:** `/p/<projekt>/audit` — jeder Schreibvorgang mit Zugang und Zeit.
**Nutzung:** `/p/<projekt>/usage` — Schreibvorgänge der letzten 30 Tage
(angelegt/aktualisiert), *Zur Prüfung* (kuratierte Einträge, die ein Agent
geändert hat), Suchen der Agenten (Anteil ohne Treffer, Anteil mit Synonymen,
häufigste Anfragen ohne Treffer), Verweise ins Leere.
**Faustregel:** Nach ein paar Wochen sollte das Verhältnis von Aktualisierungen zu
Neuanlagen steigen. Viele Neuanlagen bei ähnlichen Themen heißt: Der Projektprompt
greift nicht.
greift nicht. Ein niedriger Anteil an Suchen mit Synonymen heißt dasselbe für
die Suche.
**In der Oberfläche:** Einträge mit Herkunft „Agent" und ohne Marke „kuratiert"
sind ungeprüfte Beobachtungen. Wer sie durchsieht und bestätigt, setzt den Haken
„Kuratiert" — Agenten sehen das Feld über `recall` und `get`.
**Was „kuratiert" bewirkt:** Kuratierte Einträge stehen in der Suche bei sonst
gleicher Relevanz weiter vorn (Faktor 1,2). Ein Agent kann sie nicht nebenbei
überschreiben: `remember` meldet erst `judgment_required`, geschrieben wird nur
mit `override_curated: true` und einer Begründung (`reason`). Dabei fällt der
Haken weg, und der Eintrag steht auf der Nutzungsseite unter *Zur Prüfung*, bis
ein Mensch ihn wieder kuratiert.
---
## 9. Rollen
@ -190,8 +238,11 @@ die Einträge auch durchsieht.
## 10. Grenzen
- **Suche ist wortbasiert.** „wie deployen wir" findet „Release-Prozess" nicht.
Im Zweifel `list` für den Überblick.
- **Suche ist wortbasiert.** Sie gleicht Wortanfänge, Endungen und
zusammengesetzte Wörter aus („deployen" findet „Deployment", „prozess" findet
„Releaseprozess"), aber keine Bedeutungen: „wie deployen wir" findet
„Release-Prozess" nur, wenn `release` unter `synonyms` mitgeht oder im Eintrag
unter „Auch gesucht als" steht. Im Zweifel `list` für den Überblick.
- **`forget` legt in den Papierkorb.** Der Eintrag ist für Agenten sofort
unsichtbar, ein Mensch kann ihn 30 Tage lang in der Weboberfläche
zurückholen. Agenten können weder wiederherstellen noch den Papierkorb

View file

@ -191,6 +191,32 @@ 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.
**Umgesetzt statt Embeddings (2026-10-14):** Die Clients sind selbst
Sprachmodelle und kennen Synonyme. Das nutzt Munin jetzt an beiden Enden:
- **Beim Suchen:** `recall` nimmt `synonyms` an (höchstens 10), die in derselben
Suche mitlaufen. instructions und Werkzeugbeschreibung bitten darum, und ein
leeres Ergebnis rät zuerst zur erneuten Suche mit anderen Begriffen.
- **Beim Schreiben:** Einträge enden mit einer Zeile `Auch gesucht als: …`.
Bewusst im Inhalt statt als Schlagworte: Die Agenten-Vorlagen verlangen
Schlagworte sparsam aus dem bestehenden Bestand, und die Zeile bleibt für
Menschen sichtbar und bearbeitbar.
Ob Agenten das befolgen, zeigt erst der Betrieb — Stichproben in neuen
Einträgen (steht die Zeile da?) sind der einfachste Test.
**Lokales Modell statt Gateway:** geprüft und verworfen. Das Modell braucht
grob 0,5–2 GB Speicher (einmal, nicht je Workspace) und in Go entweder cgo —
dann ist der statische Build weg — oder einen zweiten Dienst. Der Zusatznutzen
gegenüber Synonymen vom Client ist klein.
### 2.2a Ausschnitt an der Fundstelle — ✅ umgesetzt (2026-10-14)
`entry.Snippet` suchte die ganze Anfrage wörtlich. Bei Agenten-Fragen („wie
deployen wir …") traf das nie, der Ausschnitt zeigte immer den Textanfang.
Jetzt: erst die ganze Anfrage, sonst das längste Wort (ab 4 Zeichen), das im
Text vorkommt; in `recall` samt Synonymen. Hervorgehoben wird dasselbe Wort.
### 2.3 Lange Einträge zerlegen
`get` liefert einen Eintrag ganz oder gar nicht. Ein Eintrag mit 3.000 Wörtern
@ -203,14 +229,41 @@ zurückgeben, mit Verweis auf den vollständigen Eintrag.
Agentengedächtnis sind die meisten Einträge kurz — dann wäre das eine Lösung ohne
Problem.
### 2.4 Stoppwörter und Wortstämme
### 2.4 Wortvarianten, Stoppwörter, Tippfehler — ✅ umgesetzt (2026-10-14)
Häufige Wörter („und", „der", „ist") blähen den Index auf, ohne zu unterscheiden.
Und „Deployment" findet „Deployments" nicht.
Umgesetzt in `internal/search/varianten.go`. Zu jedem Suchwort sucht der Index
mehrere Varianten, jede schwächer gewichtet als ein exakter Treffer (1):
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.
| Variante | Beispiel | Gewicht | ab Länge |
|---|---|---|---|
| Wortanfang | „deploy" → Deployment | 0,5 | 3 |
| Endung gekürzt, dann Wortanfang | „deployen" → Deployment | 0,5 | Stamm ≥ 4 |
| Teilwort | „prozess" → Releaseprozess | 0,3 | 5 |
| Tippfehler (nur `SearchFuzzy`) | „deploymnet" → Deployment | 0,3 | 4 |
Je Eintrag zählt nur die beste Variante eines Suchworts.
**Was sich dabei geändert hat:**
- **Fehler behoben:** Der Wortanfang griff früher nur, wenn das Suchwort
nirgends exakt vorkam. Stand „deploy" in irgendeinem Eintrag, fand „deploy"
den „Deployment-Prozess" nicht mehr.
- **Stoppwörter** („wie", „wir", „the" …) werden aus der *Anfrage* entfernt,
nicht aus dem Index. Agenten stellen laut `instructions` ganze Fragen; die
Füllwörter fanden über den Wortanfang sonst Unpassendes („wir" → „Wirkung").
- **Endungen:** bewusst kein vollständiger Wortstammbildner, nur `-en`, `-es`,
`-e`, `-n`, `-s`. Kein `-er`, sonst fände „Server" auch „Service".
- **Tippfehler** gleicht nur die Weboberfläche aus (`SearchFuzzy`), und nur
für Suchwörter, die sonst nichts finden. `recall` bleibt ohne.
**Laufzeit:** Der Wortschatz wird jetzt bei jedem Suchwort einmal durchlaufen,
nicht mehr nur beim Fehlschlag. `bench_test.go` (10.000 Einträge, 20.000
Wörter, Frage mit drei Suchwörtern): 0,37 ms vorher, 1,7 ms nachher, gut die
Hälfte davon Speicherbereinigung. Für Agenten wie für die Vorschläge beim
Tippen unerheblich.
**Offen:** Synonyme („Release" ↔ „Deployment") kann das nicht — dafür wäre 2.2
nötig. Und ob die Gewichte gut gewählt sind, zeigt erst der Betrieb.
---
@ -228,6 +281,13 @@ Neuanlagen bei ähnlichen Themen heißt: Die Anleitung greift nicht.
**Kostet nichts und wirkt sofort.** Deshalb steht es hier ganz oben.
**Messgrundlage ✅ (2026-10-14):** Jeder `recall` landet im Suchprotokoll
(`search_log`, migrations/006, 30 Tage Aufbewahrung). Die Nutzungsseite zeigt
den Anteil der Suchen ohne Treffer, den Anteil mit Synonymen und die häufigsten
Anfragen ohne Treffer. Bewusst eine eigene Tabelle statt `audit_events`: Suchen
sind viel häufiger als Schreibvorgänge und würden das Protokoll zudecken, und
Anfragen sind Inhalt — sie gehen mit dem Projekt und haben eine kürzere Frist.
### 3.2 Importer als Gegenstück zum Export — ✅ umgesetzt
Der Markdown-Export ist heute eine Einbahnstraße. Das Dateiformat trägt alle
@ -273,7 +333,25 @@ Erstbefüllung aus vorhandener Dokumentation.
`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
### 3.3 Dubletten über Ähnlichkeit erkennen — ✅ umgesetzt (2026-10-14)
**Umsetzung:** `internal/mcpserver/dubletten.go`. Bei `remember` mit neuem
Bezeichner sucht der Index nach dem Titel; die zehn besten Treffer werden
genauer verglichen. Maß ist nicht der BM25-Wert (kein fester Wertebereich),
sondern der Anteil gemeinsamer Titelwörter (`search.Ueberdeckung`, in beide
Richtungen, samt Wortanfang, Endung und Teilwort). Die Zeile
„Auch gesucht als: …" zählt zum Titel — so findet „Release-Ablauf" auch den
„Deployment-Prozess", wenn dort „release" steht.
Ab 50 % Überdeckung: nichts schreiben, `judgment_required` mit bis zu fünf
`candidates` zurückgeben, kein Fehler. `force: true` legt trotzdem an.
Aktualisieren (Bezeichner vorhanden) wird nie geprüft.
**Bekannter Fehlalarm:** „Server-Zugang" gegen „Datenbank-Zugang" (50 %).
Kostet den Agenten einen zweiten Aufruf mit `force`. Ob die Schwelle passt,
zeigt der Betrieb.
**Ursprüngliche Beschreibung:**
Heute verhindert nur der gleiche Bezeichner eine Dublette. Wählt ein Agent einen
neuen Slug für dasselbe Thema, steht die Erkenntnis zweimal da.
@ -306,6 +384,36 @@ Braucht keine Einbettungen — der BM25-Index liegt bereits vor.
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.3a Kuratierte Einträge schützen — ✅ umgesetzt (2026-10-14)
**Problem:** `remember` überschrieb kuratierte Einträge ohne Rückfrage; der
Haken fiel still weg. Weder der Agent noch ein Mensch bekam es mit, und eine
Wiederherstellung holte den Haken nicht zurück (sie lässt ihn bewusst, wie er
aktuell ist).
**Umsetzung** (`internal/mcpserver/kuratiert.go`), Muster wie bei 3.3:
- `remember` auf einen kuratierten Eintrag schreibt nicht, sondern meldet
`judgment_required` mit Handlungsanweisung.
- Mit `override_curated: true` und `reason` wird geschrieben. Der Haken fällt
weg (der neue Text ist ungeprüft), das Protokoll vermerkt
`kuratiert aufgehoben: <Begründung>` — zählt in der Nutzung als
Aktualisierung.
- Die Nutzungsseite listet solche Einträge unter *Zur Prüfung*, bis ein Mensch
den Haken neu setzt (`store.CuratedOverrides`, keine eigene Tabelle).
- instructions: Kuratiertes hat Vorrang vor eigenen Annahmen.
- Suche: kuratierte Einträge mit Faktor 1,2 (`curatedBoost` in
`internal/search/registry.go`) — genug für den Gleichstand, zu wenig, um
einen klar besser passenden Eintrag zu verdrängen.
**Bewusst nicht:** Sperren mit Änderungsvorschlägen. Erst sinnvoll, wenn die
Liste *Zur Prüfung* zeigt, dass kuratierte Einträge oft falsch überschrieben
werden.
**Offen:** `forget` auf kuratierte Einträge hat noch keine Hürde. Weniger
dringend — der Papierkorb hält 30 Tage, und nur Menschen holen zurück —, aber
dasselbe Muster würde passen.
### 3.4 Bezeichner vorschlagen lassen
Der slug ist der Schlüssel gegen Dubletten (siehe 3.3), aber der Agent muss ihn
@ -984,10 +1092,27 @@ Hauptarbeit bei den Tests: nachweisen, dass kein Key mehr sieht als heute.
| ✅ 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 |
| ✅ Ausgewählte Einträge löschen (5.5) | Aufräumen ohne jeden Eintrag einzeln zu öffnen | gering |
Nichts davon ist dringend: Die Oberfläche ist bewusst der zweite Zugang, MCP ist
der erste.
### 5.5 Ausgewählte Einträge löschen — ✅ umgesetzt (2026-10-14)
In der Sammelleiste der Eintragsliste steht neben *Anwenden* der Knopf
*In den Papierkorb* (`POST /p/{projekt}/entries/bulk-delete`, Schreibrecht).
- Selbes Formular wie die Schlagwort-Sammelbearbeitung, anderer Knopf
(`formaction`). Er steht hinter *Anwenden*, weil Enter in einem Textfeld
den ersten Knopf auslöst. Rückfrage über `data-confirm` (static/confirm.js).
- `store.MoveEntriesToTrash`: eine Transaktion, Auswahl über die interne ID;
fremde und schon gelöschte IDs werden übersprungen wie bei
`BulkUpdateTags`.
- Je Eintrag eine Protokollzeile `entry_delete`, damit Nutzungsübersicht und
Protokoll jede Löschung einzeln sehen.
- Die Weiterleitung behält Schlagwortfilter und Suche (gilt jetzt auch für die
Schlagwort-Sammelbearbeitung).
### 5.1 Fassung wiederherstellen — ✅ umgesetzt
Der Verlauf (3.6) zeigt alte Fassungen, aber zurückholen heißt heute: Text aus
@ -1170,10 +1295,12 @@ Als Nächstes:
**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.
Das Zugriffsprotokoll, 4.6 und seit 2026-10-14 das Suchprotokoll liefern
die Daten dafür: Wie viele `remember`-Aufrufe legen neu an, wie viele
Suchen bleiben ohne Treffer?
8. ~~**Kandidaten bei Dubletten** (3.3)~~ — erledigt 2026-10-14, zusammen mit
dem Schutz kuratierter Einträge (3.3a). **Bezeichner vorschlagen** (3.4)
bleibt offen; es ändert den MCP-Vertrag.
9. ~~**Admin je Workspace** (4.8)~~ — erledigt 2026-09-30, ohne Keycloak
(siehe die Entscheidung dort). **Keycloak** (4.3) bleibt offen, bis es
gebraucht wird.

View file

@ -15,7 +15,7 @@ statt `/data`, und die Ausgabe des Prozesses statt `docker logs`.
| Was | Wo |
|---|---|
| Kontroll-Datenbank | `DATA_DIR/control.db`: Workspaces, welcher Key zu welchem Workspace gehört, Workspace-Admins |
| Ein Workspace | `DATA_DIR/workspaces/<workspace-id>.db`: Projekte, Einträge, Keys, Protokoll dieses Workspace |
| Ein Workspace | `DATA_DIR/workspaces/<workspace-id>.db`: Projekte, Einträge, Keys, Protokoll und Suchprotokoll dieses Workspace |
| Zugehörige Hilfsdateien | `…db-wal` und `…db-shm` neben jeder Datenbank. Sie gehören im laufenden Betrieb dazu, **nie einzeln löschen, solange der Server läuft** |
| Logs | Standardausgabe des Prozesses: `docker logs munin` |
| Sicherungen | `BACKUP_DIR/<zeitstempel>/` von `scripts/backup.sh`, siehe README „Datensicherung“ |

View file

@ -222,3 +222,25 @@ Agent. Taucht die Meldung wieder auf, liegt die Ursache dort.
Neue Ergebnisse bitte mit Datum unter **Ergebnis** ergänzen, die alten stehen
lassen. So bleibt sichtbar, wie sich Änderungen ausgewirkt haben.
---
## Nachmessung vom 2026-10-14: Suchprotokoll und Wortvarianten
32 Agenten, 10 Sekunden, ein Projekt. Seit diesem Stand schreibt jeder `recall`
eine Zeile ins Suchprotokoll, und die Suche prüft Wortvarianten.
| Stand | `remember` je s | `recall` p50 / p99 |
|---|---|---|
| 2026-10-06 (5 s) | 1.374 | 4,7 / 11 ms |
| Wortvarianten, ohne Suchprotokoll | 1.278 | 5,5 / 12 ms |
| Wortvarianten und Suchprotokoll | 1.074 | 8,5 / 30 ms |
Fast der ganze Unterschied kommt vom Suchprotokoll: Jede Suche ist jetzt auch
ein Schreibvorgang und stellt sich an derselben Schreibverbindung an wie
`remember`. Für echte Nutzung bedeutungslos — dort sind es Dutzende Aufrufe
pro Stunde, nicht tausend pro Sekunde.
**Falls es je stört:** die Zeilen in einem Puffer sammeln und alle paar
hundert Millisekunden gebündelt in einer Transaktion schreiben. Dann fällt das
Protokoll aus dem Weg der Anfrage.

122
README.md
View file

@ -23,6 +23,7 @@ Browser ──HTTP─────┘ (eine SQLite-Datei je Workspace)
## Inhalt
- [Schnellstart](#schnellstart)
- [Bauen](#bauen)
- [Grundbegriffe](#grundbegriffe)
- [MCP einrichten](#mcp-einrichten)
- [Die Werkzeuge](#die-werkzeuge)
@ -61,6 +62,94 @@ Der weitere Weg:
---
## Bauen
`go run .` aus dem Schnellstart übersetzt bei jedem Start neu. Für den Betrieb
baut man stattdessen einmal ein **Binary**: eine einzelne ausführbare Datei, die
alles enthält. Vorlagen, CSS, JavaScript und Hilfeseiten sind per `//go:embed`
eingebettet; neben dem Binary braucht es nur die Konfiguration.
### Voraussetzungen
- **Go 1.26** oder neuer (`go version` prüft das). Eine ältere Go-Fassung ab 1.21
lädt die passende beim ersten Bauen selbst nach, das steht in `go.mod`.
- **Kein C-Compiler.** Der SQLite-Treiber (`modernc.org/sqlite`) ist in reinem
Go geschrieben.
Die Abhängigkeiten lädt `go build` beim ersten Mal selbst herunter.
### Für den eigenen Rechner
```bash
go build -o munin .
./munin
```
Das ergibt `munin` im Projektverzeichnis, gut 20 MB groß. Es steht in
`.gitignore` und landet nicht im Repository. Unter Windows `-o munin.exe`
schreiben — `-o` übernimmt den Namen genau so, ohne die Endung zu ergänzen.
### Für den Betrieb
So baut auch das Dockerfile:
```bash
CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o munin .
```
| Schalter | Wirkung |
|---|---|
| `CGO_ENABLED=0` | reines Go, ein statisches Binary ohne Abhängigkeit von Systembibliotheken |
| `-trimpath` | entfernt die Pfade der Baumaschine aus dem Binary |
| `-ldflags="-s -w"` | lässt Debug-Symbole weg, das Binary schrumpft auf etwa 16 MB |
### Für ein anderes Betriebssystem
Go übersetzt ohne weitere Werkzeuge für andere Systeme. `GOOS` und `GOARCH`
geben das Ziel an, etwa vom Mac aus für einen Linux-Server:
```bash
CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o munin-linux-amd64 .
CGO_ENABLED=0 GOOS=linux GOARCH=arm64 go build -trimpath -ldflags="-s -w" -o munin-linux-arm64 .
CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build -trimpath -ldflags="-s -w" -o munin.exe .
```
Als Container: `docker build -t munin .` (siehe [Docker](#docker)).
### Starten und wo die Daten landen
```bash
./munin
```
Zwei Dinge hängen am **aktuellen Verzeichnis**, nicht am Ort des Binarys:
- **`.env`** wird von dort gelesen. Fehlt sie, müssen `SESSION_SECRET` und
`ADMIN_TOKEN` als Umgebungsvariablen gesetzt sein, sonst startet der Server
nicht.
- **`DATA_DIR`** ist standardmäßig `data`, also ebenfalls relativ zum aktuellen
Verzeichnis. Wer das Binary aus einem anderen Verzeichnis startet, bekommt
dort einen neuen, leeren Bestand.
Für den Betrieb deshalb einen absoluten Pfad setzen:
```bash
DATA_DIR=/srv/munin/data ./munin
```
Darin legt der Server beim Start an:
```
data/
├── control.db Workspaces, Zuordnung Key → Workspace, Workspace-Admins
└── workspaces/
└── <id>.db je Workspace: Projekte, Einträge, Keys, Protokolle, Papierkorb
```
Neben jeder Datenbank liegen im Betrieb `…-wal` und `…-shm`. Sie gehören dazu
und dürfen nicht einzeln gelöscht werden. Welche Pfade der Server benutzt,
steht in der Startzeile des Logs (`Workspaces: …`).
## Grundbegriffe
| Begriff | Bedeutung |
@ -191,8 +280,8 @@ Konfiguration.
geschrieben hat, und ein einzelner Zugang lässt sich zurückziehen, ohne alle
anderen zu stören.
Verbindung prüfen: Der Agent sollte `recall`, `get`, `list`, `remember` und
`forget` anbieten. Tut er das nicht, hilft ein Blick ins Zugriffsprotokoll des
Verbindung prüfen: Der Agent sollte `recall`, `get`, `list`, `related`,
`remember` und `forget` anbieten. Tut er das nicht, hilft ein Blick ins Zugriffsprotokoll des
Servers — ein `401` dort heißt falscher oder zurückgezogener Key, gar kein Eintrag
heißt, dass der Client den Server nie aufgerufen hat.
@ -205,11 +294,11 @@ Wie man Agenten so anspricht, dass sie den Speicher auch nutzen:
| Werkzeug | Rolle | Zweck |
|---|---|---|
| `recall` | read | Durchsucht den Speicher. Der Haupteinstieg — auch vor dem Schreiben. |
| `recall` | read | Durchsucht den Speicher, auf Wunsch mit `synonyms`. Der Haupteinstieg — auch vor dem Schreiben. |
| `get` | read | Lädt einen Eintrag vollständig, samt Verweisen und Fassungsnummer. |
| `list` | read | Überblick: zuletzt geändert, alle Schlagworte. |
| `related` | read | Über `[[Verweise]]` verknüpfte Einträge, direkt und über einen Zwischeneintrag. |
| `remember` | write | Legt einen Eintrag an oder aktualisiert ihn. |
| `remember` | write | Legt einen Eintrag an oder aktualisiert ihn. Fragt bei ähnlichen und bei kuratierten Einträgen nach (siehe unten). |
| `forget` | write | Legt einen Eintrag in den Papierkorb (30 Tage wiederherstellbar, nur durch Menschen). |
Der Server schickt jedem Client eine **Anleitung** (`instructions`) mit, die das
@ -237,6 +326,22 @@ Ist das Budget erschöpft, bekommt das Modell keinen nackten Fehler, sondern die
Anweisung, abzubrechen und das Limit zu melden — sonst versucht es sofort wieder
oder weicht auf ein anderes Werkzeug aus.
### Rückfragen statt Schreiben
`remember` schreibt in zwei Fällen nicht sofort, sondern antwortet mit
`judgment_required` — als Antwort, nicht als Fehler, damit das Modell sie liest
und entscheidet:
| Fall | Antwort | Trotzdem schreiben mit |
|---|---|---|
| Neuer Bezeichner, aber ähnlicher Titel vorhanden | `candidates` (bis zu fünf) | `force: true` |
| Eintrag ist kuratiert | Erklärung | `override_curated: true` und `reason` |
Im zweiten Fall fällt der Haken „kuratiert" weg, das Protokoll vermerkt die
Begründung, und die Nutzungsseite zeigt den Eintrag unter *Zur Prüfung*, bis ein
Mensch ihn wieder kuratiert. Einzelheiten: `internal/mcpserver/dubletten.go`
und `kuratiert.go`.
### Gleichzeitiges Schreiben
`get` liefert eine `version`. Wird sie bei `remember` als `expected_version`
@ -264,7 +369,7 @@ Agent B: remember(…, expected_version=3) → Konflikt: neu laden, zusammenfüh
| `/admin/export.zip` | Alle Projekte des gewählten Workspace als ZIP herunterladen (Plattform- und Workspace-Admin) |
| `/admin/projects/{projekt}/edit` | Name, Beschreibung und Schreibbudget ändern; Projekt löschen |
| `/admin/audit` | Protokoll über alle Projekte des gewählten Workspace |
| `/p/{projekt}/` | Einträge durchsuchen, nach Schlagwort filtern, Schlagworte für viele Einträge auf einmal ändern |
| `/p/{projekt}/` | Einträge durchsuchen, nach Schlagwort filtern, für viele Einträge auf einmal Schlagworte ändern oder sie in den Papierkorb legen |
| `/p/{projekt}/new` | Neuen Eintrag anlegen (`?slug=` belegt den Bezeichner vor) |
| `/p/{projekt}/e/{bezeichner}` | Eintrag lesen, mit Rückverweisen |
| `/p/{projekt}/e/{bezeichner}/edit` | Eintrag bearbeiten, mit Vorschau |
@ -273,7 +378,7 @@ Agent B: remember(…, expected_version=3) → Konflikt: neu laden, zusammenfüh
| `/p/{projekt}/graph` | Verweise zwischen den Einträgen als Graph |
| `/p/{projekt}/keys` | Zugänge verwalten (Rolle `admin`) |
| `/p/{projekt}/export.zip` | Projekt als ZIP herunterladen, passt direkt in den Import (Rolle `admin`) |
| `/p/{projekt}/usage` | Nutzung: Schreibvorgänge der letzten 30 Tage (Agenten/Menschen, neu/aktualisiert), Bestand, Verweise ins Leere, aktive Zugänge |
| `/p/{projekt}/usage` | Nutzung: geänderte kuratierte Einträge zur Prüfung, Schreibvorgänge der letzten 30 Tage (Agenten/Menschen, neu/aktualisiert), Suchen der Agenten samt Anfragen ohne Treffer, Bestand, Verweise ins Leere, aktive Zugänge |
| `/p/{projekt}/audit` | Protokoll des Projekts |
| `/p/{projekt}/trash` | Papierkorb: wiederherstellen oder endgültig löschen (Schreibrecht) |
| `/hilfe` | Eingebaute Onboarding-Hilfe für alle Rollen: Einstieg, Begriffe, MCP einrichten, Arbeiten mit Agenten, Weboberfläche, Zugänge. Die Seite für Plattform-Admins sieht nur der Plattform-Admin. |
@ -533,6 +638,11 @@ Vorgänge werden 90 Tage aufbewahrt und danach automatisch gelöscht
aus denen sich Personen ableiten lassen — eine Aufbewahrungsfrist ist deshalb die
Voraussetzung dafür, es überhaupt führen zu dürfen.
Getrennt davon steht das **Suchprotokoll**: was Agenten mit `recall` gesucht
und ob sie etwas gefunden haben, ausgewertet auf der Nutzungsseite. Ohne
Adressen und Namen, aber mit dem Text der Anfragen — deshalb nur 30 Tage
(`store.SearchLogRetention`), und es verschwindet mit dem Projekt.
Jeder Workspace führt sein eigenes Protokoll in seiner eigenen Datei.
`/admin/audit` zeigt das Protokoll des gerade gewählten Workspace, nicht alle
auf einmal. Das tägliche Aufräumen läuft über alle Workspaces, auch über die,

View file

@ -33,7 +33,9 @@ munin/
│ Konfiguration, Sicherheit, Betrieb.
├── AGENTEN-GUIDE.md ← Wie man KI-Werkzeuge anbindet und anspricht,
│ damit sie den Speicher tatsächlich nutzen:
│ Projektprompt zum Übernehmen, slug-Regeln,
│ Projektprompt zum Übernehmen und wohin er gehört
│ (AGENTS.md für OpenCode/Cline, CLAUDE.md mit
│ @AGENTS.md für Claude Code), slug-Regeln,
│ Muster für einzelne Aufgaben.
├── BETRIEB.md ← Betriebshandbuch für den Ernstfall: Server startet
│ nicht, Platte voll, Key oder Geheimnis geleakt,
@ -105,6 +107,11 @@ munin/
│ │ │ MissingLinks, UntaggedEntries, RecentlyUsedKeys.
│ │ │ Keine eigene Zähltabelle.
│ │ │
│ │ ├── suchprotokoll.go ← Was Agenten mit recall suchen (search_log):
│ │ │ LogSearch, SearchStatsFor, EmptySearches,
│ │ │ PruneSearchLog. Eigene Tabelle statt Protokoll,
│ │ │ 30 Tage, verschwindet mit dem Projekt.
│ │ │
│ │ ├── papierkorb.go ← Papierkorb: MoveEntryToTrash (statt DELETE),
│ │ │ ListTrash, RestoreFromTrash, PurgeFromTrash
│ │ │ (nur was schon im Papierkorb liegt) und
@ -205,7 +212,9 @@ munin/
│ ├── entry/ ── Wie sieht ein Eintrag aus? ─────────────────────
│ │ ├── entry.go ← Markdown nach HTML (über internal/markdown),
│ │ │ Auflösen der [[…]]-Verweise, Textausschnitte für
│ │ │ Trefferlisten samt sicherer Hervorhebung.
│ │ │ Trefferlisten samt sicherer Hervorhebung (am
│ │ │ längsten Wort der Anfrage, das im Text steht),
│ │ │ die Zeile „Auch gesucht als: …" auslesen.
│ │ │
│ │ └── diff.go ← Zeilenvergleich zweier Fassungen (DiffLines) als
│ │ LCS ohne externe Abhängigkeit. Bei sehr langen
@ -254,10 +263,15 @@ munin/
│ │
│ ├── search/ ── Volltextsuche ──────────────────────────────────
│ │ ├── index.go ← Invertierter Index mit BM25-Bewertung. Titel zählen
│ │ │ dreifach, Schlagworte zweifach. Enthält die
│ │ │ Präfix-Rückfallebene: Ohne sie findet "deploy"
│ │ │ den "Deployment-Prozess" nicht — bei einer
│ │ │ KI-Anfrage ein stiller Fehltreffer.
│ │ │ dreifach, Schlagworte zweifach. Search für
│ │ │ Agenten, SearchFuzzy (mit Tippfehlern) für die
│ │ │ Weboberfläche.
│ │ │
│ │ ├── varianten.go ← Wortvarianten je Suchwort, schwächer gewichtet
│ │ │ als ein exakter Treffer: Wortanfang ("deploy" →
│ │ │ Deployment), Endung ("deployen"), Teilwort
│ │ │ ("prozess" → Releaseprozess), Tippfehler.
│ │ │ Dazu Stoppwörter, nur auf der Anfrageseite.
│ │ │
│ │ └── registry.go ← Ein Index je Projekt, verzögert aufgebaut. Getrennte
│ │ Indizes statt eines gemeinsamen mit Filter: So wird
@ -306,7 +320,8 @@ munin/
│ │ ├── handlers_entries.go ← Einträge auflisten, suchen, ansehen, bearbeiten.
│ │ │ Dazu Vorschau (preview), Vorschläge für [[…]]
│ │ │ (suggest), Fassungen und Vergleich, Graph-Daten
│ │ │ und Schlagworte für viele Einträge (bulk-tags).
│ │ │ Schlagworte für viele Einträge (bulk-tags) und
│ │ │ viele Einträge in den Papierkorb (bulk-delete).
│ │ ├── handlers_tags.go ← Seite "Alle Schlagworte" (/p/{projekt}/tags),
│ │ │ nach Häufigkeit oder alphabetisch gruppiert. Die
│ │ │ Seitenleiste zeigt nur die häufigsten (navTagLimit
@ -356,6 +371,14 @@ munin/
│ │ Qualität der Antworten, weil er bei jedem Client
│ │ im Kontext steht.
│ │
│ ├── kuratiert.go ← Kuratierte Einträge ändert remember nur mit
│ │ override_curated und Begründung; die Änderung
│ │ landet auf der Nutzungsseite zur Prüfung.
│ │
│ ├── dubletten.go ← Vor dem Anlegen: Gibt es einen Eintrag mit
│ │ ähnlichem Titel? Dann nichts schreiben, sondern
│ │ candidates zurückgeben (force legt doch an).
│ │
│ └── tools.go ← Die sechs Werkzeuge: recall, get, list, related,
│ remember, forget. Alte Einträge tragen in den
│ Antworten "stale": true. Jedes ist eine Closure
@ -426,7 +449,9 @@ Ausführung, Schalter und Ergebnisse: `LASTTEST.md`.
| `internal/entry/entry_test.go` | HTML-Escaping, Verweisauflösung, Textausschnitte, sichere Hervorhebung |
| `internal/markdown/markdown_test.go` | GFM-Erweiterungen, kein Roh-HTML und keine `javascript:`-Links, Anker nur in der Hilfe |
| `internal/entry/diff_test.go` | Zeilenvergleich, Grenzfälle, Ausweichen bei sehr langen Einträgen |
| `internal/search/search_test.go` | Bewertung, Präfix-Rückfallebene, Aktualisieren und Entfernen |
| `internal/search/search_test.go` | Bewertung, Wortanfang, Aktualisieren und Entfernen |
| `internal/search/varianten_test.go` | Wortanfang neben exaktem Treffer, Komposita, Endungen, Stoppwörter, Tippfehler nur in SearchFuzzy |
| `internal/search/bench_test.go` | Laufzeit einer Suche bei 10.000 Einträgen und 20.000 Wörtern |
| `internal/search/registry_test.go` | Index folgt der Datenbank, auch wenn sich Schreiber überholen, ein Eintrag gleich wieder gelöscht wird oder während des Aufbaus geschrieben wird; ein langsamer Aufbau bremst andere Projekte nicht |
| `internal/mcpserver/gleichzeitig_test.go` | Viele Agenten schreiben über MCP gleichzeitig denselben Eintrag: keine verlorene Fassung, Index aktuell |
| `internal/store/related_test.go` | Verwandte Einträge über zwei Ebenen, Papierkorb als Sackgasse, Grenze für veraltete Einträge, übernommene Zeitstempel beim Anlegen |
@ -435,7 +460,8 @@ Ausführung, Schalter und Ergebnisse: `LASTTEST.md`.
| `internal/web/download_test.go` | ZIP-Download: Inhalt und Protokolleintrag, nur Admins, kein fremdes Projekt, Workspace-ZIP für Plattform- und Workspace-Admin, sichere Dateinamen |
| `internal/web/import_test.go` | Import mit Vorschau und Übernehmen, Suchindex nachgezogen, ein Protokolleintrag, nur für admin, Body-Grenze je Route |
| `internal/web/web_test.go` | Anmeldung, zurückgezogene Keys, CSRF, **Projektabschottung**, **Workspace-Trennung**, Rollen, Header, Hilfeseiten samt eingesetzter Adresse |
| `internal/mcpserver/mcpserver_test.go` | Werkzeugbestand, `instructions`, Dublettenvermeidung, Konflikte, Rollen, **Projektabschottung** |
| `internal/mcpserver/mcpserver_test.go` | Werkzeugbestand, `instructions`, Dublettenvermeidung (gleicher Bezeichner und ähnlicher Titel), Synonyme, Suchprotokoll, Konflikte, Rollen, **Projektabschottung** |
| `internal/store/suchprotokoll_test.go` | Kennzahlen und Anfragen ohne Treffer, Zeitfenster, Aufräumen, verschwindet mit dem Projekt |
| `internal/hilfe/hilfe_test.go` | Seitenliste und Dateien passen zusammen, interne Links führen zu vorhandenen Seiten, kein Roh-HTML |
## Datenfluss: Agent ruft Wissen ab
@ -452,9 +478,10 @@ KI-Werkzeug POST /mcp { "method": "tools/call", "params": { "name": "recall",
→ mcpserver/tools.go (recall)
→ search/registry.go Index dieses Projekts (beim ersten Mal
aus der Datenbank aufgebaut)
→ search/index.go (Search) BM25, bei Bedarf Präfix-Rückfallebene
→ search/index.go (Search) BM25 über Wortvarianten (varianten.go)
→ store/entries.go (EntryBySlug) Treffer nachladen
→ entry/entry.go (Snippet) Textausschnitt je Treffer
→ store/suchprotokoll.go (LogSearch) Anfrage und Trefferzahl
→ JSON-Antwort mit Trefferliste und Hinweis zum weiteren Vorgehen
```
@ -465,6 +492,10 @@ KI-Werkzeug POST /mcp { … "name": "remember", "arguments": { "slug": …, "c
→ web/middleware.go (APIAuth) wie oben
→ mcpserver/tools.go (remember)
→ requireWrite() Rolle read wird hier abgewiesen
→ dubletten.go (pruefeDubletten) neuer Bezeichner, ähnlicher Titel?
→ candidates zurück, nichts schreiben
→ kuratiert.go (istKuratiert) kuratiert und kein override_curated?
→ judgment_required, nichts schreiben
→ store/entries.go (PutEntry) eine Transaktion:
· Bezeichner vorhanden? → aktualisieren
· ExpectedVersion prüfen → ggf. Konflikt

View file

@ -10,7 +10,10 @@ package entry
import (
"fmt"
"html"
"sort"
"strings"
"unicode"
"unicode/utf8"
"munin/internal/markdown"
"munin/internal/store"
@ -55,15 +58,17 @@ const snippetRadius = 120
func Snippet(content, query string) string {
text := RenderPlain(content)
idx := strings.Index(strings.ToLower(text), strings.ToLower(query))
if idx == -1 {
// Kein wörtlicher Treffer (z.B. weil der Fund über die Präfix-Suche kam) —
// dann der Anfang des Texts als Vorschau.
nadel := trefferwort(text, query)
if nadel == "" {
// Kein Wort der Anfrage steht wörtlich im Text (z.B. weil der Fund über
// eine Endung oder ein Synonym kam) — dann der Anfang des Texts als
// Vorschau.
return kuerzen(text, snippetRadius+snippetRadius)
}
idx := strings.Index(strings.ToLower(text), strings.ToLower(nadel))
start := max(idx-snippetRadius, 0)
ende := min(idx+len(query)+snippetRadius, len(text))
ende := min(idx+len(nadel)+snippetRadius, len(text))
ausschnitt := strings.TrimSpace(text[start:ende])
if start > 0 {
@ -75,6 +80,59 @@ func Snippet(content, query string) string {
return ausschnitt
}
// minTrefferwort ist die Mindestlänge eines einzelnen Worts der Anfrage, an
// dem sich der Ausschnitt ausrichten darf. Kürzere Wörter („wie", „an") stehen
// in fast jedem Text und sagen nichts über den Treffer.
const minTrefferwort = 4
// trefferwort bestimmt, an welcher Stelle der Ausschnitt ansetzt: das Stück
// der Anfrage, das im Text vorkommt. "" heißt: nichts davon kommt vor.
//
// # Warum nicht einfach die ganze Anfrage?
//
// So war es früher. Agenten stellen aber ganze Fragen („wie deployen wir den
// Dienst"), und die stehen nie wörtlich im Text. Der Ausschnitt zeigte dann
// immer nur den Textanfang — auch wenn die eigentliche Fundstelle weiter
// hinten lag.
//
// Deshalb zuerst die ganze Anfrage (in der Weboberfläche tippen Menschen oft
// genau eine Wortfolge), danach die einzelnen Wörter, längste zuerst. Die
// Länge ist ein einfacher Ersatz für „aussagekräftig": „Zertifikatswechsel"
// verrät mehr über die Fundstelle als „Dienst".
func trefferwort(text, query string) string {
query = strings.TrimSpace(query)
if query == "" {
return ""
}
klein := strings.ToLower(text)
if strings.Contains(klein, strings.ToLower(query)) {
return query
}
// FieldsFunc zerlegt an jedem Zeichen, für das die Funktion true liefert —
// hier an allem, was kein Buchstabe und keine Ziffer ist.
woerter := strings.FieldsFunc(query, func(r rune) bool {
return !unicode.IsLetter(r) && !unicode.IsDigit(r)
})
// Längste zuerst. SliceStable lässt gleich lange Wörter in der Reihenfolge
// der Anfrage stehen — so ist das Ergebnis immer dasselbe.
sort.SliceStable(woerter, func(i, j int) bool {
return utf8.RuneCountInString(woerter[i]) > utf8.RuneCountInString(woerter[j])
})
for _, w := range woerter {
if utf8.RuneCountInString(w) < minTrefferwort {
break // sortiert: alle folgenden sind noch kürzer
}
if strings.Contains(klein, strings.ToLower(w)) {
return w
}
}
return ""
}
// kuerzen schneidet einen Text auf höchstens n Zeichen.
func kuerzen(text string, n int) string {
text = strings.TrimSpace(text)
@ -97,9 +155,14 @@ func HighlightSnippet(content, query string) string {
return sicher
}
// Im escapten Text nach dem (ebenfalls escapten) Suchbegriff suchen, damit
// die Positionen zusammenpassen.
nadel := html.EscapeString(query)
// Hervorgehoben wird dasselbe Stück der Anfrage, an dem sich der
// Ausschnitt ausgerichtet hat. Gesucht wird im escapten Text nach dem
// ebenfalls escapten Stück, damit die Positionen zusammenpassen.
treffer := trefferwort(ausschnitt, query)
if treffer == "" {
return sicher
}
nadel := html.EscapeString(treffer)
idx := strings.Index(strings.ToLower(sicher), strings.ToLower(nadel))
if idx == -1 {
return sicher
@ -108,6 +171,37 @@ func HighlightSnippet(content, query string) string {
return sicher[:idx] + "<mark>" + sicher[idx:idx+len(nadel)] + "</mark>" + sicher[idx+len(nadel):]
}
// auchGesuchtAlsPraefix leitet die Zeile ein, in der ein Eintrag weitere
// Suchbegriffe nennt (siehe instructions in internal/mcpserver).
const auchGesuchtAlsPraefix = "auch gesucht als"
// AuchGesuchtAls gibt die Begriffe aus der Zeile „Auch gesucht als: …" eines
// Eintrags zurück, so wie sie dort stehen. Ohne eine solche Zeile: "".
//
// Agenten schreiben die Zeile nicht immer gleich. Erkannt werden deshalb auch
// Hervorhebungen und Aufzählungszeichen davor:
//
// Auch gesucht als: release, rollout
// *Auch gesucht als:* release, rollout
// - **Auch gesucht als:** release, rollout
//
// Gibt es mehrere solcher Zeilen, zählt die letzte — die Anleitung bittet
// darum, die Zeile ans Ende zu stellen.
func AuchGesuchtAls(content string) string {
ergebnis := ""
for _, zeile := range strings.Split(content, "\n") {
// Aufzählungszeichen, Zitatzeichen und Hervorhebungen am Anfang weg.
zeile = strings.TrimLeft(strings.TrimSpace(zeile), "-*_> ")
if !strings.HasPrefix(strings.ToLower(zeile), auchGesuchtAlsPraefix) {
continue
}
rest := zeile[len(auchGesuchtAlsPraefix):]
rest = strings.TrimLeft(rest, ":*_ ")
ergebnis = strings.TrimSpace(rest)
}
return ergebnis
}
// TitleFromContent rät einen Titel aus dem Inhalt — die erste Überschrift oder
// die erste Zeile. Wird verwendet, wenn ein Agent keinen Titel mitgeschickt hat.
func TitleFromContent(content string) string {

View file

@ -97,6 +97,60 @@ func TestSnippetWithoutMatch(t *testing.T) {
}
}
// TestSnippetBeiGanzerFrage prüft den Normalfall bei Agenten: Die Anfrage ist
// eine ganze Frage, die so nie im Text steht. Der Ausschnitt muss trotzdem an
// der Fundstelle ansetzen, nicht am Textanfang.
func TestSnippetBeiGanzerFrage(t *testing.T) {
text := strings.Repeat("Vorlauf. ", 40) + "Der Zertifikatswechsel ist im März. " + strings.Repeat("Nachlauf. ", 40)
got := Snippet(text, "wie läuft der Zertifikatswechsel")
if !strings.Contains(got, "Zertifikatswechsel") {
t.Errorf("der Ausschnitt setzt nicht an der Fundstelle an: %q", got)
}
}
// TestSnippetLaengstesWortZuerst prüft die Wahl zwischen mehreren Wörtern:
// Das längere, aussagekräftigere gewinnt, auch wenn das kürzere früher steht.
func TestSnippetLaengstesWortZuerst(t *testing.T) {
text := "Dienst. " + strings.Repeat("Füllwort. ", 60) + "Kubernetes-Cluster hier."
got := Snippet(text, "dienst kubernetes")
if !strings.Contains(got, "Kubernetes") {
t.Errorf("es wurde nicht das längste Wort gewählt: %q", got)
}
}
func TestTrefferwort(t *testing.T) {
text := "Wir deployen über GitHub Actions."
faelle := map[string]string{
"GitHub Actions": "GitHub Actions", // ganze Anfrage kommt vor
"wie deployen wir": "deployen",
"wie wir": "", // nur kurze Wörter
"gibtesnicht": "",
"": "",
"actions, github?": "actions", // gleich lang: Reihenfolge der Anfrage
}
for anfrage, erwartet := range faelle {
if got := trefferwort(text, anfrage); got != erwartet {
t.Errorf("trefferwort(%q) = %q, erwartet %q", anfrage, got, erwartet)
}
}
}
// TestHighlightSnippetEinzelwort prüft, dass bei einer ganzen Frage das Wort
// hervorgehoben wird, an dem sich der Ausschnitt ausrichtet.
func TestHighlightSnippetEinzelwort(t *testing.T) {
got := HighlightSnippet("Wir deployen über GitHub Actions.", "wie deployen wir")
if !strings.Contains(got, "<mark>deployen</mark>") {
t.Errorf("das Fundwort wurde nicht hervorgehoben: %q", got)
}
}
func TestSnippetShortText(t *testing.T) {
got := Snippet("Kurz.", "Kurz")
if got != "Kurz." {
@ -161,3 +215,23 @@ func TestTitleFromContentIsShortened(t *testing.T) {
t.Errorf("der Titel ist %d Zeichen lang — er wurde nicht gekürzt", len(got))
}
}
// ── Auch gesucht als ─────────────────────────────────────────────────────────
func TestAuchGesuchtAls(t *testing.T) {
faelle := map[string]string{
"Text.\n\nAuch gesucht als: release, rollout": "release, rollout",
"Text.\n*Auch gesucht als:* release": "release",
"- **Auch gesucht als:** release, auslieferung": "release, auslieferung",
"auch gesucht als: klein geschrieben": "klein geschrieben",
"Auch gesucht als: alt\nText\nAuch gesucht als: neu": "neu",
"Ein Text ohne die Zeile.": "",
"Wir haben auch gesucht als Team.": "", // nicht am Zeilenanfang
}
for inhalt, erwartet := range faelle {
if got := AuchGesuchtAls(inhalt); got != erwartet {
t.Errorf("AuchGesuchtAls(%q) = %q, erwartet %q", inhalt, got, erwartet)
}
}
}

View file

@ -60,8 +60,11 @@ ein **Agent** (über MCP) geschrieben hat, und unter welchem Zugangsnamen.
**Kuratiert**
Ein Haken, den ein Mensch setzt, wenn er einen Eintrag geprüft hat. Einträge
von Agenten ohne diesen Haken sind Beobachtungen, keine bestätigten Fakten.
Agenten sehen den Haken ebenfalls und können danach filtern.
ohne diesen Haken sind Beobachtungen, keine bestätigten Fakten. Agenten sehen
den Haken und können danach filtern; in der Suche stehen kuratierte Einträge
bei sonst gleicher Relevanz weiter vorn. Ein Agent ändert einen kuratierten
Eintrag nur mit Begründung. Dabei fällt der Haken weg, und der Eintrag steht
auf der Nutzungsseite zur Prüfung, bis ein Mensch ihn wieder setzt.
**API-Key** (Zugang)
Ein Schlüssel, der mit `wk_` beginnt und genau zu einem Projekt gehört. Er
@ -74,6 +77,12 @@ Hält fest, wer wann etwas geschrieben oder gelöscht hat, wer sich angemeldet h
und Suchen stehen nicht darin. Protokolleinträge werden nach 90 Tagen
automatisch gelöscht.
**Suchprotokoll**
Hält getrennt davon fest, was Agenten mit `recall` gesucht haben und ob sie
etwas gefunden haben, ohne Adresse. Die Nutzungsseite wertet es aus. Weil
Anfragen Inhalt sind, wird es nach 30 Tagen gelöscht und verschwindet mit dem
Projekt.
## Rollen
Jeder Key hat genau eine Rolle.

View file

@ -106,8 +106,8 @@ In `opencode.json` im Projektverzeichnis oder global unter
## 3. Prüfen, ob es geht
Das Werkzeug sollte fünf Munin-Werkzeuge anbieten: `recall`, `get`, `list`,
`remember` und `forget`. Ein einfacher Test:
Das Werkzeug sollte sechs Munin-Werkzeuge anbieten: `recall`, `get`, `list`,
`related`, `remember` und `forget`. Ein einfacher Test:
```
Rufe list auf und sag mir, welche Schlagworte es im Munin gibt.
@ -124,4 +124,5 @@ Rufe list auf und sag mir, welche Schlagworte es im Munin gibt.
Wenn die Verbindung steht, geht es mit
[Mit Agenten arbeiten](/hilfe/mit-agenten-arbeiten) weiter. Ohne eine kurze
Anweisung benutzt ein Agent das Gedächtnis selten von selbst.
Anweisung in `AGENTS.md` oder `CLAUDE.md` benutzt ein Agent das Gedächtnis
selten von selbst.

View file

@ -7,10 +7,32 @@ Neues, ohne vorher zu suchen.
Abhilfe schafft ein kurzer Block im Projekt- oder Systemprompt.
## Wohin der Projektprompt gehört
Jedes Werkzeug liest seine Anweisungen aus einer anderen Datei:
| Werkzeug | im Projekt | persönlich, für alle Projekte |
|---|---|---|
| OpenCode | `AGENTS.md` (ersatzweise `CLAUDE.md`) | `~/.config/opencode/AGENTS.md` |
| Cline | `AGENTS.md` oder `.clinerules/` | `~/Documents/Cline/Rules/` |
| Claude Code | `CLAUDE.md` | `~/.claude/CLAUDE.md` |
| eigene Anwendung | Systemprompt | |
**Mehrere Werkzeuge im Team:** Den Block in **eine** `AGENTS.md` im
Projektverzeichnis schreiben. OpenCode und Cline lesen sie direkt. Claude Code
liest `AGENTS.md` nur, wenn es keine `CLAUDE.md` gibt (und erst in neueren
Fassungen). Sicher ist deshalb: in die `CLAUDE.md` die Zeile `@AGENTS.md`
schreiben, gegebenenfalls als einzige Zeile. So steht der Block an genau einer
Stelle und läuft nicht auseinander.
**Persönlich statt im Projekt:** Ist Munin global eingerichtet (Claude Code mit
`--scope user`, OpenCode in `~/.config/opencode/opencode.json`), passt der Block
auch in die persönliche Datei. Dann gilt er überall, ohne dass jedes Repository
ihn braucht.
## Der Projektprompt
In `CLAUDE.md`, `.clinerules`, *Custom Instructions* oder den Systemprompt
deiner Anwendung übernehmen:
Diesen Block übernehmen:
```markdown
## Munin
@ -19,7 +41,10 @@ Für dieses Projekt gibt es einen Munin über MCP (Werkzeuge: recall,
get, list, related, remember, forget). Er ist das gemeinsame Gedächtnis des Teams.
LESEN — vor jeder Antwort zu projektspezifischen Fragen:
- Zuerst `recall` mit der Frage aufrufen, nicht mit Stichworten.
- Zuerst `recall` mit der Frage aufrufen, nicht mit Stichworten. Unter
`synonyms` weitere Namen für das Thema mitgeben (Synonyme, Übersetzung,
Abkürzungen) — die Suche vergleicht Wörter, keine Bedeutungen.
- Findet `recall` nichts, mit anderen Begriffen erneut suchen.
- Passenden Treffer mit `get` vollständig laden.
- Für mehr Kontext mit `related` die verknüpften Einträge ansehen.
- Einträge mit `"stale": true` sind über ein Jahr alt: vor Verwendung prüfen
@ -32,6 +57,11 @@ SCHREIBEN — sobald etwas festgehalten werden sollte:
- IMMER zuerst `recall`, ob es zum Thema schon einen Eintrag gibt.
- Gibt es einen: mit DESSEN slug `remember` aufrufen. Das aktualisiert ihn.
- Nur wenn wirklich nichts passt, einen neuen slug wählen.
- Meldet `remember` `judgment_required`, die `candidates` prüfen: Passt einer,
ihn fortschreiben. Nur bei wirklich anderem Thema mit `force: true` anlegen.
- Einträge mit `"curated": true` hat ein Mensch geprüft: Sie haben Vorrang.
Nur ändern, wenn sie nachweislich nicht mehr stimmen — dann mit
`override_curated: true` und einer Begründung unter `reason`.
- Vor dem Aktualisieren `get` aufrufen und die gelieferte `version` als
`expected_version` mitgeben.
@ -44,6 +74,8 @@ WAS festgehalten wird:
WIE festgehalten wird:
- Destilliert, nicht als Protokoll. Ein Eintrag beantwortet eine Frage.
- Verwandtes mit [[bezeichner]] verknüpfen.
- Am Ende eine Zeile `Auch gesucht als: …` mit 2–5 Begriffen, die nicht im
Text stehen, unter denen man den Eintrag aber suchen würde.
- Schlagworte sparsam: 1–3 aus dem bestehenden Bestand (`list` zeigt sie).
```
@ -117,10 +149,28 @@ führt beides zusammen. Ohne diese Angabe würde die fremde Änderung einfach
überschrieben. Sie wäre zwar im Verlauf noch zu finden, aber niemand würde es
merken.
## Wenn Munin nachfragt, statt zu schreiben
In zwei Fällen schreibt `remember` nicht sofort, sondern antwortet mit
`judgment_required`. Das ist kein Fehler, sondern eine Rückfrage an den Agenten:
- **Ähnlicher Eintrag vorhanden:** Ein neuer Eintrag ähnelt einem vorhandenen
(gleiche Wörter im Titel oder in „Auch gesucht als“). Die Antwort nennt die
Kandidaten. Passt einer, schreibt der Agent diesen fort; ist es wirklich ein
anderes Thema, legt er mit `force: true` doch an.
- **Kuratierter Eintrag:** Ein Mensch hat den Eintrag geprüft. Der Agent ändert
ihn nur mit `override_curated: true` und einer Begründung unter `reason`.
Dabei fällt der Haken „Kuratiert“ weg, und der Eintrag erscheint auf der
Nutzungsseite unter *Zur Prüfung*.
## Grenzen, die man kennen sollte
- **Die Suche ist wortbasiert.** „Wie deployen wir“ findet „Release-Prozess“
nicht. Im Zweifel hilft `list` für den Überblick.
- **Die Suche ist wortbasiert.** Sie gleicht Wortanfänge, Endungen und
zusammengesetzte Wörter aus („deployen“ findet „Deployment“, „prozess“ findet
„Releaseprozess“), aber keine Bedeutungen: „Wie deployen wir“ findet
„Release-Prozess“ nur, wenn der Agent `release` unter `synonyms` mitgibt oder
der Eintrag es unter „Auch gesucht als“ nennt. Im Zweifel hilft `list` für den
Überblick.
- **`forget` legt in den Papierkorb.** Der Eintrag ist für alle Agenten sofort
weg, lässt sich aber 30 Tage lang von einem Menschen zurückholen. Agenten
selbst können nichts wiederherstellen und den Papierkorb nicht leeren.
@ -146,6 +196,16 @@ bedeuten: Der Projektprompt greift nicht.
Außerdem zeigt die Seite:
- **Zur Prüfung:** kuratierte Einträge, die ein Agent seitdem geändert hat, mit
seiner Begründung. Der Link führt zum Verlauf, dort lässt sich die Änderung mit
dem geprüften Stand vergleichen. Stimmt sie, den Haken „Kuratiert“ wieder
setzen; sonst die alte Fassung wiederherstellen. Danach verschwindet der
Eintrag aus der Liste.
- **Suchen der Agenten:** wie oft Agenten gesucht haben, wie oft ohne Treffer
und wie oft mit Synonymen. Darunter die häufigsten Anfragen ohne Treffer.
Steht das Gesuchte unter anderen Wörtern da, hilft eine Zeile
„Auch gesucht als: …“ im Eintrag. Fehlt es, ist das ein Kandidat für einen
neuen Eintrag.
- **Verweise ins Leere:** Bezeichner, auf die mit `[[…]]` verwiesen wird, ohne
dass es den Eintrag gibt. Die meistgenannten sind gute Kandidaten für den
nächsten Eintrag; mit Schreibrecht führt *anlegen* direkt dorthin.

View file

@ -13,7 +13,10 @@ zurückgezogen, endet auch die Sitzung im Browser sofort.
- **Suchen:** Das Suchfeld über der Eintragsliste durchsucht Titel,
Schlagworte und Inhalt. Titel zählen am stärksten. Umlaute sind egal:
„Uberblick“ findet auch „Überblick“. Ein Wortanfang reicht, „deploy“ findet
auch „Deployment“.
auch „Deployment“. Ebenso findet „prozess“ den „Releaseprozess“ und
„zertifikate“ das „Zertifikat“. Kleine Tippfehler gleicht die Suche aus,
wenn sonst nichts passt: „deploymnet“ findet „Deployment“. Genaue Treffer
stehen immer vor ungefähren.
- **Nach Schlagwort filtern:** In der Seitenleiste stehen die zehn häufigsten
Schlagworte des Projekts. Ein Klick filtert die Liste. Gibt es mehr, führt
*Alle Schlagworte…* zu einer Seite mit der vollständigen Liste, nach
@ -118,6 +121,10 @@ zurückgeholt wurde. Hat jemand den Eintrag geändert, während du den Verlauf
angesehen hast, wird nichts überschrieben; prüfe dann den neuen Stand und
versuche es erneut.
Der Haken **Kuratiert** bleibt beim Wiederherstellen so, wie er gerade ist. Hat
ein Agent einen geprüften Eintrag geändert (dabei fällt der Haken weg) und
holst du den geprüften Stand zurück, setze den Haken danach wieder.
## Papierkorb
**Löschen** legt einen Eintrag in den Papierkorb, egal ob ein Mensch in der
@ -150,9 +157,16 @@ auffindbar, und der Verlauf zeigt, was sich geändert hat.
## Viele Einträge auf einmal
Mit Schreibrecht hat die Eintragsliste vor jedem Eintrag ein Auswahlkästchen.
Unter der Liste lassen sich für alle ausgewählten Einträge Schlagworte
hinzufügen oder entfernen, mehrere durch Komma getrennt. Praktisch, um ein
Schlagwort umzubenennen: das neue hinzufügen, das alte entfernen.
Unter der Liste gibt es für alle ausgewählten Einträge zwei Aktionen:
- **Schlagworte ändern:** hinzufügen oder entfernen, mehrere durch Komma
getrennt, dann *Anwenden*. Praktisch, um ein Schlagwort umzubenennen: das
neue hinzufügen, das alte entfernen.
- **In den Papierkorb:** legt alle ausgewählten Einträge nach einer Rückfrage
in den Papierkorb, wie das Löschen eines einzelnen Eintrags. Danach stehst
du wieder in derselben Liste, mit derselben Suche oder demselben Filter. War
es zu viel, holt der Papierkorb alles mit *Alle … wiederherstellen* in einem
Zug zurück.
## Import

View file

@ -91,6 +91,12 @@ wer sich angemeldet hat (auch Fehlversuche) und wann Zugänge angelegt oder
zurückgezogen wurden. Einträge werden nach 90 Tagen gelöscht, weil sie Namen
und Adressen enthalten.
Was Agenten suchen, steht getrennt im Suchprotokoll und wird nach 30 Tagen
gelöscht. Die häufigsten Anfragen ohne Treffer zeigt die Nutzungsseite allen
mit Zugang zum Projekt, auch mit Leserecht. Eine Anfrage kann verraten, woran
jemand gerade arbeitet. Wie bei den Einträgen gilt deshalb: keine Geheimnisse
hineinschreiben.
## Was man beim Teilen von Wissen bedenken sollte
**Keine Geheimnisse in Einträge.** Passwörter, Tokens und private Schlüssel

View file

@ -0,0 +1,118 @@
package mcpserver
// Dubletten über Ähnlichkeit erkennen (AUSBAUSTUFEN.md 3.3).
//
// Der Bezeichner schützt nur vor Dubletten unter GLEICHEM Namen. Wählt ein
// Agent für ein vorhandenes Thema einen neuen Bezeichner („deployment-ablauf"
// statt „deployment-prozess"), stünde dieselbe Erkenntnis zweimal da — und
// die beiden Fassungen laufen mit der Zeit auseinander.
//
// Deshalb prüft remember vor dem Anlegen, ob es schon einen Eintrag mit
// ähnlichem Titel gibt. Wenn ja, legt es NICHT an, sondern gibt die
// Kandidaten zurück und lässt den Agenten entscheiden.
//
// # Warum zurückgeben statt selbst entscheiden?
//
// Ein Agent, der einen Hinweis mit Kandidaten bekommt, korrigiert sich: Er
// lädt den passenden Eintrag und schreibt ihn fort. Einer, dessen Aufruf
// ohne Erklärung abgelehnt wird, versucht es mit einem noch schlechteren
// Bezeichner. Und manchmal ist ein ähnlicher Titel eben doch ein anderes
// Thema — das weiß der Agent besser als jede Schwelle. Dafür gibt es force.
import (
"context"
"errors"
"math"
"sort"
"munin/internal/entry"
"munin/internal/search"
"munin/internal/store"
"munin/internal/workspace"
)
const (
// dublettenSchwelle ist der Mindestanteil gemeinsamer Titelwörter, ab dem
// ein vorhandener Eintrag als Kandidat gilt (siehe search.Ueberdeckung).
//
// 0,5 heißt: Die Hälfte der Wörter stimmt überein. „Deployment-Ablauf"
// und „Deployment-Prozess" erreichen das gerade — genau der Fall, um den
// es geht. Dafür meldet die Prüfung auch „Server-Zugang" bei
// „Datenbank-Zugang". Ein solcher Fehlalarm kostet den Agenten einen
// zweiten Aufruf mit force; eine übersehene Dublette kostet auf Dauer
// mehr.
dublettenSchwelle = 0.5
// dublettenSuchtiefe: So viele Suchtreffer werden genauer verglichen.
// Die Suche findet Kandidaten, die Überdeckung entscheidet.
dublettenSuchtiefe = 10
// dublettenMaxKandidaten: So viele Kandidaten gehen höchstens zurück.
dublettenMaxKandidaten = 5
)
// pruefeDubletten sucht vorhandene Einträge, die einem neu anzulegenden
// ähneln. Ist slug schon vergeben, wird aktualisiert statt angelegt — dann
// gibt es nichts zu prüfen, und das Ergebnis ist leer.
//
// Verglichen wird der Titel samt der Zeile „Auch gesucht als: …", in beide
// Richtungen. So findet ein neuer „Release-Ablauf" den vorhandenen
// „Deployment-Prozess", wenn dort „release" als Suchbegriff steht.
func pruefeDubletten(ctx context.Context, ws *workspace.Workspace, project store.Project,
slug, titel, inhalt string) ([]dublettenKandidat, error) {
_, err := ws.Store.EntryBySlug(ctx, project.ID, slug)
if err == nil {
return nil, nil // vorhanden: Aktualisierung, keine Neuanlage
}
if !errors.Is(err, store.ErrNotFound) {
return nil, err
}
idx, err := ws.Search.For(ctx, project.ID)
if err != nil {
return nil, err
}
neuerName := titel + " " + entry.AuchGesuchtAls(inhalt)
var kandidaten []dublettenKandidat
for _, t := range idx.Search(neuerName, dublettenSuchtiefe) {
e, err := ws.Store.EntryBySlug(ctx, project.ID, t.DocID)
if errors.Is(err, store.ErrNotFound) {
continue // Index kennt einen Eintrag, den es nicht mehr gibt
}
if err != nil {
return nil, err
}
vorhandenerName := e.Title + " " + entry.AuchGesuchtAls(e.Content)
// In beide Richtungen, der höhere Wert zählt: „Deployment" (neu) ist
// vollständig in „Deployment-Prozess" enthalten, umgekehrt nur zur
// Hälfte. Beides ist ein Hinweis auf dasselbe Thema.
anteil := max(
search.Ueberdeckung(titel, vorhandenerName),
search.Ueberdeckung(e.Title, neuerName),
)
if anteil < dublettenSchwelle {
continue
}
kandidaten = append(kandidaten, dublettenKandidat{
Slug: e.Slug,
Title: e.Title,
Overlap: int(math.Round(anteil * 100)),
})
}
// Ähnlichste zuerst. SliceStable lässt gleich ähnliche in der Reihenfolge
// der Suche stehen — die ist der bessere Gleichstandsbrecher als Zufall.
sort.SliceStable(kandidaten, func(i, j int) bool {
return kandidaten[i].Overlap > kandidaten[j].Overlap
})
if len(kandidaten) > dublettenMaxKandidaten {
kandidaten = kandidaten[:dublettenMaxKandidaten]
}
return kandidaten, nil
}

View file

@ -102,8 +102,12 @@ func TestGleichzeitigesSchreibenHaeltSuchindexAktuell(t *testing.T) {
wort := fmt.Sprintf("marke%sx%s", buchstaben(runde*1000+a*100+i), "ende")
erg, err := client.CallTool(ctx, &mcp.CallToolParams{
Name: "remember",
// force: Jede Runde legt unter neuem Bezeichner mit
// demselben Titel an. Die Dublettenprüfung würde das
// zu Recht anhalten — hier geht es aber ums
// gleichzeitige Schreiben, nicht um Dubletten.
Arguments: map[string]any{
"slug": slug, "title": "Umkämpft", "content": "Stand " + wort,
"slug": slug, "title": "Umkämpft", "content": "Stand " + wort, "force": true,
},
})
if err != nil {

View file

@ -0,0 +1,72 @@
package mcpserver
// Kuratierte Einträge vor unbemerktem Überschreiben schützen.
//
// „Kuratiert" heißt: Ein Mensch hat den Inhalt geprüft. Schreibt ein Agent
// einen solchen Eintrag um, ist der neue Text nicht mehr geprüft — die
// Markierung fällt deshalb weg. Das ist richtig. Früher geschah es aber
// still: Der Agent erfuhr nicht, dass er geprüftes Wissen ersetzt hatte, und
// kein Mensch bekam es mit.
//
// Jetzt läuft es in zwei Schritten, wie bei den Dubletten (dubletten.go):
//
// 1. remember auf einen kuratierten Eintrag schreibt NICHT, sondern meldet
// judgment_required mit einer Erklärung.
// 2. Hält der Agent die Änderung für nötig, ruft er erneut auf, mit
// override_curated: true und einer Begründung (reason). Dann wird
// geschrieben, die Markierung fällt weg, und das Protokoll vermerkt den
// Vorgang samt Begründung. Die Nutzungsseite listet solche Einträge, bis
// ein Mensch sie wieder als kuratiert markiert.
//
// # Warum nicht ganz sperren?
//
// Weil kuratiertes Wissen auch veralten kann. Ein Agent, der nachweislich
// sieht, dass ein Server umgezogen ist, soll das festhalten dürfen. Die
// Hürde soll Fehlgriffe aufhalten, nicht berechtigte Korrekturen.
//
// Zwischen Prüfung und Schreiben könnte ein Mensch den Eintrag gerade
// kuratieren. Dieses Zeitfenster ist Millisekunden breit; der Verlauf hält
// den geprüften Stand in jedem Fall fest.
import (
"context"
"errors"
"strings"
"munin/internal/store"
"munin/internal/workspace"
)
// istKuratiert prüft, ob unter slug ein kuratierter Eintrag liegt.
// Gibt es den Eintrag nicht, ist er auch nicht kuratiert.
func istKuratiert(ctx context.Context, ws *workspace.Workspace, project store.Project, slug string) (bool, error) {
e, err := ws.Store.EntryBySlug(ctx, project.ID, slug)
if errors.Is(err, store.ErrNotFound) {
return false, nil
}
if err != nil {
return false, err
}
return e.Curated, nil
}
// kuratiertHinweis ist die Antwort an den Agenten, wenn er einen kuratierten
// Eintrag ohne override_curated ändern will. Sie sagt ausdrücklich, was er
// tun soll — wie alle Meldungen an Modelle.
const kuratiertHinweis = "Nichts geschrieben: Dieser Eintrag ist kuratiert — ein Mensch hat ihn geprüft. " +
"Geprüftes Wissen hat Vorrang vor eigenen Annahmen. Ändere ihn nur, wenn er nachweislich nicht " +
"mehr stimmt (etwa weil du gerade das Gegenteil beobachtet hast). Dann denselben Aufruf mit " +
"\"override_curated\": true und unter \"reason\" einer kurzen Begründung wiederholen. " +
"Die Markierung \"kuratiert\" fällt dabei weg, und ein Mensch prüft die Änderung. " +
"Sonst: nichts ändern, oder die Ergänzung in einem eigenen Eintrag festhalten und mit [[slug]] verweisen."
// begruendungPruefen stellt sicher, dass ein override_curated eine
// Begründung trägt. Ohne sie wüsste der prüfende Mensch nicht, warum der
// geprüfte Stand ersetzt wurde.
func begruendungPruefen(in rememberInput) error {
if in.OverrideCurated && strings.TrimSpace(in.Reason) == "" {
return errors.New("override_curated braucht eine Begründung unter \"reason\": " +
"Warum stimmt der kuratierte Stand nicht mehr?")
}
return nil
}

View file

@ -2,6 +2,7 @@ package mcpserver
import (
"context"
"errors"
"fmt"
"path/filepath"
"strings"
@ -292,7 +293,7 @@ func TestReadRoleCanRead(t *testing.T) {
// ── Suchen und Lesen ─────────────────────────────────────────────────────────
// TestRecallFindsByPrefix prüft die Präfix-Rückfallebene: "deploy" muss
// TestRecallFindsByPrefix prüft die Suche nach dem Wortanfang: "deploy" muss
// "Deployment-Prozess" finden. Ohne sie liefert die Suche ein leeres Ergebnis,
// und das Modell erfährt nie, dass es den Eintrag gäbe.
func TestRecallFindsByPrefix(t *testing.T) {
@ -323,6 +324,86 @@ func TestRecallEmptyGivesHint(t *testing.T) {
}
}
// TestRecallMitSynonymen prüft, dass Synonyme einen Eintrag finden, der das
// Thema unter einem anderen Namen führt. Ohne sie findet eine Frage nach dem
// „Deployment" den „Release-Ablauf" nicht — kein Wort passt.
func TestRecallMitSynonymen(t *testing.T) {
h, ws, projekt := aufbau(t)
ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{
"slug": "release-ablauf", "title": "Release-Ablauf",
"content": "Neue Fassungen gehen dienstags raus.",
})
ohne := ruf(t, h, ws, projekt, auth.RoleRead, "recall", map[string]any{
"query": "wie deployen wir",
})
if strings.Contains(text(ohne), "release-ablauf") {
t.Fatalf("der Eintrag wurde schon ohne Synonyme gefunden — der Test prüft so nichts: %s", text(ohne))
}
mit := ruf(t, h, ws, projekt, auth.RoleRead, "recall", map[string]any{
"query": "wie deployen wir",
"synonyms": []any{"release", "rollout"},
})
if mit.IsError {
t.Fatalf("recall schlug fehl: %s", text(mit))
}
if !strings.Contains(text(mit), "release-ablauf") {
t.Errorf("das Synonym fand den Eintrag nicht: %s", text(mit))
}
}
// TestRecallProtokolliert prüft, dass jede Suche im Suchprotokoll landet —
// mit Synonymen und der Zahl der Treffer.
func TestRecallProtokolliert(t *testing.T) {
h, ws, projekt := aufbau(t)
ruf(t, h, ws, projekt, auth.RoleRead, "recall", map[string]any{
"query": "gibtesnicht", "synonyms": []any{"auchnicht"},
})
st, err := ws.Store.SearchStatsFor(context.Background(), projekt.ID, time.Now())
if err != nil {
t.Fatal(err)
}
if st.Total != 1 || st.Empty != 1 || st.WithSynonyms != 1 {
t.Errorf("SearchStats = %+v, erwartet je 1", st)
}
}
// TestMitSynonymenDeckelt prüft die Obergrenze für Synonyme.
func TestMitSynonymenDeckelt(t *testing.T) {
viele := make([]string, recallMaxSynonyms+5)
for i := range viele {
viele[i] = fmt.Sprintf("wort%d", i)
}
got := mitSynonymen("frage", begrenzeSynonyme(viele))
if strings.Contains(got, fmt.Sprintf("wort%d", recallMaxSynonyms)) {
t.Errorf("mehr als %d Synonyme wurden übernommen: %q", recallMaxSynonyms, got)
}
if got := begrenzeSynonyme([]string{" ", "release", ""}); len(got) != 1 {
t.Errorf("begrenzeSynonyme = %q, erwartet nur [release]", got)
}
if mitSynonymen("frage", nil) != "frage" {
t.Error("ohne Synonyme soll die Anfrage unverändert bleiben")
}
}
// TestRecallLeerVerweistAufSynonyme prüft, dass ein leeres Ergebnis zuerst
// zum erneuten Suchen mit anderen Begriffen anhält — nicht gleich zum Anlegen
// eines neuen Eintrags, der dann womöglich eine Dublette ist.
func TestRecallLeerVerweistAufSynonyme(t *testing.T) {
h, ws, projekt := aufbau(t)
ergebnis := ruf(t, h, ws, projekt, auth.RoleRead, "recall", map[string]any{"query": "gibtesnicht"})
if !strings.Contains(text(ergebnis), "synonyms") {
t.Errorf("der Hinweis nennt keine erneute Suche mit Synonymen: %s", text(ergebnis))
}
}
func TestRecallFiltersByTag(t *testing.T) {
h, ws, projekt := aufbau(t)
@ -331,6 +412,7 @@ func TestRecallFiltersByTag(t *testing.T) {
})
ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{
"slug": "b", "title": "Deployment B", "content": "deployment", "tags": []any{"docs"},
"force": true, // ähnlicher Titel ist hier gewollt, siehe dubletten.go
})
ergebnis := ruf(t, h, ws, projekt, auth.RoleRead, "recall",
@ -431,6 +513,7 @@ func TestRecallLimitIsCapped(t *testing.T) {
"slug": "eintrag-" + string(rune('a'+i%26)) + string(rune('a'+i/26)),
"title": "Eintrag",
"content": "gemeinsames Stichwort",
"force": true, // 60 gleiche Titel sind hier gewollt, siehe dubletten.go
})
}
@ -466,6 +549,7 @@ func TestWriteBudgetStopsRunawayAgent(t *testing.T) {
for i := 0; i < grenze; i++ {
ergebnis := ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{
"slug": fmt.Sprintf("eintrag-%d", i), "title": "Eintrag", "content": "Inhalt",
"force": true, // gleiche Titel sind hier gewollt, siehe dubletten.go
})
if ergebnis.IsError {
t.Fatalf("Aufruf %d von %d abgelehnt, obwohl im Budget: %s", i+1, grenze, text(ergebnis))
@ -743,6 +827,7 @@ func TestStaleEntriesAreMarked(t *testing.T) {
}
ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{
"slug": "neues-deployment", "title": "Deployment neu", "content": "Deployment per Pipeline",
"force": true, // ähnlich zu „Deployment alt" ist hier gewollt, siehe dubletten.go
})
recall := text(ruf(t, h, ws, projekt, auth.RoleRead, "recall", map[string]any{"query": "Deployment"}))
@ -767,3 +852,196 @@ func TestStaleEntriesAreMarked(t *testing.T) {
t.Errorf("list: genau ein Eintrag sollte stale sein: %s", liste)
}
}
// ── Dubletten ────────────────────────────────────────────────────────────────
// TestRememberMeldetDublette prüft den Kern von 3.3: Ein neuer Eintrag mit
// ähnlichem Titel wird nicht angelegt, stattdessen kommen die Kandidaten
// zurück — und erst mit force wird doch angelegt.
func TestRememberMeldetDublette(t *testing.T) {
h, ws, projekt := aufbau(t)
ctx := context.Background()
ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{
"slug": "deployment-prozess", "title": "Deployment-Prozess", "content": "Über die Pipeline.",
})
ergebnis := ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{
"slug": "deployment-ablauf", "title": "Deployment-Ablauf", "content": "Auch über die Pipeline.",
})
if ergebnis.IsError {
t.Fatalf("die Dublettenmeldung soll kein Fehler sein: %s", text(ergebnis))
}
antwort := text(ergebnis)
for _, erwartet := range []string{`"judgment_required":true`, `"deployment-prozess"`, "force"} {
if !strings.Contains(antwort, erwartet) {
t.Errorf("%s fehlt in der Antwort: %s", erwartet, antwort)
}
}
if _, err := ws.Store.EntryBySlug(ctx, projekt.ID, "deployment-ablauf"); !errors.Is(err, store.ErrNotFound) {
t.Fatalf("die Dublette wurde trotzdem angelegt (err = %v)", err)
}
// Mit force wird angelegt.
ergebnis = ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{
"slug": "deployment-ablauf", "title": "Deployment-Ablauf", "content": "Auch über die Pipeline.",
"force": true,
})
if !strings.Contains(text(ergebnis), `"created":true`) {
t.Errorf("mit force wurde nicht angelegt: %s", text(ergebnis))
}
}
// TestRememberAktualisierenOhnePruefung prüft, dass das Fortschreiben eines
// vorhandenen Eintrags nie angehalten wird — auch wenn es ähnliche gibt.
func TestRememberAktualisierenOhnePruefung(t *testing.T) {
h, ws, projekt := aufbau(t)
ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{
"slug": "deployment-prozess", "title": "Deployment-Prozess", "content": "A",
})
ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{
"slug": "deployment-ablauf", "title": "Deployment-Ablauf", "content": "B", "force": true,
})
ergebnis := ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{
"slug": "deployment-ablauf", "title": "Deployment-Ablauf", "content": "B, ergänzt",
})
if strings.Contains(text(ergebnis), "judgment_required") {
t.Errorf("eine Aktualisierung wurde angehalten: %s", text(ergebnis))
}
}
// TestRememberUnaehnlichLegtAn prüft, dass ein anderes Thema ohne Umweg
// angelegt wird.
func TestRememberUnaehnlichLegtAn(t *testing.T) {
h, ws, projekt := aufbau(t)
ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{
"slug": "deployment-prozess", "title": "Deployment-Prozess", "content": "Über die Pipeline.",
})
ergebnis := ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{
"slug": "vpn-zugang", "title": "VPN-Zugang", "content": "Über die Pipeline beantragen.",
})
if !strings.Contains(text(ergebnis), `"created":true`) {
t.Errorf("ein unähnlicher Eintrag wurde nicht angelegt: %s", text(ergebnis))
}
}
// TestRememberDubletteUeberSuchbegriffe prüft das Zusammenspiel mit der Zeile
// „Auch gesucht als": Ein neuer „Release-Ablauf" findet den vorhandenen
// „Deployment-Prozess", weil dort „release" als Suchbegriff steht.
func TestRememberDubletteUeberSuchbegriffe(t *testing.T) {
h, ws, projekt := aufbau(t)
ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{
"slug": "deployment-prozess", "title": "Deployment-Prozess",
"content": "Über die Pipeline.\n\nAuch gesucht als: release, rollout",
})
ergebnis := ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{
"slug": "release-ablauf", "title": "Release-Ablauf", "content": "Dienstags.",
})
if !strings.Contains(text(ergebnis), `"judgment_required":true`) {
t.Errorf("die Dublette über die Suchbegriffe wurde nicht erkannt: %s", text(ergebnis))
}
}
// ── Kuratierte Einträge ──────────────────────────────────────────────────────
// legeKuratiertAn legt einen von einem Menschen geprüften Eintrag an.
func legeKuratiertAn(t *testing.T, ws *workspace.Workspace, projekt store.Project) {
t.Helper()
if _, err := ws.Store.PutEntry(context.Background(), store.EntryWrite{
ProjectID: projekt.ID, Slug: "vpn", Title: "VPN-Zugang",
Content: "Geprüfter Stand: Zugang über vpn.intern.", Source: store.SourceHuman, Curated: true,
}); err != nil {
t.Fatal(err)
}
}
// TestRememberKuratiertBrauchtFreigabe prüft den ersten Schritt: Ohne
// override_curated wird ein kuratierter Eintrag nicht angetastet.
func TestRememberKuratiertBrauchtFreigabe(t *testing.T) {
h, ws, projekt := aufbau(t)
legeKuratiertAn(t, ws, projekt)
ergebnis := ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{
"slug": "vpn", "title": "VPN-Zugang", "content": "Ungeprüfte Vermutung.",
})
if ergebnis.IsError {
t.Fatalf("die Meldung soll kein Fehler sein: %s", text(ergebnis))
}
if !strings.Contains(text(ergebnis), `"judgment_required":true`) ||
!strings.Contains(text(ergebnis), "override_curated") {
t.Errorf("Meldung ohne Hinweis auf override_curated: %s", text(ergebnis))
}
e, err := ws.Store.EntryBySlug(context.Background(), projekt.ID, "vpn")
if err != nil {
t.Fatal(err)
}
if !e.Curated || !strings.Contains(e.Content, "Geprüfter Stand") || e.Version != 1 {
t.Errorf("der kuratierte Eintrag wurde verändert: %+v", e)
}
}
// TestRememberKuratiertOhneBegruendung prüft, dass override_curated ohne
// Begründung abgelehnt wird.
func TestRememberKuratiertOhneBegruendung(t *testing.T) {
h, ws, projekt := aufbau(t)
legeKuratiertAn(t, ws, projekt)
ergebnis := ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{
"slug": "vpn", "title": "VPN-Zugang", "content": "Neu.", "override_curated": true,
})
if !ergebnis.IsError || !strings.Contains(text(ergebnis), "reason") {
t.Errorf("override_curated ohne reason wurde nicht abgelehnt: %s", text(ergebnis))
}
}
// TestRememberKuratiertMitFreigabe prüft den zweiten Schritt: Mit Begründung
// wird geschrieben, die Markierung fällt, und der Eintrag steht zur Prüfung
// auf der Nutzungsseite — bis ein Mensch ihn wieder kuratiert.
func TestRememberKuratiertMitFreigabe(t *testing.T) {
h, ws, projekt := aufbau(t)
ctx := context.Background()
legeKuratiertAn(t, ws, projekt)
ergebnis := ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{
"slug": "vpn", "title": "VPN-Zugang", "content": "Zugang jetzt über vpn2.intern.",
"override_curated": true, "reason": "vpn.intern antwortet nicht mehr, Umzug laut Ticket 4711",
})
if ergebnis.IsError {
t.Fatalf("remember schlug fehl: %s", text(ergebnis))
}
if !strings.Contains(text(ergebnis), "kuratiert") {
t.Errorf("die Antwort sagt nicht, dass die Markierung weg ist: %s", text(ergebnis))
}
e, err := ws.Store.EntryBySlug(ctx, projekt.ID, "vpn")
if err != nil {
t.Fatal(err)
}
if e.Curated || !strings.Contains(e.Content, "vpn2") {
t.Errorf("Eintrag nach der Änderung: curated=%v, content=%q", e.Curated, e.Content)
}
offen, err := ws.Store.CuratedOverrides(ctx, projekt.ID, 10)
if err != nil {
t.Fatal(err)
}
if len(offen) != 1 || offen[0].Slug != "vpn" || !strings.Contains(offen[0].Reason, "Ticket 4711") {
t.Fatalf("Liste zur Prüfung = %+v, erwartet vpn mit Begründung", offen)
}
// Ein Mensch prüft und setzt den Haken neu → aus der Liste.
if _, err := ws.Store.PutEntry(ctx, store.EntryWrite{
ProjectID: projekt.ID, Slug: "vpn", Title: e.Title, Content: e.Content,
Source: store.SourceHuman, Curated: true,
}); err != nil {
t.Fatal(err)
}
if offen, _ := ws.Store.CuratedOverrides(ctx, projekt.ID, 10); len(offen) != 0 {
t.Errorf("nach erneuter Kuratierung noch in der Liste: %+v", offen)
}
}

View file

@ -44,8 +44,14 @@ gemeinsame Gedächtnis von Menschen und KI-Agenten.
Vorgehen beim LESEN:
1. "recall" mit einer inhaltlichen Frage aufrufen (nicht mit Stichworten).
Die Suche vergleicht Wörter, nicht Bedeutungen. Deshalb unter "synonyms"
die Begriffe mitgeben, unter denen das Thema sonst noch heißen könnte:
Synonyme, die deutsche oder englische Übersetzung, Abkürzungen.
Beispiel: Frage nach dem Deployment → synonyms: release, rollout, auslieferung.
2. Liefert die Trefferliste einen passenden Eintrag, ihn mit "get" vollständig laden.
3. "list" gibt einen Überblick, wenn nicht klar ist, wonach zu suchen ist.
3. Findet "recall" nichts Passendes, mit anderen Begriffen erneut suchen,
bevor du annimmst, dass es nichts gibt.
4. "list" gibt einen Überblick, wenn nicht klar ist, wonach zu suchen ist.
Vorgehen beim SCHREIBEN:
1. IMMER zuerst "recall" aufrufen, um zu prüfen, ob es zum Thema schon etwas gibt.
@ -53,6 +59,15 @@ Vorgehen beim SCHREIBEN:
aufrufen — das aktualisiert ihn, statt eine zweite, widersprüchliche
Fassung daneben zu stellen.
3. Nur wenn es wirklich nichts Passendes gibt, einen neuen Bezeichner wählen.
Ähnelt der Titel einem vorhandenen Eintrag, legt "remember" nichts an,
sondern meldet "judgment_required" mit den "candidates". Passt einer,
dessen slug verwenden; ist es wirklich ein anderes Thema, erneut mit
"force": true aufrufen.
4. Am Ende des Inhalts eine Zeile mit Begriffen anfügen, unter denen jemand
den Eintrag suchen würde, die aber nicht im Text stehen:
Auch gesucht als: release, rollout, auslieferung
Synonyme, Übersetzungen, Abkürzungen — 2 bis 5 Begriffe genügen. Ist die
Zeile schon vorhanden, sie beim Aktualisieren erhalten und ergänzen.
Zum Bezeichner (slug):
Kurz, sprechend, dauerhaft: "deployment-prozess", nicht "notiz-2026-01-15".
@ -64,6 +79,14 @@ Verweise:
"related" liefert zu einem Eintrag die verknüpften Einträge, auch solche, die
"recall" nicht findet, weil kein Suchwort passt.
Kuratierte Einträge ("curated": true):
Ein Mensch hat sie geprüft. Sie haben Vorrang vor eigenen Annahmen und vor
ungeprüften Einträgen. Nur ändern, wenn sie nachweislich nicht mehr stimmen.
"remember" auf einen kuratierten Eintrag schreibt zunächst nicht, sondern
meldet "judgment_required". Ist die Änderung nötig: erneut mit
"override_curated": true und einer Begründung unter "reason". Die
Markierung fällt dann weg, und ein Mensch prüft die Änderung.
Alter von Wissen:
Einträge mit "stale": true sind seit über einem Jahr unverändert. Sie können
noch stimmen, sollten aber nicht ungeprüft als aktueller Stand weitergegeben
@ -152,9 +175,10 @@ func (h *Handler) Forget(projectID string) {
// ── Ein- und Ausgaben der Werkzeuge ──────────────────────────────────────────
type recallInput struct {
Query string `json:"query" jsonschema:"Inhaltliche Frage oder Suchbegriff"`
Tags []string `json:"tags,omitempty" jsonschema:"Optional: nur Einträge mit diesen Schlagworten"`
Limit int `json:"limit,omitempty" jsonschema:"Optional: Höchstzahl der Treffer (Standard 10, Maximum 50)"`
Query string `json:"query" jsonschema:"Inhaltliche Frage oder Suchbegriff"`
Synonyms []string `json:"synonyms,omitempty" jsonschema:"Optional, aber empfohlen: weitere Begriffe für dasselbe Thema — Synonyme, Übersetzung (deutsch/englisch), Abkürzungen. Werden in derselben Suche mitgesucht. Höchstens 10."`
Tags []string `json:"tags,omitempty" jsonschema:"Optional: nur Einträge mit diesen Schlagworten"`
Limit int `json:"limit,omitempty" jsonschema:"Optional: Höchstzahl der Treffer (Standard 10, Maximum 50)"`
}
type recallMatch struct {
@ -197,9 +221,20 @@ type getOutput struct {
type rememberInput struct {
Slug string `json:"slug,omitempty" jsonschema:"Kurzer, dauerhafter Bezeichner (a-z, 0-9, - und _). Bei einem vorhandenen Eintrag wird dieser aktualisiert. Fehlt er, wird er aus dem Titel abgeleitet."`
Title string `json:"title" jsonschema:"Titel des Eintrags"`
Content string `json:"content" jsonschema:"Inhalt als Markdown. [[bezeichner]] verweist auf andere Einträge."`
Content string `json:"content" jsonschema:"Inhalt als Markdown. [[bezeichner]] verweist auf andere Einträge. Am Ende eine Zeile 'Auch gesucht als: …' mit Synonymen und Übersetzungen, die nicht im Text stehen."`
Tags []string `json:"tags,omitempty" jsonschema:"Optional: Schlagworte zum Einordnen"`
ExpectedVersion int `json:"expected_version,omitempty" jsonschema:"Optional: die von get gelieferte Fassung — meldet einen Konflikt, wenn inzwischen jemand anderes geschrieben hat"`
OverrideCurated bool `json:"override_curated,omitempty" jsonschema:"Optional: true ändert einen kuratierten (von Menschen geprüften) Eintrag. Nur, wenn er nachweislich nicht mehr stimmt — mit Begründung unter reason."`
Reason string `json:"reason,omitempty" jsonschema:"Pflicht bei override_curated: kurz, warum der geprüfte Stand nicht mehr stimmt. Ein Mensch liest das."`
Force bool `json:"force,omitempty" jsonschema:"Optional: true legt den Eintrag an, obwohl es ähnliche gibt. Nur verwenden, nachdem die gemeldeten candidates geprüft wurden und keiner passt."`
}
// dublettenKandidat ist ein vorhandener Eintrag, der einem neu anzulegenden
// ähnelt (siehe pruefeDubletten).
type dublettenKandidat struct {
Slug string `json:"slug"`
Title string `json:"title"`
Overlap int `json:"overlap" jsonschema:"Übereinstimmung der Titel in Prozent"`
}
type rememberOutput struct {
@ -207,6 +242,10 @@ type rememberOutput struct {
Version int `json:"version" jsonschema:"Neue Fassung nach dem Schreiben"`
Created bool `json:"created" jsonschema:"true bei einem neuen Eintrag, false bei einer Aktualisierung"`
Message string `json:"message"`
// Nur gesetzt, wenn NICHT geschrieben wurde, weil es ähnliche Einträge gibt.
JudgmentRequired bool `json:"judgment_required,omitempty" jsonschema:"true: nichts geschrieben — erst die Meldung lesen und entscheiden"`
Candidates []dublettenKandidat `json:"candidates,omitempty" jsonschema:"Vorhandene Einträge, die dem neuen ähneln"`
}
type forgetInput struct {

View file

@ -39,6 +39,7 @@ func workspaceFrom(ctx context.Context) (*workspace.Workspace, error) {
const (
recallDefaultLimit = 10
recallMaxLimit = 50
recallMaxSynonyms = 10 // mehr bringt nichts, verwässert aber die Bewertung
listDefaultLimit = 50
listMaxLimit = 200
@ -143,7 +144,9 @@ func (h *Handler) addRecall(server *mcp.Server, project store.Project) {
&mcp.Tool{
Name: "recall",
Description: "Durchsucht das Gedächtnis dieses Projekts. IMMER zuerst aufrufen — " +
"sowohl vor dem Beantworten einer Frage als auch vor dem Schreiben, um Dubletten zu vermeiden.",
"sowohl vor dem Beantworten einer Frage als auch vor dem Schreiben, um Dubletten zu vermeiden. " +
"Die Suche vergleicht Wörter, keine Bedeutungen: unter \"synonyms\" weitere Begriffe " +
"für dasselbe Thema mitgeben (Synonyme, Übersetzung, Abkürzungen).",
},
func(ctx context.Context, _ *mcp.CallToolRequest, in recallInput) (*mcp.CallToolResult, recallOutput, error) {
ws, err := workspaceFrom(ctx)
@ -170,7 +173,8 @@ func (h *Handler) addRecall(server *mcp.Server, project store.Project) {
// Etwas großzügiger suchen als am Ende ausgegeben wird: Die
// Schlagwortfilterung unten entfernt womöglich noch Treffer.
treffer := idx.Search(in.Query, limit*3)
synonyme := begrenzeSynonyme(in.Synonyms)
treffer := idx.Search(mitSynonymen(in.Query, synonyme), limit*3)
gesucht := store.NormalizeTags(in.Tags)
jetzt := time.Now()
@ -202,7 +206,7 @@ func (h *Handler) addRecall(server *mcp.Server, project store.Project) {
ergebnisse = append(ergebnisse, recallMatch{
Slug: e.Slug,
Title: e.Title,
Snippet: entry.Snippet(e.Content, in.Query),
Snippet: entry.Snippet(e.Content, mitSynonymen(in.Query, synonyme)),
Tags: e.Tags,
Source: e.Source,
Curated: e.Curated,
@ -211,10 +215,20 @@ func (h *Handler) addRecall(server *mcp.Server, project store.Project) {
})
}
// Ins Suchprotokoll, für die Nutzungsübersicht. WithoutCancel: Bricht
// der Client jetzt ab, ist die Suche trotzdem geschehen.
sess, _ := web.SessionFromContext(ctx)
ws.Store.LogSearch(context.WithoutCancel(ctx), store.SearchLogEntry{
ProjectID: project.ID, APIKeyID: sess.Key.ID,
Query: in.Query, Synonyms: synonyme, Results: len(ergebnisse),
})
out := recallOutput{Results: ergebnisse, Total: len(ergebnisse)}
if len(ergebnisse) == 0 {
out.Hint = "Nichts gefunden. Mit \"list\" einen Überblick verschaffen, " +
"oder — falls etwas Neues festgehalten werden soll — mit \"remember\" einen Eintrag anlegen."
out.Hint = "Nichts gefunden. Erst mit anderen Begriffen erneut suchen (Synonyme, Übersetzung, " +
"Abkürzungen unter \"synonyms\") oder mit \"list\" einen Überblick verschaffen. " +
"Erst wenn es wirklich nichts gibt und etwas Neues festgehalten werden soll, " +
"mit \"remember\" einen Eintrag anlegen."
} else {
out.Hint = "Für den vollständigen Inhalt \"get\" mit dem slug aufrufen."
// Veraltete Treffer bleiben in der Liste und rutschen auch
@ -231,6 +245,42 @@ func (h *Handler) addRecall(server *mcp.Server, project store.Project) {
)
}
// mitSynonymen hängt die Synonyme an die Anfrage an, sodass sie in derselben
// Suche mitlaufen.
//
// # Warum so einfach?
//
// BM25 zählt jedes Suchwort für sich und addiert. Ein Eintrag, der das Thema
// unter einem anderen Namen führt („Release" statt „Deployment"), wird damit
// gefunden; einer, der beide Namen enthält, steht weiter oben. Genau das ist
// gewollt — eine eigene Gewichtung für Synonyme bräuchte es dafür nicht.
//
// Die Synonyme sollten vorher durch begrenzeSynonyme gegangen sein.
func mitSynonymen(query string, synonyme []string) string {
if len(synonyme) == 0 {
return query
}
return query + " " + strings.Join(synonyme, " ")
}
// begrenzeSynonyme entfernt leere Angaben und kappt die Liste bei
// recallMaxSynonyms.
//
// Die Obergrenze verhindert, dass ein übereifriges Modell dreißig Begriffe
// mitschickt: Dann passt am Ende fast jeder Eintrag auf irgendeinen davon.
func begrenzeSynonyme(synonyme []string) []string {
var sauber []string
for _, s := range synonyme {
if s = strings.TrimSpace(s); s != "" {
sauber = append(sauber, s)
}
if len(sauber) == recallMaxSynonyms {
break
}
}
return sauber
}
// hatAlleTags prüft, ob alle gesuchten Schlagworte vorhanden sind.
func hatAlleTags(vorhanden, gesucht []string) bool {
if len(gesucht) == 0 {
@ -378,7 +428,10 @@ func (h *Handler) addRemember(server *mcp.Server, project store.Project) {
Name: "remember",
Description: "Legt einen Eintrag an oder aktualisiert ihn. Der slug entscheidet: " +
"Ein vorhandener Eintrag wird überschrieben, kein neuer danebengestellt. " +
"VOR dem Aufruf immer \"recall\" verwenden, um einen passenden vorhandenen Eintrag zu finden.",
"VOR dem Aufruf immer \"recall\" verwenden, um einen passenden vorhandenen Eintrag zu finden. " +
"Ähnelt ein NEUER Eintrag einem vorhandenen, wird nichts geschrieben: Die Antwort nennt " +
"\"candidates\" — einen davon fortschreiben oder mit \"force\": true doch neu anlegen. " +
"Kuratierte Einträge (von Menschen geprüft) ändert es nur mit \"override_curated\": true und \"reason\".",
},
func(ctx context.Context, _ *mcp.CallToolRequest, in rememberInput) (*mcp.CallToolResult, rememberOutput, error) {
ws, err := workspaceFrom(ctx)
@ -391,6 +444,9 @@ func (h *Handler) addRemember(server *mcp.Server, project store.Project) {
if strings.TrimSpace(in.Content) == "" {
return nil, rememberOutput{}, errors.New("content darf nicht leer sein")
}
if err := begruendungPruefen(in); err != nil {
return nil, rememberOutput{}, err
}
slug := strings.TrimSpace(in.Slug)
if slug == "" {
@ -407,6 +463,43 @@ func (h *Handler) addRemember(server *mcp.Server, project store.Project) {
titel = entry.TitleFromContent(in.Content)
}
// Gibt es schon einen ähnlichen Eintrag unter anderem Bezeichner?
// Dann nicht anlegen, sondern die Kandidaten zurückgeben (siehe
// dubletten.go). Kein Fehler: Das Modell soll die Kandidaten als
// Daten lesen und entscheiden, nicht einen Fehlschlag behandeln.
if !in.Force {
kandidaten, err := pruefeDubletten(ctx, ws, project, slug, titel, in.Content)
if err != nil {
return nil, rememberOutput{}, err
}
if len(kandidaten) > 0 {
return nil, rememberOutput{
Slug: slug,
JudgmentRequired: true,
Candidates: kandidaten,
Message: "Nichts geschrieben: Es gibt bereits ähnliche Einträge (candidates). " +
"Passt einer davon, ihn mit \"get\" laden, den neuen Inhalt einarbeiten und " +
"mit DESSEN slug erneut \"remember\" aufrufen. Ist es wirklich ein anderes Thema, " +
"denselben Aufruf mit \"force\": true wiederholen.",
}, nil
}
}
// Kuratierter Eintrag? Dann nur mit override_curated (siehe
// kuratiert.go). Wie bei den Dubletten kein Fehler, sondern eine
// Meldung, die das Modell liest und auf die es entscheidet.
warKuratiert, err := istKuratiert(ctx, ws, project, slug)
if err != nil {
return nil, rememberOutput{}, err
}
if warKuratiert && !in.OverrideCurated {
return nil, rememberOutput{
Slug: slug,
JudgmentRequired: true,
Message: kuratiertHinweis,
}, nil
}
geschrieben, err := ws.Store.PutEntry(ctx, store.EntryWrite{
ProjectID: project.ID,
Slug: slug,
@ -429,11 +522,19 @@ func (h *Handler) addRemember(server *mcp.Server, project store.Project) {
ws.Search.Refresh(ctx, project.ID, geschrieben.Slug)
// Hat der Agent einen kuratierten Eintrag geändert, steht das samt
// Begründung im Protokoll. Daraus baut die Nutzungsseite die Liste
// „bitte prüfen" (store.CuratedOverrides).
detail := store.EntryWriteDetail(geschrieben)
if warKuratiert {
detail = store.CuratedOverrideDetail(in.Reason)
}
sess, _ := web.SessionFromContext(ctx)
ws.Store.LogAudit(ctx, store.AuditEvent{
ProjectID: project.ID, APIKeyID: sess.Key.ID, Actor: actorName(ctx),
Action: store.ActionEntryWrite, EntrySlug: slug, Source: store.SourceMCP,
Detail: store.EntryWriteDetail(geschrieben),
Detail: detail,
})
// "angelegt" oder "aktualisiert"? Für das Modell ist dieser
@ -446,6 +547,10 @@ func (h *Handler) addRemember(server *mcp.Server, project store.Project) {
meldung := fmt.Sprintf("Eintrag %q aktualisiert (Fassung %d).", slug, geschrieben.Version)
switch {
case warKuratiert:
meldung = fmt.Sprintf("Eintrag %q aktualisiert (Fassung %d). Er war kuratiert; die Markierung "+
"ist aufgehoben, und ein Mensch prüft die Änderung. Der geprüfte Stand steht im Verlauf.",
slug, geschrieben.Version)
case istNeu:
meldung = fmt.Sprintf("Eintrag %q angelegt.", slug)
case geschrieben.RestoredFromTrash:

View file

@ -0,0 +1,48 @@
package search
import (
"fmt"
"math/rand"
"strings"
"testing"
)
// baueGrossenIndex legt einen Index in der Größenordnung eines großen Projekts
// an: 10.000 Einträge à 150 Wörter aus einem Wortschatz von 20.000 Wörtern.
//
// Die Wörter sind künstlich ("wort123"), das genügt: Für die Laufzeit zählt,
// wie viele verschiedene Wörter der Index durchgehen muss, nicht was sie heißen.
// Der feste Startwert macht jeden Lauf gleich.
func baueGrossenIndex() *Index {
const (
eintraege = 10_000
woerter = 150
wortschatz = 20_000
)
zufall := rand.New(rand.NewSource(1))
idx := NewIndex()
var text strings.Builder
for i := 0; i < eintraege; i++ {
text.Reset()
for j := 0; j < woerter; j++ {
fmt.Fprintf(&text, "wort%d ", zufall.Intn(wortschatz))
}
idx.Index(fmt.Sprintf("eintrag-%d", i), "Titel", text.String(), nil)
}
return idx
}
// BenchmarkSearch misst eine typische Agenten-Anfrage: eine ganze Frage mit
// Stoppwörtern und Wörtern, die es im Index nicht gibt.
//
// go test ./internal/search/ -bench . -run '^$'
func BenchmarkSearch(b *testing.B) {
idx := baueGrossenIndex()
b.ResetTimer()
for i := 0; i < b.N; i++ {
idx.Search("wie funktioniert wort123 mit wort4567", 10)
}
}

View file

@ -78,8 +78,9 @@ type posting struct {
// docMeta speichert die Metadaten die BM25 pro Dokument benötigt.
type docMeta struct {
length int // Anzahl der Terme (nach Tokenisierung und Boost)
title string // Titel für die Ergebnisanzeige
length int // Anzahl der Terme (nach Tokenisierung und Boost)
title string // Titel für die Ergebnisanzeige
boost float64 // Faktor auf den Endwert, 1 = neutral (siehe IndexWithBoost)
}
// Result ist ein einzelnes Suchergebnis mit BM25-Score.
@ -125,6 +126,22 @@ func NewIndex() *Index {
// title, body und tags werden getrennt übergeben um unterschiedliche
// Boost-Faktoren anwenden zu können.
func (s *Index) Index(docID, title, body string, tags []string) {
s.IndexWithBoost(docID, title, body, tags, 1)
}
// IndexWithBoost indiziert wie Index, gibt dem Dokument aber einen Faktor
// mit, mit dem sein Endwert bei jeder Suche malgenommen wird.
//
// Damit lassen sich Dokumente bei sonst gleicher Relevanz bevorzugen, ohne
// dass der Index wissen muss, warum. Die Registry nutzt das für kuratierte
// Einträge (siehe curatedBoost in registry.go). Ein Faktor von 1 ist neutral;
// Werte ≤ 0 werden als 1 behandelt, damit kein Dokument aus Versehen
// unauffindbar wird.
func (s *Index) IndexWithBoost(docID, title, body string, tags []string, boost float64) {
if boost <= 0 {
boost = 1
}
s.mu.Lock()
defer s.mu.Unlock()
@ -160,7 +177,7 @@ func (s *Index) Index(docID, title, body string, tags []string) {
}
// Metadaten und Rückwärts-Index speichern.
s.docs[docID] = docMeta{length: len(allTokens), title: title}
s.docs[docID] = docMeta{length: len(allTokens), title: title, boost: boost}
s.docTerms[docID] = termList
s.totalTerms += len(allTokens)
}
@ -206,11 +223,32 @@ func (s *Index) removeUnsafe(docID string) {
// Search führt eine BM25-Suche für den übergebenen Suchbegriff durch.
// Die Ergebnisse sind absteigend nach Relevanz sortiert (bester Treffer zuerst).
// limit begrenzt die Anzahl der zurückgegebenen Ergebnisse.
//
// Neben exakten Treffern findet Search auch Wortanfänge, Wörter mit anderer
// Endung und zusammengesetzte Wörter (siehe varianten.go). Tippfehler
// gleicht Search nicht aus — dafür gibt es SearchFuzzy.
func (s *Index) Search(queryStr string, limit int) []Result {
return s.suche(queryStr, limit, false)
}
// SearchFuzzy sucht wie Search, findet aber zusätzlich Wörter mit Tippfehlern
// — allerdings nur für Suchwörter, die sonst gar nichts treffen.
//
// # Warum nicht immer?
//
// Gedacht ist das für Menschen in der Weboberfläche. Agenten machen kaum
// Tippfehler; bei ihnen brächte die Toleranz vor allem zusätzliche, unsichere
// Treffer. Deshalb nutzt das MCP-Werkzeug recall weiterhin Search.
func (s *Index) SearchFuzzy(queryStr string, limit int) []Result {
return s.suche(queryStr, limit, true)
}
// suche ist die gemeinsame Umsetzung von Search und SearchFuzzy.
func (s *Index) suche(queryStr string, limit int, tippfehler bool) []Result {
s.mu.RLock()
defer s.mu.RUnlock()
queryTokens := tokenize(queryStr)
queryTokens := ohneStoppwoerter(tokenize(queryStr))
if len(queryTokens) == 0 || len(s.docs) == 0 {
return nil
}
@ -224,42 +262,38 @@ func (s *Index) Search(queryStr string, limit int) []Result {
scores := make(map[string]float64)
for _, term := range queryTokens {
postings, found := s.invertedIndex[term]
if !found {
// Rückfallebene: als Wortanfang suchen.
//
// Ohne das findet "deploy" den Eintrag "Deployment-Prozess" nicht,
// weil BM25 auf exakten Tokens arbeitet. Bei einem Menschen ist das
// ärgerlich, bei einer KI-Anfrage ein stiller Fehltreffer — sie
// bekommt schlicht kein Ergebnis und weiß nicht, dass es eines gäbe.
//
// Der Durchlauf über alle Terme ist linear und damit teurer als der
// Lookup. Er greift aber nur, wenn es keinen exakten Treffer gab,
// und der Wortschatz eines Projekts bleibt überschaubar.
postings = s.prefixPostings(term)
if len(postings) == 0 {
continue
// Je Dokument zählt für dieses Suchwort nur die beste Variante —
// ein exakter Treffer wird also nicht durch denselben Treffer als
// Wortanfang noch einmal aufgewertet.
beste := make(map[string]float64)
for _, v := range s.variantenFuer(term, tippfehler) {
// IDF (Inverse Document Frequency):
// Seltene Terme sind aussagekräftiger als häufige (z.B. "und", "der").
// Formel: log((N - nq + 0.5) / (nq + 0.5) + 1)
// nq = Anzahl der Dokumente die diesen Term enthalten
nq := float64(len(v.postings))
idf := math.Log((N-nq+0.5)/(nq+0.5) + 1)
for _, p := range v.postings {
meta := s.docs[p.docID]
docLen := float64(meta.length)
// BM25-Termgewicht:
// Zähler: tf * (k1 + 1) — gibt bei tf=0 den Wert 0
// Nenner: tf + k1 * (1 - b + b * |D| / avgdl) — beugt Übergewichtung vor
tf := float64(p.freq)
tfNorm := tf * (bm25K1 + 1) / (tf + bm25K1*(1-bm25B+bm25B*docLen/avgDocLen))
wert := v.gewicht * idf * tfNorm
if wert > beste[p.docID] {
beste[p.docID] = wert
}
}
}
// IDF (Inverse Document Frequency):
// Seltene Terme sind aussagekräftiger als häufige (z.B. "und", "der").
// Formel: log((N - nq + 0.5) / (nq + 0.5) + 1)
// nq = Anzahl der Dokumente die diesen Term enthalten
nq := float64(len(postings))
idf := math.Log((N-nq+0.5)/(nq+0.5) + 1)
for _, p := range postings {
meta := s.docs[p.docID]
docLen := float64(meta.length)
// BM25-Termgewicht:
// Zähler: tf * (k1 + 1) — gibt bei tf=0 den Wert 0
// Nenner: tf + k1 * (1 - b + b * |D| / avgdl) — beugt Übergewichtung vor
tf := float64(p.freq)
tfNorm := tf * (bm25K1 + 1) / (tf + bm25K1*(1-bm25B+bm25B*docLen/avgDocLen))
scores[p.docID] += idf * tfNorm
for docID, wert := range beste {
scores[docID] += wert
}
}
@ -267,10 +301,11 @@ func (s *Index) Search(queryStr string, limit int) []Result {
return nil
}
// Ergebnisse in eine sortierbare Liste umwandeln.
// Ergebnisse in eine sortierbare Liste umwandeln — dabei den Faktor des
// Dokuments anwenden (siehe IndexWithBoost).
results := make([]Result, 0, len(scores))
for docID, score := range scores {
results = append(results, Result{DocID: docID, Score: score})
results = append(results, Result{DocID: docID, Score: score * s.docs[docID].boost})
}
// Absteigend nach Score sortieren (bester Treffer zuerst).
@ -367,39 +402,3 @@ func repeatTokens(tokens []string, n int) []string {
}
return result
}
// prefixPostings sammelt die Fundstellen aller Terme, die mit prefix beginnen.
//
// Terme unter drei Zeichen werden übersprungen: "de" würde sonst auf halbe
// Wörterbücher passen und das Ranking unbrauchbar machen.
func (s *Index) prefixPostings(prefix string) []posting {
if len(prefix) < 3 {
return nil
}
// Je Dokument die höchste gefundene Häufigkeit übernehmen. Würde man
// aufsummieren, bekämen Dokumente mit vielen ähnlichen Wörtern ("deploy",
// "deployment", "deployt") ein unverdient hohes Gewicht.
beste := make(map[string]int)
for term, postings := range s.invertedIndex {
if !strings.HasPrefix(term, prefix) {
continue
}
for _, p := range postings {
if p.freq > beste[p.docID] {
beste[p.docID] = p.freq
}
}
}
if len(beste) == 0 {
return nil
}
ergebnis := make([]posting, 0, len(beste))
for docID, freq := range beste {
ergebnis = append(ergebnis, posting{docID: docID, freq: freq})
}
return ergebnis
}

View file

@ -53,6 +53,23 @@ type fach struct {
idx *Index // nil, solange der Index noch nicht (oder nicht mehr) aufgebaut ist
}
// curatedBoost bevorzugt kuratierte Einträge in der Suche: Ihr Wert wird mit
// diesem Faktor malgenommen.
//
// Kuratiert heißt: Ein Mensch hat den Inhalt geprüft. Bei sonst ähnlicher
// Relevanz soll ein Agent zuerst das geprüfte Wissen sehen. Der Faktor ist
// bewusst klein — ein kuratierter Eintrag, der nur am Rande passt, soll einen
// genau passenden ungeprüften nicht verdrängen.
const curatedBoost = 1.2
// boostFuer gibt den Suchfaktor eines Eintrags zurück.
func boostFuer(e store.Entry) float64 {
if e.Curated {
return curatedBoost
}
return 1
}
// NewRegistry erstellt eine leere Registry. Die Indizes entstehen beim ersten
// Zugriff auf ein Projekt (siehe For).
func NewRegistry(s *store.Store) *Registry {
@ -127,7 +144,7 @@ func (r *Registry) fill(ctx context.Context, idx *Index, projectID string) error
}
for _, e := range eintraege {
idx.Index(e.Slug, e.Title, e.Content, e.Tags)
idx.IndexWithBoost(e.Slug, e.Title, e.Content, e.Tags, boostFuer(e))
}
return nil
}
@ -183,7 +200,7 @@ func (r *Registry) Refresh(ctx context.Context, projectID, slug string) {
log.Printf("Suchindex: %q in Projekt %s nicht lesbar, Index wird neu aufgebaut: %v", slug, projectID, err)
f.idx = nil
default:
f.idx.Index(e.Slug, e.Title, e.Content, e.Tags)
f.idx.IndexWithBoost(e.Slug, e.Title, e.Content, e.Tags, boostFuer(e))
}
}

View file

@ -206,3 +206,29 @@ func TestAenderungWaehrendDesAufbausGehtNichtVerloren(t *testing.T) {
t.Error("der Index hält den Stand von vor der Änderung")
}
}
// TestRegistryBevorzugtKuratierte prüft, dass die Registry kuratierten
// Einträgen den Suchfaktor mitgibt — beim Aufbau wie beim Nachziehen.
func TestRegistryBevorzugtKuratierte(t *testing.T) {
r, db, projekt, idx := aufbauRegistry(t)
ctx := context.Background()
for _, kuratiert := range []bool{false, true} {
slug := "ungeprueft"
if kuratiert {
slug = "geprueft"
}
if _, err := db.PutEntry(ctx, store.EntryWrite{
ProjectID: projekt.ID, Slug: slug, Title: "Deployment", Content: "Ablauf.",
Source: store.SourceHuman, Curated: kuratiert,
}); err != nil {
t.Fatal(err)
}
r.Refresh(ctx, projekt.ID, slug)
}
treffer := idx.Search("deployment", 10)
if len(treffer) != 2 || treffer[0].DocID != "geprueft" {
t.Errorf("Treffer = %v, erwartet den kuratierten zuerst", ergebnisIDs(treffer))
}
}

View file

@ -75,8 +75,8 @@ func TestSearchPrefixNeedsThreeChars(t *testing.T) {
}
}
// TestSearchExactBeatsPrefix prüft, dass die Rückfallebene nur greift, wenn es
// keinen exakten Treffer gibt.
// TestSearchExactBeatsPrefix prüft, dass ein exakter Treffer vor einem Treffer
// über den Wortanfang steht.
func TestSearchExactBeatsPrefix(t *testing.T) {
idx := NewIndex()
idx.Index("exakt", "Test", "Das Wort ssh kommt hier vor.", nil)
@ -264,3 +264,46 @@ func TestPraefixMitUmlaut(t *testing.T) {
t.Error("Präfix ohne Umlaut fand den Eintrag nicht")
}
}
// ── Bevorzugung (Boost) ──────────────────────────────────────────────────────
// TestBoostBevorzugtBeiGleicherRelevanz prüft, dass der Faktor bei sonst
// gleichen Dokumenten den Ausschlag gibt.
func TestBoostBevorzugtBeiGleicherRelevanz(t *testing.T) {
idx := NewIndex()
idx.Index("normal", "Deployment", "Ablauf.", nil)
idx.IndexWithBoost("bevorzugt", "Deployment", "Ablauf.", nil, 1.2)
treffer := idx.Search("deployment", 10)
if len(treffer) != 2 || treffer[0].DocID != "bevorzugt" {
t.Errorf("Treffer = %v, erwartet bevorzugt zuerst", ergebnisIDs(treffer))
}
}
// TestBoostVerdraengtKeinenGenauenTreffer prüft, dass der kleine Faktor einen
// klar besser passenden Eintrag nicht überholt.
func TestBoostVerdraengtKeinenGenauenTreffer(t *testing.T) {
idx := NewIndex()
idx.Index("genau", "Zertifikatswechsel", "Der Zertifikatswechsel im März.", nil)
idx.IndexWithBoost("rand", "Wartung", "Allgemeines, unter anderem ein Zertifikatswechsel.", nil, 1.2)
treffer := idx.Search("zertifikatswechsel", 10)
if len(treffer) != 2 || treffer[0].DocID != "genau" {
t.Errorf("Treffer = %v, erwartet genau zuerst", ergebnisIDs(treffer))
}
}
// TestBoostOhneWertIstNeutral prüft, dass ein Faktor ≤ 0 kein Dokument
// unauffindbar macht.
func TestBoostOhneWertIstNeutral(t *testing.T) {
idx := NewIndex()
idx.IndexWithBoost("null", "Deployment", "Ablauf.", nil, 0)
treffer := idx.Search("deployment", 10)
if len(treffer) != 1 || treffer[0].Score <= 0 {
t.Errorf("Treffer = %+v — ein Faktor 0 hat das Dokument entwertet", treffer)
}
}

View file

@ -0,0 +1,387 @@
package search
import (
"strings"
"unicode/utf8"
)
// # Wortvarianten
//
// BM25 vergleicht ganze Wörter. Ein Suchwort trifft also nur Einträge, in denen
// genau dieses Wort steht. Im Alltag reicht das nicht:
//
// deploy soll Deployment finden (Wortanfang)
// deployen soll Deployment finden (Endung)
// prozess soll Releaseprozess finden (zusammengesetztes Wort)
// deploymnet soll Deployment finden (Tippfehler, nur Weboberfläche)
//
// Deshalb sucht der Index zu jedem Suchwort mehrere Varianten. Jede Variante
// hat ein Gewicht: Ein exakter Treffer zählt voll, ein Treffer über eine
// Variante nur anteilig. So rutschen ungenaue Treffer nach hinten, statt die
// genauen zu verdrängen.
//
// Trifft ein Eintrag über mehrere Varianten (etwa „ssh" exakt und „sshd" als
// Wortanfang), zählt nur die beste. Sonst bekäme ein Eintrag mit vielen
// ähnlichen Wörtern ein unverdient hohes Gewicht.
// Gewichte der Varianten — ein exakter Treffer zählt 1.
const (
// gewichtWortanfang gilt für Wörter, die mit dem Suchwort oder seinem
// Stamm beginnen: „deploy" → „deployment".
gewichtWortanfang = 0.5
// gewichtTeilwort gilt für Wörter, die das Suchwort irgendwo enthalten:
// „prozess" → „releaseprozess". Niedriger als der Wortanfang, weil
// Teilwörter öfter zufällig passen.
gewichtTeilwort = 0.3
// gewichtTippfehler gilt für Wörter, die sich um ein oder zwei Buchstaben
// unterscheiden: „deploymnet" → „deployment".
gewichtTippfehler = 0.3
)
// Mindestlängen der Suchwörter für die einzelnen Varianten. Kürzere Wörter
// passen auf zu vieles: „de" ist der Anfang halber Wörterbücher, und „port"
// steckt in „Reporting", „Export" und „Support".
const (
minWortanfang = 3
minTeilwort = 5
minStamm = 4 // Mindestlänge dessen, was nach dem Kürzen übrig bleibt
minTippfehler = 4
)
// variante ist eine Gruppe von Fundstellen, die über dieselbe Art von
// Ähnlichkeit gefunden wurden, samt ihrem Gewicht.
type variante struct {
postings []posting
gewicht float64
}
// variantenFuer sucht alle Fundstellen eines Suchworts: exakt, über den
// Wortanfang, als Teilwort und — nur wenn tippfehler gesetzt ist und sonst
// gar nichts passt — mit Tippfehlern.
//
// Muss unter der Lesesperre des Index aufgerufen werden.
//
// # Laufzeit
//
// Der exakte Treffer ist ein einzelner Map-Zugriff. Für die übrigen Varianten
// geht die Funktion einmal den ganzen Wortschatz des Projekts durch. Gemessen
// (siehe bench_test.go) sind das bei 20.000 verschiedenen Wörtern einige
// Zehntel Millisekunden je Anfrage — für Agenten wie Menschen nicht spürbar.
func (s *Index) variantenFuer(term string, tippfehler bool) []variante {
var ergebnis []variante
if exakt := s.invertedIndex[term]; len(exakt) > 0 {
ergebnis = append(ergebnis, variante{postings: exakt, gewicht: 1})
}
stamm := kuerzeEndung(term)
sucheWortanfang := len(term) >= minWortanfang
sucheTeilwort := len(term) >= minTeilwort
// Je Variante: Dokument → höchste Häufigkeit eines passenden Worts.
// Höchste statt Summe aus demselben Grund wie oben: „deploy", „deployment"
// und „deployt" im selben Eintrag sollen nicht dreifach zählen.
wortanfang := make(map[string]int)
teilwort := make(map[string]int)
for wort, postings := range s.invertedIndex {
switch {
case sucheWortanfang && strings.HasPrefix(wort, term):
merkeHoechste(wortanfang, postings)
case stamm != "" && strings.HasPrefix(wort, stamm):
merkeHoechste(wortanfang, postings)
case sucheTeilwort && strings.Contains(wort, term):
merkeHoechste(teilwort, postings)
}
}
if len(wortanfang) > 0 {
ergebnis = append(ergebnis, variante{postings: alsPostings(wortanfang), gewicht: gewichtWortanfang})
}
if len(teilwort) > 0 {
ergebnis = append(ergebnis, variante{postings: alsPostings(teilwort), gewicht: gewichtTeilwort})
}
// Tippfehler nur als letzte Rückfallebene: Sie sind die unsicherste
// Variante und kosten den meisten Rechenaufwand.
if tippfehler && len(ergebnis) == 0 {
if treffer := s.tippfehlerPostings(term); len(treffer) > 0 {
ergebnis = append(ergebnis, variante{postings: treffer, gewicht: gewichtTippfehler})
}
}
return ergebnis
}
// tippfehlerPostings sammelt die Fundstellen aller Wörter, die sich vom
// Suchwort nur um wenige Buchstaben unterscheiden.
func (s *Index) tippfehlerPostings(term string) []posting {
suchwort := []rune(term)
erlaubt := erlaubteTippfehler(len(suchwort))
if erlaubt == 0 {
return nil
}
beste := make(map[string]int)
for wort, postings := range s.invertedIndex {
// Schneller Vorfilter: Unterscheiden sich die Längen schon stärker als
// erlaubt, kann der Abstand nicht kleiner sein. Das erspart die
// eigentliche Rechnung für fast alle Wörter.
laenge := utf8.RuneCountInString(wort)
if abs(laenge-len(suchwort)) > erlaubt {
continue
}
if levenshtein(suchwort, []rune(wort)) <= erlaubt {
merkeHoechste(beste, postings)
}
}
return alsPostings(beste)
}
// erlaubteTippfehler legt fest, wie viele Buchstaben bei einem Wort dieser
// Länge abweichen dürfen. Bei kurzen Wörtern wäre schon ein Fehler zu viel:
// „host" und „post" sind verschiedene Dinge.
func erlaubteTippfehler(laenge int) int {
switch {
case laenge < minTippfehler:
return 0
case laenge < 8:
return 1
default:
return 2
}
}
// levenshtein berechnet den Editierabstand zweier Wörter: die kleinste Zahl
// an Buchstaben, die man einfügen, löschen oder ersetzen muss, um aus a das
// Wort b zu machen.
//
// deploymnet → deployment Abstand 2 (n→e, e→n)
// sever → server Abstand 1 (r einfügen)
//
// Klassisch rechnet man das mit einer Tabelle von (len(a)+1) × (len(b)+1)
// Feldern. Jede Zeile braucht aber nur die Zeile davor — deshalb genügen hier
// zwei Zeilen, die abwechselnd überschrieben werden.
func levenshtein(a, b []rune) int {
vorher := make([]int, len(b)+1)
aktuell := make([]int, len(b)+1)
// Erste Zeile: aus dem leeren Wort wird b durch j-maliges Einfügen.
for j := range vorher {
vorher[j] = j
}
for i := 1; i <= len(a); i++ {
aktuell[0] = i // aus a[:i] wird das leere Wort durch i-maliges Löschen
for j := 1; j <= len(b); j++ {
kosten := 1
if a[i-1] == b[j-1] {
kosten = 0
}
aktuell[j] = min(
vorher[j]+1, // Buchstabe aus a löschen
aktuell[j-1]+1, // Buchstabe aus b einfügen
vorher[j-1]+kosten, // Buchstabe ersetzen (oder gleich lassen)
)
}
vorher, aktuell = aktuell, vorher
}
return vorher[len(b)]
}
// endungen sind die Wortendungen, die kuerzeEndung abschneidet — die
// häufigsten deutschen Plural- und Beugungsendungen, dazu das englische -s.
// Längere Endungen stehen vorn, damit „prozesses" zu „prozess" wird und nicht
// zu „prozesse".
//
// Bewusst nicht dabei ist „-er": Im Deutschen bildet es meist ein eigenes Wort
// („Rechner", „Benutzer"), und aus „Server" würde „Serv", das dann auch
// „Service" fände.
var endungen = []string{"en", "es", "e", "n", "s"}
// kuerzeEndung schneidet eine Beugungsendung ab und gibt den Stamm zurück —
// oder "", wenn es nichts zu kürzen gibt.
//
// deployen → deploy (findet dann „deployment")
// zertifikate → zertifikat
// datenbanken → datenbank
// logs → "" (der Rest „log" wäre zu kurz)
//
// Das ist absichtlich kein vollständiger Wortstammbildner. Die kennen hunderte
// Regeln und verfälschen dabei Fachbegriffe. Hier geht es nur darum, dass ein
// Suchwort mit Endung auch das Wort ohne Endung findet. Da der Stamm nur als
// Wortanfang gesucht wird, findet umgekehrt „zertifikat" auch „zertifikate".
func kuerzeEndung(term string) string {
for _, endung := range endungen {
if strings.HasSuffix(term, endung) && len(term)-len(endung) >= minStamm {
return term[:len(term)-len(endung)]
}
}
return ""
}
// stoppwoerter sind Wörter, die in fast jeder Frage stehen, aber nichts über
// das Thema verraten.
//
// # Warum nur beim Suchen?
//
// Die Liste wird nur auf die Anfrage angewendet, nicht beim Indizieren. Der
// Index bleibt also vollständig, und wer gezielt nach „was" sucht, findet es
// weiterhin (siehe ohneStoppwoerter).
//
// # Warum überhaupt?
//
// Die instructions bitten Agenten, ganze Fragen zu stellen: „wie deployen wir
// den Dienst". BM25 gewichtet häufige Wörter zwar herunter, aber nicht auf
// null. Fehlt ein Wort wie „wir" im Projekt ganz, greift außerdem die Suche
// nach dem Wortanfang und findet „wird" und „wirkung" — reines Rauschen.
//
// Die Wörter stehen in der Form, die tokenize erzeugt: klein und mit
// gefalteten Umlauten („fur" statt „für"). Ein Test stellt das sicher.
//
// Nicht dabei sind Wörter, die in der IT etwas bedeuten können, etwa „it"
// oder „go".
var stoppwoerter = map[string]bool{
// Deutsch
"der": true, "die": true, "das": true, "den": true, "dem": true, "des": true,
"ein": true, "eine": true, "einen": true, "einem": true, "einer": true, "eines": true,
"und": true, "oder": true, "aber": true, "auch": true, "nur": true, "noch": true,
"ist": true, "sind": true, "war": true, "wird": true, "werden": true, "wurde": true,
"hat": true, "haben": true, "habe": true, "gibt": true, "kann": true, "konnen": true,
"muss": true, "soll": true, "sollte": true,
"wie": true, "was": true, "wo": true, "wer": true, "wann": true, "warum": true,
"welche": true, "welcher": true, "welches": true, "womit": true, "wozu": true,
"ich": true, "du": true, "er": true, "sie": true, "es": true, "wir": true, "ihr": true,
"man": true, "mein": true, "unser": true, "unsere": true, "unseren": true,
"mit": true, "von": true, "zu": true, "zum": true, "zur": true, "im": true, "in": true,
"am": true, "an": true, "auf": true, "aus": true, "bei": true, "fur": true,
"uber": true, "nach": true, "vor": true, "um": true, "ob": true,
"nicht": true, "kein": true, "keine": true, "so": true, "dass": true, "da": true,
"hier": true, "dort": true, "dann": true, "denn": true, "sich": true,
// Englisch
"the": true, "and": true, "or": true, "of": true, "to": true, "on": true,
"for": true, "with": true, "is": true, "are": true, "be": true,
"how": true, "what": true, "where": true, "who": true, "why": true, "when": true,
"which": true, "do": true, "does": true, "we": true, "you": true, "our": true,
"can": true, "this": true, "that": true,
}
// ohneStoppwoerter entfernt die Stoppwörter aus einer Anfrage.
//
// Besteht die Anfrage nur aus Stoppwörtern, bleibt sie unverändert — dann
// meint jemand offenbar genau diese Wörter, und eine leere Anfrage hülfe
// niemandem.
func ohneStoppwoerter(tokens []string) []string {
rest := make([]string, 0, len(tokens))
for _, t := range tokens {
if !stoppwoerter[t] {
rest = append(rest, t)
}
}
if len(rest) == 0 {
return tokens
}
return rest
}
// merkeHoechste trägt für jedes Dokument die höchste Häufigkeit ein.
func merkeHoechste(beste map[string]int, postings []posting) {
for _, p := range postings {
if p.freq > beste[p.docID] {
beste[p.docID] = p.freq
}
}
}
// alsPostings wandelt die Zuordnung Dokument → Häufigkeit in eine Liste um.
func alsPostings(beste map[string]int) []posting {
ergebnis := make([]posting, 0, len(beste))
for docID, freq := range beste {
ergebnis = append(ergebnis, posting{docID: docID, freq: freq})
}
return ergebnis
}
// abs gibt den Betrag einer ganzen Zahl zurück.
func abs(n int) int {
if n < 0 {
return -n
}
return n
}
// Ueberdeckung gibt an, welcher Anteil der Wörter aus anfrage in text
// vorkommt — als Zahl von 0 (keines) bis 1 (alle). Stoppwörter zählen nicht.
//
// Gedacht für kurze Texte wie Titel, um Dubletten zu erkennen (siehe remember
// in internal/mcpserver). Ein Wort gilt als vorhanden, wenn es dort exakt,
// als Wortanfang, mit anderer Endung oder als Teil eines zusammengesetzten
// Worts steht — dieselben Varianten wie bei der Suche, nur in beide
// Richtungen:
//
// „Deployment-Ablauf" vs. „Deployment-Prozess" → 0,5 (deployment ✓, ablauf ✗)
// „Releaseprozess" vs. „Release-Prozess" → 1 (beginnt mit „release")
// „Deployments" vs. „Deployment" → 1 (Endung)
//
// # Warum nicht der BM25-Wert?
//
// BM25 hat keinen festen Wertebereich: Ob 4,2 viel oder wenig ist, hängt von
// der Größe des Projekts und der Länge der Einträge ab. Eine Schwelle darauf
// müsste je Projekt neu eingestellt werden. Der Anteil gemeinsamer Wörter ist
// dagegen in jedem Projekt gleich zu lesen.
func Ueberdeckung(anfrage, text string) float64 {
woerter := ohneStoppwoerter(tokenize(anfrage))
if len(woerter) == 0 {
return 0
}
vorhanden := tokenize(text)
// Doppelte Wörter nur einmal zählen: „Server-Server-Setup" ist nicht
// zu zwei Dritteln „Server".
gesehen := make(map[string]bool, len(woerter))
gesamt, gefunden := 0, 0
for _, w := range woerter {
if gesehen[w] {
continue
}
gesehen[w] = true
gesamt++
for _, v := range vorhanden {
if passtZu(w, v) {
gefunden++
break
}
}
}
return float64(gefunden) / float64(gesamt)
}
// passtZu prüft, ob zwei einzelne Wörter als dasselbe gelten.
func passtZu(a, b string) bool {
if a == b {
return true
}
// Wortanfang, in beide Richtungen: „deploy" ↔ „deployment",
// „releaseprozess" ↔ „release". Beide müssen lang genug sein, sonst passt
// „de" zu allem.
if len(a) >= minStamm && len(b) >= minStamm &&
(strings.HasPrefix(a, b) || strings.HasPrefix(b, a)) {
return true
}
// Endung: „zertifikate" ↔ „zertifikat".
if stamm := kuerzeEndung(a); stamm != "" && strings.HasPrefix(b, stamm) {
return true
}
if stamm := kuerzeEndung(b); stamm != "" && strings.HasPrefix(a, stamm) {
return true
}
// Teil eines zusammengesetzten Worts: „prozess" ↔ „releaseprozess".
if len(a) >= minTeilwort && strings.Contains(b, a) {
return true
}
return len(b) >= minTeilwort && strings.Contains(a, b)
}

View file

@ -0,0 +1,277 @@
package search
import (
"slices"
"testing"
)
// enthaelt prüft, ob ein Eintrag unter den Treffern ist.
func enthaelt(treffer []Result, docID string) bool {
return slices.Contains(ergebnisIDs(treffer), docID)
}
// ── Wortanfang ───────────────────────────────────────────────────────────────
// TestWortanfangNebenExaktemTreffer prüft den Fehler, der zu diesem Umbau
// geführt hat: Früher griff die Suche nach dem Wortanfang nur, wenn es keinen
// exakten Treffer gab. Stand „deploy" in irgendeinem Eintrag, fand „deploy"
// den „Deployment-Prozess" nicht mehr — ein stiller Fehltreffer, der umso
// häufiger wird, je größer ein Projekt ist.
func TestWortanfangNebenExaktemTreffer(t *testing.T) {
idx := NewIndex()
idx.Index("skript", "Skript", "Das Skript heißt deploy und liegt im Repository.", nil)
idx.Index("prozess", "Deployment-Prozess", "So kommt eine neue Fassung in Produktion.", nil)
treffer := idx.Search("deploy", 10)
if !enthaelt(treffer, "prozess") {
t.Fatalf("Treffer = %v — der Wortanfang wird neben dem exakten Treffer nicht gefunden", ergebnisIDs(treffer))
}
if treffer[0].DocID != "skript" {
t.Errorf("bester Treffer = %q, erwartet den exakten", treffer[0].DocID)
}
}
// TestWortanfangZaehltNichtDoppelt prüft, dass ein Eintrag mit vielen Wörtern
// desselben Anfangs nicht vor einem exakten Treffer landet.
func TestWortanfangZaehltNichtDoppelt(t *testing.T) {
idx := NewIndex()
idx.Index("exakt", "Hinweis", "Hier steht config.", nil)
idx.Index("viele", "Hinweis", "configuration configure configured configs configurable", nil)
treffer := idx.Search("config", 10)
if len(treffer) != 2 || treffer[0].DocID != "exakt" {
t.Errorf("Treffer = %v, erwartet exakt vor viele", ergebnisIDs(treffer))
}
}
// ── Zusammengesetzte Wörter ──────────────────────────────────────────────────
// TestTeilwortFindetKomposita prüft zusammengeschriebene deutsche Wörter. Mit
// Bindestrich („Release-Prozess") genügte schon immer die Zerlegung in Wörter;
// zusammengeschrieben ist „prozess" aber weder ein eigenes Wort noch ein
// Wortanfang.
func TestTeilwortFindetKomposita(t *testing.T) {
idx := NewIndex()
idx.Index("release", "Releaseprozess", "Ablauf für neue Fassungen.", nil)
idx.Index("zertifikat", "Wartung", "Der jährliche SSL-Zertifikatswechsel steht im Kalender.", nil)
idx.Index("datenbank", "Datenbankzugang", "Zugriff nur über das VPN.", nil)
faelle := map[string]string{
"prozess": "release",
"wechsel": "zertifikat",
"zugang": "datenbank",
"datenbank": "datenbank", // Wortanfang, kein Teilwort — darf trotzdem nicht verloren gehen
}
for anfrage, erwartet := range faelle {
if treffer := idx.Search(anfrage, 10); !enthaelt(treffer, erwartet) {
t.Errorf("Search(%q) = %v, erwartet %s", anfrage, ergebnisIDs(treffer), erwartet)
}
}
}
// TestTeilwortBrauchtFuenfZeichen prüft die Untergrenze: Kurze Wörter stecken
// zufällig in zu vielen anderen.
func TestTeilwortBrauchtFuenfZeichen(t *testing.T) {
idx := NewIndex()
idx.Index("bericht", "Reporting", "Monatliche Auswertung.", nil)
if treffer := idx.Search("port", 10); len(treffer) > 0 {
t.Errorf("„port\" fand %v — vierbuchstabige Teilwörter sollten nicht greifen", ergebnisIDs(treffer))
}
}
// TestExaktVorTeilwort prüft die Gewichtung: Das eigenständige Wort steht vor
// dem Wort, in dem es nur steckt.
func TestExaktVorTeilwort(t *testing.T) {
idx := NewIndex()
idx.Index("kompositum", "Releaseprozess", "Ablauf.", nil)
idx.Index("eigenstaendig", "Prozess", "Ablauf.", nil)
treffer := idx.Search("prozess", 10)
if len(treffer) != 2 || treffer[0].DocID != "eigenstaendig" {
t.Errorf("Treffer = %v, erwartet eigenstaendig vor kompositum", ergebnisIDs(treffer))
}
}
// ── Endungen ─────────────────────────────────────────────────────────────────
// TestEndungenWerdenAusgeglichen prüft Suchwörter, die länger sind als das
// Wort im Eintrag. Die Suche nach dem Wortanfang hilft hier nicht: „deployen"
// ist kein Anfang von „deployment".
func TestEndungenWerdenAusgeglichen(t *testing.T) {
idx := NewIndex()
idx.Index("deployment", "Deployment", "Ablauf.", nil)
idx.Index("zertifikat", "Zertifikat", "Erneuerung.", nil)
idx.Index("datenbank", "Datenbank", "Sicherung.", nil)
faelle := map[string]string{
"deployen": "deployment",
"zertifikate": "zertifikat",
"datenbanken": "datenbank",
}
for anfrage, erwartet := range faelle {
if treffer := idx.Search(anfrage, 10); !enthaelt(treffer, erwartet) {
t.Errorf("Search(%q) = %v, erwartet %s", anfrage, ergebnisIDs(treffer), erwartet)
}
}
}
func TestKuerzeEndung(t *testing.T) {
faelle := map[string]string{
"deployen": "deploy",
"zertifikate": "zertifikat",
"datenbanken": "datenbank",
"prozesses": "prozess", // längere Endung zuerst
"deployments": "deployment",
"logs": "", // Rest „log" wäre zu kurz
"nginx": "", // keine Endung
"server": "", // „-er" wird bewusst nicht gekürzt
}
for eingabe, erwartet := range faelle {
if got := kuerzeEndung(eingabe); got != erwartet {
t.Errorf("kuerzeEndung(%q) = %q, erwartet %q", eingabe, got, erwartet)
}
}
}
// ── Stoppwörter ──────────────────────────────────────────────────────────────
// TestStoppwoerterErzeugenKeinRauschen prüft eine typische Agenten-Frage.
// Ohne Stoppwörter fand „wir" über den Wortanfang den Eintrag zur „Wirkung".
func TestStoppwoerterErzeugenKeinRauschen(t *testing.T) {
idx := NewIndex()
idx.Index("deployment", "Deployment-Prozess", "So kommt eine Fassung in Produktion.", nil)
idx.Index("kuehlung", "Kühlung", "Die Wirkung der Lüfter im Serverraum.", nil)
treffer := idx.Search("wie deployen wir", 10)
if !enthaelt(treffer, "deployment") {
t.Errorf("Treffer = %v, erwartet deployment", ergebnisIDs(treffer))
}
if enthaelt(treffer, "kuehlung") {
t.Errorf("Treffer = %v — ein Stoppwort hat einen unpassenden Eintrag gefunden", ergebnisIDs(treffer))
}
}
// TestNurStoppwoerterBleibtSuchbar prüft die Ausnahme: Besteht die Anfrage nur
// aus Stoppwörtern, wird trotzdem gesucht.
func TestNurStoppwoerterBleibtSuchbar(t *testing.T) {
idx := NewIndex()
idx.Index("anleitung", "Wie man testet", "Schritt für Schritt.", nil)
if treffer := idx.Search("wie", 10); len(treffer) == 0 {
t.Error("eine Anfrage nur aus Stoppwörtern fand nichts")
}
}
// TestStoppwoerterSindGefaltet stellt sicher, dass jedes Stoppwort in der Form
// in der Liste steht, die tokenize erzeugt. Stünde dort „für" statt „fur",
// würde es nie greifen.
func TestStoppwoerterSindGefaltet(t *testing.T) {
for wort := range stoppwoerter {
if got := tokenize(wort); len(got) != 1 || got[0] != wort {
t.Errorf("Stoppwort %q wird zu %v — es muss in gefalteter Form in der Liste stehen", wort, got)
}
}
}
// ── Tippfehler ───────────────────────────────────────────────────────────────
// TestTippfehlerNurInSearchFuzzy prüft, dass Tippfehler nur dort ausgeglichen
// werden, wo es gewollt ist: in der Weboberfläche, nicht für Agenten.
func TestTippfehlerNurInSearchFuzzy(t *testing.T) {
idx := NewIndex()
idx.Index("deployment", "Deployment", "Ablauf.", nil)
idx.Index("server", "Server", "Wartung.", nil)
faelle := map[string]string{
"deploymnet": "deployment", // zwei vertauschte Buchstaben
"sever": "server", // ein Buchstabe fehlt
}
for anfrage, erwartet := range faelle {
if treffer := idx.SearchFuzzy(anfrage, 10); !enthaelt(treffer, erwartet) {
t.Errorf("SearchFuzzy(%q) = %v, erwartet %s", anfrage, ergebnisIDs(treffer), erwartet)
}
if treffer := idx.Search(anfrage, 10); len(treffer) > 0 {
t.Errorf("Search(%q) = %v — Search soll Tippfehler nicht ausgleichen", anfrage, ergebnisIDs(treffer))
}
}
}
// TestTippfehlerNurAlsRueckfall prüft, dass die Tippfehler-Suche nicht greift,
// wenn das Suchwort schon etwas findet — sonst fände „host" auch „post".
func TestTippfehlerNurAlsRueckfall(t *testing.T) {
idx := NewIndex()
idx.Index("host", "Host", "Rechner im Netz.", nil)
idx.Index("cost", "Cost", "Kostenstelle.", nil)
treffer := idx.SearchFuzzy("host", 10)
if len(treffer) != 1 || treffer[0].DocID != "host" {
t.Errorf("Treffer = %v, erwartet nur host", ergebnisIDs(treffer))
}
}
func TestLevenshtein(t *testing.T) {
faelle := []struct {
a, b string
abstand int
}{
{"server", "server", 0},
{"sever", "server", 1}, // einfügen
{"servers", "server", 1}, // löschen
{"sarver", "server", 1}, // ersetzen
{"deploymnet", "deployment", 2},
{"", "abc", 3},
{"prüfung", "prufung", 1}, // Runen, nicht Bytes
}
for _, f := range faelle {
if got := levenshtein([]rune(f.a), []rune(f.b)); got != f.abstand {
t.Errorf("levenshtein(%q, %q) = %d, erwartet %d", f.a, f.b, got, f.abstand)
}
}
}
func TestErlaubteTippfehler(t *testing.T) {
faelle := map[int]int{3: 0, 4: 1, 7: 1, 8: 2, 15: 2}
for laenge, erwartet := range faelle {
if got := erlaubteTippfehler(laenge); got != erwartet {
t.Errorf("erlaubteTippfehler(%d) = %d, erwartet %d", laenge, got, erwartet)
}
}
}
// ── Überdeckung (Dubletten) ──────────────────────────────────────────────────
func TestUeberdeckung(t *testing.T) {
faelle := []struct {
anfrage, text string
erwartet float64
}{
{"Deployment-Ablauf", "Deployment-Prozess", 0.5},
{"Releaseprozess", "Release-Prozess", 1},
{"Release-Prozess", "Releaseprozess", 1},
{"Deployments", "Deployment", 1},
{"Zertifikate erneuern", "Zertifikat erneuern", 1},
{"Wie wir deployen", "Deployment", 1}, // Stoppwörter zählen nicht
{"Server-Server-Setup", "Server", 0.5}, // doppelte Wörter einmal
{"VPN-Zugang", "Drucker einrichten", 0},
{"", "Deployment", 0},
{"Port", "Reporting", 0}, // zu kurz für ein Teilwort
}
for _, f := range faelle {
if got := Ueberdeckung(f.anfrage, f.text); got != f.erwartet {
t.Errorf("Ueberdeckung(%q, %q) = %v, erwartet %v", f.anfrage, f.text, got, f.erwartet)
}
}
}

View file

@ -70,6 +70,31 @@ func EntryRestoreDetail(vonFassung int) string {
return fmt.Sprintf("wiederhergestellt: Fassung %d", vonFassung)
}
// DetailCuratedOverridden leitet im Protokoll einen Schreibvorgang ein, mit dem
// ein Agent einen kuratierten Eintrag geändert hat — und damit die Markierung
// aufgehoben (siehe internal/mcpserver/kuratiert.go). Danach folgt die
// Begründung des Agenten.
//
// Fachlich ist das eine Aktualisierung und zählt in der Nutzungsübersicht auch
// so (alles außer "angelegt" und leer). Der eigene Anfang macht es aber
// auffindbar: CuratedOverrides sucht mit LIKE danach.
const DetailCuratedOverridden = "kuratiert aufgehoben"
// maxBegruendung begrenzt die Begründung im Protokoll. Sie soll einordnen,
// nicht den Eintrag noch einmal enthalten.
const maxBegruendung = 300
// CuratedOverrideDetail baut den Protokolltext für DetailCuratedOverridden.
func CuratedOverrideDetail(begruendung string) string {
begruendung = strings.TrimSpace(begruendung)
// In Runen zählen, nicht in Bytes — sonst könnte der Schnitt mitten in
// einem Umlaut landen.
if r := []rune(begruendung); len(r) > maxBegruendung {
begruendung = string(r[:maxBegruendung]) + "…"
}
return DetailCuratedOverridden + ": " + begruendung
}
// EntryWriteDetail sagt anhand des gespeicherten Eintrags, ob der
// Schreibvorgang ihn angelegt oder aktualisiert hat.
//

View file

@ -0,0 +1,42 @@
-- Suchprotokoll: Was Agenten mit "recall" suchen und ob sie etwas finden.
--
-- ── Warum? ───────────────────────────────────────────────────────────────────
--
-- Ohne diese Zahlen lässt sich nicht beurteilen, ob die Suche gut genug ist.
-- Eine Suche ohne Treffer ist das deutlichste Zeichen: Entweder gibt es das
-- Wissen nicht — dann ist die Anfrage ein Hinweis, was fehlt — oder es steht
-- unter anderen Wörtern da. Beides will man sehen (AUSBAUSTUFEN.md 3.1).
--
-- ── Warum nicht im Zugriffsprotokoll (audit_events)? ─────────────────────────
--
-- Gesucht wird viel öfter als geschrieben. Im Zugriffsprotokoll würden die
-- Suchen die Schreibvorgänge zudecken, um die es dort geht. Außerdem enthält
-- eine Anfrage Inhalt („wie kommt man an das Passwort für …"), und Inhalt hat
-- eine kürzere Aufbewahrungsfrist verdient als die 90 Tage des Protokolls:
-- Die tägliche Aufräumaufgabe löscht Zeilen nach 30 Tagen
-- (store.SearchLogRetention).
--
-- ── Aufbau ───────────────────────────────────────────────────────────────────
--
-- Eine Zeile je recall-Aufruf. Bewusst ohne IP-Adresse und ohne Namen: Für
-- die Auswertung zählt, WAS gesucht wurde, nicht wer. api_key_id bleibt, damit
-- sich ein einzelner Agent mit auffälligem Verhalten eingrenzen lässt.
--
-- Die ID ist hier eine fortlaufende Zahl (INTEGER PRIMARY KEY) statt eines
-- Zufallswerts wie in den anderen Tabellen: Niemand verweist auf eine
-- einzelne Zeile, und eine Zahl ist beim Einfügen am billigsten.
--
-- Anders als das Zugriffsprotokoll hängt die Tabelle per Fremdschlüssel am
-- Projekt: Wird es gelöscht, verschwinden auch seine Anfragen. Sie sind Inhalt
-- des Projekts, kein Nachweis darüber, wer was getan hat.
CREATE TABLE search_log (
id INTEGER PRIMARY KEY,
ts TEXT NOT NULL,
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
api_key_id TEXT NOT NULL DEFAULT '',
query TEXT NOT NULL,
synonyms TEXT NOT NULL DEFAULT '', -- mit Komma getrennt, leer = keine
results INTEGER NOT NULL -- Zahl der ausgegebenen Treffer
);
CREATE INDEX idx_search_log_project ON search_log (project_id, ts);

View file

@ -15,6 +15,7 @@ import (
"context"
"database/sql"
"fmt"
"strings"
"time"
)
@ -312,3 +313,59 @@ func (s *Store) RecentlyUsedKeys(ctx context.Context, projectID string, limit in
}
return keys, rows.Err()
}
// CuratedOverride ist ein Eintrag, den ein Mensch kuratiert hatte und den
// danach ein Agent geändert hat — mit dem jüngsten solchen Vorgang.
type CuratedOverride struct {
Slug string
Title string
Actor string // Name des Zugangs, der zuletzt geändert hat
Reason string // Begründung des Agenten
At time.Time // Zeitpunkt der Änderung
}
// CuratedOverrides gibt die Einträge zurück, die ein Agent trotz
// Kuratierung geändert hat und die seitdem niemand wieder als kuratiert
// markiert hat — jüngste zuerst, höchstens limit Stück.
//
// Das ist die Liste „bitte prüfen": Sobald ein Mensch den Eintrag ansieht und
// den Haken neu setzt, verschwindet er von selbst daraus. Eine eigene Tabelle
// für offene Prüfungen braucht es dafür nicht.
//
// Grundlage ist das Protokoll. Nach dessen Aufbewahrungsfrist (90 Tage) fällt
// ein nie geprüfter Eintrag aus der Liste — er ist dann aber immer noch als
// nicht kuratiert zu erkennen.
func (s *Store) CuratedOverrides(ctx context.Context, projectID string, limit int) ([]CuratedOverride, error) {
// MAX(a.ts) mit Spalten ohne Aggregat: SQLite nimmt diese dann aus genau
// der Zeile mit dem Höchstwert — Zugang und Begründung gehören also zum
// jüngsten Vorgang (wie in EmptySearches).
rows, err := s.lesen.QueryContext(ctx,
`SELECT a.entry_slug, e.title, a.actor, a.detail, MAX(a.ts)
FROM audit_events a
JOIN entries e ON e.project_id = a.project_id AND e.slug = a.entry_slug
WHERE a.project_id = ? AND a.action = ? AND a.detail LIKE ?
AND e.curated = 0 AND e.deleted_at IS NULL
GROUP BY a.entry_slug
ORDER BY MAX(a.ts) DESC
LIMIT ?`,
projectID, ActionEntryWrite, DetailCuratedOverridden+"%", limit,
)
if err != nil {
return nil, fmt.Errorf("geänderte kuratierte Einträge konnten nicht gelesen werden: %w", err)
}
defer rows.Close()
var liste []CuratedOverride
for rows.Next() {
var c CuratedOverride
var detail, zeitpunkt string
if err := rows.Scan(&c.Slug, &c.Title, &c.Actor, &detail, &zeitpunkt); err != nil {
return nil, err
}
c.Reason = strings.TrimSpace(strings.TrimPrefix(
strings.TrimPrefix(detail, DetailCuratedOverridden), ":"))
c.At = parseTime(zeitpunkt)
liste = append(liste, c)
}
return liste, rows.Err()
}

View file

@ -2,8 +2,10 @@ package store
import (
"context"
"strings"
"testing"
"time"
"unicode/utf8"
)
// TestUsageSummariesZaehltSchreibvorgaenge: Neuanlagen, Aktualisierungen und
@ -148,3 +150,45 @@ func TestRecentlyUsedKeys(t *testing.T) {
t.Errorf("RecentlyUsedKeys = %v, erwartet nur „benutzt“", keys)
}
}
// TestCuratedOverrideDetailKuerzt prüft, dass eine ausufernde Begründung
// gekürzt wird — in Zeichen, nicht in Bytes, damit kein Umlaut zerbricht.
func TestCuratedOverrideDetailKuerzt(t *testing.T) {
lang := strings.Repeat("ü", maxBegruendung+50)
got := CuratedOverrideDetail(lang)
if !strings.HasPrefix(got, DetailCuratedOverridden+": ") {
t.Errorf("Anfang fehlt: %q", got[:40])
}
if !utf8.ValidString(got) {
t.Error("die Kürzung hat ein Zeichen zerbrochen")
}
if n := utf8.RuneCountInString(got); n > len(DetailCuratedOverridden)+2+maxBegruendung+1 {
t.Errorf("Detail ist %d Zeichen lang, nicht gekürzt", n)
}
}
// TestCuratedOverridesOhneGeloeschte prüft, dass Einträge im Papierkorb nicht
// zur Prüfung erscheinen — es gibt dort nichts mehr zu prüfen.
func TestCuratedOverridesOhneGeloeschte(t *testing.T) {
s := testStore(t)
ctx := context.Background()
p := testProject(t, s)
if _, err := s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "weg", Title: "Weg", Content: "x", Source: SourceAgent}); err != nil {
t.Fatal(err)
}
s.LogAudit(ctx, AuditEvent{ProjectID: p.ID, Actor: "agent", Action: ActionEntryWrite,
EntrySlug: "weg", Source: SourceMCP, Detail: CuratedOverrideDetail("Grund")})
if liste, _ := s.CuratedOverrides(ctx, p.ID, 10); len(liste) != 1 {
t.Fatalf("vor dem Löschen: %d Einträge, erwartet 1", len(liste))
}
if err := s.MoveEntryToTrash(ctx, p.ID, "weg", "mensch"); err != nil {
t.Fatal(err)
}
if liste, _ := s.CuratedOverrides(ctx, p.ID, 10); len(liste) != 0 {
t.Errorf("ein Eintrag im Papierkorb steht zur Prüfung: %+v", liste)
}
}

View file

@ -18,6 +18,7 @@ package store
import (
"context"
"database/sql"
"errors"
"fmt"
"time"
)
@ -57,6 +58,50 @@ func (s *Store) MoveEntryToTrash(ctx context.Context, projectID, slug, geloescht
return requireOneRow(res)
}
// MoveEntriesToTrash legt mehrere Einträge in einem Zug in den Papierkorb —
// die Sammelauswahl in der Eintragsliste.
//
// Ausgewählt wird über die interne ID, nicht den Bezeichner, genau wie bei
// BulkUpdateTags. Und genau wie dort werden IDs, die nicht zu projectID
// gehören, stillschweigend übersprungen: Sonst könnte eine untergeschobene ID
// über dieses Formular Einträge eines fremden Projekts löschen. Ebenso
// übersprungen werden Einträge, die schon im Papierkorb liegen (etwa weil das
// Formular doppelt abgeschickt wurde).
//
// Alles läuft in einer Transaktion: Entweder liegen danach alle gültigen
// Einträge im Papierkorb oder keiner. Zurück kommen die Bezeichner der
// tatsächlich gelöschten Einträge — für Suchindex und Protokoll.
func (s *Store) MoveEntriesToTrash(ctx context.Context, projectID string, entryIDs []string, geloeschtVon string) ([]string, error) {
var geloescht []string
jetzt := NowString()
err := s.tx(ctx, func(tx *sql.Tx) error {
for _, id := range entryIDs {
// RETURNING liefert die Spalte der geänderten Zeile gleich mit —
// so braucht es keine zweite Abfrage, um den Bezeichner zu holen.
var slug string
err := tx.QueryRowContext(ctx,
`UPDATE entries SET deleted_at = ?, deleted_by = ?
WHERE id = ? AND project_id = ? AND `+nichtImPapierkorb+`
RETURNING slug`,
jetzt, geloeschtVon, id, projectID,
).Scan(&slug)
if errors.Is(err, sql.ErrNoRows) {
continue // fremdes Projekt, schon im Papierkorb oder gibt es nicht
}
if err != nil {
return fmt.Errorf("Eintrag konnte nicht in den Papierkorb gelegt werden: %w", err)
}
geloescht = append(geloescht, slug)
}
return nil
})
if err != nil {
return nil, err
}
return geloescht, nil
}
// ListTrash gibt den Papierkorb eines Projekts zurück, zuletzt gelöschte zuerst.
//
// geloeschtVon schränkt auf einen Zugang ein (leer = alle). So lässt sich

View file

@ -294,3 +294,50 @@ func TestPurgeTrashAfterRetention(t *testing.T) {
t.Error("ein normaler Eintrag wurde beim Aufräumen entfernt")
}
}
// TestMoveEntriesToTrash prüft das Sammellöschen: Die gültigen Einträge
// landen im Papierkorb, IDs aus fremden Projekten und schon gelöschte werden
// übersprungen, Unbeteiligte bleiben unberührt.
func TestMoveEntriesToTrash(t *testing.T) {
s := testStore(t)
ctx := context.Background()
p := testProject(t, s)
fremdesProjekt, err := s.CreateProject(ctx, testWorkspace().ID, "fremd", "Fremd", "")
if err != nil {
t.Fatal(err)
}
a, _ := s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "a", Title: "A", Content: "x", Source: SourceHuman})
b, _ := s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "b", Title: "B", Content: "x", Source: SourceHuman})
s.PutEntry(ctx, EntryWrite{ProjectID: p.ID, Slug: "bleibt", Title: "Bleibt", Content: "x", Source: SourceHuman})
fremd, _ := s.PutEntry(ctx, EntryWrite{ProjectID: fremdesProjekt.ID, Slug: "f", Title: "F", Content: "x", Source: SourceHuman})
geloescht, err := s.MoveEntriesToTrash(ctx, p.ID, []string{a.ID, b.ID, fremd.ID, "gibtesnicht"}, "mensch")
if err != nil {
t.Fatal(err)
}
if len(geloescht) != 2 || geloescht[0] != "a" || geloescht[1] != "b" {
t.Errorf("gelöscht = %v, erwartet [a b]", geloescht)
}
if _, err := s.EntryBySlug(ctx, fremdesProjekt.ID, "f"); err != nil {
t.Errorf("der Eintrag eines fremden Projekts wurde angetastet: %v", err)
}
if _, err := s.EntryBySlug(ctx, p.ID, "bleibt"); err != nil {
t.Errorf("ein nicht ausgewählter Eintrag ist weg: %v", err)
}
papierkorb, err := s.ListTrash(ctx, p.ID, "")
if err != nil {
t.Fatal(err)
}
if len(papierkorb) != 2 || papierkorb[0].DeletedBy != "mensch" {
t.Errorf("Papierkorb = %+v, erwartet a und b, gelöscht von mensch", papierkorb)
}
// Doppelt abgeschickt: nichts mehr zu tun, kein Fehler.
nochmal, err := s.MoveEntriesToTrash(ctx, p.ID, []string{a.ID, b.ID}, "mensch")
if err != nil || len(nochmal) != 0 {
t.Errorf("zweiter Aufruf: gelöscht = %v, err = %v — erwartet nichts", nochmal, err)
}
}

View file

@ -0,0 +1,140 @@
package store
// Suchprotokoll: Was Agenten mit "recall" suchen und ob sie etwas finden
// (migrations/006_suchprotokoll.sql).
import (
"context"
"fmt"
"log"
"strings"
"time"
)
// SearchLogRetention ist die Aufbewahrungsfrist des Suchprotokolls. Gleich
// dem Zeitraum der Nutzungsübersicht: Länger braucht die Auswertung die
// Zeilen nicht, und Anfragen enthalten Inhalt — der soll nicht länger liegen
// als nötig.
const SearchLogRetention = UsageWindow
// SearchLogEntry ist eine Suche, wie sie ins Protokoll geht.
type SearchLogEntry struct {
ProjectID string
APIKeyID string
Query string
Synonyms []string
Results int // Zahl der ausgegebenen Treffer, nach dem Schlagwortfilter
}
// LogSearch schreibt eine Suche ins Protokoll.
//
// Wie LogAudit: Ein Fehler lässt die Suche nicht scheitern, er landet nur im
// Serverlog. Der Agent soll seine Treffer bekommen, auch wenn die
// Protokollzeile nicht geschrieben werden konnte.
func (s *Store) LogSearch(ctx context.Context, e SearchLogEntry) {
_, err := s.schreiben.ExecContext(ctx,
`INSERT INTO search_log (ts, project_id, api_key_id, query, synonyms, results)
VALUES (?, ?, ?, ?, ?, ?)`,
NowString(), e.ProjectID, e.APIKeyID, e.Query, strings.Join(e.Synonyms, ", "), e.Results,
)
if err != nil {
log.Printf("Suchprotokoll: Suche konnte nicht vermerkt werden: %v", err)
}
}
// PruneSearchLog löscht Suchen, die älter als die angegebene Grenze sind.
// Die Grenze ist ein Zeitstempel im Datenbankformat, wie bei PruneAudit.
func (s *Store) PruneSearchLog(ctx context.Context, aelterAls string) (int64, error) {
res, err := s.schreiben.ExecContext(ctx, `DELETE FROM search_log WHERE ts < ?`, aelterAls)
if err != nil {
return 0, fmt.Errorf("Suchprotokoll konnte nicht aufgeräumt werden: %w", err)
}
return res.RowsAffected()
}
// SearchStats sind die Kennzahlen des Suchprotokolls eines Projekts.
type SearchStats struct {
Total int // alle Suchen im Zeitraum
Empty int // davon ohne Treffer
WithSynonyms int // davon mit Synonymen
}
// EmptyPercent ist der Anteil der Suchen ohne Treffer in Prozent (0–100).
func (s SearchStats) EmptyPercent() int {
return prozent(s.Empty, s.Total)
}
// SynonymPercent ist der Anteil der Suchen mit Synonymen in Prozent (0–100).
// Zeigt, ob die Agenten die Bitte aus den instructions befolgen.
func (s SearchStats) SynonymPercent() int {
return prozent(s.WithSynonyms, s.Total)
}
// SearchStatsFor zählt die Suchen eines Projekts im Zeitraum UsageWindow.
func (s *Store) SearchStatsFor(ctx context.Context, projectID string, jetzt time.Time) (SearchStats, error) {
seit := jetzt.UTC().Add(-UsageWindow).Format(time.RFC3339Nano)
// Wie in UsageSummaries: CASE … THEN 1 ELSE 0 macht aus jeder Zeile eine
// 1 oder 0, SUM zählt. COALESCE, weil SUM über null Zeilen NULL liefert.
var st SearchStats
err := s.lesen.QueryRowContext(ctx,
`SELECT COUNT(*),
COALESCE(SUM(CASE WHEN results = 0 THEN 1 ELSE 0 END), 0),
COALESCE(SUM(CASE WHEN synonyms <> '' THEN 1 ELSE 0 END), 0)
FROM search_log
WHERE project_id = ? AND ts >= ?`,
projectID, seit,
).Scan(&st.Total, &st.Empty, &st.WithSynonyms)
if err != nil {
return SearchStats{}, fmt.Errorf("Suchprotokoll konnte nicht gelesen werden: %w", err)
}
return st, nil
}
// EmptySearch ist eine Anfrage, die nichts gefunden hat — zusammengefasst
// über alle Male, die sie gestellt wurde.
type EmptySearch struct {
Query string
Synonyms string // die zuletzt mitgegebenen Synonyme
Count int
Last time.Time
}
// EmptySearches gibt die Anfragen ohne Treffer zurück, häufigste zuerst,
// höchstens limit Stück.
//
// Gleiche Anfragen werden ohne Rücksicht auf Groß- und Kleinschreibung
// zusammengefasst. lower() kennt in SQLite nur ASCII — „Über" und „über"
// bleiben also zwei Zeilen. Für eine Übersicht reicht das.
func (s *Store) EmptySearches(ctx context.Context, projectID string, jetzt time.Time, limit int) ([]EmptySearch, error) {
seit := jetzt.UTC().Add(-UsageWindow).Format(time.RFC3339Nano)
// MAX(ts) ist der jüngste Zeitpunkt. Die Synonyme der jüngsten Zeile holt
// eine Eigenheit von SQLite: Steht MAX() in der Abfrage, kommen die übrigen
// Spalten ohne Aggregat aus genau der Zeile mit dem Höchstwert.
rows, err := s.lesen.QueryContext(ctx,
`SELECT query, synonyms, COUNT(*) AS anzahl, MAX(ts)
FROM search_log
WHERE project_id = ? AND ts >= ? AND results = 0
GROUP BY lower(trim(query))
ORDER BY anzahl DESC, MAX(ts) DESC
LIMIT ?`,
projectID, seit, limit,
)
if err != nil {
return nil, fmt.Errorf("Suchen ohne Treffer konnten nicht gelesen werden: %w", err)
}
defer rows.Close()
var liste []EmptySearch
for rows.Next() {
var e EmptySearch
var zuletzt string
if err := rows.Scan(&e.Query, &e.Synonyms, &e.Count, &zuletzt); err != nil {
return nil, err
}
e.Last = parseTime(zuletzt)
liste = append(liste, e)
}
return liste, rows.Err()
}

View file

@ -0,0 +1,127 @@
package store
import (
"context"
"testing"
"time"
)
// TestSearchStatsZaehlt prüft die Kennzahlen: alle Suchen, ohne Treffer, mit
// Synonymen — und nur innerhalb des Zeitfensters und des Projekts.
func TestSearchStatsZaehlt(t *testing.T) {
s := testStore(t)
ctx := context.Background()
p := testProject(t, s)
s.LogSearch(ctx, SearchLogEntry{ProjectID: p.ID, Query: "deployment", Results: 3})
s.LogSearch(ctx, SearchLogEntry{ProjectID: p.ID, Query: "release", Synonyms: []string{"rollout"}, Results: 0})
s.LogSearch(ctx, SearchLogEntry{ProjectID: p.ID, Query: "vpn", Results: 0})
// Außerhalb des Fensters: darf nicht mitzählen.
alt := time.Now().UTC().Add(-UsageWindow - time.Hour).Format(time.RFC3339Nano)
if _, err := s.schreiben.ExecContext(ctx,
`INSERT INTO search_log (ts, project_id, query, results) VALUES (?, ?, 'alt', 0)`, alt, p.ID); err != nil {
t.Fatal(err)
}
st, err := s.SearchStatsFor(ctx, p.ID, time.Now())
if err != nil {
t.Fatal(err)
}
if st.Total != 3 || st.Empty != 2 || st.WithSynonyms != 1 {
t.Errorf("SearchStats = %+v, erwartet Total 3, Empty 2, WithSynonyms 1", st)
}
if st.EmptyPercent() != 67 || st.SynonymPercent() != 33 {
t.Errorf("Anteile = %d %% / %d %%, erwartet 67 / 33", st.EmptyPercent(), st.SynonymPercent())
}
}
// TestSearchStatsLeer prüft ein Projekt ohne Suchen: alles 0, kein Fehler.
func TestSearchStatsLeer(t *testing.T) {
s := testStore(t)
p := testProject(t, s)
st, err := s.SearchStatsFor(context.Background(), p.ID, time.Now())
if err != nil {
t.Fatal(err)
}
if st != (SearchStats{}) || st.EmptyPercent() != 0 {
t.Errorf("SearchStats = %+v, erwartet nur Nullen", st)
}
}
// TestEmptySearchesFasstZusammen prüft, dass gleiche Anfragen ohne Treffer
// zusammengefasst werden, unabhängig von Groß- und Kleinschreibung, und die
// häufigste vorne steht.
func TestEmptySearchesFasstZusammen(t *testing.T) {
s := testStore(t)
ctx := context.Background()
p := testProject(t, s)
s.LogSearch(ctx, SearchLogEntry{ProjectID: p.ID, Query: "vpn zugang", Results: 0})
s.LogSearch(ctx, SearchLogEntry{ProjectID: p.ID, Query: "VPN Zugang", Results: 0})
s.LogSearch(ctx, SearchLogEntry{ProjectID: p.ID, Query: " vpn zugang ", Synonyms: []string{"remote"}, Results: 0})
s.LogSearch(ctx, SearchLogEntry{ProjectID: p.ID, Query: "drucker", Results: 0})
s.LogSearch(ctx, SearchLogEntry{ProjectID: p.ID, Query: "deployment", Results: 2}) // hat Treffer
liste, err := s.EmptySearches(ctx, p.ID, time.Now(), 10)
if err != nil {
t.Fatal(err)
}
if len(liste) != 2 {
t.Fatalf("%d Anfragen, erwartet 2: %+v", len(liste), liste)
}
if liste[0].Count != 3 {
t.Errorf("häufigste Anfrage = %+v, erwartet 3× vpn zugang", liste[0])
}
if liste[0].Synonyms != "remote" {
t.Errorf("Synonyme = %q, erwartet die der jüngsten Suche (remote)", liste[0].Synonyms)
}
if liste[1].Query != "drucker" {
t.Errorf("zweite Anfrage = %q, erwartet drucker", liste[1].Query)
}
}
// TestPruneSearchLog prüft das Aufräumen nach Ablauf der Frist.
func TestPruneSearchLog(t *testing.T) {
s := testStore(t)
ctx := context.Background()
p := testProject(t, s)
alt := time.Now().UTC().Add(-SearchLogRetention - time.Hour).Format(time.RFC3339Nano)
if _, err := s.schreiben.ExecContext(ctx,
`INSERT INTO search_log (ts, project_id, query, results) VALUES (?, ?, 'alt', 0)`, alt, p.ID); err != nil {
t.Fatal(err)
}
s.LogSearch(ctx, SearchLogEntry{ProjectID: p.ID, Query: "neu", Results: 0})
grenze := time.Now().UTC().Add(-SearchLogRetention).Format(time.RFC3339Nano)
n, err := s.PruneSearchLog(ctx, grenze)
if err != nil {
t.Fatal(err)
}
if n != 1 {
t.Errorf("%d Zeilen entfernt, erwartet 1", n)
}
}
// TestSuchprotokollVerschwindetMitProjekt prüft den Fremdschlüssel: Anfragen
// sind Inhalt des Projekts und gehen mit ihm.
func TestSuchprotokollVerschwindetMitProjekt(t *testing.T) {
s := testStore(t)
ctx := context.Background()
p := testProject(t, s)
s.LogSearch(ctx, SearchLogEntry{ProjectID: p.ID, Query: "geheim", Results: 0})
if err := s.DeleteProject(ctx, p.ID); err != nil {
t.Fatal(err)
}
var anzahl int
if err := s.lesen.QueryRowContext(ctx, `SELECT COUNT(*) FROM search_log`).Scan(&anzahl); err != nil {
t.Fatal(err)
}
if anzahl != 0 {
t.Errorf("%d Anfragen des gelöschten Projekts sind noch da", anzahl)
}
}

View file

@ -24,6 +24,11 @@ type entryListData struct {
Query string
Tag string
Total int
// RetentionDays: so lange liegen gelöschte Einträge im Papierkorb — für
// die Rückfrage beim Sammellöschen, damit dort dieselbe Zahl steht, nach
// der auch aufgeräumt wird.
RetentionDays int
}
// entryListItem ist eine Zeile der Übersicht bzw. ein Suchtreffer.
@ -42,7 +47,7 @@ func (s *Server) handleEntryList(w http.ResponseWriter, r *http.Request) {
query := strings.TrimSpace(r.URL.Query().Get("q"))
tag := strings.TrimSpace(r.URL.Query().Get("tag"))
daten := entryListData{Query: query, Tag: tag}
daten := entryListData{Query: query, Tag: tag, RetentionDays: store.TrashRetentionDays}
var err error
if query != "" {
@ -83,7 +88,7 @@ func (s *Server) searchEntries(r *http.Request, project store.Project, query str
return nil, err
}
treffer := idx.Search(query, 30)
treffer := idx.SearchFuzzy(query, 30)
items := make([]entryListItem, 0, len(treffer))
for _, t := range treffer {
@ -264,7 +269,7 @@ func (s *Server) handleEntrySuggest(w http.ResponseWriter, r *http.Request) {
s.serverError(w, "Vorschläge konnten nicht geladen werden", err)
return
}
for _, treffer := range idx.Search(query, suggestLimit) {
for _, treffer := range idx.SearchFuzzy(query, suggestLimit) {
e, err := ws.Store.EntryBySlug(r.Context(), project.ID, treffer.DocID)
if err != nil {
continue // Index kann einen Eintrag kennen, den es nicht mehr gibt
@ -426,10 +431,7 @@ func (s *Server) handleEntryBulkTags(w http.ResponseWriter, r *http.Request) {
return
}
ziel := "/p/" + project.Slug + "/"
if tag := r.FormValue("tag"); tag != "" {
ziel += "?tag=" + url.QueryEscape(tag)
}
ziel := sammelZiel(r, project)
ids := r.Form["ids"]
add := splitTags(r.FormValue("add_tags"))
@ -459,11 +461,90 @@ func (s *Server) handleEntryBulkTags(w http.ResponseWriter, r *http.Request) {
}
meldung := fmt.Sprintf("%d Eintrag/Einträge aktualisiert", len(geaendert))
http.Redirect(w, r, mitHinweis(ziel, meldung), http.StatusSeeOther)
}
// handleEntryBulkDelete legt die in der Eintragsliste ausgewählten Einträge
// in den Papierkorb — das Gegenstück zu handleEntryDelete für viele auf
// einmal. Die Rückfrage stellt der Browser vorher (data-confirm am Knopf,
// siehe static/confirm.js).
//
// Wie beim einzelnen Löschen ist nichts endgültig: Die Einträge liegen 30
// Tage im Papierkorb, und dort holt „Alle … wiederherstellen" sie in einem Zug
// zurück, falls versehentlich zu viel ausgewählt war.
func (s *Server) handleEntryBulkDelete(w http.ResponseWriter, r *http.Request) {
ws, _ := WorkspaceFrom(r)
project, _ := ProjectFrom(r)
sess, _ := SessionFrom(r)
if err := r.ParseForm(); err != nil {
s.serverError(w, "Formular konnte nicht gelesen werden", err)
return
}
ziel := sammelZiel(r, project)
ids := r.Form["ids"]
if len(ids) == 0 {
http.Redirect(w, r, mitHinweis(ziel, "Nichts ausgewählt — es wurde nichts gelöscht."), http.StatusSeeOther)
return
}
geloescht, err := ws.Store.MoveEntriesToTrash(r.Context(), project.ID, ids, sess.ActorName())
if err != nil {
s.serverError(w, "Einträge konnten nicht gelöscht werden", err)
return
}
// Je Eintrag eine eigene Protokollzeile, genau wie beim einzelnen
// Löschen. So zählt die Nutzungsübersicht jede Löschung, und im Protokoll
// steht bei jedem Eintrag, wer ihn wann gelöscht hat.
for _, slug := range geloescht {
ws.Search.Refresh(r.Context(), project.ID, slug)
ws.Store.LogAudit(r.Context(), store.AuditEvent{
ProjectID: project.ID, APIKeyID: sess.Key.ID, Actor: sess.ActorName(),
Action: store.ActionEntryDelete, EntrySlug: slug, Source: store.SourceWeb,
Detail: "in den Papierkorb (Sammelauswahl)",
})
}
meldung := fmt.Sprintf("%d Einträge liegen jetzt im Papierkorb und lassen sich dort %d Tage lang wiederherstellen.",
len(geloescht), store.TrashRetentionDays)
if len(geloescht) == 1 {
meldung = fmt.Sprintf("1 Eintrag liegt jetzt im Papierkorb und lässt sich dort %d Tage lang wiederherstellen.",
store.TrashRetentionDays)
}
http.Redirect(w, r, mitHinweis(ziel, meldung), http.StatusSeeOther)
}
// sammelZiel ist die Adresse, zu der die Sammelaktionen der Eintragsliste
// zurückführen: dieselbe Liste, mit demselben Schlagwortfilter und derselben
// Suche wie vorher. Sonst stünde man nach dem Löschen aus einer Trefferliste
// plötzlich vor allen Einträgen.
func sammelZiel(r *http.Request, project store.Project) string {
werte := url.Values{}
if tag := r.FormValue("tag"); tag != "" {
werte.Set("tag", tag)
}
if q := r.FormValue("q"); q != "" {
werte.Set("q", q)
}
ziel := "/p/" + project.Slug + "/"
if len(werte) > 0 {
ziel += "?" + werte.Encode()
}
return ziel
}
// mitHinweis hängt eine Meldung als Parameter "hinweis" an eine Adresse an —
// mit "?" oder "&", je nachdem, ob schon Parameter dastehen.
func mitHinweis(ziel, meldung string) string {
trenner := "?"
if strings.Contains(ziel, "?") {
trenner = "&"
}
http.Redirect(w, r, ziel+trenner+"hinweis="+url.QueryEscape(meldung), http.StatusSeeOther)
return ziel + trenner + "hinweis=" + url.QueryEscape(meldung)
}
// ── Änderungshistorie ────────────────────────────────────────────────────────

View file

@ -29,6 +29,13 @@ type usageData struct {
Missing []store.MissingLink
MissingMore int // wie viele Verweise ins Leere über die Liste hinaus
Keys []store.APIKey
// Kuratierte Einträge, die ein Agent seitdem geändert hat — zur Prüfung.
CuratedOverrides []store.CuratedOverride
// Suchen der Agenten über recall (Suchprotokoll, migrations/006).
Searches store.SearchStats
EmptySearches []store.EmptySearch
}
// handleProjectUsage zeigt die Nutzungsübersicht eines Projekts.
@ -61,6 +68,24 @@ func (s *Server) handleProjectUsage(w http.ResponseWriter, r *http.Request) {
return
}
zurPruefung, err := ws.Store.CuratedOverrides(ctx, project.ID, nutzungListenLaenge)
if err != nil {
s.serverError(w, "Geänderte kuratierte Einträge konnten nicht geladen werden", err)
return
}
suchen, err := ws.Store.SearchStatsFor(ctx, project.ID, time.Now())
if err != nil {
s.serverError(w, "Suchprotokoll konnte nicht geladen werden", err)
return
}
ohneTreffer, err := ws.Store.EmptySearches(ctx, project.ID, time.Now(), nutzungListenLaenge)
if err != nil {
s.serverError(w, "Suchen ohne Treffer konnten nicht geladen werden", err)
return
}
daten := usageData{
// Ein Projekt ohne Einträge und Schreibvorgänge fehlt in der map —
// dann liefert alle[project.ID] den Nullwert, also überall 0.
@ -69,6 +94,11 @@ func (s *Server) handleProjectUsage(w http.ResponseWriter, r *http.Request) {
Untagged: ohneSchlagwort,
Missing: insLeere,
Keys: keys,
CuratedOverrides: zurPruefung,
Searches: suchen,
EmptySearches: ohneTreffer,
}
// Die Verweise ins Leere kommen vollständig (die Gesamtzahl ist selbst

View file

@ -184,6 +184,7 @@ func (s *Server) Routes(mux *http.ServeMux, staticFS fs.FS) {
mux.Handle("POST /p/{project}/e/{slug}/delete", mitSchreibrecht(s.handleEntryDelete))
mux.Handle("POST /p/{project}/save", mitSchreibrecht(s.handleEntrySave))
mux.Handle("POST /p/{project}/entries/bulk-tags", mitSchreibrecht(s.handleEntryBulkTags))
mux.Handle("POST /p/{project}/entries/bulk-delete", mitSchreibrecht(s.handleEntryBulkDelete))
// Papierkorb: auch das Ansehen nur mit Schreibrecht — siehe handlers_papierkorb.go.
mux.Handle("GET /p/{project}/trash", mitSchreibrecht(s.handleTrashList))
mux.Handle("POST /p/{project}/trash/restore", mitSchreibrecht(s.handleTrashRestore))

View file

@ -1106,6 +1106,125 @@ func TestEntryBulkTags(t *testing.T) {
}
}
// sammelLoeschen schickt die Sammelauswahl der Eintragsliste an bulk-delete.
// rolle bestimmt den Key, mit dem angemeldet wird; extra ergänzt das Formular
// (etwa um tag oder q).
func sammelLoeschen(t *testing.T, srv *Server, db *store.Store, projekt store.Project,
rolle auth.Role, ids []string, extra url.Values) *httptest.ResponseRecorder {
t.Helper()
h := testHandler(t, srv)
key := erzeugeKey(t, srv, db, projekt.ID, "mensch-"+string(rolle), rolle)
cookie := anmelden(t, h, key)
keys, _ := db.ListAPIKeys(context.Background(), projekt.ID)
var keyID string
for _, k := range keys {
if k.Name == "mensch-"+string(rolle) {
keyID = k.ID
}
}
token := auth.NewSigner([]byte(testSecret)).CSRFToken(auth.SubjectKeyPrefix + projekt.WorkspaceID + ":" + keyID)
formular := url.Values{"ids": ids, auth.CSRFFieldName: {token}}
for k, v := range extra {
formular[k] = v
}
r := httptest.NewRequest(http.MethodPost, "/p/testprojekt/entries/bulk-delete", strings.NewReader(formular.Encode()))
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
r.AddCookie(cookie)
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
return w
}
// TestEntryBulkDelete: Die ausgewählten Einträge liegen danach im Papierkorb,
// je Eintrag steht eine Zeile im Protokoll, der Rest bleibt — und die
// Weiterleitung führt zurück in dieselbe gefilterte Liste.
func TestEntryBulkDelete(t *testing.T) {
srv, db, projekt := testServer(t)
ctx := context.Background()
a, _ := db.PutEntry(ctx, store.EntryWrite{ProjectID: projekt.ID, Slug: "a", Title: "A", Source: store.SourceHuman})
b, _ := db.PutEntry(ctx, store.EntryWrite{ProjectID: projekt.ID, Slug: "b", Title: "B", Source: store.SourceHuman})
db.PutEntry(ctx, store.EntryWrite{ProjectID: projekt.ID, Slug: "c", Title: "C", Source: store.SourceHuman})
w := sammelLoeschen(t, srv, db, projekt, auth.RoleWrite, []string{a.ID, b.ID}, url.Values{"tag": {"ops"}})
if w.Code != http.StatusSeeOther {
t.Fatalf("Status = %d, erwartet Weiterleitung (%s)", w.Code, w.Body.String())
}
if ziel := w.Header().Get("Location"); !strings.Contains(ziel, "tag=ops") || !strings.Contains(ziel, "hinweis=") {
t.Errorf("Weiterleitung nach %q — Filter oder Hinweis fehlt", ziel)
}
papierkorb, _ := db.ListTrash(ctx, projekt.ID, "")
if len(papierkorb) != 2 {
t.Errorf("%d Einträge im Papierkorb, erwartet 2", len(papierkorb))
}
if _, err := db.EntryBySlug(ctx, projekt.ID, "c"); err != nil {
t.Errorf("C war nicht ausgewählt und ist trotzdem weg: %v", err)
}
ereignisse, _ := db.ListAudit(ctx, store.AuditFilter{ProjectID: projekt.ID})
loeschungen := 0
for _, e := range ereignisse {
if e.Action == store.ActionEntryDelete {
loeschungen++
}
}
if loeschungen != 2 {
t.Errorf("%d Löschungen im Protokoll, erwartet eine je Eintrag (2)", loeschungen)
}
}
// TestEntryBulkDeleteNurMitSchreibrecht: Mit Leserecht wird nichts gelöscht.
func TestEntryBulkDeleteNurMitSchreibrecht(t *testing.T) {
srv, db, projekt := testServer(t)
ctx := context.Background()
a, _ := db.PutEntry(ctx, store.EntryWrite{ProjectID: projekt.ID, Slug: "a", Title: "A", Source: store.SourceHuman})
w := sammelLoeschen(t, srv, db, projekt, auth.RoleRead, []string{a.ID}, nil)
if w.Code == http.StatusSeeOther {
t.Errorf("Leserecht wurde weitergeleitet wie nach Erfolg (Status %d)", w.Code)
}
if _, err := db.EntryBySlug(ctx, projekt.ID, "a"); err != nil {
t.Errorf("mit Leserecht wurde gelöscht: %v", err)
}
}
// TestEintragslisteZeigtSammelloeschen: Der Knopf steht mit Rückfrage in der
// Liste — und zwar hinter „Anwenden", damit Enter in einem Schlagwortfeld nie
// löscht.
func TestEintragslisteZeigtSammelloeschen(t *testing.T) {
srv, db, projekt := testServer(t)
h := testHandler(t, srv)
db.PutEntry(context.Background(), store.EntryWrite{ProjectID: projekt.ID, Slug: "a", Title: "A", Source: store.SourceHuman})
schreiber := anmelden(t, h, erzeugeKey(t, srv, db, projekt.ID, "mensch", auth.RoleWrite))
body := seiteHolen(h, schreiber, "/p/testprojekt/").Body.String()
anwenden := strings.Index(body, ">Anwenden<")
loeschen := strings.Index(body, `formaction="/p/testprojekt/entries/bulk-delete"`)
if anwenden < 0 || loeschen < 0 {
t.Fatal("Sammelleiste ohne Anwenden oder ohne Löschknopf")
}
if loeschen < anwenden {
t.Error("der Löschknopf steht vor „Anwenden“ — Enter in einem Textfeld würde löschen")
}
if !strings.Contains(body, "30 Tage lang wiederherstellen") {
t.Error("die Rückfrage nennt die Frist des Papierkorbs nicht")
}
leser := anmelden(t, h, erzeugeKey(t, srv, db, projekt.ID, "leser", auth.RoleRead))
if strings.Contains(seiteHolen(h, leser, "/p/testprojekt/").Body.String(), "bulk-delete") {
t.Error("die Leserolle sieht den Löschknopf")
}
}
// TestEntryVersionsAndDiff prüft den Verlauf und den Vergleich zweier
// Fassungen — inklusive des Falls "Vergleich gegen die aktuelle Fassung",
// wenn es keine spätere abgelöste Fassung mehr gibt.
@ -2435,6 +2554,57 @@ func TestNutzungsseite(t *testing.T) {
}
}
// TestNutzungsseiteZeigtSuchen: Suchen ohne Treffer erscheinen mit Anzahl —
// und eine Anfrage mit HTML darin wird als Text ausgegeben, nicht ausgeführt.
func TestNutzungsseiteZeigtSuchen(t *testing.T) {
srv, db, projekt := testServer(t)
h := testHandler(t, srv)
ctx := context.Background()
db.LogSearch(ctx, store.SearchLogEntry{ProjectID: projekt.ID, Query: "vpn zugang", Results: 0})
db.LogSearch(ctx, store.SearchLogEntry{ProjectID: projekt.ID, Query: "vpn zugang", Results: 0})
db.LogSearch(ctx, store.SearchLogEntry{ProjectID: projekt.ID, Query: "<script>x</script>", Results: 0})
db.LogSearch(ctx, store.SearchLogEntry{ProjectID: projekt.ID, Query: "deployment",
Synonyms: []string{"release"}, Results: 1})
cookie := anmelden(t, h, erzeugeKey(t, srv, db, projekt.ID, "leser", auth.RoleRead))
body := seiteHolen(h, cookie, "/p/testprojekt/usage").Body.String()
for _, erwartet := range []string{"vpn zugang", "2×", "75 %", "25 %"} {
if !strings.Contains(body, erwartet) {
t.Errorf("%q fehlt auf der Nutzungsseite", erwartet)
}
}
if strings.Contains(body, "<script>x</script>") {
t.Error("eine Suchanfrage wurde ungeschützt als HTML ausgegeben")
}
if strings.Contains(body, "deployment") {
t.Error("eine Suche mit Treffer steht in der Liste ohne Treffer")
}
}
// TestNutzungsseiteZeigtZurPruefung: Ein kuratierter Eintrag, den ein Agent
// geändert hat, steht mit Begründung und Link zum Verlauf oben auf der Seite.
func TestNutzungsseiteZeigtZurPruefung(t *testing.T) {
srv, db, projekt := testServer(t)
h := testHandler(t, srv)
ctx := context.Background()
db.PutEntry(ctx, store.EntryWrite{ProjectID: projekt.ID, Slug: "vpn", Title: "VPN-Zugang",
Content: "neu", Source: store.SourceAgent})
db.LogAudit(ctx, store.AuditEvent{ProjectID: projekt.ID, Actor: "agent-x", Action: store.ActionEntryWrite,
EntrySlug: "vpn", Source: store.SourceMCP, Detail: store.CuratedOverrideDetail("Server umgezogen")})
cookie := anmelden(t, h, erzeugeKey(t, srv, db, projekt.ID, "leser", auth.RoleRead))
body := seiteHolen(h, cookie, "/p/testprojekt/usage").Body.String()
for _, erwartet := range []string{"Zur Prüfung", "VPN-Zugang", "/p/testprojekt/e/vpn/versions", "Server umgezogen", "agent-x"} {
if !strings.Contains(body, erwartet) {
t.Errorf("%q fehlt auf der Nutzungsseite", erwartet)
}
}
}
// TestNutzungsseiteLeeresProjekt: Ein Projekt ganz ohne Daten darf nicht
// stolpern (es fehlt dann in der map der Kennzahlen).
func TestNutzungsseiteLeeresProjekt(t *testing.T) {

View file

@ -235,10 +235,16 @@ func agent(ctx context.Context, httpTransport http.RoundTripper, url, key string
werkzeug = "remember"
// Ein eindeutiges Wort je Schreibvorgang — daran erkennt
// pruefeBestand, ob der Suchindex den letzten Stand kennt.
//
// force: Alle umkämpften Einträge tragen denselben Titel. Ohne
// force hielte die Dublettenprüfung jede Neuanlage nach der
// ersten an — der Aufruf zählte als erfolgreich, ohne dass eine
// Fassung entsteht, und die Bilanz in pruefeBestand ginge nicht auf.
args = map[string]any{
"slug": fmt.Sprintf("gemeinsam-%02d", zufall.IntN(*lastSlugs)),
"title": "Gemeinsamer Eintrag",
"content": "Stand " + eindeutigesWort(nr, i) + "\n\n" + fuellText(i),
"force": true,
}
case w < *lastSchreiben+(100-*lastSchreiben)*2/3:
werkzeug = "recall"

15
main.go
View file

@ -434,7 +434,8 @@ func exportRegularly(e *export.Exporter, control *store.ControlStore, workspaces
}
// aufraeumenRegelmaessig räumt täglich in allen Workspaces auf: das Protokoll
// (nach 90 Tagen) und den Papierkorb (nach store.TrashRetention).
// (nach 90 Tagen), das Suchprotokoll (nach store.SearchLogRetention) und den
// Papierkorb (nach store.TrashRetention).
//
// Jeder Workspace führt Protokoll und Papierkorb in seiner eigenen Datei. Die
// Fristen gelten also nur dann, wenn wirklich jede Datei aufgeräumt wird — ein
@ -507,6 +508,18 @@ func pruneAlle(control *store.ControlStore, workspaces *workspace.Manager) {
log.Printf("Protokoll von %q aufgeräumt: %d Einträge älter als %d Tage entfernt",
w.Name, n, int(auditRetention.Hours()/24))
}
// Das Suchprotokoll hat eine eigene, kürzere Frist: Anfragen sind
// Inhalt, und die Auswertung braucht ohnehin nur 30 Tage.
suchgrenze := time.Now().UTC().Add(-store.SearchLogRetention).Format(time.RFC3339Nano)
n, err = offen.Store.PruneSearchLog(ctx, suchgrenze)
if err != nil {
return err
}
if n > 0 {
log.Printf("Suchprotokoll von %q aufgeräumt: %d Suchen älter als %d Tage entfernt",
w.Name, n, store.UsageWindowDays)
}
return nil
})
if err != nil {

View file

@ -41,6 +41,7 @@
<form method="POST" action="/p/{{.Project.Slug}}/entries/bulk-tags" id="sammelform">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
{{if .Data.Tag}}<input type="hidden" name="tag" value="{{.Data.Tag}}">{{end}}
{{if .Data.Query}}<input type="hidden" name="q" value="{{.Data.Query}}">{{end}}
{{end}}
<ul class="eintraege">
@ -76,6 +77,12 @@
<input type="text" name="add_tags" placeholder="Schlagworte hinzufügen, kommagetrennt" autocomplete="off">
<input type="text" name="remove_tags" placeholder="Schlagworte entfernen, kommagetrennt" autocomplete="off">
<button type="submit" class="zweitrangig">Anwenden</button>
{{/* formaction schickt dasselbe Formular an eine andere Adresse. Der
Knopf steht bewusst hinter "Anwenden": Enter in einem Textfeld
löst immer den ersten Knopf aus, und das soll nie das Löschen
sein. Die Rückfrage liest static/confirm.js am Knopf. */}}
<button type="submit" class="warnung" formaction="/p/{{.Project.Slug}}/entries/bulk-delete"
data-confirm="Die ausgewählten Einträge in den Papierkorb legen? Sie lassen sich dort {{.Data.RetentionDays}} Tage lang wiederherstellen.">In den Papierkorb</button>
</div>
</form>
{{end}}

View file

@ -32,6 +32,28 @@
</div>
</div>
{{/* ── Zur Prüfung ─────────────────────────────────────────────────────── */}}
{{/* Steht oben, weil hier etwas zu tun ist. Ein Eintrag verschwindet von
selbst aus der Liste, sobald ihn jemand wieder als kuratiert markiert. */}}
{{if .Data.CuratedOverrides}}
<section class="abschnitt">
<h2>Zur Prüfung: kuratiert, dann von Agenten geändert</h2>
<span class="hilfe">Diese Einträge hatte ein Mensch geprüft. Danach hat ein Agent sie
mit Begründung geändert, dabei fiel die Markierung „kuratiert“ weg. Im Verlauf lässt
sich die Änderung mit dem geprüften Stand vergleichen. Stimmt sie, den Haken
„Kuratiert“ wieder setzen; sonst die alte Fassung wiederherstellen.</span>
<ul class="kompakt-liste">
{{range .Data.CuratedOverrides}}
<li>
<a href="/p/{{$slug}}/e/{{.Slug}}/versions">{{.Title}}</a>
<span class="hilfe">· {{.Actor}} · {{datum .At}}</span>
{{if .Reason}}<br><span class="hilfe">„{{.Reason}}“</span>{{end}}
</li>
{{end}}
</ul>
</section>
{{end}}
{{/* ── Schreibvorgänge ─────────────────────────────────────────────────── */}}
<section class="abschnitt">
<h2>Schreibvorgänge der letzten {{.Data.WindowDays}} Tage</h2>
@ -99,6 +121,49 @@
{{end}}
</section>
{{/* ── Suchen der Agenten ─────────────────────────────────────────────── */}}
{{$s := .Data.Searches}}
<section class="abschnitt">
<h2>Suchen der Agenten in den letzten {{.Data.WindowDays}} Tagen</h2>
<span class="hilfe">Was Agenten mit <code>recall</code> gesucht haben. Suchen ohne Treffer
zeigen entweder Wissen, das fehlt, oder Wissen, das unter anderen Wörtern dasteht.</span>
{{if $s.Total}}
<div class="kennzahlen">
<div class="kennzahl">
<span class="kennzahl-wert">{{$s.Total}}</span>
<span class="kennzahl-name">Suchen</span>
</div>
<div class="kennzahl">
<span class="kennzahl-wert">{{$s.EmptyPercent}} %</span>
<span class="kennzahl-name">ohne Treffer ({{$s.Empty}})</span>
</div>
<div class="kennzahl">
<span class="kennzahl-wert">{{$s.SynonymPercent}} %</span>
<span class="kennzahl-name">mit Synonymen</span>
</div>
</div>
{{if .Data.EmptySearches}}
<p><strong>Ohne Treffer</strong>, häufigste zuerst.
<span class="hilfe">Steht das Gesuchte unter anderen Wörtern da, hilft eine Zeile
„Auch gesucht als: …“ im Eintrag. Fehlt es, ist das ein Kandidat für einen
neuen Eintrag.</span></p>
<ul class="kompakt-liste">
{{range .Data.EmptySearches}}
<li>
„{{.Query}}“
{{if .Synonyms}}<span class="hilfe">(auch: {{.Synonyms}})</span>{{end}}
<span class="hilfe">· {{.Count}}× · zuletzt {{datum .Last}}</span>
</li>
{{end}}
</ul>
{{end}}
{{else}}
<p class="hilfe">In den letzten {{.Data.WindowDays}} Tagen hat kein Agent gesucht.</p>
{{end}}
</section>
{{/* ── Verweise ins Leere ─────────────────────────────────────────────── */}}
{{if .Data.Missing}}
<section class="abschnitt">