From 1d07485e728712a6418818c953f92334f8e8149b Mon Sep 17 00:00:00 2001 From: Thomas Adamek Date: Wed, 14 Oct 2026 23:36:24 +0200 Subject: [PATCH] Ausbaustufe mit Suchoptimierung, Bugfixes und UI Anpassungen --- AGENTEN-GUIDE.md | 67 ++- AUSBAUSTUFEN.md | 149 ++++++- BETRIEB.md | 2 +- LASTTEST.md | 22 + README.md | 122 +++++- REPOMAP.md | 51 ++- internal/entry/entry.go | 110 ++++- internal/entry/entry_test.go | 74 ++++ internal/hilfe/seiten/begriffe.md | 13 +- internal/hilfe/seiten/mcp-einrichten.md | 7 +- internal/hilfe/seiten/mit-agenten-arbeiten.md | 70 +++- internal/hilfe/seiten/weboberflaeche.md | 22 +- internal/hilfe/seiten/zugaenge.md | 6 + internal/mcpserver/dubletten.go | 118 ++++++ internal/mcpserver/gleichzeitig_test.go | 6 +- internal/mcpserver/kuratiert.go | 72 ++++ internal/mcpserver/mcpserver_test.go | 280 ++++++++++++- internal/mcpserver/server.go | 49 ++- internal/mcpserver/tools.go | 119 +++++- internal/search/bench_test.go | 48 +++ internal/search/index.go | 149 ++++--- internal/search/registry.go | 21 +- internal/search/registry_test.go | 26 ++ internal/search/search_test.go | 47 ++- internal/search/varianten.go | 387 ++++++++++++++++++ internal/search/varianten_test.go | 277 +++++++++++++ internal/store/audit.go | 25 ++ .../store/migrations/006_suchprotokoll.sql | 42 ++ internal/store/nutzung.go | 57 +++ internal/store/nutzung_test.go | 44 ++ internal/store/papierkorb.go | 45 ++ internal/store/papierkorb_test.go | 47 +++ internal/store/suchprotokoll.go | 140 +++++++ internal/store/suchprotokoll_test.go | 127 ++++++ internal/web/handlers_entries.go | 97 ++++- internal/web/handlers_nutzung.go | 30 ++ internal/web/server.go | 1 + internal/web/web_test.go | 170 ++++++++ last_test.go | 6 + main.go | 15 +- templates/entries.html | 7 + templates/nutzung.html | 65 +++ 42 files changed, 3072 insertions(+), 160 deletions(-) create mode 100644 internal/mcpserver/dubletten.go create mode 100644 internal/mcpserver/kuratiert.go create mode 100644 internal/search/bench_test.go create mode 100644 internal/search/varianten.go create mode 100644 internal/search/varianten_test.go create mode 100644 internal/store/migrations/006_suchprotokoll.sql create mode 100644 internal/store/suchprotokoll.go create mode 100644 internal/store/suchprotokoll_test.go diff --git a/AGENTEN-GUIDE.md b/AGENTEN-GUIDE.md index 574a00d..79ef5cb 100644 --- a/AGENTEN-GUIDE.md +++ b/AGENTEN-GUIDE.md @@ -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//audit` — jeder Schreibvorgang mit Zugang und Zeit. +**Nutzung:** `/p//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 diff --git a/AUSBAUSTUFEN.md b/AUSBAUSTUFEN.md index 4121a40..bce787e 100644 --- a/AUSBAUSTUFEN.md +++ b/AUSBAUSTUFEN.md @@ -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: ` — 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. diff --git a/BETRIEB.md b/BETRIEB.md index 007e671..603f0d2 100644 --- a/BETRIEB.md +++ b/BETRIEB.md @@ -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/.db`: Projekte, Einträge, Keys, Protokoll dieses Workspace | +| Ein Workspace | `DATA_DIR/workspaces/.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//` von `scripts/backup.sh`, siehe README „Datensicherung“ | diff --git a/LASTTEST.md b/LASTTEST.md index 1c90487..46f8d2f 100644 --- a/LASTTEST.md +++ b/LASTTEST.md @@ -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. diff --git a/README.md b/README.md index 8428afa..60e469a 100644 --- a/README.md +++ b/README.md @@ -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/ + └── .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, diff --git a/REPOMAP.md b/REPOMAP.md index a39df2c..ee62bf2 100644 --- a/REPOMAP.md +++ b/REPOMAP.md @@ -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 diff --git a/internal/entry/entry.go b/internal/entry/entry.go index ff32f6b..ac2a371 100644 --- a/internal/entry/entry.go +++ b/internal/entry/entry.go @@ -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] + "" + sicher[idx:idx+len(nadel)] + "" + 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 { diff --git a/internal/entry/entry_test.go b/internal/entry/entry_test.go index 32bdf27..2d33068 100644 --- a/internal/entry/entry_test.go +++ b/internal/entry/entry_test.go @@ -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, "deployen") { + 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) + } + } +} diff --git a/internal/hilfe/seiten/begriffe.md b/internal/hilfe/seiten/begriffe.md index a096385..4fa6adf 100644 --- a/internal/hilfe/seiten/begriffe.md +++ b/internal/hilfe/seiten/begriffe.md @@ -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. diff --git a/internal/hilfe/seiten/mcp-einrichten.md b/internal/hilfe/seiten/mcp-einrichten.md index a700b6e..942cec6 100644 --- a/internal/hilfe/seiten/mcp-einrichten.md +++ b/internal/hilfe/seiten/mcp-einrichten.md @@ -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. diff --git a/internal/hilfe/seiten/mit-agenten-arbeiten.md b/internal/hilfe/seiten/mit-agenten-arbeiten.md index 0e326fb..1b3dc61 100644 --- a/internal/hilfe/seiten/mit-agenten-arbeiten.md +++ b/internal/hilfe/seiten/mit-agenten-arbeiten.md @@ -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. diff --git a/internal/hilfe/seiten/weboberflaeche.md b/internal/hilfe/seiten/weboberflaeche.md index 0893411..648b643 100644 --- a/internal/hilfe/seiten/weboberflaeche.md +++ b/internal/hilfe/seiten/weboberflaeche.md @@ -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 diff --git a/internal/hilfe/seiten/zugaenge.md b/internal/hilfe/seiten/zugaenge.md index 2cbdcff..11c31ab 100644 --- a/internal/hilfe/seiten/zugaenge.md +++ b/internal/hilfe/seiten/zugaenge.md @@ -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 diff --git a/internal/mcpserver/dubletten.go b/internal/mcpserver/dubletten.go new file mode 100644 index 0000000..93acd73 --- /dev/null +++ b/internal/mcpserver/dubletten.go @@ -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 +} diff --git a/internal/mcpserver/gleichzeitig_test.go b/internal/mcpserver/gleichzeitig_test.go index c80c0a6..1f947f1 100644 --- a/internal/mcpserver/gleichzeitig_test.go +++ b/internal/mcpserver/gleichzeitig_test.go @@ -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 { diff --git a/internal/mcpserver/kuratiert.go b/internal/mcpserver/kuratiert.go new file mode 100644 index 0000000..0c975d4 --- /dev/null +++ b/internal/mcpserver/kuratiert.go @@ -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 +} diff --git a/internal/mcpserver/mcpserver_test.go b/internal/mcpserver/mcpserver_test.go index 0c19d77..36fbd67 100644 --- a/internal/mcpserver/mcpserver_test.go +++ b/internal/mcpserver/mcpserver_test.go @@ -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) + } +} diff --git a/internal/mcpserver/server.go b/internal/mcpserver/server.go index b8caeb5..d42a6fc 100644 --- a/internal/mcpserver/server.go +++ b/internal/mcpserver/server.go @@ -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 { diff --git a/internal/mcpserver/tools.go b/internal/mcpserver/tools.go index cdb1471..390b6da 100644 --- a/internal/mcpserver/tools.go +++ b/internal/mcpserver/tools.go @@ -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: diff --git a/internal/search/bench_test.go b/internal/search/bench_test.go new file mode 100644 index 0000000..5f3e1b4 --- /dev/null +++ b/internal/search/bench_test.go @@ -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) + } +} diff --git a/internal/search/index.go b/internal/search/index.go index 2d6b3cb..cabedb2 100644 --- a/internal/search/index.go +++ b/internal/search/index.go @@ -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 -} diff --git a/internal/search/registry.go b/internal/search/registry.go index 2c86d5c..48d63ab 100644 --- a/internal/search/registry.go +++ b/internal/search/registry.go @@ -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)) } } diff --git a/internal/search/registry_test.go b/internal/search/registry_test.go index 52fd85a..3aa1c09 100644 --- a/internal/search/registry_test.go +++ b/internal/search/registry_test.go @@ -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)) + } +} diff --git a/internal/search/search_test.go b/internal/search/search_test.go index 02cc044..7241c11 100644 --- a/internal/search/search_test.go +++ b/internal/search/search_test.go @@ -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) + } +} diff --git a/internal/search/varianten.go b/internal/search/varianten.go new file mode 100644 index 0000000..5587677 --- /dev/null +++ b/internal/search/varianten.go @@ -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) +} diff --git a/internal/search/varianten_test.go b/internal/search/varianten_test.go new file mode 100644 index 0000000..1a259a3 --- /dev/null +++ b/internal/search/varianten_test.go @@ -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) + } + } +} diff --git a/internal/store/audit.go b/internal/store/audit.go index 18bf360..c83c502 100644 --- a/internal/store/audit.go +++ b/internal/store/audit.go @@ -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. // diff --git a/internal/store/migrations/006_suchprotokoll.sql b/internal/store/migrations/006_suchprotokoll.sql new file mode 100644 index 0000000..b71d734 --- /dev/null +++ b/internal/store/migrations/006_suchprotokoll.sql @@ -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); diff --git a/internal/store/nutzung.go b/internal/store/nutzung.go index 18eb55e..d1863eb 100644 --- a/internal/store/nutzung.go +++ b/internal/store/nutzung.go @@ -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() +} diff --git a/internal/store/nutzung_test.go b/internal/store/nutzung_test.go index 51b28f6..8955cc0 100644 --- a/internal/store/nutzung_test.go +++ b/internal/store/nutzung_test.go @@ -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) + } +} diff --git a/internal/store/papierkorb.go b/internal/store/papierkorb.go index 8aa5181..695b969 100644 --- a/internal/store/papierkorb.go +++ b/internal/store/papierkorb.go @@ -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 diff --git a/internal/store/papierkorb_test.go b/internal/store/papierkorb_test.go index 0f76140..8c3dd8f 100644 --- a/internal/store/papierkorb_test.go +++ b/internal/store/papierkorb_test.go @@ -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) + } +} diff --git a/internal/store/suchprotokoll.go b/internal/store/suchprotokoll.go new file mode 100644 index 0000000..4a6fc71 --- /dev/null +++ b/internal/store/suchprotokoll.go @@ -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() +} diff --git a/internal/store/suchprotokoll_test.go b/internal/store/suchprotokoll_test.go new file mode 100644 index 0000000..8ecea7c --- /dev/null +++ b/internal/store/suchprotokoll_test.go @@ -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) + } +} diff --git a/internal/web/handlers_entries.go b/internal/web/handlers_entries.go index 74e70b6..801daec 100644 --- a/internal/web/handlers_entries.go +++ b/internal/web/handlers_entries.go @@ -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 ──────────────────────────────────────────────────────── diff --git a/internal/web/handlers_nutzung.go b/internal/web/handlers_nutzung.go index 2995306..3b22d94 100644 --- a/internal/web/handlers_nutzung.go +++ b/internal/web/handlers_nutzung.go @@ -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 diff --git a/internal/web/server.go b/internal/web/server.go index 47b2bc8..193f796 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -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)) diff --git a/internal/web/web_test.go b/internal/web/web_test.go index a2ef3bf..4d01c70 100644 --- a/internal/web/web_test.go +++ b/internal/web/web_test.go @@ -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: "", 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, "") { + 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) { diff --git a/last_test.go b/last_test.go index 3b8b8f8..afe9aa1 100644 --- a/last_test.go +++ b/last_test.go @@ -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" diff --git a/main.go b/main.go index c328e12..f1e2035 100644 --- a/main.go +++ b/main.go @@ -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 { diff --git a/templates/entries.html b/templates/entries.html index ff86480..1711f55 100644 --- a/templates/entries.html +++ b/templates/entries.html @@ -41,6 +41,7 @@
{{if .Data.Tag}}{{end}} + {{if .Data.Query}}{{end}} {{end}}
    @@ -76,6 +77,12 @@ + {{/* 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. */}} + {{end}} diff --git a/templates/nutzung.html b/templates/nutzung.html index f5d06ad..7054cb2 100644 --- a/templates/nutzung.html +++ b/templates/nutzung.html @@ -32,6 +32,28 @@ +{{/* ── 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}} +
    +

    Zur Prüfung: kuratiert, dann von Agenten geändert

    + 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. +
      + {{range .Data.CuratedOverrides}} +
    • + {{.Title}} + · {{.Actor}} · {{datum .At}} + {{if .Reason}}
      „{{.Reason}}“{{end}} +
    • + {{end}} +
    +
    +{{end}} + {{/* ── Schreibvorgänge ─────────────────────────────────────────────────── */}}

    Schreibvorgänge der letzten {{.Data.WindowDays}} Tage

    @@ -99,6 +121,49 @@ {{end}}
    +{{/* ── Suchen der Agenten ─────────────────────────────────────────────── */}} +{{$s := .Data.Searches}} +
    +

    Suchen der Agenten in den letzten {{.Data.WindowDays}} Tagen

    + Was Agenten mit recall gesucht haben. Suchen ohne Treffer + zeigen entweder Wissen, das fehlt, oder Wissen, das unter anderen Wörtern dasteht. + + {{if $s.Total}} +
    +
    + {{$s.Total}} + Suchen +
    +
    + {{$s.EmptyPercent}} % + ohne Treffer ({{$s.Empty}}) +
    +
    + {{$s.SynonymPercent}} % + mit Synonymen +
    +
    + + {{if .Data.EmptySearches}} +

    Ohne Treffer, häufigste zuerst. + 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.

    +
      + {{range .Data.EmptySearches}} +
    • + „{{.Query}}“ + {{if .Synonyms}}(auch: {{.Synonyms}}){{end}} + · {{.Count}}× · zuletzt {{datum .Last}} +
    • + {{end}} +
    + {{end}} + {{else}} +

    In den letzten {{.Data.WindowDays}} Tagen hat kein Agent gesucht.

    + {{end}} +
    + {{/* ── Verweise ins Leere ─────────────────────────────────────────────── */}} {{if .Data.Missing}}