Ausbaustufe mit Suchoptimierung, Bugfixes und UI Anpassungen
This commit is contained in:
parent
71c7e9bf5b
commit
1d07485e72
42 changed files with 3072 additions and 160 deletions
|
|
@ -34,11 +34,11 @@ und im Protokoll.
|
|||
|
||||
| Werkzeug | Parameter | Rolle |
|
||||
|---|---|---|
|
||||
| `recall` | `query`, `tags?`, `limit?` | read |
|
||||
| `recall` | `query`, `synonyms?`, `tags?`, `limit?` | read |
|
||||
| `get` | `slug` | read |
|
||||
| `list` | `tag?`, `curated?`, `limit?` | read |
|
||||
| `related` | `slug`, `distance?`, `limit?` | read |
|
||||
| `remember` | `title`, `content`, `slug?`, `tags?`, `expected_version?` | write |
|
||||
| `remember` | `title`, `content`, `slug?`, `tags?`, `expected_version?`, `force?`, `override_curated?`, `reason?` | write |
|
||||
| `forget` | `slug` | write |
|
||||
|
||||
---
|
||||
|
|
@ -56,8 +56,28 @@ Deshalb der folgende Block.
|
|||
|
||||
## 4. Projektprompt
|
||||
|
||||
In `CLAUDE.md`, `.clinerules`, „Custom Instructions" oder den Systemprompt der
|
||||
eigenen Anwendung:
|
||||
### Wohin der Block gehört
|
||||
|
||||
| Werkzeug | im Projekt | persönlich, für alle Projekte |
|
||||
|---|---|---|
|
||||
| OpenCode | `AGENTS.md` (ersatzweise `CLAUDE.md`) | `~/.config/opencode/AGENTS.md` |
|
||||
| Cline | `AGENTS.md` oder `.clinerules/` | `~/Documents/Cline/Rules/` |
|
||||
| Claude Code | `CLAUDE.md` | `~/.claude/CLAUDE.md` |
|
||||
| eigene Anwendung | Systemprompt | |
|
||||
|
||||
**Mehrere Werkzeuge im Team:** Den Block in **eine** `AGENTS.md` im
|
||||
Projektverzeichnis schreiben. OpenCode und Cline lesen sie direkt. Claude Code
|
||||
liest `AGENTS.md` nur, wenn es keine `CLAUDE.md` gibt (und erst in neueren
|
||||
Fassungen). Sicher ist deshalb: in die `CLAUDE.md` die Zeile `@AGENTS.md`
|
||||
schreiben, gegebenenfalls als einzige Zeile. So steht der Block an genau einer
|
||||
Stelle und läuft nicht auseinander.
|
||||
|
||||
**Persönlich statt im Projekt:** Ist Munin global eingerichtet (Claude Code mit
|
||||
`--scope user`, OpenCode in `~/.config/opencode/opencode.json`), passt der Block
|
||||
auch in die persönliche Datei. Dann gilt er überall, ohne dass jedes Repository
|
||||
ihn braucht.
|
||||
|
||||
### Der Block
|
||||
|
||||
```markdown
|
||||
## Munin
|
||||
|
|
@ -66,7 +86,10 @@ Für dieses Projekt gibt es einen Munin über MCP (Werkzeuge: recall,
|
|||
get, list, related, remember, forget). Er ist das gemeinsame Gedächtnis des Teams.
|
||||
|
||||
LESEN — vor jeder Antwort zu projektspezifischen Fragen:
|
||||
- Zuerst `recall` mit der Frage aufrufen, nicht mit Stichworten.
|
||||
- Zuerst `recall` mit der Frage aufrufen, nicht mit Stichworten. Unter
|
||||
`synonyms` weitere Namen für das Thema mitgeben (Synonyme, Übersetzung,
|
||||
Abkürzungen) — die Suche vergleicht Wörter, keine Bedeutungen.
|
||||
- Findet `recall` nichts, mit anderen Begriffen erneut suchen.
|
||||
- Passenden Treffer mit `get` vollständig laden.
|
||||
- Für mehr Kontext mit `related` die verknüpften Einträge ansehen.
|
||||
- Einträge mit `"stale": true` sind über ein Jahr alt: vor Verwendung prüfen
|
||||
|
|
@ -79,6 +102,11 @@ SCHREIBEN — sobald etwas festgehalten werden sollte:
|
|||
- IMMER zuerst `recall`, ob es zum Thema schon einen Eintrag gibt.
|
||||
- Gibt es einen: mit DESSEN slug `remember` aufrufen. Das aktualisiert ihn.
|
||||
- Nur wenn wirklich nichts passt, einen neuen slug wählen.
|
||||
- Meldet `remember` `judgment_required`, die `candidates` prüfen: Passt einer,
|
||||
ihn fortschreiben. Nur bei wirklich anderem Thema mit `force: true` anlegen.
|
||||
- Einträge mit `"curated": true` hat ein Mensch geprüft: Sie haben Vorrang.
|
||||
Nur ändern, wenn sie nachweislich nicht mehr stimmen — dann mit
|
||||
`override_curated: true` und einer Begründung unter `reason`.
|
||||
- Vor dem Aktualisieren `get` aufrufen und die gelieferte `version` als
|
||||
`expected_version` mitgeben.
|
||||
|
||||
|
|
@ -91,6 +119,8 @@ WAS festgehalten wird:
|
|||
WIE festgehalten wird:
|
||||
- Destilliert, nicht als Protokoll. Ein Eintrag beantwortet eine Frage.
|
||||
- Verwandtes mit [[bezeichner]] verknüpfen.
|
||||
- Am Ende eine Zeile `Auch gesucht als: …` mit 2–5 Begriffen, die nicht im
|
||||
Text stehen, unter denen man den Eintrag aber suchen würde.
|
||||
- Schlagworte sparsam: 1–3 aus dem bestehenden Bestand (`list` zeigt sie).
|
||||
```
|
||||
|
||||
|
|
@ -158,6 +188,11 @@ schreibe zurück — mit expected_version aus dem get.
|
|||
| Neuer slug für vorhandenes Thema | slug-Regeln oben; `recall` liefert den vorhandenen |
|
||||
| Zeitstempel im slug | verbieten — Fassungen zählt das System |
|
||||
| Jede Erkenntnis als eigener Eintrag | „Ein Eintrag beantwortet eine Frage" |
|
||||
| Thema unter anderem Namen gesucht, nichts gefunden | `synonyms` bei `recall`, „Auch gesucht als" im Eintrag |
|
||||
|
||||
Als letzte Hürde prüft `remember` beim Anlegen, ob es schon einen Eintrag mit
|
||||
ähnlichem Titel gibt, und fragt dann nach (`judgment_required` mit
|
||||
`candidates`). Erst `force: true` legt trotzdem an.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -165,14 +200,27 @@ schreibe zurück — mit expected_version aus dem get.
|
|||
|
||||
**Protokoll:** `/p/<projekt>/audit` — jeder Schreibvorgang mit Zugang und Zeit.
|
||||
|
||||
**Nutzung:** `/p/<projekt>/usage` — Schreibvorgänge der letzten 30 Tage
|
||||
(angelegt/aktualisiert), *Zur Prüfung* (kuratierte Einträge, die ein Agent
|
||||
geändert hat), Suchen der Agenten (Anteil ohne Treffer, Anteil mit Synonymen,
|
||||
häufigste Anfragen ohne Treffer), Verweise ins Leere.
|
||||
|
||||
**Faustregel:** Nach ein paar Wochen sollte das Verhältnis von Aktualisierungen zu
|
||||
Neuanlagen steigen. Viele Neuanlagen bei ähnlichen Themen heißt: Der Projektprompt
|
||||
greift nicht.
|
||||
greift nicht. Ein niedriger Anteil an Suchen mit Synonymen heißt dasselbe für
|
||||
die Suche.
|
||||
|
||||
**In der Oberfläche:** Einträge mit Herkunft „Agent" und ohne Marke „kuratiert"
|
||||
sind ungeprüfte Beobachtungen. Wer sie durchsieht und bestätigt, setzt den Haken
|
||||
„Kuratiert" — Agenten sehen das Feld über `recall` und `get`.
|
||||
|
||||
**Was „kuratiert" bewirkt:** Kuratierte Einträge stehen in der Suche bei sonst
|
||||
gleicher Relevanz weiter vorn (Faktor 1,2). Ein Agent kann sie nicht nebenbei
|
||||
überschreiben: `remember` meldet erst `judgment_required`, geschrieben wird nur
|
||||
mit `override_curated: true` und einer Begründung (`reason`). Dabei fällt der
|
||||
Haken weg, und der Eintrag steht auf der Nutzungsseite unter *Zur Prüfung*, bis
|
||||
ein Mensch ihn wieder kuratiert.
|
||||
|
||||
---
|
||||
|
||||
## 9. Rollen
|
||||
|
|
@ -190,8 +238,11 @@ die Einträge auch durchsieht.
|
|||
|
||||
## 10. Grenzen
|
||||
|
||||
- **Suche ist wortbasiert.** „wie deployen wir" findet „Release-Prozess" nicht.
|
||||
Im Zweifel `list` für den Überblick.
|
||||
- **Suche ist wortbasiert.** Sie gleicht Wortanfänge, Endungen und
|
||||
zusammengesetzte Wörter aus („deployen" findet „Deployment", „prozess" findet
|
||||
„Releaseprozess"), aber keine Bedeutungen: „wie deployen wir" findet
|
||||
„Release-Prozess" nur, wenn `release` unter `synonyms` mitgeht oder im Eintrag
|
||||
unter „Auch gesucht als" steht. Im Zweifel `list` für den Überblick.
|
||||
- **`forget` legt in den Papierkorb.** Der Eintrag ist für Agenten sofort
|
||||
unsichtbar, ein Mensch kann ihn 30 Tage lang in der Weboberfläche
|
||||
zurückholen. Agenten können weder wiederherstellen noch den Papierkorb
|
||||
|
|
|
|||
149
AUSBAUSTUFEN.md
149
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: <Begründung>` — zählt in der Nutzung als
|
||||
Aktualisierung.
|
||||
- Die Nutzungsseite listet solche Einträge unter *Zur Prüfung*, bis ein Mensch
|
||||
den Haken neu setzt (`store.CuratedOverrides`, keine eigene Tabelle).
|
||||
- instructions: Kuratiertes hat Vorrang vor eigenen Annahmen.
|
||||
- Suche: kuratierte Einträge mit Faktor 1,2 (`curatedBoost` in
|
||||
`internal/search/registry.go`) — genug für den Gleichstand, zu wenig, um
|
||||
einen klar besser passenden Eintrag zu verdrängen.
|
||||
|
||||
**Bewusst nicht:** Sperren mit Änderungsvorschlägen. Erst sinnvoll, wenn die
|
||||
Liste *Zur Prüfung* zeigt, dass kuratierte Einträge oft falsch überschrieben
|
||||
werden.
|
||||
|
||||
**Offen:** `forget` auf kuratierte Einträge hat noch keine Hürde. Weniger
|
||||
dringend — der Papierkorb hält 30 Tage, und nur Menschen holen zurück —, aber
|
||||
dasselbe Muster würde passen.
|
||||
|
||||
### 3.4 Bezeichner vorschlagen lassen
|
||||
|
||||
Der slug ist der Schlüssel gegen Dubletten (siehe 3.3), aber der Agent muss ihn
|
||||
|
|
@ -984,10 +1092,27 @@ Hauptarbeit bei den Tests: nachweisen, dass kein Key mehr sieht als heute.
|
|||
| ✅ Echte Server-Adresse auf der Zugangsseite (5.2) | Konfiguration direkt kopierbar | gering |
|
||||
| ✅ Mehr Markdown in Einträgen (5.3) | Tabellen, Checklisten, Durchstreichen | gering |
|
||||
| ✅ Hilfe an Ort und Stelle verlinken (5.4) | Hilfe dort, wo man hängt | gering |
|
||||
| ✅ Ausgewählte Einträge löschen (5.5) | Aufräumen ohne jeden Eintrag einzeln zu öffnen | gering |
|
||||
|
||||
Nichts davon ist dringend: Die Oberfläche ist bewusst der zweite Zugang, MCP ist
|
||||
der erste.
|
||||
|
||||
### 5.5 Ausgewählte Einträge löschen — ✅ umgesetzt (2026-10-14)
|
||||
|
||||
In der Sammelleiste der Eintragsliste steht neben *Anwenden* der Knopf
|
||||
*In den Papierkorb* (`POST /p/{projekt}/entries/bulk-delete`, Schreibrecht).
|
||||
|
||||
- Selbes Formular wie die Schlagwort-Sammelbearbeitung, anderer Knopf
|
||||
(`formaction`). Er steht hinter *Anwenden*, weil Enter in einem Textfeld
|
||||
den ersten Knopf auslöst. Rückfrage über `data-confirm` (static/confirm.js).
|
||||
- `store.MoveEntriesToTrash`: eine Transaktion, Auswahl über die interne ID;
|
||||
fremde und schon gelöschte IDs werden übersprungen wie bei
|
||||
`BulkUpdateTags`.
|
||||
- Je Eintrag eine Protokollzeile `entry_delete`, damit Nutzungsübersicht und
|
||||
Protokoll jede Löschung einzeln sehen.
|
||||
- Die Weiterleitung behält Schlagwortfilter und Suche (gilt jetzt auch für die
|
||||
Schlagwort-Sammelbearbeitung).
|
||||
|
||||
### 5.1 Fassung wiederherstellen — ✅ umgesetzt
|
||||
|
||||
Der Verlauf (3.6) zeigt alte Fassungen, aber zurückholen heißt heute: Text aus
|
||||
|
|
@ -1170,10 +1295,12 @@ Als Nächstes:
|
|||
**Bewusst entscheiden, nicht nebenbei**
|
||||
|
||||
7. **`instructions` schärfen** (3.1) — braucht ein paar Wochen echter Nutzung.
|
||||
Das Zugriffsprotokoll und 4.6 liefern die Daten dafür: Wie viele
|
||||
`remember`-Aufrufe legen neu an, wie viele aktualisieren?
|
||||
8. **Kandidaten bei Dubletten** (3.3) und **Bezeichner vorschlagen** (3.4) —
|
||||
beide ändern den MCP-Vertrag.
|
||||
Das Zugriffsprotokoll, 4.6 und seit 2026-10-14 das Suchprotokoll liefern
|
||||
die Daten dafür: Wie viele `remember`-Aufrufe legen neu an, wie viele
|
||||
Suchen bleiben ohne Treffer?
|
||||
8. ~~**Kandidaten bei Dubletten** (3.3)~~ — erledigt 2026-10-14, zusammen mit
|
||||
dem Schutz kuratierter Einträge (3.3a). **Bezeichner vorschlagen** (3.4)
|
||||
bleibt offen; es ändert den MCP-Vertrag.
|
||||
9. ~~**Admin je Workspace** (4.8)~~ — erledigt 2026-09-30, ohne Keycloak
|
||||
(siehe die Entscheidung dort). **Keycloak** (4.3) bleibt offen, bis es
|
||||
gebraucht wird.
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ statt `/data`, und die Ausgabe des Prozesses statt `docker logs`.
|
|||
| Was | Wo |
|
||||
|---|---|
|
||||
| Kontroll-Datenbank | `DATA_DIR/control.db`: Workspaces, welcher Key zu welchem Workspace gehört, Workspace-Admins |
|
||||
| Ein Workspace | `DATA_DIR/workspaces/<workspace-id>.db`: Projekte, Einträge, Keys, Protokoll dieses Workspace |
|
||||
| Ein Workspace | `DATA_DIR/workspaces/<workspace-id>.db`: Projekte, Einträge, Keys, Protokoll und Suchprotokoll dieses Workspace |
|
||||
| Zugehörige Hilfsdateien | `…db-wal` und `…db-shm` neben jeder Datenbank. Sie gehören im laufenden Betrieb dazu, **nie einzeln löschen, solange der Server läuft** |
|
||||
| Logs | Standardausgabe des Prozesses: `docker logs munin` |
|
||||
| Sicherungen | `BACKUP_DIR/<zeitstempel>/` von `scripts/backup.sh`, siehe README „Datensicherung“ |
|
||||
|
|
|
|||
22
LASTTEST.md
22
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.
|
||||
|
|
|
|||
122
README.md
122
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/
|
||||
└── <id>.db je Workspace: Projekte, Einträge, Keys, Protokolle, Papierkorb
|
||||
```
|
||||
|
||||
Neben jeder Datenbank liegen im Betrieb `…-wal` und `…-shm`. Sie gehören dazu
|
||||
und dürfen nicht einzeln gelöscht werden. Welche Pfade der Server benutzt,
|
||||
steht in der Startzeile des Logs (`Workspaces: …`).
|
||||
|
||||
## Grundbegriffe
|
||||
|
||||
| Begriff | Bedeutung |
|
||||
|
|
@ -191,8 +280,8 @@ Konfiguration.
|
|||
geschrieben hat, und ein einzelner Zugang lässt sich zurückziehen, ohne alle
|
||||
anderen zu stören.
|
||||
|
||||
Verbindung prüfen: Der Agent sollte `recall`, `get`, `list`, `remember` und
|
||||
`forget` anbieten. Tut er das nicht, hilft ein Blick ins Zugriffsprotokoll des
|
||||
Verbindung prüfen: Der Agent sollte `recall`, `get`, `list`, `related`,
|
||||
`remember` und `forget` anbieten. Tut er das nicht, hilft ein Blick ins Zugriffsprotokoll des
|
||||
Servers — ein `401` dort heißt falscher oder zurückgezogener Key, gar kein Eintrag
|
||||
heißt, dass der Client den Server nie aufgerufen hat.
|
||||
|
||||
|
|
@ -205,11 +294,11 @@ Wie man Agenten so anspricht, dass sie den Speicher auch nutzen:
|
|||
|
||||
| Werkzeug | Rolle | Zweck |
|
||||
|---|---|---|
|
||||
| `recall` | read | Durchsucht den Speicher. Der Haupteinstieg — auch vor dem Schreiben. |
|
||||
| `recall` | read | Durchsucht den Speicher, auf Wunsch mit `synonyms`. Der Haupteinstieg — auch vor dem Schreiben. |
|
||||
| `get` | read | Lädt einen Eintrag vollständig, samt Verweisen und Fassungsnummer. |
|
||||
| `list` | read | Überblick: zuletzt geändert, alle Schlagworte. |
|
||||
| `related` | read | Über `[[Verweise]]` verknüpfte Einträge, direkt und über einen Zwischeneintrag. |
|
||||
| `remember` | write | Legt einen Eintrag an oder aktualisiert ihn. |
|
||||
| `remember` | write | Legt einen Eintrag an oder aktualisiert ihn. Fragt bei ähnlichen und bei kuratierten Einträgen nach (siehe unten). |
|
||||
| `forget` | write | Legt einen Eintrag in den Papierkorb (30 Tage wiederherstellbar, nur durch Menschen). |
|
||||
|
||||
Der Server schickt jedem Client eine **Anleitung** (`instructions`) mit, die das
|
||||
|
|
@ -237,6 +326,22 @@ Ist das Budget erschöpft, bekommt das Modell keinen nackten Fehler, sondern die
|
|||
Anweisung, abzubrechen und das Limit zu melden — sonst versucht es sofort wieder
|
||||
oder weicht auf ein anderes Werkzeug aus.
|
||||
|
||||
### Rückfragen statt Schreiben
|
||||
|
||||
`remember` schreibt in zwei Fällen nicht sofort, sondern antwortet mit
|
||||
`judgment_required` — als Antwort, nicht als Fehler, damit das Modell sie liest
|
||||
und entscheidet:
|
||||
|
||||
| Fall | Antwort | Trotzdem schreiben mit |
|
||||
|---|---|---|
|
||||
| Neuer Bezeichner, aber ähnlicher Titel vorhanden | `candidates` (bis zu fünf) | `force: true` |
|
||||
| Eintrag ist kuratiert | Erklärung | `override_curated: true` und `reason` |
|
||||
|
||||
Im zweiten Fall fällt der Haken „kuratiert" weg, das Protokoll vermerkt die
|
||||
Begründung, und die Nutzungsseite zeigt den Eintrag unter *Zur Prüfung*, bis ein
|
||||
Mensch ihn wieder kuratiert. Einzelheiten: `internal/mcpserver/dubletten.go`
|
||||
und `kuratiert.go`.
|
||||
|
||||
### Gleichzeitiges Schreiben
|
||||
|
||||
`get` liefert eine `version`. Wird sie bei `remember` als `expected_version`
|
||||
|
|
@ -264,7 +369,7 @@ Agent B: remember(…, expected_version=3) → Konflikt: neu laden, zusammenfüh
|
|||
| `/admin/export.zip` | Alle Projekte des gewählten Workspace als ZIP herunterladen (Plattform- und Workspace-Admin) |
|
||||
| `/admin/projects/{projekt}/edit` | Name, Beschreibung und Schreibbudget ändern; Projekt löschen |
|
||||
| `/admin/audit` | Protokoll über alle Projekte des gewählten Workspace |
|
||||
| `/p/{projekt}/` | Einträge durchsuchen, nach Schlagwort filtern, Schlagworte für viele Einträge auf einmal ändern |
|
||||
| `/p/{projekt}/` | Einträge durchsuchen, nach Schlagwort filtern, für viele Einträge auf einmal Schlagworte ändern oder sie in den Papierkorb legen |
|
||||
| `/p/{projekt}/new` | Neuen Eintrag anlegen (`?slug=` belegt den Bezeichner vor) |
|
||||
| `/p/{projekt}/e/{bezeichner}` | Eintrag lesen, mit Rückverweisen |
|
||||
| `/p/{projekt}/e/{bezeichner}/edit` | Eintrag bearbeiten, mit Vorschau |
|
||||
|
|
@ -273,7 +378,7 @@ Agent B: remember(…, expected_version=3) → Konflikt: neu laden, zusammenfüh
|
|||
| `/p/{projekt}/graph` | Verweise zwischen den Einträgen als Graph |
|
||||
| `/p/{projekt}/keys` | Zugänge verwalten (Rolle `admin`) |
|
||||
| `/p/{projekt}/export.zip` | Projekt als ZIP herunterladen, passt direkt in den Import (Rolle `admin`) |
|
||||
| `/p/{projekt}/usage` | Nutzung: Schreibvorgänge der letzten 30 Tage (Agenten/Menschen, neu/aktualisiert), Bestand, Verweise ins Leere, aktive Zugänge |
|
||||
| `/p/{projekt}/usage` | Nutzung: geänderte kuratierte Einträge zur Prüfung, Schreibvorgänge der letzten 30 Tage (Agenten/Menschen, neu/aktualisiert), Suchen der Agenten samt Anfragen ohne Treffer, Bestand, Verweise ins Leere, aktive Zugänge |
|
||||
| `/p/{projekt}/audit` | Protokoll des Projekts |
|
||||
| `/p/{projekt}/trash` | Papierkorb: wiederherstellen oder endgültig löschen (Schreibrecht) |
|
||||
| `/hilfe` | Eingebaute Onboarding-Hilfe für alle Rollen: Einstieg, Begriffe, MCP einrichten, Arbeiten mit Agenten, Weboberfläche, Zugänge. Die Seite für Plattform-Admins sieht nur der Plattform-Admin. |
|
||||
|
|
@ -533,6 +638,11 @@ Vorgänge werden 90 Tage aufbewahrt und danach automatisch gelöscht
|
|||
aus denen sich Personen ableiten lassen — eine Aufbewahrungsfrist ist deshalb die
|
||||
Voraussetzung dafür, es überhaupt führen zu dürfen.
|
||||
|
||||
Getrennt davon steht das **Suchprotokoll**: was Agenten mit `recall` gesucht
|
||||
und ob sie etwas gefunden haben, ausgewertet auf der Nutzungsseite. Ohne
|
||||
Adressen und Namen, aber mit dem Text der Anfragen — deshalb nur 30 Tage
|
||||
(`store.SearchLogRetention`), und es verschwindet mit dem Projekt.
|
||||
|
||||
Jeder Workspace führt sein eigenes Protokoll in seiner eigenen Datei.
|
||||
`/admin/audit` zeigt das Protokoll des gerade gewählten Workspace, nicht alle
|
||||
auf einmal. Das tägliche Aufräumen läuft über alle Workspaces, auch über die,
|
||||
|
|
|
|||
51
REPOMAP.md
51
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
|
||||
|
|
|
|||
|
|
@ -10,7 +10,10 @@ package entry
|
|||
import (
|
||||
"fmt"
|
||||
"html"
|
||||
"sort"
|
||||
"strings"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
"munin/internal/markdown"
|
||||
"munin/internal/store"
|
||||
|
|
@ -55,15 +58,17 @@ const snippetRadius = 120
|
|||
func Snippet(content, query string) string {
|
||||
text := RenderPlain(content)
|
||||
|
||||
idx := strings.Index(strings.ToLower(text), strings.ToLower(query))
|
||||
if idx == -1 {
|
||||
// Kein wörtlicher Treffer (z.B. weil der Fund über die Präfix-Suche kam) —
|
||||
// dann der Anfang des Texts als Vorschau.
|
||||
nadel := trefferwort(text, query)
|
||||
if nadel == "" {
|
||||
// Kein Wort der Anfrage steht wörtlich im Text (z.B. weil der Fund über
|
||||
// eine Endung oder ein Synonym kam) — dann der Anfang des Texts als
|
||||
// Vorschau.
|
||||
return kuerzen(text, snippetRadius+snippetRadius)
|
||||
}
|
||||
idx := strings.Index(strings.ToLower(text), strings.ToLower(nadel))
|
||||
|
||||
start := max(idx-snippetRadius, 0)
|
||||
ende := min(idx+len(query)+snippetRadius, len(text))
|
||||
ende := min(idx+len(nadel)+snippetRadius, len(text))
|
||||
|
||||
ausschnitt := strings.TrimSpace(text[start:ende])
|
||||
if start > 0 {
|
||||
|
|
@ -75,6 +80,59 @@ func Snippet(content, query string) string {
|
|||
return ausschnitt
|
||||
}
|
||||
|
||||
// minTrefferwort ist die Mindestlänge eines einzelnen Worts der Anfrage, an
|
||||
// dem sich der Ausschnitt ausrichten darf. Kürzere Wörter („wie", „an") stehen
|
||||
// in fast jedem Text und sagen nichts über den Treffer.
|
||||
const minTrefferwort = 4
|
||||
|
||||
// trefferwort bestimmt, an welcher Stelle der Ausschnitt ansetzt: das Stück
|
||||
// der Anfrage, das im Text vorkommt. "" heißt: nichts davon kommt vor.
|
||||
//
|
||||
// # Warum nicht einfach die ganze Anfrage?
|
||||
//
|
||||
// So war es früher. Agenten stellen aber ganze Fragen („wie deployen wir den
|
||||
// Dienst"), und die stehen nie wörtlich im Text. Der Ausschnitt zeigte dann
|
||||
// immer nur den Textanfang — auch wenn die eigentliche Fundstelle weiter
|
||||
// hinten lag.
|
||||
//
|
||||
// Deshalb zuerst die ganze Anfrage (in der Weboberfläche tippen Menschen oft
|
||||
// genau eine Wortfolge), danach die einzelnen Wörter, längste zuerst. Die
|
||||
// Länge ist ein einfacher Ersatz für „aussagekräftig": „Zertifikatswechsel"
|
||||
// verrät mehr über die Fundstelle als „Dienst".
|
||||
func trefferwort(text, query string) string {
|
||||
query = strings.TrimSpace(query)
|
||||
if query == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
klein := strings.ToLower(text)
|
||||
if strings.Contains(klein, strings.ToLower(query)) {
|
||||
return query
|
||||
}
|
||||
|
||||
// FieldsFunc zerlegt an jedem Zeichen, für das die Funktion true liefert —
|
||||
// hier an allem, was kein Buchstabe und keine Ziffer ist.
|
||||
woerter := strings.FieldsFunc(query, func(r rune) bool {
|
||||
return !unicode.IsLetter(r) && !unicode.IsDigit(r)
|
||||
})
|
||||
|
||||
// Längste zuerst. SliceStable lässt gleich lange Wörter in der Reihenfolge
|
||||
// der Anfrage stehen — so ist das Ergebnis immer dasselbe.
|
||||
sort.SliceStable(woerter, func(i, j int) bool {
|
||||
return utf8.RuneCountInString(woerter[i]) > utf8.RuneCountInString(woerter[j])
|
||||
})
|
||||
|
||||
for _, w := range woerter {
|
||||
if utf8.RuneCountInString(w) < minTrefferwort {
|
||||
break // sortiert: alle folgenden sind noch kürzer
|
||||
}
|
||||
if strings.Contains(klein, strings.ToLower(w)) {
|
||||
return w
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// kuerzen schneidet einen Text auf höchstens n Zeichen.
|
||||
func kuerzen(text string, n int) string {
|
||||
text = strings.TrimSpace(text)
|
||||
|
|
@ -97,9 +155,14 @@ func HighlightSnippet(content, query string) string {
|
|||
return sicher
|
||||
}
|
||||
|
||||
// Im escapten Text nach dem (ebenfalls escapten) Suchbegriff suchen, damit
|
||||
// die Positionen zusammenpassen.
|
||||
nadel := html.EscapeString(query)
|
||||
// Hervorgehoben wird dasselbe Stück der Anfrage, an dem sich der
|
||||
// Ausschnitt ausgerichtet hat. Gesucht wird im escapten Text nach dem
|
||||
// ebenfalls escapten Stück, damit die Positionen zusammenpassen.
|
||||
treffer := trefferwort(ausschnitt, query)
|
||||
if treffer == "" {
|
||||
return sicher
|
||||
}
|
||||
nadel := html.EscapeString(treffer)
|
||||
idx := strings.Index(strings.ToLower(sicher), strings.ToLower(nadel))
|
||||
if idx == -1 {
|
||||
return sicher
|
||||
|
|
@ -108,6 +171,37 @@ func HighlightSnippet(content, query string) string {
|
|||
return sicher[:idx] + "<mark>" + sicher[idx:idx+len(nadel)] + "</mark>" + sicher[idx+len(nadel):]
|
||||
}
|
||||
|
||||
// auchGesuchtAlsPraefix leitet die Zeile ein, in der ein Eintrag weitere
|
||||
// Suchbegriffe nennt (siehe instructions in internal/mcpserver).
|
||||
const auchGesuchtAlsPraefix = "auch gesucht als"
|
||||
|
||||
// AuchGesuchtAls gibt die Begriffe aus der Zeile „Auch gesucht als: …" eines
|
||||
// Eintrags zurück, so wie sie dort stehen. Ohne eine solche Zeile: "".
|
||||
//
|
||||
// Agenten schreiben die Zeile nicht immer gleich. Erkannt werden deshalb auch
|
||||
// Hervorhebungen und Aufzählungszeichen davor:
|
||||
//
|
||||
// Auch gesucht als: release, rollout
|
||||
// *Auch gesucht als:* release, rollout
|
||||
// - **Auch gesucht als:** release, rollout
|
||||
//
|
||||
// Gibt es mehrere solcher Zeilen, zählt die letzte — die Anleitung bittet
|
||||
// darum, die Zeile ans Ende zu stellen.
|
||||
func AuchGesuchtAls(content string) string {
|
||||
ergebnis := ""
|
||||
for _, zeile := range strings.Split(content, "\n") {
|
||||
// Aufzählungszeichen, Zitatzeichen und Hervorhebungen am Anfang weg.
|
||||
zeile = strings.TrimLeft(strings.TrimSpace(zeile), "-*_> ")
|
||||
if !strings.HasPrefix(strings.ToLower(zeile), auchGesuchtAlsPraefix) {
|
||||
continue
|
||||
}
|
||||
rest := zeile[len(auchGesuchtAlsPraefix):]
|
||||
rest = strings.TrimLeft(rest, ":*_ ")
|
||||
ergebnis = strings.TrimSpace(rest)
|
||||
}
|
||||
return ergebnis
|
||||
}
|
||||
|
||||
// TitleFromContent rät einen Titel aus dem Inhalt — die erste Überschrift oder
|
||||
// die erste Zeile. Wird verwendet, wenn ein Agent keinen Titel mitgeschickt hat.
|
||||
func TitleFromContent(content string) string {
|
||||
|
|
|
|||
|
|
@ -97,6 +97,60 @@ func TestSnippetWithoutMatch(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
// TestSnippetBeiGanzerFrage prüft den Normalfall bei Agenten: Die Anfrage ist
|
||||
// eine ganze Frage, die so nie im Text steht. Der Ausschnitt muss trotzdem an
|
||||
// der Fundstelle ansetzen, nicht am Textanfang.
|
||||
func TestSnippetBeiGanzerFrage(t *testing.T) {
|
||||
text := strings.Repeat("Vorlauf. ", 40) + "Der Zertifikatswechsel ist im März. " + strings.Repeat("Nachlauf. ", 40)
|
||||
|
||||
got := Snippet(text, "wie läuft der Zertifikatswechsel")
|
||||
|
||||
if !strings.Contains(got, "Zertifikatswechsel") {
|
||||
t.Errorf("der Ausschnitt setzt nicht an der Fundstelle an: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSnippetLaengstesWortZuerst prüft die Wahl zwischen mehreren Wörtern:
|
||||
// Das längere, aussagekräftigere gewinnt, auch wenn das kürzere früher steht.
|
||||
func TestSnippetLaengstesWortZuerst(t *testing.T) {
|
||||
text := "Dienst. " + strings.Repeat("Füllwort. ", 60) + "Kubernetes-Cluster hier."
|
||||
|
||||
got := Snippet(text, "dienst kubernetes")
|
||||
|
||||
if !strings.Contains(got, "Kubernetes") {
|
||||
t.Errorf("es wurde nicht das längste Wort gewählt: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrefferwort(t *testing.T) {
|
||||
text := "Wir deployen über GitHub Actions."
|
||||
|
||||
faelle := map[string]string{
|
||||
"GitHub Actions": "GitHub Actions", // ganze Anfrage kommt vor
|
||||
"wie deployen wir": "deployen",
|
||||
"wie wir": "", // nur kurze Wörter
|
||||
"gibtesnicht": "",
|
||||
"": "",
|
||||
"actions, github?": "actions", // gleich lang: Reihenfolge der Anfrage
|
||||
}
|
||||
|
||||
for anfrage, erwartet := range faelle {
|
||||
if got := trefferwort(text, anfrage); got != erwartet {
|
||||
t.Errorf("trefferwort(%q) = %q, erwartet %q", anfrage, got, erwartet)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestHighlightSnippetEinzelwort prüft, dass bei einer ganzen Frage das Wort
|
||||
// hervorgehoben wird, an dem sich der Ausschnitt ausrichtet.
|
||||
func TestHighlightSnippetEinzelwort(t *testing.T) {
|
||||
got := HighlightSnippet("Wir deployen über GitHub Actions.", "wie deployen wir")
|
||||
|
||||
if !strings.Contains(got, "<mark>deployen</mark>") {
|
||||
t.Errorf("das Fundwort wurde nicht hervorgehoben: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSnippetShortText(t *testing.T) {
|
||||
got := Snippet("Kurz.", "Kurz")
|
||||
if got != "Kurz." {
|
||||
|
|
@ -161,3 +215,23 @@ func TestTitleFromContentIsShortened(t *testing.T) {
|
|||
t.Errorf("der Titel ist %d Zeichen lang — er wurde nicht gekürzt", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
// ── Auch gesucht als ─────────────────────────────────────────────────────────
|
||||
|
||||
func TestAuchGesuchtAls(t *testing.T) {
|
||||
faelle := map[string]string{
|
||||
"Text.\n\nAuch gesucht als: release, rollout": "release, rollout",
|
||||
"Text.\n*Auch gesucht als:* release": "release",
|
||||
"- **Auch gesucht als:** release, auslieferung": "release, auslieferung",
|
||||
"auch gesucht als: klein geschrieben": "klein geschrieben",
|
||||
"Auch gesucht als: alt\nText\nAuch gesucht als: neu": "neu",
|
||||
"Ein Text ohne die Zeile.": "",
|
||||
"Wir haben auch gesucht als Team.": "", // nicht am Zeilenanfang
|
||||
}
|
||||
|
||||
for inhalt, erwartet := range faelle {
|
||||
if got := AuchGesuchtAls(inhalt); got != erwartet {
|
||||
t.Errorf("AuchGesuchtAls(%q) = %q, erwartet %q", inhalt, got, erwartet)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
118
internal/mcpserver/dubletten.go
Normal file
118
internal/mcpserver/dubletten.go
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
package mcpserver
|
||||
|
||||
// Dubletten über Ähnlichkeit erkennen (AUSBAUSTUFEN.md 3.3).
|
||||
//
|
||||
// Der Bezeichner schützt nur vor Dubletten unter GLEICHEM Namen. Wählt ein
|
||||
// Agent für ein vorhandenes Thema einen neuen Bezeichner („deployment-ablauf"
|
||||
// statt „deployment-prozess"), stünde dieselbe Erkenntnis zweimal da — und
|
||||
// die beiden Fassungen laufen mit der Zeit auseinander.
|
||||
//
|
||||
// Deshalb prüft remember vor dem Anlegen, ob es schon einen Eintrag mit
|
||||
// ähnlichem Titel gibt. Wenn ja, legt es NICHT an, sondern gibt die
|
||||
// Kandidaten zurück und lässt den Agenten entscheiden.
|
||||
//
|
||||
// # Warum zurückgeben statt selbst entscheiden?
|
||||
//
|
||||
// Ein Agent, der einen Hinweis mit Kandidaten bekommt, korrigiert sich: Er
|
||||
// lädt den passenden Eintrag und schreibt ihn fort. Einer, dessen Aufruf
|
||||
// ohne Erklärung abgelehnt wird, versucht es mit einem noch schlechteren
|
||||
// Bezeichner. Und manchmal ist ein ähnlicher Titel eben doch ein anderes
|
||||
// Thema — das weiß der Agent besser als jede Schwelle. Dafür gibt es force.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"math"
|
||||
"sort"
|
||||
|
||||
"munin/internal/entry"
|
||||
"munin/internal/search"
|
||||
"munin/internal/store"
|
||||
"munin/internal/workspace"
|
||||
)
|
||||
|
||||
const (
|
||||
// dublettenSchwelle ist der Mindestanteil gemeinsamer Titelwörter, ab dem
|
||||
// ein vorhandener Eintrag als Kandidat gilt (siehe search.Ueberdeckung).
|
||||
//
|
||||
// 0,5 heißt: Die Hälfte der Wörter stimmt überein. „Deployment-Ablauf"
|
||||
// und „Deployment-Prozess" erreichen das gerade — genau der Fall, um den
|
||||
// es geht. Dafür meldet die Prüfung auch „Server-Zugang" bei
|
||||
// „Datenbank-Zugang". Ein solcher Fehlalarm kostet den Agenten einen
|
||||
// zweiten Aufruf mit force; eine übersehene Dublette kostet auf Dauer
|
||||
// mehr.
|
||||
dublettenSchwelle = 0.5
|
||||
|
||||
// dublettenSuchtiefe: So viele Suchtreffer werden genauer verglichen.
|
||||
// Die Suche findet Kandidaten, die Überdeckung entscheidet.
|
||||
dublettenSuchtiefe = 10
|
||||
|
||||
// dublettenMaxKandidaten: So viele Kandidaten gehen höchstens zurück.
|
||||
dublettenMaxKandidaten = 5
|
||||
)
|
||||
|
||||
// pruefeDubletten sucht vorhandene Einträge, die einem neu anzulegenden
|
||||
// ähneln. Ist slug schon vergeben, wird aktualisiert statt angelegt — dann
|
||||
// gibt es nichts zu prüfen, und das Ergebnis ist leer.
|
||||
//
|
||||
// Verglichen wird der Titel samt der Zeile „Auch gesucht als: …", in beide
|
||||
// Richtungen. So findet ein neuer „Release-Ablauf" den vorhandenen
|
||||
// „Deployment-Prozess", wenn dort „release" als Suchbegriff steht.
|
||||
func pruefeDubletten(ctx context.Context, ws *workspace.Workspace, project store.Project,
|
||||
slug, titel, inhalt string) ([]dublettenKandidat, error) {
|
||||
|
||||
_, err := ws.Store.EntryBySlug(ctx, project.ID, slug)
|
||||
if err == nil {
|
||||
return nil, nil // vorhanden: Aktualisierung, keine Neuanlage
|
||||
}
|
||||
if !errors.Is(err, store.ErrNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
idx, err := ws.Search.For(ctx, project.ID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
neuerName := titel + " " + entry.AuchGesuchtAls(inhalt)
|
||||
|
||||
var kandidaten []dublettenKandidat
|
||||
for _, t := range idx.Search(neuerName, dublettenSuchtiefe) {
|
||||
e, err := ws.Store.EntryBySlug(ctx, project.ID, t.DocID)
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
continue // Index kennt einen Eintrag, den es nicht mehr gibt
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
vorhandenerName := e.Title + " " + entry.AuchGesuchtAls(e.Content)
|
||||
|
||||
// In beide Richtungen, der höhere Wert zählt: „Deployment" (neu) ist
|
||||
// vollständig in „Deployment-Prozess" enthalten, umgekehrt nur zur
|
||||
// Hälfte. Beides ist ein Hinweis auf dasselbe Thema.
|
||||
anteil := max(
|
||||
search.Ueberdeckung(titel, vorhandenerName),
|
||||
search.Ueberdeckung(e.Title, neuerName),
|
||||
)
|
||||
if anteil < dublettenSchwelle {
|
||||
continue
|
||||
}
|
||||
|
||||
kandidaten = append(kandidaten, dublettenKandidat{
|
||||
Slug: e.Slug,
|
||||
Title: e.Title,
|
||||
Overlap: int(math.Round(anteil * 100)),
|
||||
})
|
||||
}
|
||||
|
||||
// Ähnlichste zuerst. SliceStable lässt gleich ähnliche in der Reihenfolge
|
||||
// der Suche stehen — die ist der bessere Gleichstandsbrecher als Zufall.
|
||||
sort.SliceStable(kandidaten, func(i, j int) bool {
|
||||
return kandidaten[i].Overlap > kandidaten[j].Overlap
|
||||
})
|
||||
if len(kandidaten) > dublettenMaxKandidaten {
|
||||
kandidaten = kandidaten[:dublettenMaxKandidaten]
|
||||
}
|
||||
return kandidaten, nil
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
72
internal/mcpserver/kuratiert.go
Normal file
72
internal/mcpserver/kuratiert.go
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
package mcpserver
|
||||
|
||||
// Kuratierte Einträge vor unbemerktem Überschreiben schützen.
|
||||
//
|
||||
// „Kuratiert" heißt: Ein Mensch hat den Inhalt geprüft. Schreibt ein Agent
|
||||
// einen solchen Eintrag um, ist der neue Text nicht mehr geprüft — die
|
||||
// Markierung fällt deshalb weg. Das ist richtig. Früher geschah es aber
|
||||
// still: Der Agent erfuhr nicht, dass er geprüftes Wissen ersetzt hatte, und
|
||||
// kein Mensch bekam es mit.
|
||||
//
|
||||
// Jetzt läuft es in zwei Schritten, wie bei den Dubletten (dubletten.go):
|
||||
//
|
||||
// 1. remember auf einen kuratierten Eintrag schreibt NICHT, sondern meldet
|
||||
// judgment_required mit einer Erklärung.
|
||||
// 2. Hält der Agent die Änderung für nötig, ruft er erneut auf, mit
|
||||
// override_curated: true und einer Begründung (reason). Dann wird
|
||||
// geschrieben, die Markierung fällt weg, und das Protokoll vermerkt den
|
||||
// Vorgang samt Begründung. Die Nutzungsseite listet solche Einträge, bis
|
||||
// ein Mensch sie wieder als kuratiert markiert.
|
||||
//
|
||||
// # Warum nicht ganz sperren?
|
||||
//
|
||||
// Weil kuratiertes Wissen auch veralten kann. Ein Agent, der nachweislich
|
||||
// sieht, dass ein Server umgezogen ist, soll das festhalten dürfen. Die
|
||||
// Hürde soll Fehlgriffe aufhalten, nicht berechtigte Korrekturen.
|
||||
//
|
||||
// Zwischen Prüfung und Schreiben könnte ein Mensch den Eintrag gerade
|
||||
// kuratieren. Dieses Zeitfenster ist Millisekunden breit; der Verlauf hält
|
||||
// den geprüften Stand in jedem Fall fest.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"munin/internal/store"
|
||||
"munin/internal/workspace"
|
||||
)
|
||||
|
||||
// istKuratiert prüft, ob unter slug ein kuratierter Eintrag liegt.
|
||||
// Gibt es den Eintrag nicht, ist er auch nicht kuratiert.
|
||||
func istKuratiert(ctx context.Context, ws *workspace.Workspace, project store.Project, slug string) (bool, error) {
|
||||
e, err := ws.Store.EntryBySlug(ctx, project.ID, slug)
|
||||
if errors.Is(err, store.ErrNotFound) {
|
||||
return false, nil
|
||||
}
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return e.Curated, nil
|
||||
}
|
||||
|
||||
// kuratiertHinweis ist die Antwort an den Agenten, wenn er einen kuratierten
|
||||
// Eintrag ohne override_curated ändern will. Sie sagt ausdrücklich, was er
|
||||
// tun soll — wie alle Meldungen an Modelle.
|
||||
const kuratiertHinweis = "Nichts geschrieben: Dieser Eintrag ist kuratiert — ein Mensch hat ihn geprüft. " +
|
||||
"Geprüftes Wissen hat Vorrang vor eigenen Annahmen. Ändere ihn nur, wenn er nachweislich nicht " +
|
||||
"mehr stimmt (etwa weil du gerade das Gegenteil beobachtet hast). Dann denselben Aufruf mit " +
|
||||
"\"override_curated\": true und unter \"reason\" einer kurzen Begründung wiederholen. " +
|
||||
"Die Markierung \"kuratiert\" fällt dabei weg, und ein Mensch prüft die Änderung. " +
|
||||
"Sonst: nichts ändern, oder die Ergänzung in einem eigenen Eintrag festhalten und mit [[slug]] verweisen."
|
||||
|
||||
// begruendungPruefen stellt sicher, dass ein override_curated eine
|
||||
// Begründung trägt. Ohne sie wüsste der prüfende Mensch nicht, warum der
|
||||
// geprüfte Stand ersetzt wurde.
|
||||
func begruendungPruefen(in rememberInput) error {
|
||||
if in.OverrideCurated && strings.TrimSpace(in.Reason) == "" {
|
||||
return errors.New("override_curated braucht eine Begründung unter \"reason\": " +
|
||||
"Warum stimmt der kuratierte Stand nicht mehr?")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -153,6 +176,7 @@ func (h *Handler) Forget(projectID string) {
|
|||
|
||||
type recallInput struct {
|
||||
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)"`
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
48
internal/search/bench_test.go
Normal file
48
internal/search/bench_test.go
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
package search
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// baueGrossenIndex legt einen Index in der Größenordnung eines großen Projekts
|
||||
// an: 10.000 Einträge à 150 Wörter aus einem Wortschatz von 20.000 Wörtern.
|
||||
//
|
||||
// Die Wörter sind künstlich ("wort123"), das genügt: Für die Laufzeit zählt,
|
||||
// wie viele verschiedene Wörter der Index durchgehen muss, nicht was sie heißen.
|
||||
// Der feste Startwert macht jeden Lauf gleich.
|
||||
func baueGrossenIndex() *Index {
|
||||
const (
|
||||
eintraege = 10_000
|
||||
woerter = 150
|
||||
wortschatz = 20_000
|
||||
)
|
||||
|
||||
zufall := rand.New(rand.NewSource(1))
|
||||
idx := NewIndex()
|
||||
|
||||
var text strings.Builder
|
||||
for i := 0; i < eintraege; i++ {
|
||||
text.Reset()
|
||||
for j := 0; j < woerter; j++ {
|
||||
fmt.Fprintf(&text, "wort%d ", zufall.Intn(wortschatz))
|
||||
}
|
||||
idx.Index(fmt.Sprintf("eintrag-%d", i), "Titel", text.String(), nil)
|
||||
}
|
||||
return idx
|
||||
}
|
||||
|
||||
// BenchmarkSearch misst eine typische Agenten-Anfrage: eine ganze Frage mit
|
||||
// Stoppwörtern und Wörtern, die es im Index nicht gibt.
|
||||
//
|
||||
// go test ./internal/search/ -bench . -run '^$'
|
||||
func BenchmarkSearch(b *testing.B) {
|
||||
idx := baueGrossenIndex()
|
||||
b.ResetTimer()
|
||||
|
||||
for i := 0; i < b.N; i++ {
|
||||
idx.Search("wie funktioniert wort123 mit wort4567", 10)
|
||||
}
|
||||
}
|
||||
|
|
@ -80,6 +80,7 @@ type posting struct {
|
|||
type docMeta struct {
|
||||
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,32 +262,20 @@ 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(postings))
|
||||
nq := float64(len(v.postings))
|
||||
idf := math.Log((N-nq+0.5)/(nq+0.5) + 1)
|
||||
|
||||
for _, p := range postings {
|
||||
for _, p := range v.postings {
|
||||
meta := s.docs[p.docID]
|
||||
docLen := float64(meta.length)
|
||||
|
||||
|
|
@ -259,7 +285,15 @@ func (s *Index) Search(queryStr string, limit int) []Result {
|
|||
tf := float64(p.freq)
|
||||
tfNorm := tf * (bm25K1 + 1) / (tf + bm25K1*(1-bm25B+bm25B*docLen/avgDocLen))
|
||||
|
||||
scores[p.docID] += idf * tfNorm
|
||||
wert := v.gewicht * idf * tfNorm
|
||||
if wert > beste[p.docID] {
|
||||
beste[p.docID] = wert
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
387
internal/search/varianten.go
Normal file
387
internal/search/varianten.go
Normal file
|
|
@ -0,0 +1,387 @@
|
|||
package search
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// # Wortvarianten
|
||||
//
|
||||
// BM25 vergleicht ganze Wörter. Ein Suchwort trifft also nur Einträge, in denen
|
||||
// genau dieses Wort steht. Im Alltag reicht das nicht:
|
||||
//
|
||||
// deploy soll Deployment finden (Wortanfang)
|
||||
// deployen soll Deployment finden (Endung)
|
||||
// prozess soll Releaseprozess finden (zusammengesetztes Wort)
|
||||
// deploymnet soll Deployment finden (Tippfehler, nur Weboberfläche)
|
||||
//
|
||||
// Deshalb sucht der Index zu jedem Suchwort mehrere Varianten. Jede Variante
|
||||
// hat ein Gewicht: Ein exakter Treffer zählt voll, ein Treffer über eine
|
||||
// Variante nur anteilig. So rutschen ungenaue Treffer nach hinten, statt die
|
||||
// genauen zu verdrängen.
|
||||
//
|
||||
// Trifft ein Eintrag über mehrere Varianten (etwa „ssh" exakt und „sshd" als
|
||||
// Wortanfang), zählt nur die beste. Sonst bekäme ein Eintrag mit vielen
|
||||
// ähnlichen Wörtern ein unverdient hohes Gewicht.
|
||||
|
||||
// Gewichte der Varianten — ein exakter Treffer zählt 1.
|
||||
const (
|
||||
// gewichtWortanfang gilt für Wörter, die mit dem Suchwort oder seinem
|
||||
// Stamm beginnen: „deploy" → „deployment".
|
||||
gewichtWortanfang = 0.5
|
||||
|
||||
// gewichtTeilwort gilt für Wörter, die das Suchwort irgendwo enthalten:
|
||||
// „prozess" → „releaseprozess". Niedriger als der Wortanfang, weil
|
||||
// Teilwörter öfter zufällig passen.
|
||||
gewichtTeilwort = 0.3
|
||||
|
||||
// gewichtTippfehler gilt für Wörter, die sich um ein oder zwei Buchstaben
|
||||
// unterscheiden: „deploymnet" → „deployment".
|
||||
gewichtTippfehler = 0.3
|
||||
)
|
||||
|
||||
// Mindestlängen der Suchwörter für die einzelnen Varianten. Kürzere Wörter
|
||||
// passen auf zu vieles: „de" ist der Anfang halber Wörterbücher, und „port"
|
||||
// steckt in „Reporting", „Export" und „Support".
|
||||
const (
|
||||
minWortanfang = 3
|
||||
minTeilwort = 5
|
||||
minStamm = 4 // Mindestlänge dessen, was nach dem Kürzen übrig bleibt
|
||||
minTippfehler = 4
|
||||
)
|
||||
|
||||
// variante ist eine Gruppe von Fundstellen, die über dieselbe Art von
|
||||
// Ähnlichkeit gefunden wurden, samt ihrem Gewicht.
|
||||
type variante struct {
|
||||
postings []posting
|
||||
gewicht float64
|
||||
}
|
||||
|
||||
// variantenFuer sucht alle Fundstellen eines Suchworts: exakt, über den
|
||||
// Wortanfang, als Teilwort und — nur wenn tippfehler gesetzt ist und sonst
|
||||
// gar nichts passt — mit Tippfehlern.
|
||||
//
|
||||
// Muss unter der Lesesperre des Index aufgerufen werden.
|
||||
//
|
||||
// # Laufzeit
|
||||
//
|
||||
// Der exakte Treffer ist ein einzelner Map-Zugriff. Für die übrigen Varianten
|
||||
// geht die Funktion einmal den ganzen Wortschatz des Projekts durch. Gemessen
|
||||
// (siehe bench_test.go) sind das bei 20.000 verschiedenen Wörtern einige
|
||||
// Zehntel Millisekunden je Anfrage — für Agenten wie Menschen nicht spürbar.
|
||||
func (s *Index) variantenFuer(term string, tippfehler bool) []variante {
|
||||
var ergebnis []variante
|
||||
|
||||
if exakt := s.invertedIndex[term]; len(exakt) > 0 {
|
||||
ergebnis = append(ergebnis, variante{postings: exakt, gewicht: 1})
|
||||
}
|
||||
|
||||
stamm := kuerzeEndung(term)
|
||||
sucheWortanfang := len(term) >= minWortanfang
|
||||
sucheTeilwort := len(term) >= minTeilwort
|
||||
|
||||
// Je Variante: Dokument → höchste Häufigkeit eines passenden Worts.
|
||||
// Höchste statt Summe aus demselben Grund wie oben: „deploy", „deployment"
|
||||
// und „deployt" im selben Eintrag sollen nicht dreifach zählen.
|
||||
wortanfang := make(map[string]int)
|
||||
teilwort := make(map[string]int)
|
||||
|
||||
for wort, postings := range s.invertedIndex {
|
||||
switch {
|
||||
case sucheWortanfang && strings.HasPrefix(wort, term):
|
||||
merkeHoechste(wortanfang, postings)
|
||||
case stamm != "" && strings.HasPrefix(wort, stamm):
|
||||
merkeHoechste(wortanfang, postings)
|
||||
case sucheTeilwort && strings.Contains(wort, term):
|
||||
merkeHoechste(teilwort, postings)
|
||||
}
|
||||
}
|
||||
|
||||
if len(wortanfang) > 0 {
|
||||
ergebnis = append(ergebnis, variante{postings: alsPostings(wortanfang), gewicht: gewichtWortanfang})
|
||||
}
|
||||
if len(teilwort) > 0 {
|
||||
ergebnis = append(ergebnis, variante{postings: alsPostings(teilwort), gewicht: gewichtTeilwort})
|
||||
}
|
||||
|
||||
// Tippfehler nur als letzte Rückfallebene: Sie sind die unsicherste
|
||||
// Variante und kosten den meisten Rechenaufwand.
|
||||
if tippfehler && len(ergebnis) == 0 {
|
||||
if treffer := s.tippfehlerPostings(term); len(treffer) > 0 {
|
||||
ergebnis = append(ergebnis, variante{postings: treffer, gewicht: gewichtTippfehler})
|
||||
}
|
||||
}
|
||||
|
||||
return ergebnis
|
||||
}
|
||||
|
||||
// tippfehlerPostings sammelt die Fundstellen aller Wörter, die sich vom
|
||||
// Suchwort nur um wenige Buchstaben unterscheiden.
|
||||
func (s *Index) tippfehlerPostings(term string) []posting {
|
||||
suchwort := []rune(term)
|
||||
erlaubt := erlaubteTippfehler(len(suchwort))
|
||||
if erlaubt == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
beste := make(map[string]int)
|
||||
for wort, postings := range s.invertedIndex {
|
||||
// Schneller Vorfilter: Unterscheiden sich die Längen schon stärker als
|
||||
// erlaubt, kann der Abstand nicht kleiner sein. Das erspart die
|
||||
// eigentliche Rechnung für fast alle Wörter.
|
||||
laenge := utf8.RuneCountInString(wort)
|
||||
if abs(laenge-len(suchwort)) > erlaubt {
|
||||
continue
|
||||
}
|
||||
if levenshtein(suchwort, []rune(wort)) <= erlaubt {
|
||||
merkeHoechste(beste, postings)
|
||||
}
|
||||
}
|
||||
return alsPostings(beste)
|
||||
}
|
||||
|
||||
// erlaubteTippfehler legt fest, wie viele Buchstaben bei einem Wort dieser
|
||||
// Länge abweichen dürfen. Bei kurzen Wörtern wäre schon ein Fehler zu viel:
|
||||
// „host" und „post" sind verschiedene Dinge.
|
||||
func erlaubteTippfehler(laenge int) int {
|
||||
switch {
|
||||
case laenge < minTippfehler:
|
||||
return 0
|
||||
case laenge < 8:
|
||||
return 1
|
||||
default:
|
||||
return 2
|
||||
}
|
||||
}
|
||||
|
||||
// levenshtein berechnet den Editierabstand zweier Wörter: die kleinste Zahl
|
||||
// an Buchstaben, die man einfügen, löschen oder ersetzen muss, um aus a das
|
||||
// Wort b zu machen.
|
||||
//
|
||||
// deploymnet → deployment Abstand 2 (n→e, e→n)
|
||||
// sever → server Abstand 1 (r einfügen)
|
||||
//
|
||||
// Klassisch rechnet man das mit einer Tabelle von (len(a)+1) × (len(b)+1)
|
||||
// Feldern. Jede Zeile braucht aber nur die Zeile davor — deshalb genügen hier
|
||||
// zwei Zeilen, die abwechselnd überschrieben werden.
|
||||
func levenshtein(a, b []rune) int {
|
||||
vorher := make([]int, len(b)+1)
|
||||
aktuell := make([]int, len(b)+1)
|
||||
|
||||
// Erste Zeile: aus dem leeren Wort wird b durch j-maliges Einfügen.
|
||||
for j := range vorher {
|
||||
vorher[j] = j
|
||||
}
|
||||
|
||||
for i := 1; i <= len(a); i++ {
|
||||
aktuell[0] = i // aus a[:i] wird das leere Wort durch i-maliges Löschen
|
||||
for j := 1; j <= len(b); j++ {
|
||||
kosten := 1
|
||||
if a[i-1] == b[j-1] {
|
||||
kosten = 0
|
||||
}
|
||||
aktuell[j] = min(
|
||||
vorher[j]+1, // Buchstabe aus a löschen
|
||||
aktuell[j-1]+1, // Buchstabe aus b einfügen
|
||||
vorher[j-1]+kosten, // Buchstabe ersetzen (oder gleich lassen)
|
||||
)
|
||||
}
|
||||
vorher, aktuell = aktuell, vorher
|
||||
}
|
||||
return vorher[len(b)]
|
||||
}
|
||||
|
||||
// endungen sind die Wortendungen, die kuerzeEndung abschneidet — die
|
||||
// häufigsten deutschen Plural- und Beugungsendungen, dazu das englische -s.
|
||||
// Längere Endungen stehen vorn, damit „prozesses" zu „prozess" wird und nicht
|
||||
// zu „prozesse".
|
||||
//
|
||||
// Bewusst nicht dabei ist „-er": Im Deutschen bildet es meist ein eigenes Wort
|
||||
// („Rechner", „Benutzer"), und aus „Server" würde „Serv", das dann auch
|
||||
// „Service" fände.
|
||||
var endungen = []string{"en", "es", "e", "n", "s"}
|
||||
|
||||
// kuerzeEndung schneidet eine Beugungsendung ab und gibt den Stamm zurück —
|
||||
// oder "", wenn es nichts zu kürzen gibt.
|
||||
//
|
||||
// deployen → deploy (findet dann „deployment")
|
||||
// zertifikate → zertifikat
|
||||
// datenbanken → datenbank
|
||||
// logs → "" (der Rest „log" wäre zu kurz)
|
||||
//
|
||||
// Das ist absichtlich kein vollständiger Wortstammbildner. Die kennen hunderte
|
||||
// Regeln und verfälschen dabei Fachbegriffe. Hier geht es nur darum, dass ein
|
||||
// Suchwort mit Endung auch das Wort ohne Endung findet. Da der Stamm nur als
|
||||
// Wortanfang gesucht wird, findet umgekehrt „zertifikat" auch „zertifikate".
|
||||
func kuerzeEndung(term string) string {
|
||||
for _, endung := range endungen {
|
||||
if strings.HasSuffix(term, endung) && len(term)-len(endung) >= minStamm {
|
||||
return term[:len(term)-len(endung)]
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// stoppwoerter sind Wörter, die in fast jeder Frage stehen, aber nichts über
|
||||
// das Thema verraten.
|
||||
//
|
||||
// # Warum nur beim Suchen?
|
||||
//
|
||||
// Die Liste wird nur auf die Anfrage angewendet, nicht beim Indizieren. Der
|
||||
// Index bleibt also vollständig, und wer gezielt nach „was" sucht, findet es
|
||||
// weiterhin (siehe ohneStoppwoerter).
|
||||
//
|
||||
// # Warum überhaupt?
|
||||
//
|
||||
// Die instructions bitten Agenten, ganze Fragen zu stellen: „wie deployen wir
|
||||
// den Dienst". BM25 gewichtet häufige Wörter zwar herunter, aber nicht auf
|
||||
// null. Fehlt ein Wort wie „wir" im Projekt ganz, greift außerdem die Suche
|
||||
// nach dem Wortanfang und findet „wird" und „wirkung" — reines Rauschen.
|
||||
//
|
||||
// Die Wörter stehen in der Form, die tokenize erzeugt: klein und mit
|
||||
// gefalteten Umlauten („fur" statt „für"). Ein Test stellt das sicher.
|
||||
//
|
||||
// Nicht dabei sind Wörter, die in der IT etwas bedeuten können, etwa „it"
|
||||
// oder „go".
|
||||
var stoppwoerter = map[string]bool{
|
||||
// Deutsch
|
||||
"der": true, "die": true, "das": true, "den": true, "dem": true, "des": true,
|
||||
"ein": true, "eine": true, "einen": true, "einem": true, "einer": true, "eines": true,
|
||||
"und": true, "oder": true, "aber": true, "auch": true, "nur": true, "noch": true,
|
||||
"ist": true, "sind": true, "war": true, "wird": true, "werden": true, "wurde": true,
|
||||
"hat": true, "haben": true, "habe": true, "gibt": true, "kann": true, "konnen": true,
|
||||
"muss": true, "soll": true, "sollte": true,
|
||||
"wie": true, "was": true, "wo": true, "wer": true, "wann": true, "warum": true,
|
||||
"welche": true, "welcher": true, "welches": true, "womit": true, "wozu": true,
|
||||
"ich": true, "du": true, "er": true, "sie": true, "es": true, "wir": true, "ihr": true,
|
||||
"man": true, "mein": true, "unser": true, "unsere": true, "unseren": true,
|
||||
"mit": true, "von": true, "zu": true, "zum": true, "zur": true, "im": true, "in": true,
|
||||
"am": true, "an": true, "auf": true, "aus": true, "bei": true, "fur": true,
|
||||
"uber": true, "nach": true, "vor": true, "um": true, "ob": true,
|
||||
"nicht": true, "kein": true, "keine": true, "so": true, "dass": true, "da": true,
|
||||
"hier": true, "dort": true, "dann": true, "denn": true, "sich": true,
|
||||
|
||||
// Englisch
|
||||
"the": true, "and": true, "or": true, "of": true, "to": true, "on": true,
|
||||
"for": true, "with": true, "is": true, "are": true, "be": true,
|
||||
"how": true, "what": true, "where": true, "who": true, "why": true, "when": true,
|
||||
"which": true, "do": true, "does": true, "we": true, "you": true, "our": true,
|
||||
"can": true, "this": true, "that": true,
|
||||
}
|
||||
|
||||
// ohneStoppwoerter entfernt die Stoppwörter aus einer Anfrage.
|
||||
//
|
||||
// Besteht die Anfrage nur aus Stoppwörtern, bleibt sie unverändert — dann
|
||||
// meint jemand offenbar genau diese Wörter, und eine leere Anfrage hülfe
|
||||
// niemandem.
|
||||
func ohneStoppwoerter(tokens []string) []string {
|
||||
rest := make([]string, 0, len(tokens))
|
||||
for _, t := range tokens {
|
||||
if !stoppwoerter[t] {
|
||||
rest = append(rest, t)
|
||||
}
|
||||
}
|
||||
if len(rest) == 0 {
|
||||
return tokens
|
||||
}
|
||||
return rest
|
||||
}
|
||||
|
||||
// merkeHoechste trägt für jedes Dokument die höchste Häufigkeit ein.
|
||||
func merkeHoechste(beste map[string]int, postings []posting) {
|
||||
for _, p := range postings {
|
||||
if p.freq > beste[p.docID] {
|
||||
beste[p.docID] = p.freq
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// alsPostings wandelt die Zuordnung Dokument → Häufigkeit in eine Liste um.
|
||||
func alsPostings(beste map[string]int) []posting {
|
||||
ergebnis := make([]posting, 0, len(beste))
|
||||
for docID, freq := range beste {
|
||||
ergebnis = append(ergebnis, posting{docID: docID, freq: freq})
|
||||
}
|
||||
return ergebnis
|
||||
}
|
||||
|
||||
// abs gibt den Betrag einer ganzen Zahl zurück.
|
||||
func abs(n int) int {
|
||||
if n < 0 {
|
||||
return -n
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
// Ueberdeckung gibt an, welcher Anteil der Wörter aus anfrage in text
|
||||
// vorkommt — als Zahl von 0 (keines) bis 1 (alle). Stoppwörter zählen nicht.
|
||||
//
|
||||
// Gedacht für kurze Texte wie Titel, um Dubletten zu erkennen (siehe remember
|
||||
// in internal/mcpserver). Ein Wort gilt als vorhanden, wenn es dort exakt,
|
||||
// als Wortanfang, mit anderer Endung oder als Teil eines zusammengesetzten
|
||||
// Worts steht — dieselben Varianten wie bei der Suche, nur in beide
|
||||
// Richtungen:
|
||||
//
|
||||
// „Deployment-Ablauf" vs. „Deployment-Prozess" → 0,5 (deployment ✓, ablauf ✗)
|
||||
// „Releaseprozess" vs. „Release-Prozess" → 1 (beginnt mit „release")
|
||||
// „Deployments" vs. „Deployment" → 1 (Endung)
|
||||
//
|
||||
// # Warum nicht der BM25-Wert?
|
||||
//
|
||||
// BM25 hat keinen festen Wertebereich: Ob 4,2 viel oder wenig ist, hängt von
|
||||
// der Größe des Projekts und der Länge der Einträge ab. Eine Schwelle darauf
|
||||
// müsste je Projekt neu eingestellt werden. Der Anteil gemeinsamer Wörter ist
|
||||
// dagegen in jedem Projekt gleich zu lesen.
|
||||
func Ueberdeckung(anfrage, text string) float64 {
|
||||
woerter := ohneStoppwoerter(tokenize(anfrage))
|
||||
if len(woerter) == 0 {
|
||||
return 0
|
||||
}
|
||||
vorhanden := tokenize(text)
|
||||
|
||||
// Doppelte Wörter nur einmal zählen: „Server-Server-Setup" ist nicht
|
||||
// zu zwei Dritteln „Server".
|
||||
gesehen := make(map[string]bool, len(woerter))
|
||||
gesamt, gefunden := 0, 0
|
||||
for _, w := range woerter {
|
||||
if gesehen[w] {
|
||||
continue
|
||||
}
|
||||
gesehen[w] = true
|
||||
gesamt++
|
||||
|
||||
for _, v := range vorhanden {
|
||||
if passtZu(w, v) {
|
||||
gefunden++
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return float64(gefunden) / float64(gesamt)
|
||||
}
|
||||
|
||||
// passtZu prüft, ob zwei einzelne Wörter als dasselbe gelten.
|
||||
func passtZu(a, b string) bool {
|
||||
if a == b {
|
||||
return true
|
||||
}
|
||||
// Wortanfang, in beide Richtungen: „deploy" ↔ „deployment",
|
||||
// „releaseprozess" ↔ „release". Beide müssen lang genug sein, sonst passt
|
||||
// „de" zu allem.
|
||||
if len(a) >= minStamm && len(b) >= minStamm &&
|
||||
(strings.HasPrefix(a, b) || strings.HasPrefix(b, a)) {
|
||||
return true
|
||||
}
|
||||
// Endung: „zertifikate" ↔ „zertifikat".
|
||||
if stamm := kuerzeEndung(a); stamm != "" && strings.HasPrefix(b, stamm) {
|
||||
return true
|
||||
}
|
||||
if stamm := kuerzeEndung(b); stamm != "" && strings.HasPrefix(a, stamm) {
|
||||
return true
|
||||
}
|
||||
// Teil eines zusammengesetzten Worts: „prozess" ↔ „releaseprozess".
|
||||
if len(a) >= minTeilwort && strings.Contains(b, a) {
|
||||
return true
|
||||
}
|
||||
return len(b) >= minTeilwort && strings.Contains(a, b)
|
||||
}
|
||||
277
internal/search/varianten_test.go
Normal file
277
internal/search/varianten_test.go
Normal file
|
|
@ -0,0 +1,277 @@
|
|||
package search
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// enthaelt prüft, ob ein Eintrag unter den Treffern ist.
|
||||
func enthaelt(treffer []Result, docID string) bool {
|
||||
return slices.Contains(ergebnisIDs(treffer), docID)
|
||||
}
|
||||
|
||||
// ── Wortanfang ───────────────────────────────────────────────────────────────
|
||||
|
||||
// TestWortanfangNebenExaktemTreffer prüft den Fehler, der zu diesem Umbau
|
||||
// geführt hat: Früher griff die Suche nach dem Wortanfang nur, wenn es keinen
|
||||
// exakten Treffer gab. Stand „deploy" in irgendeinem Eintrag, fand „deploy"
|
||||
// den „Deployment-Prozess" nicht mehr — ein stiller Fehltreffer, der umso
|
||||
// häufiger wird, je größer ein Projekt ist.
|
||||
func TestWortanfangNebenExaktemTreffer(t *testing.T) {
|
||||
idx := NewIndex()
|
||||
idx.Index("skript", "Skript", "Das Skript heißt deploy und liegt im Repository.", nil)
|
||||
idx.Index("prozess", "Deployment-Prozess", "So kommt eine neue Fassung in Produktion.", nil)
|
||||
|
||||
treffer := idx.Search("deploy", 10)
|
||||
|
||||
if !enthaelt(treffer, "prozess") {
|
||||
t.Fatalf("Treffer = %v — der Wortanfang wird neben dem exakten Treffer nicht gefunden", ergebnisIDs(treffer))
|
||||
}
|
||||
if treffer[0].DocID != "skript" {
|
||||
t.Errorf("bester Treffer = %q, erwartet den exakten", treffer[0].DocID)
|
||||
}
|
||||
}
|
||||
|
||||
// TestWortanfangZaehltNichtDoppelt prüft, dass ein Eintrag mit vielen Wörtern
|
||||
// desselben Anfangs nicht vor einem exakten Treffer landet.
|
||||
func TestWortanfangZaehltNichtDoppelt(t *testing.T) {
|
||||
idx := NewIndex()
|
||||
idx.Index("exakt", "Hinweis", "Hier steht config.", nil)
|
||||
idx.Index("viele", "Hinweis", "configuration configure configured configs configurable", nil)
|
||||
|
||||
treffer := idx.Search("config", 10)
|
||||
|
||||
if len(treffer) != 2 || treffer[0].DocID != "exakt" {
|
||||
t.Errorf("Treffer = %v, erwartet exakt vor viele", ergebnisIDs(treffer))
|
||||
}
|
||||
}
|
||||
|
||||
// ── Zusammengesetzte Wörter ──────────────────────────────────────────────────
|
||||
|
||||
// TestTeilwortFindetKomposita prüft zusammengeschriebene deutsche Wörter. Mit
|
||||
// Bindestrich („Release-Prozess") genügte schon immer die Zerlegung in Wörter;
|
||||
// zusammengeschrieben ist „prozess" aber weder ein eigenes Wort noch ein
|
||||
// Wortanfang.
|
||||
func TestTeilwortFindetKomposita(t *testing.T) {
|
||||
idx := NewIndex()
|
||||
idx.Index("release", "Releaseprozess", "Ablauf für neue Fassungen.", nil)
|
||||
idx.Index("zertifikat", "Wartung", "Der jährliche SSL-Zertifikatswechsel steht im Kalender.", nil)
|
||||
idx.Index("datenbank", "Datenbankzugang", "Zugriff nur über das VPN.", nil)
|
||||
|
||||
faelle := map[string]string{
|
||||
"prozess": "release",
|
||||
"wechsel": "zertifikat",
|
||||
"zugang": "datenbank",
|
||||
"datenbank": "datenbank", // Wortanfang, kein Teilwort — darf trotzdem nicht verloren gehen
|
||||
}
|
||||
|
||||
for anfrage, erwartet := range faelle {
|
||||
if treffer := idx.Search(anfrage, 10); !enthaelt(treffer, erwartet) {
|
||||
t.Errorf("Search(%q) = %v, erwartet %s", anfrage, ergebnisIDs(treffer), erwartet)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestTeilwortBrauchtFuenfZeichen prüft die Untergrenze: Kurze Wörter stecken
|
||||
// zufällig in zu vielen anderen.
|
||||
func TestTeilwortBrauchtFuenfZeichen(t *testing.T) {
|
||||
idx := NewIndex()
|
||||
idx.Index("bericht", "Reporting", "Monatliche Auswertung.", nil)
|
||||
|
||||
if treffer := idx.Search("port", 10); len(treffer) > 0 {
|
||||
t.Errorf("„port\" fand %v — vierbuchstabige Teilwörter sollten nicht greifen", ergebnisIDs(treffer))
|
||||
}
|
||||
}
|
||||
|
||||
// TestExaktVorTeilwort prüft die Gewichtung: Das eigenständige Wort steht vor
|
||||
// dem Wort, in dem es nur steckt.
|
||||
func TestExaktVorTeilwort(t *testing.T) {
|
||||
idx := NewIndex()
|
||||
idx.Index("kompositum", "Releaseprozess", "Ablauf.", nil)
|
||||
idx.Index("eigenstaendig", "Prozess", "Ablauf.", nil)
|
||||
|
||||
treffer := idx.Search("prozess", 10)
|
||||
|
||||
if len(treffer) != 2 || treffer[0].DocID != "eigenstaendig" {
|
||||
t.Errorf("Treffer = %v, erwartet eigenstaendig vor kompositum", ergebnisIDs(treffer))
|
||||
}
|
||||
}
|
||||
|
||||
// ── Endungen ─────────────────────────────────────────────────────────────────
|
||||
|
||||
// TestEndungenWerdenAusgeglichen prüft Suchwörter, die länger sind als das
|
||||
// Wort im Eintrag. Die Suche nach dem Wortanfang hilft hier nicht: „deployen"
|
||||
// ist kein Anfang von „deployment".
|
||||
func TestEndungenWerdenAusgeglichen(t *testing.T) {
|
||||
idx := NewIndex()
|
||||
idx.Index("deployment", "Deployment", "Ablauf.", nil)
|
||||
idx.Index("zertifikat", "Zertifikat", "Erneuerung.", nil)
|
||||
idx.Index("datenbank", "Datenbank", "Sicherung.", nil)
|
||||
|
||||
faelle := map[string]string{
|
||||
"deployen": "deployment",
|
||||
"zertifikate": "zertifikat",
|
||||
"datenbanken": "datenbank",
|
||||
}
|
||||
|
||||
for anfrage, erwartet := range faelle {
|
||||
if treffer := idx.Search(anfrage, 10); !enthaelt(treffer, erwartet) {
|
||||
t.Errorf("Search(%q) = %v, erwartet %s", anfrage, ergebnisIDs(treffer), erwartet)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestKuerzeEndung(t *testing.T) {
|
||||
faelle := map[string]string{
|
||||
"deployen": "deploy",
|
||||
"zertifikate": "zertifikat",
|
||||
"datenbanken": "datenbank",
|
||||
"prozesses": "prozess", // längere Endung zuerst
|
||||
"deployments": "deployment",
|
||||
"logs": "", // Rest „log" wäre zu kurz
|
||||
"nginx": "", // keine Endung
|
||||
"server": "", // „-er" wird bewusst nicht gekürzt
|
||||
}
|
||||
|
||||
for eingabe, erwartet := range faelle {
|
||||
if got := kuerzeEndung(eingabe); got != erwartet {
|
||||
t.Errorf("kuerzeEndung(%q) = %q, erwartet %q", eingabe, got, erwartet)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Stoppwörter ──────────────────────────────────────────────────────────────
|
||||
|
||||
// TestStoppwoerterErzeugenKeinRauschen prüft eine typische Agenten-Frage.
|
||||
// Ohne Stoppwörter fand „wir" über den Wortanfang den Eintrag zur „Wirkung".
|
||||
func TestStoppwoerterErzeugenKeinRauschen(t *testing.T) {
|
||||
idx := NewIndex()
|
||||
idx.Index("deployment", "Deployment-Prozess", "So kommt eine Fassung in Produktion.", nil)
|
||||
idx.Index("kuehlung", "Kühlung", "Die Wirkung der Lüfter im Serverraum.", nil)
|
||||
|
||||
treffer := idx.Search("wie deployen wir", 10)
|
||||
|
||||
if !enthaelt(treffer, "deployment") {
|
||||
t.Errorf("Treffer = %v, erwartet deployment", ergebnisIDs(treffer))
|
||||
}
|
||||
if enthaelt(treffer, "kuehlung") {
|
||||
t.Errorf("Treffer = %v — ein Stoppwort hat einen unpassenden Eintrag gefunden", ergebnisIDs(treffer))
|
||||
}
|
||||
}
|
||||
|
||||
// TestNurStoppwoerterBleibtSuchbar prüft die Ausnahme: Besteht die Anfrage nur
|
||||
// aus Stoppwörtern, wird trotzdem gesucht.
|
||||
func TestNurStoppwoerterBleibtSuchbar(t *testing.T) {
|
||||
idx := NewIndex()
|
||||
idx.Index("anleitung", "Wie man testet", "Schritt für Schritt.", nil)
|
||||
|
||||
if treffer := idx.Search("wie", 10); len(treffer) == 0 {
|
||||
t.Error("eine Anfrage nur aus Stoppwörtern fand nichts")
|
||||
}
|
||||
}
|
||||
|
||||
// TestStoppwoerterSindGefaltet stellt sicher, dass jedes Stoppwort in der Form
|
||||
// in der Liste steht, die tokenize erzeugt. Stünde dort „für" statt „fur",
|
||||
// würde es nie greifen.
|
||||
func TestStoppwoerterSindGefaltet(t *testing.T) {
|
||||
for wort := range stoppwoerter {
|
||||
if got := tokenize(wort); len(got) != 1 || got[0] != wort {
|
||||
t.Errorf("Stoppwort %q wird zu %v — es muss in gefalteter Form in der Liste stehen", wort, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tippfehler ───────────────────────────────────────────────────────────────
|
||||
|
||||
// TestTippfehlerNurInSearchFuzzy prüft, dass Tippfehler nur dort ausgeglichen
|
||||
// werden, wo es gewollt ist: in der Weboberfläche, nicht für Agenten.
|
||||
func TestTippfehlerNurInSearchFuzzy(t *testing.T) {
|
||||
idx := NewIndex()
|
||||
idx.Index("deployment", "Deployment", "Ablauf.", nil)
|
||||
idx.Index("server", "Server", "Wartung.", nil)
|
||||
|
||||
faelle := map[string]string{
|
||||
"deploymnet": "deployment", // zwei vertauschte Buchstaben
|
||||
"sever": "server", // ein Buchstabe fehlt
|
||||
}
|
||||
|
||||
for anfrage, erwartet := range faelle {
|
||||
if treffer := idx.SearchFuzzy(anfrage, 10); !enthaelt(treffer, erwartet) {
|
||||
t.Errorf("SearchFuzzy(%q) = %v, erwartet %s", anfrage, ergebnisIDs(treffer), erwartet)
|
||||
}
|
||||
if treffer := idx.Search(anfrage, 10); len(treffer) > 0 {
|
||||
t.Errorf("Search(%q) = %v — Search soll Tippfehler nicht ausgleichen", anfrage, ergebnisIDs(treffer))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestTippfehlerNurAlsRueckfall prüft, dass die Tippfehler-Suche nicht greift,
|
||||
// wenn das Suchwort schon etwas findet — sonst fände „host" auch „post".
|
||||
func TestTippfehlerNurAlsRueckfall(t *testing.T) {
|
||||
idx := NewIndex()
|
||||
idx.Index("host", "Host", "Rechner im Netz.", nil)
|
||||
idx.Index("cost", "Cost", "Kostenstelle.", nil)
|
||||
|
||||
treffer := idx.SearchFuzzy("host", 10)
|
||||
|
||||
if len(treffer) != 1 || treffer[0].DocID != "host" {
|
||||
t.Errorf("Treffer = %v, erwartet nur host", ergebnisIDs(treffer))
|
||||
}
|
||||
}
|
||||
|
||||
func TestLevenshtein(t *testing.T) {
|
||||
faelle := []struct {
|
||||
a, b string
|
||||
abstand int
|
||||
}{
|
||||
{"server", "server", 0},
|
||||
{"sever", "server", 1}, // einfügen
|
||||
{"servers", "server", 1}, // löschen
|
||||
{"sarver", "server", 1}, // ersetzen
|
||||
{"deploymnet", "deployment", 2},
|
||||
{"", "abc", 3},
|
||||
{"prüfung", "prufung", 1}, // Runen, nicht Bytes
|
||||
}
|
||||
|
||||
for _, f := range faelle {
|
||||
if got := levenshtein([]rune(f.a), []rune(f.b)); got != f.abstand {
|
||||
t.Errorf("levenshtein(%q, %q) = %d, erwartet %d", f.a, f.b, got, f.abstand)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestErlaubteTippfehler(t *testing.T) {
|
||||
faelle := map[int]int{3: 0, 4: 1, 7: 1, 8: 2, 15: 2}
|
||||
|
||||
for laenge, erwartet := range faelle {
|
||||
if got := erlaubteTippfehler(laenge); got != erwartet {
|
||||
t.Errorf("erlaubteTippfehler(%d) = %d, erwartet %d", laenge, got, erwartet)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Überdeckung (Dubletten) ──────────────────────────────────────────────────
|
||||
|
||||
func TestUeberdeckung(t *testing.T) {
|
||||
faelle := []struct {
|
||||
anfrage, text string
|
||||
erwartet float64
|
||||
}{
|
||||
{"Deployment-Ablauf", "Deployment-Prozess", 0.5},
|
||||
{"Releaseprozess", "Release-Prozess", 1},
|
||||
{"Release-Prozess", "Releaseprozess", 1},
|
||||
{"Deployments", "Deployment", 1},
|
||||
{"Zertifikate erneuern", "Zertifikat erneuern", 1},
|
||||
{"Wie wir deployen", "Deployment", 1}, // Stoppwörter zählen nicht
|
||||
{"Server-Server-Setup", "Server", 0.5}, // doppelte Wörter einmal
|
||||
{"VPN-Zugang", "Drucker einrichten", 0},
|
||||
{"", "Deployment", 0},
|
||||
{"Port", "Reporting", 0}, // zu kurz für ein Teilwort
|
||||
}
|
||||
|
||||
for _, f := range faelle {
|
||||
if got := Ueberdeckung(f.anfrage, f.text); got != f.erwartet {
|
||||
t.Errorf("Ueberdeckung(%q, %q) = %v, erwartet %v", f.anfrage, f.text, got, f.erwartet)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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.
|
||||
//
|
||||
|
|
|
|||
42
internal/store/migrations/006_suchprotokoll.sql
Normal file
42
internal/store/migrations/006_suchprotokoll.sql
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
-- Suchprotokoll: Was Agenten mit "recall" suchen und ob sie etwas finden.
|
||||
--
|
||||
-- ── Warum? ───────────────────────────────────────────────────────────────────
|
||||
--
|
||||
-- Ohne diese Zahlen lässt sich nicht beurteilen, ob die Suche gut genug ist.
|
||||
-- Eine Suche ohne Treffer ist das deutlichste Zeichen: Entweder gibt es das
|
||||
-- Wissen nicht — dann ist die Anfrage ein Hinweis, was fehlt — oder es steht
|
||||
-- unter anderen Wörtern da. Beides will man sehen (AUSBAUSTUFEN.md 3.1).
|
||||
--
|
||||
-- ── Warum nicht im Zugriffsprotokoll (audit_events)? ─────────────────────────
|
||||
--
|
||||
-- Gesucht wird viel öfter als geschrieben. Im Zugriffsprotokoll würden die
|
||||
-- Suchen die Schreibvorgänge zudecken, um die es dort geht. Außerdem enthält
|
||||
-- eine Anfrage Inhalt („wie kommt man an das Passwort für …"), und Inhalt hat
|
||||
-- eine kürzere Aufbewahrungsfrist verdient als die 90 Tage des Protokolls:
|
||||
-- Die tägliche Aufräumaufgabe löscht Zeilen nach 30 Tagen
|
||||
-- (store.SearchLogRetention).
|
||||
--
|
||||
-- ── Aufbau ───────────────────────────────────────────────────────────────────
|
||||
--
|
||||
-- Eine Zeile je recall-Aufruf. Bewusst ohne IP-Adresse und ohne Namen: Für
|
||||
-- die Auswertung zählt, WAS gesucht wurde, nicht wer. api_key_id bleibt, damit
|
||||
-- sich ein einzelner Agent mit auffälligem Verhalten eingrenzen lässt.
|
||||
--
|
||||
-- Die ID ist hier eine fortlaufende Zahl (INTEGER PRIMARY KEY) statt eines
|
||||
-- Zufallswerts wie in den anderen Tabellen: Niemand verweist auf eine
|
||||
-- einzelne Zeile, und eine Zahl ist beim Einfügen am billigsten.
|
||||
--
|
||||
-- Anders als das Zugriffsprotokoll hängt die Tabelle per Fremdschlüssel am
|
||||
-- Projekt: Wird es gelöscht, verschwinden auch seine Anfragen. Sie sind Inhalt
|
||||
-- des Projekts, kein Nachweis darüber, wer was getan hat.
|
||||
CREATE TABLE search_log (
|
||||
id INTEGER PRIMARY KEY,
|
||||
ts TEXT NOT NULL,
|
||||
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
|
||||
api_key_id TEXT NOT NULL DEFAULT '',
|
||||
query TEXT NOT NULL,
|
||||
synonyms TEXT NOT NULL DEFAULT '', -- mit Komma getrennt, leer = keine
|
||||
results INTEGER NOT NULL -- Zahl der ausgegebenen Treffer
|
||||
);
|
||||
|
||||
CREATE INDEX idx_search_log_project ON search_log (project_id, ts);
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
140
internal/store/suchprotokoll.go
Normal file
140
internal/store/suchprotokoll.go
Normal file
|
|
@ -0,0 +1,140 @@
|
|||
package store
|
||||
|
||||
// Suchprotokoll: Was Agenten mit "recall" suchen und ob sie etwas finden
|
||||
// (migrations/006_suchprotokoll.sql).
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// SearchLogRetention ist die Aufbewahrungsfrist des Suchprotokolls. Gleich
|
||||
// dem Zeitraum der Nutzungsübersicht: Länger braucht die Auswertung die
|
||||
// Zeilen nicht, und Anfragen enthalten Inhalt — der soll nicht länger liegen
|
||||
// als nötig.
|
||||
const SearchLogRetention = UsageWindow
|
||||
|
||||
// SearchLogEntry ist eine Suche, wie sie ins Protokoll geht.
|
||||
type SearchLogEntry struct {
|
||||
ProjectID string
|
||||
APIKeyID string
|
||||
Query string
|
||||
Synonyms []string
|
||||
Results int // Zahl der ausgegebenen Treffer, nach dem Schlagwortfilter
|
||||
}
|
||||
|
||||
// LogSearch schreibt eine Suche ins Protokoll.
|
||||
//
|
||||
// Wie LogAudit: Ein Fehler lässt die Suche nicht scheitern, er landet nur im
|
||||
// Serverlog. Der Agent soll seine Treffer bekommen, auch wenn die
|
||||
// Protokollzeile nicht geschrieben werden konnte.
|
||||
func (s *Store) LogSearch(ctx context.Context, e SearchLogEntry) {
|
||||
_, err := s.schreiben.ExecContext(ctx,
|
||||
`INSERT INTO search_log (ts, project_id, api_key_id, query, synonyms, results)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
NowString(), e.ProjectID, e.APIKeyID, e.Query, strings.Join(e.Synonyms, ", "), e.Results,
|
||||
)
|
||||
if err != nil {
|
||||
log.Printf("Suchprotokoll: Suche konnte nicht vermerkt werden: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// PruneSearchLog löscht Suchen, die älter als die angegebene Grenze sind.
|
||||
// Die Grenze ist ein Zeitstempel im Datenbankformat, wie bei PruneAudit.
|
||||
func (s *Store) PruneSearchLog(ctx context.Context, aelterAls string) (int64, error) {
|
||||
res, err := s.schreiben.ExecContext(ctx, `DELETE FROM search_log WHERE ts < ?`, aelterAls)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("Suchprotokoll konnte nicht aufgeräumt werden: %w", err)
|
||||
}
|
||||
return res.RowsAffected()
|
||||
}
|
||||
|
||||
// SearchStats sind die Kennzahlen des Suchprotokolls eines Projekts.
|
||||
type SearchStats struct {
|
||||
Total int // alle Suchen im Zeitraum
|
||||
Empty int // davon ohne Treffer
|
||||
WithSynonyms int // davon mit Synonymen
|
||||
}
|
||||
|
||||
// EmptyPercent ist der Anteil der Suchen ohne Treffer in Prozent (0–100).
|
||||
func (s SearchStats) EmptyPercent() int {
|
||||
return prozent(s.Empty, s.Total)
|
||||
}
|
||||
|
||||
// SynonymPercent ist der Anteil der Suchen mit Synonymen in Prozent (0–100).
|
||||
// Zeigt, ob die Agenten die Bitte aus den instructions befolgen.
|
||||
func (s SearchStats) SynonymPercent() int {
|
||||
return prozent(s.WithSynonyms, s.Total)
|
||||
}
|
||||
|
||||
// SearchStatsFor zählt die Suchen eines Projekts im Zeitraum UsageWindow.
|
||||
func (s *Store) SearchStatsFor(ctx context.Context, projectID string, jetzt time.Time) (SearchStats, error) {
|
||||
seit := jetzt.UTC().Add(-UsageWindow).Format(time.RFC3339Nano)
|
||||
|
||||
// Wie in UsageSummaries: CASE … THEN 1 ELSE 0 macht aus jeder Zeile eine
|
||||
// 1 oder 0, SUM zählt. COALESCE, weil SUM über null Zeilen NULL liefert.
|
||||
var st SearchStats
|
||||
err := s.lesen.QueryRowContext(ctx,
|
||||
`SELECT COUNT(*),
|
||||
COALESCE(SUM(CASE WHEN results = 0 THEN 1 ELSE 0 END), 0),
|
||||
COALESCE(SUM(CASE WHEN synonyms <> '' THEN 1 ELSE 0 END), 0)
|
||||
FROM search_log
|
||||
WHERE project_id = ? AND ts >= ?`,
|
||||
projectID, seit,
|
||||
).Scan(&st.Total, &st.Empty, &st.WithSynonyms)
|
||||
if err != nil {
|
||||
return SearchStats{}, fmt.Errorf("Suchprotokoll konnte nicht gelesen werden: %w", err)
|
||||
}
|
||||
return st, nil
|
||||
}
|
||||
|
||||
// EmptySearch ist eine Anfrage, die nichts gefunden hat — zusammengefasst
|
||||
// über alle Male, die sie gestellt wurde.
|
||||
type EmptySearch struct {
|
||||
Query string
|
||||
Synonyms string // die zuletzt mitgegebenen Synonyme
|
||||
Count int
|
||||
Last time.Time
|
||||
}
|
||||
|
||||
// EmptySearches gibt die Anfragen ohne Treffer zurück, häufigste zuerst,
|
||||
// höchstens limit Stück.
|
||||
//
|
||||
// Gleiche Anfragen werden ohne Rücksicht auf Groß- und Kleinschreibung
|
||||
// zusammengefasst. lower() kennt in SQLite nur ASCII — „Über" und „über"
|
||||
// bleiben also zwei Zeilen. Für eine Übersicht reicht das.
|
||||
func (s *Store) EmptySearches(ctx context.Context, projectID string, jetzt time.Time, limit int) ([]EmptySearch, error) {
|
||||
seit := jetzt.UTC().Add(-UsageWindow).Format(time.RFC3339Nano)
|
||||
|
||||
// MAX(ts) ist der jüngste Zeitpunkt. Die Synonyme der jüngsten Zeile holt
|
||||
// eine Eigenheit von SQLite: Steht MAX() in der Abfrage, kommen die übrigen
|
||||
// Spalten ohne Aggregat aus genau der Zeile mit dem Höchstwert.
|
||||
rows, err := s.lesen.QueryContext(ctx,
|
||||
`SELECT query, synonyms, COUNT(*) AS anzahl, MAX(ts)
|
||||
FROM search_log
|
||||
WHERE project_id = ? AND ts >= ? AND results = 0
|
||||
GROUP BY lower(trim(query))
|
||||
ORDER BY anzahl DESC, MAX(ts) DESC
|
||||
LIMIT ?`,
|
||||
projectID, seit, limit,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("Suchen ohne Treffer konnten nicht gelesen werden: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var liste []EmptySearch
|
||||
for rows.Next() {
|
||||
var e EmptySearch
|
||||
var zuletzt string
|
||||
if err := rows.Scan(&e.Query, &e.Synonyms, &e.Count, &zuletzt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
e.Last = parseTime(zuletzt)
|
||||
liste = append(liste, e)
|
||||
}
|
||||
return liste, rows.Err()
|
||||
}
|
||||
127
internal/store/suchprotokoll_test.go
Normal file
127
internal/store/suchprotokoll_test.go
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestSearchStatsZaehlt prüft die Kennzahlen: alle Suchen, ohne Treffer, mit
|
||||
// Synonymen — und nur innerhalb des Zeitfensters und des Projekts.
|
||||
func TestSearchStatsZaehlt(t *testing.T) {
|
||||
s := testStore(t)
|
||||
ctx := context.Background()
|
||||
p := testProject(t, s)
|
||||
|
||||
s.LogSearch(ctx, SearchLogEntry{ProjectID: p.ID, Query: "deployment", Results: 3})
|
||||
s.LogSearch(ctx, SearchLogEntry{ProjectID: p.ID, Query: "release", Synonyms: []string{"rollout"}, Results: 0})
|
||||
s.LogSearch(ctx, SearchLogEntry{ProjectID: p.ID, Query: "vpn", Results: 0})
|
||||
|
||||
// Außerhalb des Fensters: darf nicht mitzählen.
|
||||
alt := time.Now().UTC().Add(-UsageWindow - time.Hour).Format(time.RFC3339Nano)
|
||||
if _, err := s.schreiben.ExecContext(ctx,
|
||||
`INSERT INTO search_log (ts, project_id, query, results) VALUES (?, ?, 'alt', 0)`, alt, p.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
st, err := s.SearchStatsFor(ctx, p.ID, time.Now())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if st.Total != 3 || st.Empty != 2 || st.WithSynonyms != 1 {
|
||||
t.Errorf("SearchStats = %+v, erwartet Total 3, Empty 2, WithSynonyms 1", st)
|
||||
}
|
||||
if st.EmptyPercent() != 67 || st.SynonymPercent() != 33 {
|
||||
t.Errorf("Anteile = %d %% / %d %%, erwartet 67 / 33", st.EmptyPercent(), st.SynonymPercent())
|
||||
}
|
||||
}
|
||||
|
||||
// TestSearchStatsLeer prüft ein Projekt ohne Suchen: alles 0, kein Fehler.
|
||||
func TestSearchStatsLeer(t *testing.T) {
|
||||
s := testStore(t)
|
||||
p := testProject(t, s)
|
||||
|
||||
st, err := s.SearchStatsFor(context.Background(), p.ID, time.Now())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if st != (SearchStats{}) || st.EmptyPercent() != 0 {
|
||||
t.Errorf("SearchStats = %+v, erwartet nur Nullen", st)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEmptySearchesFasstZusammen prüft, dass gleiche Anfragen ohne Treffer
|
||||
// zusammengefasst werden, unabhängig von Groß- und Kleinschreibung, und die
|
||||
// häufigste vorne steht.
|
||||
func TestEmptySearchesFasstZusammen(t *testing.T) {
|
||||
s := testStore(t)
|
||||
ctx := context.Background()
|
||||
p := testProject(t, s)
|
||||
|
||||
s.LogSearch(ctx, SearchLogEntry{ProjectID: p.ID, Query: "vpn zugang", Results: 0})
|
||||
s.LogSearch(ctx, SearchLogEntry{ProjectID: p.ID, Query: "VPN Zugang", Results: 0})
|
||||
s.LogSearch(ctx, SearchLogEntry{ProjectID: p.ID, Query: " vpn zugang ", Synonyms: []string{"remote"}, Results: 0})
|
||||
s.LogSearch(ctx, SearchLogEntry{ProjectID: p.ID, Query: "drucker", Results: 0})
|
||||
s.LogSearch(ctx, SearchLogEntry{ProjectID: p.ID, Query: "deployment", Results: 2}) // hat Treffer
|
||||
|
||||
liste, err := s.EmptySearches(ctx, p.ID, time.Now(), 10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(liste) != 2 {
|
||||
t.Fatalf("%d Anfragen, erwartet 2: %+v", len(liste), liste)
|
||||
}
|
||||
if liste[0].Count != 3 {
|
||||
t.Errorf("häufigste Anfrage = %+v, erwartet 3× vpn zugang", liste[0])
|
||||
}
|
||||
if liste[0].Synonyms != "remote" {
|
||||
t.Errorf("Synonyme = %q, erwartet die der jüngsten Suche (remote)", liste[0].Synonyms)
|
||||
}
|
||||
if liste[1].Query != "drucker" {
|
||||
t.Errorf("zweite Anfrage = %q, erwartet drucker", liste[1].Query)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPruneSearchLog prüft das Aufräumen nach Ablauf der Frist.
|
||||
func TestPruneSearchLog(t *testing.T) {
|
||||
s := testStore(t)
|
||||
ctx := context.Background()
|
||||
p := testProject(t, s)
|
||||
|
||||
alt := time.Now().UTC().Add(-SearchLogRetention - time.Hour).Format(time.RFC3339Nano)
|
||||
if _, err := s.schreiben.ExecContext(ctx,
|
||||
`INSERT INTO search_log (ts, project_id, query, results) VALUES (?, ?, 'alt', 0)`, alt, p.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s.LogSearch(ctx, SearchLogEntry{ProjectID: p.ID, Query: "neu", Results: 0})
|
||||
|
||||
grenze := time.Now().UTC().Add(-SearchLogRetention).Format(time.RFC3339Nano)
|
||||
n, err := s.PruneSearchLog(ctx, grenze)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if n != 1 {
|
||||
t.Errorf("%d Zeilen entfernt, erwartet 1", n)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSuchprotokollVerschwindetMitProjekt prüft den Fremdschlüssel: Anfragen
|
||||
// sind Inhalt des Projekts und gehen mit ihm.
|
||||
func TestSuchprotokollVerschwindetMitProjekt(t *testing.T) {
|
||||
s := testStore(t)
|
||||
ctx := context.Background()
|
||||
p := testProject(t, s)
|
||||
|
||||
s.LogSearch(ctx, SearchLogEntry{ProjectID: p.ID, Query: "geheim", Results: 0})
|
||||
if err := s.DeleteProject(ctx, p.ID); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
var anzahl int
|
||||
if err := s.lesen.QueryRowContext(ctx, `SELECT COUNT(*) FROM search_log`).Scan(&anzahl); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if anzahl != 0 {
|
||||
t.Errorf("%d Anfragen des gelöschten Projekts sind noch da", anzahl)
|
||||
}
|
||||
}
|
||||
|
|
@ -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 ────────────────────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -1106,6 +1106,125 @@ func TestEntryBulkTags(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
// sammelLoeschen schickt die Sammelauswahl der Eintragsliste an bulk-delete.
|
||||
// rolle bestimmt den Key, mit dem angemeldet wird; extra ergänzt das Formular
|
||||
// (etwa um tag oder q).
|
||||
func sammelLoeschen(t *testing.T, srv *Server, db *store.Store, projekt store.Project,
|
||||
rolle auth.Role, ids []string, extra url.Values) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
h := testHandler(t, srv)
|
||||
|
||||
key := erzeugeKey(t, srv, db, projekt.ID, "mensch-"+string(rolle), rolle)
|
||||
cookie := anmelden(t, h, key)
|
||||
|
||||
keys, _ := db.ListAPIKeys(context.Background(), projekt.ID)
|
||||
var keyID string
|
||||
for _, k := range keys {
|
||||
if k.Name == "mensch-"+string(rolle) {
|
||||
keyID = k.ID
|
||||
}
|
||||
}
|
||||
token := auth.NewSigner([]byte(testSecret)).CSRFToken(auth.SubjectKeyPrefix + projekt.WorkspaceID + ":" + keyID)
|
||||
|
||||
formular := url.Values{"ids": ids, auth.CSRFFieldName: {token}}
|
||||
for k, v := range extra {
|
||||
formular[k] = v
|
||||
}
|
||||
r := httptest.NewRequest(http.MethodPost, "/p/testprojekt/entries/bulk-delete", strings.NewReader(formular.Encode()))
|
||||
r.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
||||
r.AddCookie(cookie)
|
||||
|
||||
w := httptest.NewRecorder()
|
||||
h.ServeHTTP(w, r)
|
||||
return w
|
||||
}
|
||||
|
||||
// TestEntryBulkDelete: Die ausgewählten Einträge liegen danach im Papierkorb,
|
||||
// je Eintrag steht eine Zeile im Protokoll, der Rest bleibt — und die
|
||||
// Weiterleitung führt zurück in dieselbe gefilterte Liste.
|
||||
func TestEntryBulkDelete(t *testing.T) {
|
||||
srv, db, projekt := testServer(t)
|
||||
ctx := context.Background()
|
||||
|
||||
a, _ := db.PutEntry(ctx, store.EntryWrite{ProjectID: projekt.ID, Slug: "a", Title: "A", Source: store.SourceHuman})
|
||||
b, _ := db.PutEntry(ctx, store.EntryWrite{ProjectID: projekt.ID, Slug: "b", Title: "B", Source: store.SourceHuman})
|
||||
db.PutEntry(ctx, store.EntryWrite{ProjectID: projekt.ID, Slug: "c", Title: "C", Source: store.SourceHuman})
|
||||
|
||||
w := sammelLoeschen(t, srv, db, projekt, auth.RoleWrite, []string{a.ID, b.ID}, url.Values{"tag": {"ops"}})
|
||||
|
||||
if w.Code != http.StatusSeeOther {
|
||||
t.Fatalf("Status = %d, erwartet Weiterleitung (%s)", w.Code, w.Body.String())
|
||||
}
|
||||
if ziel := w.Header().Get("Location"); !strings.Contains(ziel, "tag=ops") || !strings.Contains(ziel, "hinweis=") {
|
||||
t.Errorf("Weiterleitung nach %q — Filter oder Hinweis fehlt", ziel)
|
||||
}
|
||||
|
||||
papierkorb, _ := db.ListTrash(ctx, projekt.ID, "")
|
||||
if len(papierkorb) != 2 {
|
||||
t.Errorf("%d Einträge im Papierkorb, erwartet 2", len(papierkorb))
|
||||
}
|
||||
if _, err := db.EntryBySlug(ctx, projekt.ID, "c"); err != nil {
|
||||
t.Errorf("C war nicht ausgewählt und ist trotzdem weg: %v", err)
|
||||
}
|
||||
|
||||
ereignisse, _ := db.ListAudit(ctx, store.AuditFilter{ProjectID: projekt.ID})
|
||||
loeschungen := 0
|
||||
for _, e := range ereignisse {
|
||||
if e.Action == store.ActionEntryDelete {
|
||||
loeschungen++
|
||||
}
|
||||
}
|
||||
if loeschungen != 2 {
|
||||
t.Errorf("%d Löschungen im Protokoll, erwartet eine je Eintrag (2)", loeschungen)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEntryBulkDeleteNurMitSchreibrecht: Mit Leserecht wird nichts gelöscht.
|
||||
func TestEntryBulkDeleteNurMitSchreibrecht(t *testing.T) {
|
||||
srv, db, projekt := testServer(t)
|
||||
ctx := context.Background()
|
||||
|
||||
a, _ := db.PutEntry(ctx, store.EntryWrite{ProjectID: projekt.ID, Slug: "a", Title: "A", Source: store.SourceHuman})
|
||||
|
||||
w := sammelLoeschen(t, srv, db, projekt, auth.RoleRead, []string{a.ID}, nil)
|
||||
|
||||
if w.Code == http.StatusSeeOther {
|
||||
t.Errorf("Leserecht wurde weitergeleitet wie nach Erfolg (Status %d)", w.Code)
|
||||
}
|
||||
if _, err := db.EntryBySlug(ctx, projekt.ID, "a"); err != nil {
|
||||
t.Errorf("mit Leserecht wurde gelöscht: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestEintragslisteZeigtSammelloeschen: Der Knopf steht mit Rückfrage in der
|
||||
// Liste — und zwar hinter „Anwenden", damit Enter in einem Schlagwortfeld nie
|
||||
// löscht.
|
||||
func TestEintragslisteZeigtSammelloeschen(t *testing.T) {
|
||||
srv, db, projekt := testServer(t)
|
||||
h := testHandler(t, srv)
|
||||
db.PutEntry(context.Background(), store.EntryWrite{ProjectID: projekt.ID, Slug: "a", Title: "A", Source: store.SourceHuman})
|
||||
|
||||
schreiber := anmelden(t, h, erzeugeKey(t, srv, db, projekt.ID, "mensch", auth.RoleWrite))
|
||||
body := seiteHolen(h, schreiber, "/p/testprojekt/").Body.String()
|
||||
|
||||
anwenden := strings.Index(body, ">Anwenden<")
|
||||
loeschen := strings.Index(body, `formaction="/p/testprojekt/entries/bulk-delete"`)
|
||||
if anwenden < 0 || loeschen < 0 {
|
||||
t.Fatal("Sammelleiste ohne Anwenden oder ohne Löschknopf")
|
||||
}
|
||||
if loeschen < anwenden {
|
||||
t.Error("der Löschknopf steht vor „Anwenden“ — Enter in einem Textfeld würde löschen")
|
||||
}
|
||||
if !strings.Contains(body, "30 Tage lang wiederherstellen") {
|
||||
t.Error("die Rückfrage nennt die Frist des Papierkorbs nicht")
|
||||
}
|
||||
|
||||
leser := anmelden(t, h, erzeugeKey(t, srv, db, projekt.ID, "leser", auth.RoleRead))
|
||||
if strings.Contains(seiteHolen(h, leser, "/p/testprojekt/").Body.String(), "bulk-delete") {
|
||||
t.Error("die Leserolle sieht den Löschknopf")
|
||||
}
|
||||
}
|
||||
|
||||
// TestEntryVersionsAndDiff prüft den Verlauf und den Vergleich zweier
|
||||
// Fassungen — inklusive des Falls "Vergleich gegen die aktuelle Fassung",
|
||||
// wenn es keine spätere abgelöste Fassung mehr gibt.
|
||||
|
|
@ -2435,6 +2554,57 @@ func TestNutzungsseite(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
// TestNutzungsseiteZeigtSuchen: Suchen ohne Treffer erscheinen mit Anzahl —
|
||||
// und eine Anfrage mit HTML darin wird als Text ausgegeben, nicht ausgeführt.
|
||||
func TestNutzungsseiteZeigtSuchen(t *testing.T) {
|
||||
srv, db, projekt := testServer(t)
|
||||
h := testHandler(t, srv)
|
||||
ctx := context.Background()
|
||||
|
||||
db.LogSearch(ctx, store.SearchLogEntry{ProjectID: projekt.ID, Query: "vpn zugang", Results: 0})
|
||||
db.LogSearch(ctx, store.SearchLogEntry{ProjectID: projekt.ID, Query: "vpn zugang", Results: 0})
|
||||
db.LogSearch(ctx, store.SearchLogEntry{ProjectID: projekt.ID, Query: "<script>x</script>", Results: 0})
|
||||
db.LogSearch(ctx, store.SearchLogEntry{ProjectID: projekt.ID, Query: "deployment",
|
||||
Synonyms: []string{"release"}, Results: 1})
|
||||
|
||||
cookie := anmelden(t, h, erzeugeKey(t, srv, db, projekt.ID, "leser", auth.RoleRead))
|
||||
body := seiteHolen(h, cookie, "/p/testprojekt/usage").Body.String()
|
||||
|
||||
for _, erwartet := range []string{"vpn zugang", "2×", "75 %", "25 %"} {
|
||||
if !strings.Contains(body, erwartet) {
|
||||
t.Errorf("%q fehlt auf der Nutzungsseite", erwartet)
|
||||
}
|
||||
}
|
||||
if strings.Contains(body, "<script>x</script>") {
|
||||
t.Error("eine Suchanfrage wurde ungeschützt als HTML ausgegeben")
|
||||
}
|
||||
if strings.Contains(body, "deployment") {
|
||||
t.Error("eine Suche mit Treffer steht in der Liste ohne Treffer")
|
||||
}
|
||||
}
|
||||
|
||||
// TestNutzungsseiteZeigtZurPruefung: Ein kuratierter Eintrag, den ein Agent
|
||||
// geändert hat, steht mit Begründung und Link zum Verlauf oben auf der Seite.
|
||||
func TestNutzungsseiteZeigtZurPruefung(t *testing.T) {
|
||||
srv, db, projekt := testServer(t)
|
||||
h := testHandler(t, srv)
|
||||
ctx := context.Background()
|
||||
|
||||
db.PutEntry(ctx, store.EntryWrite{ProjectID: projekt.ID, Slug: "vpn", Title: "VPN-Zugang",
|
||||
Content: "neu", Source: store.SourceAgent})
|
||||
db.LogAudit(ctx, store.AuditEvent{ProjectID: projekt.ID, Actor: "agent-x", Action: store.ActionEntryWrite,
|
||||
EntrySlug: "vpn", Source: store.SourceMCP, Detail: store.CuratedOverrideDetail("Server umgezogen")})
|
||||
|
||||
cookie := anmelden(t, h, erzeugeKey(t, srv, db, projekt.ID, "leser", auth.RoleRead))
|
||||
body := seiteHolen(h, cookie, "/p/testprojekt/usage").Body.String()
|
||||
|
||||
for _, erwartet := range []string{"Zur Prüfung", "VPN-Zugang", "/p/testprojekt/e/vpn/versions", "Server umgezogen", "agent-x"} {
|
||||
if !strings.Contains(body, erwartet) {
|
||||
t.Errorf("%q fehlt auf der Nutzungsseite", erwartet)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestNutzungsseiteLeeresProjekt: Ein Projekt ganz ohne Daten darf nicht
|
||||
// stolpern (es fehlt dann in der map der Kennzahlen).
|
||||
func TestNutzungsseiteLeeresProjekt(t *testing.T) {
|
||||
|
|
|
|||
|
|
@ -235,10 +235,16 @@ func agent(ctx context.Context, httpTransport http.RoundTripper, url, key string
|
|||
werkzeug = "remember"
|
||||
// Ein eindeutiges Wort je Schreibvorgang — daran erkennt
|
||||
// pruefeBestand, ob der Suchindex den letzten Stand kennt.
|
||||
//
|
||||
// force: Alle umkämpften Einträge tragen denselben Titel. Ohne
|
||||
// force hielte die Dublettenprüfung jede Neuanlage nach der
|
||||
// ersten an — der Aufruf zählte als erfolgreich, ohne dass eine
|
||||
// Fassung entsteht, und die Bilanz in pruefeBestand ginge nicht auf.
|
||||
args = map[string]any{
|
||||
"slug": fmt.Sprintf("gemeinsam-%02d", zufall.IntN(*lastSlugs)),
|
||||
"title": "Gemeinsamer Eintrag",
|
||||
"content": "Stand " + eindeutigesWort(nr, i) + "\n\n" + fuellText(i),
|
||||
"force": true,
|
||||
}
|
||||
case w < *lastSchreiben+(100-*lastSchreiben)*2/3:
|
||||
werkzeug = "recall"
|
||||
|
|
|
|||
15
main.go
15
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 {
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@
|
|||
<form method="POST" action="/p/{{.Project.Slug}}/entries/bulk-tags" id="sammelform">
|
||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||
{{if .Data.Tag}}<input type="hidden" name="tag" value="{{.Data.Tag}}">{{end}}
|
||||
{{if .Data.Query}}<input type="hidden" name="q" value="{{.Data.Query}}">{{end}}
|
||||
{{end}}
|
||||
|
||||
<ul class="eintraege">
|
||||
|
|
@ -76,6 +77,12 @@
|
|||
<input type="text" name="add_tags" placeholder="Schlagworte hinzufügen, kommagetrennt" autocomplete="off">
|
||||
<input type="text" name="remove_tags" placeholder="Schlagworte entfernen, kommagetrennt" autocomplete="off">
|
||||
<button type="submit" class="zweitrangig">Anwenden</button>
|
||||
{{/* formaction schickt dasselbe Formular an eine andere Adresse. Der
|
||||
Knopf steht bewusst hinter "Anwenden": Enter in einem Textfeld
|
||||
löst immer den ersten Knopf aus, und das soll nie das Löschen
|
||||
sein. Die Rückfrage liest static/confirm.js am Knopf. */}}
|
||||
<button type="submit" class="warnung" formaction="/p/{{.Project.Slug}}/entries/bulk-delete"
|
||||
data-confirm="Die ausgewählten Einträge in den Papierkorb legen? Sie lassen sich dort {{.Data.RetentionDays}} Tage lang wiederherstellen.">In den Papierkorb</button>
|
||||
</div>
|
||||
</form>
|
||||
{{end}}
|
||||
|
|
|
|||
|
|
@ -32,6 +32,28 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{{/* ── Zur Prüfung ─────────────────────────────────────────────────────── */}}
|
||||
{{/* Steht oben, weil hier etwas zu tun ist. Ein Eintrag verschwindet von
|
||||
selbst aus der Liste, sobald ihn jemand wieder als kuratiert markiert. */}}
|
||||
{{if .Data.CuratedOverrides}}
|
||||
<section class="abschnitt">
|
||||
<h2>Zur Prüfung: kuratiert, dann von Agenten geändert</h2>
|
||||
<span class="hilfe">Diese Einträge hatte ein Mensch geprüft. Danach hat ein Agent sie
|
||||
mit Begründung geändert, dabei fiel die Markierung „kuratiert“ weg. Im Verlauf lässt
|
||||
sich die Änderung mit dem geprüften Stand vergleichen. Stimmt sie, den Haken
|
||||
„Kuratiert“ wieder setzen; sonst die alte Fassung wiederherstellen.</span>
|
||||
<ul class="kompakt-liste">
|
||||
{{range .Data.CuratedOverrides}}
|
||||
<li>
|
||||
<a href="/p/{{$slug}}/e/{{.Slug}}/versions">{{.Title}}</a>
|
||||
<span class="hilfe">· {{.Actor}} · {{datum .At}}</span>
|
||||
{{if .Reason}}<br><span class="hilfe">„{{.Reason}}“</span>{{end}}
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
</section>
|
||||
{{end}}
|
||||
|
||||
{{/* ── Schreibvorgänge ─────────────────────────────────────────────────── */}}
|
||||
<section class="abschnitt">
|
||||
<h2>Schreibvorgänge der letzten {{.Data.WindowDays}} Tage</h2>
|
||||
|
|
@ -99,6 +121,49 @@
|
|||
{{end}}
|
||||
</section>
|
||||
|
||||
{{/* ── Suchen der Agenten ─────────────────────────────────────────────── */}}
|
||||
{{$s := .Data.Searches}}
|
||||
<section class="abschnitt">
|
||||
<h2>Suchen der Agenten in den letzten {{.Data.WindowDays}} Tagen</h2>
|
||||
<span class="hilfe">Was Agenten mit <code>recall</code> gesucht haben. Suchen ohne Treffer
|
||||
zeigen entweder Wissen, das fehlt, oder Wissen, das unter anderen Wörtern dasteht.</span>
|
||||
|
||||
{{if $s.Total}}
|
||||
<div class="kennzahlen">
|
||||
<div class="kennzahl">
|
||||
<span class="kennzahl-wert">{{$s.Total}}</span>
|
||||
<span class="kennzahl-name">Suchen</span>
|
||||
</div>
|
||||
<div class="kennzahl">
|
||||
<span class="kennzahl-wert">{{$s.EmptyPercent}} %</span>
|
||||
<span class="kennzahl-name">ohne Treffer ({{$s.Empty}})</span>
|
||||
</div>
|
||||
<div class="kennzahl">
|
||||
<span class="kennzahl-wert">{{$s.SynonymPercent}} %</span>
|
||||
<span class="kennzahl-name">mit Synonymen</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{{if .Data.EmptySearches}}
|
||||
<p><strong>Ohne Treffer</strong>, häufigste zuerst.
|
||||
<span class="hilfe">Steht das Gesuchte unter anderen Wörtern da, hilft eine Zeile
|
||||
„Auch gesucht als: …“ im Eintrag. Fehlt es, ist das ein Kandidat für einen
|
||||
neuen Eintrag.</span></p>
|
||||
<ul class="kompakt-liste">
|
||||
{{range .Data.EmptySearches}}
|
||||
<li>
|
||||
„{{.Query}}“
|
||||
{{if .Synonyms}}<span class="hilfe">(auch: {{.Synonyms}})</span>{{end}}
|
||||
<span class="hilfe">· {{.Count}}× · zuletzt {{datum .Last}}</span>
|
||||
</li>
|
||||
{{end}}
|
||||
</ul>
|
||||
{{end}}
|
||||
{{else}}
|
||||
<p class="hilfe">In den letzten {{.Data.WindowDays}} Tagen hat kein Agent gesucht.</p>
|
||||
{{end}}
|
||||
</section>
|
||||
|
||||
{{/* ── Verweise ins Leere ─────────────────────────────────────────────── */}}
|
||||
{{if .Data.Missing}}
|
||||
<section class="abschnitt">
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue