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 |
|
| Werkzeug | Parameter | Rolle |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `recall` | `query`, `tags?`, `limit?` | read |
|
| `recall` | `query`, `synonyms?`, `tags?`, `limit?` | read |
|
||||||
| `get` | `slug` | read |
|
| `get` | `slug` | read |
|
||||||
| `list` | `tag?`, `curated?`, `limit?` | read |
|
| `list` | `tag?`, `curated?`, `limit?` | read |
|
||||||
| `related` | `slug`, `distance?`, `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 |
|
| `forget` | `slug` | write |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -56,8 +56,28 @@ Deshalb der folgende Block.
|
||||||
|
|
||||||
## 4. Projektprompt
|
## 4. Projektprompt
|
||||||
|
|
||||||
In `CLAUDE.md`, `.clinerules`, „Custom Instructions" oder den Systemprompt der
|
### Wohin der Block gehört
|
||||||
eigenen Anwendung:
|
|
||||||
|
| 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
|
```markdown
|
||||||
## Munin
|
## 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.
|
get, list, related, remember, forget). Er ist das gemeinsame Gedächtnis des Teams.
|
||||||
|
|
||||||
LESEN — vor jeder Antwort zu projektspezifischen Fragen:
|
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.
|
- Passenden Treffer mit `get` vollständig laden.
|
||||||
- Für mehr Kontext mit `related` die verknüpften Einträge ansehen.
|
- 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
|
- 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.
|
- IMMER zuerst `recall`, ob es zum Thema schon einen Eintrag gibt.
|
||||||
- Gibt es einen: mit DESSEN slug `remember` aufrufen. Das aktualisiert ihn.
|
- Gibt es einen: mit DESSEN slug `remember` aufrufen. Das aktualisiert ihn.
|
||||||
- Nur wenn wirklich nichts passt, einen neuen slug wählen.
|
- 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
|
- Vor dem Aktualisieren `get` aufrufen und die gelieferte `version` als
|
||||||
`expected_version` mitgeben.
|
`expected_version` mitgeben.
|
||||||
|
|
||||||
|
|
@ -91,6 +119,8 @@ WAS festgehalten wird:
|
||||||
WIE festgehalten wird:
|
WIE festgehalten wird:
|
||||||
- Destilliert, nicht als Protokoll. Ein Eintrag beantwortet eine Frage.
|
- Destilliert, nicht als Protokoll. Ein Eintrag beantwortet eine Frage.
|
||||||
- Verwandtes mit [[bezeichner]] verknüpfen.
|
- 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).
|
- 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 |
|
| Neuer slug für vorhandenes Thema | slug-Regeln oben; `recall` liefert den vorhandenen |
|
||||||
| Zeitstempel im slug | verbieten — Fassungen zählt das System |
|
| Zeitstempel im slug | verbieten — Fassungen zählt das System |
|
||||||
| Jede Erkenntnis als eigener Eintrag | „Ein Eintrag beantwortet eine Frage" |
|
| 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.
|
**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
|
**Faustregel:** Nach ein paar Wochen sollte das Verhältnis von Aktualisierungen zu
|
||||||
Neuanlagen steigen. Viele Neuanlagen bei ähnlichen Themen heißt: Der Projektprompt
|
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"
|
**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
|
sind ungeprüfte Beobachtungen. Wer sie durchsieht und bestätigt, setzt den Haken
|
||||||
„Kuratiert" — Agenten sehen das Feld über `recall` und `get`.
|
„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
|
## 9. Rollen
|
||||||
|
|
@ -190,8 +238,11 @@ die Einträge auch durchsieht.
|
||||||
|
|
||||||
## 10. Grenzen
|
## 10. Grenzen
|
||||||
|
|
||||||
- **Suche ist wortbasiert.** „wie deployen wir" findet „Release-Prozess" nicht.
|
- **Suche ist wortbasiert.** Sie gleicht Wortanfänge, Endungen und
|
||||||
Im Zweifel `list` für den Überblick.
|
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
|
- **`forget` legt in den Papierkorb.** Der Eintrag ist für Agenten sofort
|
||||||
unsichtbar, ein Mensch kann ihn 30 Tage lang in der Weboberfläche
|
unsichtbar, ein Mensch kann ihn 30 Tage lang in der Weboberfläche
|
||||||
zurückholen. Agenten können weder wiederherstellen noch den Papierkorb
|
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).
|
**Vorher versuchen:** die `instructions` an echten Anfragen schärfen (siehe 3.1).
|
||||||
Erfahrungsgemäß holt das einen guten Teil des Unterschieds, kostet aber nichts.
|
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
|
### 2.3 Lange Einträge zerlegen
|
||||||
|
|
||||||
`get` liefert einen Eintrag ganz oder gar nicht. Ein Eintrag mit 3.000 Wörtern
|
`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
|
Agentengedächtnis sind die meisten Einträge kurz — dann wäre das eine Lösung ohne
|
||||||
Problem.
|
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.
|
Umgesetzt in `internal/search/varianten.go`. Zu jedem Suchwort sucht der Index
|
||||||
Und „Deployment" findet „Deployments" nicht.
|
mehrere Varianten, jede schwächer gewichtet als ein exakter Treffer (1):
|
||||||
|
|
||||||
Beides ist Standardwerkzeug jeder Suchmaschine, kostet aber Genauigkeit bei
|
| Variante | Beispiel | Gewicht | ab Länge |
|
||||||
Fachbegriffen. **Erst messen, ob es im Alltag stört** — BM25 gewichtet häufige
|
|---|---|---|---|
|
||||||
Wörter ohnehin bereits herunter.
|
| 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.
|
**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
|
### 3.2 Importer als Gegenstück zum Export — ✅ umgesetzt
|
||||||
|
|
||||||
Der Markdown-Export ist heute eine Einbahnstraße. Das Dateiformat trägt alle
|
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
|
`MAX_API_BODY_BYTES` wirkte nur nach unten. Jetzt wählt `LimitBodies` die
|
||||||
Grenze einmal außen je Route.
|
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
|
Heute verhindert nur der gleiche Bezeichner eine Dublette. Wählt ein Agent einen
|
||||||
neuen Slug für dasselbe Thema, steht die Erkenntnis zweimal da.
|
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
|
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.
|
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
|
### 3.4 Bezeichner vorschlagen lassen
|
||||||
|
|
||||||
Der slug ist der Schlüssel gegen Dubletten (siehe 3.3), aber der Agent muss ihn
|
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 |
|
| ✅ Echte Server-Adresse auf der Zugangsseite (5.2) | Konfiguration direkt kopierbar | gering |
|
||||||
| ✅ Mehr Markdown in Einträgen (5.3) | Tabellen, Checklisten, Durchstreichen | 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 |
|
| ✅ 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
|
Nichts davon ist dringend: Die Oberfläche ist bewusst der zweite Zugang, MCP ist
|
||||||
der erste.
|
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
|
### 5.1 Fassung wiederherstellen — ✅ umgesetzt
|
||||||
|
|
||||||
Der Verlauf (3.6) zeigt alte Fassungen, aber zurückholen heißt heute: Text aus
|
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**
|
**Bewusst entscheiden, nicht nebenbei**
|
||||||
|
|
||||||
7. **`instructions` schärfen** (3.1) — braucht ein paar Wochen echter Nutzung.
|
7. **`instructions` schärfen** (3.1) — braucht ein paar Wochen echter Nutzung.
|
||||||
Das Zugriffsprotokoll und 4.6 liefern die Daten dafür: Wie viele
|
Das Zugriffsprotokoll, 4.6 und seit 2026-10-14 das Suchprotokoll liefern
|
||||||
`remember`-Aufrufe legen neu an, wie viele aktualisieren?
|
die Daten dafür: Wie viele `remember`-Aufrufe legen neu an, wie viele
|
||||||
8. **Kandidaten bei Dubletten** (3.3) und **Bezeichner vorschlagen** (3.4) —
|
Suchen bleiben ohne Treffer?
|
||||||
beide ändern den MCP-Vertrag.
|
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
|
9. ~~**Admin je Workspace** (4.8)~~ — erledigt 2026-09-30, ohne Keycloak
|
||||||
(siehe die Entscheidung dort). **Keycloak** (4.3) bleibt offen, bis es
|
(siehe die Entscheidung dort). **Keycloak** (4.3) bleibt offen, bis es
|
||||||
gebraucht wird.
|
gebraucht wird.
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,7 @@ statt `/data`, und die Ausgabe des Prozesses statt `docker logs`.
|
||||||
| Was | Wo |
|
| Was | Wo |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Kontroll-Datenbank | `DATA_DIR/control.db`: Workspaces, welcher Key zu welchem Workspace gehört, Workspace-Admins |
|
| 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** |
|
| 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` |
|
| Logs | Standardausgabe des Prozesses: `docker logs munin` |
|
||||||
| Sicherungen | `BACKUP_DIR/<zeitstempel>/` von `scripts/backup.sh`, siehe README „Datensicherung“ |
|
| 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
|
Neue Ergebnisse bitte mit Datum unter **Ergebnis** ergänzen, die alten stehen
|
||||||
lassen. So bleibt sichtbar, wie sich Änderungen ausgewirkt haben.
|
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
|
## Inhalt
|
||||||
|
|
||||||
- [Schnellstart](#schnellstart)
|
- [Schnellstart](#schnellstart)
|
||||||
|
- [Bauen](#bauen)
|
||||||
- [Grundbegriffe](#grundbegriffe)
|
- [Grundbegriffe](#grundbegriffe)
|
||||||
- [MCP einrichten](#mcp-einrichten)
|
- [MCP einrichten](#mcp-einrichten)
|
||||||
- [Die Werkzeuge](#die-werkzeuge)
|
- [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
|
## Grundbegriffe
|
||||||
|
|
||||||
| Begriff | Bedeutung |
|
| Begriff | Bedeutung |
|
||||||
|
|
@ -191,8 +280,8 @@ Konfiguration.
|
||||||
geschrieben hat, und ein einzelner Zugang lässt sich zurückziehen, ohne alle
|
geschrieben hat, und ein einzelner Zugang lässt sich zurückziehen, ohne alle
|
||||||
anderen zu stören.
|
anderen zu stören.
|
||||||
|
|
||||||
Verbindung prüfen: Der Agent sollte `recall`, `get`, `list`, `remember` und
|
Verbindung prüfen: Der Agent sollte `recall`, `get`, `list`, `related`,
|
||||||
`forget` anbieten. Tut er das nicht, hilft ein Blick ins Zugriffsprotokoll des
|
`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
|
Servers — ein `401` dort heißt falscher oder zurückgezogener Key, gar kein Eintrag
|
||||||
heißt, dass der Client den Server nie aufgerufen hat.
|
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 |
|
| 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. |
|
| `get` | read | Lädt einen Eintrag vollständig, samt Verweisen und Fassungsnummer. |
|
||||||
| `list` | read | Überblick: zuletzt geändert, alle Schlagworte. |
|
| `list` | read | Überblick: zuletzt geändert, alle Schlagworte. |
|
||||||
| `related` | read | Über `[[Verweise]]` verknüpfte Einträge, direkt und über einen Zwischeneintrag. |
|
| `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). |
|
| `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
|
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
|
Anweisung, abzubrechen und das Limit zu melden — sonst versucht es sofort wieder
|
||||||
oder weicht auf ein anderes Werkzeug aus.
|
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
|
### Gleichzeitiges Schreiben
|
||||||
|
|
||||||
`get` liefert eine `version`. Wird sie bei `remember` als `expected_version`
|
`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/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/projects/{projekt}/edit` | Name, Beschreibung und Schreibbudget ändern; Projekt löschen |
|
||||||
| `/admin/audit` | Protokoll über alle Projekte des gewählten Workspace |
|
| `/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}/new` | Neuen Eintrag anlegen (`?slug=` belegt den Bezeichner vor) |
|
||||||
| `/p/{projekt}/e/{bezeichner}` | Eintrag lesen, mit Rückverweisen |
|
| `/p/{projekt}/e/{bezeichner}` | Eintrag lesen, mit Rückverweisen |
|
||||||
| `/p/{projekt}/e/{bezeichner}/edit` | Eintrag bearbeiten, mit Vorschau |
|
| `/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}/graph` | Verweise zwischen den Einträgen als Graph |
|
||||||
| `/p/{projekt}/keys` | Zugänge verwalten (Rolle `admin`) |
|
| `/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}/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}/audit` | Protokoll des Projekts |
|
||||||
| `/p/{projekt}/trash` | Papierkorb: wiederherstellen oder endgültig löschen (Schreibrecht) |
|
| `/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. |
|
| `/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
|
aus denen sich Personen ableiten lassen — eine Aufbewahrungsfrist ist deshalb die
|
||||||
Voraussetzung dafür, es überhaupt führen zu dürfen.
|
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.
|
Jeder Workspace führt sein eigenes Protokoll in seiner eigenen Datei.
|
||||||
`/admin/audit` zeigt das Protokoll des gerade gewählten Workspace, nicht alle
|
`/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,
|
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.
|
│ Konfiguration, Sicherheit, Betrieb.
|
||||||
├── AGENTEN-GUIDE.md ← Wie man KI-Werkzeuge anbindet und anspricht,
|
├── AGENTEN-GUIDE.md ← Wie man KI-Werkzeuge anbindet und anspricht,
|
||||||
│ damit sie den Speicher tatsächlich nutzen:
|
│ 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.
|
│ Muster für einzelne Aufgaben.
|
||||||
├── BETRIEB.md ← Betriebshandbuch für den Ernstfall: Server startet
|
├── BETRIEB.md ← Betriebshandbuch für den Ernstfall: Server startet
|
||||||
│ nicht, Platte voll, Key oder Geheimnis geleakt,
|
│ nicht, Platte voll, Key oder Geheimnis geleakt,
|
||||||
|
|
@ -105,6 +107,11 @@ munin/
|
||||||
│ │ │ MissingLinks, UntaggedEntries, RecentlyUsedKeys.
|
│ │ │ MissingLinks, UntaggedEntries, RecentlyUsedKeys.
|
||||||
│ │ │ Keine eigene Zähltabelle.
|
│ │ │ 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),
|
│ │ ├── papierkorb.go ← Papierkorb: MoveEntryToTrash (statt DELETE),
|
||||||
│ │ │ ListTrash, RestoreFromTrash, PurgeFromTrash
|
│ │ │ ListTrash, RestoreFromTrash, PurgeFromTrash
|
||||||
│ │ │ (nur was schon im Papierkorb liegt) und
|
│ │ │ (nur was schon im Papierkorb liegt) und
|
||||||
|
|
@ -205,7 +212,9 @@ munin/
|
||||||
│ ├── entry/ ── Wie sieht ein Eintrag aus? ─────────────────────
|
│ ├── entry/ ── Wie sieht ein Eintrag aus? ─────────────────────
|
||||||
│ │ ├── entry.go ← Markdown nach HTML (über internal/markdown),
|
│ │ ├── entry.go ← Markdown nach HTML (über internal/markdown),
|
||||||
│ │ │ Auflösen der [[…]]-Verweise, Textausschnitte für
|
│ │ │ 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
|
│ │ └── diff.go ← Zeilenvergleich zweier Fassungen (DiffLines) als
|
||||||
│ │ LCS ohne externe Abhängigkeit. Bei sehr langen
|
│ │ LCS ohne externe Abhängigkeit. Bei sehr langen
|
||||||
|
|
@ -254,10 +263,15 @@ munin/
|
||||||
│ │
|
│ │
|
||||||
│ ├── search/ ── Volltextsuche ──────────────────────────────────
|
│ ├── search/ ── Volltextsuche ──────────────────────────────────
|
||||||
│ │ ├── index.go ← Invertierter Index mit BM25-Bewertung. Titel zählen
|
│ │ ├── index.go ← Invertierter Index mit BM25-Bewertung. Titel zählen
|
||||||
│ │ │ dreifach, Schlagworte zweifach. Enthält die
|
│ │ │ dreifach, Schlagworte zweifach. Search für
|
||||||
│ │ │ Präfix-Rückfallebene: Ohne sie findet "deploy"
|
│ │ │ Agenten, SearchFuzzy (mit Tippfehlern) für die
|
||||||
│ │ │ den "Deployment-Prozess" nicht — bei einer
|
│ │ │ Weboberfläche.
|
||||||
│ │ │ KI-Anfrage ein stiller Fehltreffer.
|
│ │ │
|
||||||
|
│ │ ├── 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
|
│ │ └── registry.go ← Ein Index je Projekt, verzögert aufgebaut. Getrennte
|
||||||
│ │ Indizes statt eines gemeinsamen mit Filter: So wird
|
│ │ Indizes statt eines gemeinsamen mit Filter: So wird
|
||||||
|
|
@ -306,7 +320,8 @@ munin/
|
||||||
│ │ ├── handlers_entries.go ← Einträge auflisten, suchen, ansehen, bearbeiten.
|
│ │ ├── handlers_entries.go ← Einträge auflisten, suchen, ansehen, bearbeiten.
|
||||||
│ │ │ Dazu Vorschau (preview), Vorschläge für [[…]]
|
│ │ │ Dazu Vorschau (preview), Vorschläge für [[…]]
|
||||||
│ │ │ (suggest), Fassungen und Vergleich, Graph-Daten
|
│ │ │ (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),
|
│ │ ├── handlers_tags.go ← Seite "Alle Schlagworte" (/p/{projekt}/tags),
|
||||||
│ │ │ nach Häufigkeit oder alphabetisch gruppiert. Die
|
│ │ │ nach Häufigkeit oder alphabetisch gruppiert. Die
|
||||||
│ │ │ Seitenleiste zeigt nur die häufigsten (navTagLimit
|
│ │ │ Seitenleiste zeigt nur die häufigsten (navTagLimit
|
||||||
|
|
@ -356,6 +371,14 @@ munin/
|
||||||
│ │ Qualität der Antworten, weil er bei jedem Client
|
│ │ Qualität der Antworten, weil er bei jedem Client
|
||||||
│ │ im Kontext steht.
|
│ │ 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,
|
│ └── tools.go ← Die sechs Werkzeuge: recall, get, list, related,
|
||||||
│ remember, forget. Alte Einträge tragen in den
|
│ remember, forget. Alte Einträge tragen in den
|
||||||
│ Antworten "stale": true. Jedes ist eine Closure
|
│ 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/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/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/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/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/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 |
|
| `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/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/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/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 |
|
| `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
|
## Datenfluss: Agent ruft Wissen ab
|
||||||
|
|
@ -452,9 +478,10 @@ KI-Werkzeug POST /mcp { "method": "tools/call", "params": { "name": "recall",
|
||||||
→ mcpserver/tools.go (recall)
|
→ mcpserver/tools.go (recall)
|
||||||
→ search/registry.go Index dieses Projekts (beim ersten Mal
|
→ search/registry.go Index dieses Projekts (beim ersten Mal
|
||||||
aus der Datenbank aufgebaut)
|
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
|
→ store/entries.go (EntryBySlug) Treffer nachladen
|
||||||
→ entry/entry.go (Snippet) Textausschnitt je Treffer
|
→ entry/entry.go (Snippet) Textausschnitt je Treffer
|
||||||
|
→ store/suchprotokoll.go (LogSearch) Anfrage und Trefferzahl
|
||||||
→ JSON-Antwort mit Trefferliste und Hinweis zum weiteren Vorgehen
|
→ 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
|
→ web/middleware.go (APIAuth) wie oben
|
||||||
→ mcpserver/tools.go (remember)
|
→ mcpserver/tools.go (remember)
|
||||||
→ requireWrite() Rolle read wird hier abgewiesen
|
→ 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:
|
→ store/entries.go (PutEntry) eine Transaktion:
|
||||||
· Bezeichner vorhanden? → aktualisieren
|
· Bezeichner vorhanden? → aktualisieren
|
||||||
· ExpectedVersion prüfen → ggf. Konflikt
|
· ExpectedVersion prüfen → ggf. Konflikt
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,10 @@ package entry
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"html"
|
"html"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
"unicode/utf8"
|
||||||
|
|
||||||
"munin/internal/markdown"
|
"munin/internal/markdown"
|
||||||
"munin/internal/store"
|
"munin/internal/store"
|
||||||
|
|
@ -55,15 +58,17 @@ const snippetRadius = 120
|
||||||
func Snippet(content, query string) string {
|
func Snippet(content, query string) string {
|
||||||
text := RenderPlain(content)
|
text := RenderPlain(content)
|
||||||
|
|
||||||
idx := strings.Index(strings.ToLower(text), strings.ToLower(query))
|
nadel := trefferwort(text, query)
|
||||||
if idx == -1 {
|
if nadel == "" {
|
||||||
// Kein wörtlicher Treffer (z.B. weil der Fund über die Präfix-Suche kam) —
|
// Kein Wort der Anfrage steht wörtlich im Text (z.B. weil der Fund über
|
||||||
// dann der Anfang des Texts als Vorschau.
|
// eine Endung oder ein Synonym kam) — dann der Anfang des Texts als
|
||||||
|
// Vorschau.
|
||||||
return kuerzen(text, snippetRadius+snippetRadius)
|
return kuerzen(text, snippetRadius+snippetRadius)
|
||||||
}
|
}
|
||||||
|
idx := strings.Index(strings.ToLower(text), strings.ToLower(nadel))
|
||||||
|
|
||||||
start := max(idx-snippetRadius, 0)
|
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])
|
ausschnitt := strings.TrimSpace(text[start:ende])
|
||||||
if start > 0 {
|
if start > 0 {
|
||||||
|
|
@ -75,6 +80,59 @@ func Snippet(content, query string) string {
|
||||||
return ausschnitt
|
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.
|
// kuerzen schneidet einen Text auf höchstens n Zeichen.
|
||||||
func kuerzen(text string, n int) string {
|
func kuerzen(text string, n int) string {
|
||||||
text = strings.TrimSpace(text)
|
text = strings.TrimSpace(text)
|
||||||
|
|
@ -97,9 +155,14 @@ func HighlightSnippet(content, query string) string {
|
||||||
return sicher
|
return sicher
|
||||||
}
|
}
|
||||||
|
|
||||||
// Im escapten Text nach dem (ebenfalls escapten) Suchbegriff suchen, damit
|
// Hervorgehoben wird dasselbe Stück der Anfrage, an dem sich der
|
||||||
// die Positionen zusammenpassen.
|
// Ausschnitt ausgerichtet hat. Gesucht wird im escapten Text nach dem
|
||||||
nadel := html.EscapeString(query)
|
// 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))
|
idx := strings.Index(strings.ToLower(sicher), strings.ToLower(nadel))
|
||||||
if idx == -1 {
|
if idx == -1 {
|
||||||
return sicher
|
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):]
|
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
|
// TitleFromContent rät einen Titel aus dem Inhalt — die erste Überschrift oder
|
||||||
// die erste Zeile. Wird verwendet, wenn ein Agent keinen Titel mitgeschickt hat.
|
// die erste Zeile. Wird verwendet, wenn ein Agent keinen Titel mitgeschickt hat.
|
||||||
func TitleFromContent(content string) string {
|
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) {
|
func TestSnippetShortText(t *testing.T) {
|
||||||
got := Snippet("Kurz.", "Kurz")
|
got := Snippet("Kurz.", "Kurz")
|
||||||
if got != "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))
|
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**
|
**Kuratiert**
|
||||||
Ein Haken, den ein Mensch setzt, wenn er einen Eintrag geprüft hat. Einträge
|
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.
|
ohne diesen Haken sind Beobachtungen, keine bestätigten Fakten. Agenten sehen
|
||||||
Agenten sehen den Haken ebenfalls und können danach filtern.
|
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)
|
**API-Key** (Zugang)
|
||||||
Ein Schlüssel, der mit `wk_` beginnt und genau zu einem Projekt gehört. Er
|
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
|
und Suchen stehen nicht darin. Protokolleinträge werden nach 90 Tagen
|
||||||
automatisch gelöscht.
|
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
|
## Rollen
|
||||||
|
|
||||||
Jeder Key hat genau eine Rolle.
|
Jeder Key hat genau eine Rolle.
|
||||||
|
|
|
||||||
|
|
@ -106,8 +106,8 @@ In `opencode.json` im Projektverzeichnis oder global unter
|
||||||
|
|
||||||
## 3. Prüfen, ob es geht
|
## 3. Prüfen, ob es geht
|
||||||
|
|
||||||
Das Werkzeug sollte fünf Munin-Werkzeuge anbieten: `recall`, `get`, `list`,
|
Das Werkzeug sollte sechs Munin-Werkzeuge anbieten: `recall`, `get`, `list`,
|
||||||
`remember` und `forget`. Ein einfacher Test:
|
`related`, `remember` und `forget`. Ein einfacher Test:
|
||||||
|
|
||||||
```
|
```
|
||||||
Rufe list auf und sag mir, welche Schlagworte es im Munin gibt.
|
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
|
Wenn die Verbindung steht, geht es mit
|
||||||
[Mit Agenten arbeiten](/hilfe/mit-agenten-arbeiten) weiter. Ohne eine kurze
|
[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.
|
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
|
## Der Projektprompt
|
||||||
|
|
||||||
In `CLAUDE.md`, `.clinerules`, *Custom Instructions* oder den Systemprompt
|
Diesen Block übernehmen:
|
||||||
deiner Anwendung übernehmen:
|
|
||||||
|
|
||||||
```markdown
|
```markdown
|
||||||
## Munin
|
## 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.
|
get, list, related, remember, forget). Er ist das gemeinsame Gedächtnis des Teams.
|
||||||
|
|
||||||
LESEN — vor jeder Antwort zu projektspezifischen Fragen:
|
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.
|
- Passenden Treffer mit `get` vollständig laden.
|
||||||
- Für mehr Kontext mit `related` die verknüpften Einträge ansehen.
|
- 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
|
- 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.
|
- IMMER zuerst `recall`, ob es zum Thema schon einen Eintrag gibt.
|
||||||
- Gibt es einen: mit DESSEN slug `remember` aufrufen. Das aktualisiert ihn.
|
- Gibt es einen: mit DESSEN slug `remember` aufrufen. Das aktualisiert ihn.
|
||||||
- Nur wenn wirklich nichts passt, einen neuen slug wählen.
|
- 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
|
- Vor dem Aktualisieren `get` aufrufen und die gelieferte `version` als
|
||||||
`expected_version` mitgeben.
|
`expected_version` mitgeben.
|
||||||
|
|
||||||
|
|
@ -44,6 +74,8 @@ WAS festgehalten wird:
|
||||||
WIE festgehalten wird:
|
WIE festgehalten wird:
|
||||||
- Destilliert, nicht als Protokoll. Ein Eintrag beantwortet eine Frage.
|
- Destilliert, nicht als Protokoll. Ein Eintrag beantwortet eine Frage.
|
||||||
- Verwandtes mit [[bezeichner]] verknüpfen.
|
- 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).
|
- 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
|
überschrieben. Sie wäre zwar im Verlauf noch zu finden, aber niemand würde es
|
||||||
merken.
|
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
|
## Grenzen, die man kennen sollte
|
||||||
|
|
||||||
- **Die Suche ist wortbasiert.** „Wie deployen wir“ findet „Release-Prozess“
|
- **Die Suche ist wortbasiert.** Sie gleicht Wortanfänge, Endungen und
|
||||||
nicht. Im Zweifel hilft `list` für den Überblick.
|
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
|
- **`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
|
weg, lässt sich aber 30 Tage lang von einem Menschen zurückholen. Agenten
|
||||||
selbst können nichts wiederherstellen und den Papierkorb nicht leeren.
|
selbst können nichts wiederherstellen und den Papierkorb nicht leeren.
|
||||||
|
|
@ -146,6 +196,16 @@ bedeuten: Der Projektprompt greift nicht.
|
||||||
|
|
||||||
Außerdem zeigt die Seite:
|
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
|
- **Verweise ins Leere:** Bezeichner, auf die mit `[[…]]` verwiesen wird, ohne
|
||||||
dass es den Eintrag gibt. Die meistgenannten sind gute Kandidaten für den
|
dass es den Eintrag gibt. Die meistgenannten sind gute Kandidaten für den
|
||||||
nächsten Eintrag; mit Schreibrecht führt *anlegen* direkt dorthin.
|
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,
|
- **Suchen:** Das Suchfeld über der Eintragsliste durchsucht Titel,
|
||||||
Schlagworte und Inhalt. Titel zählen am stärksten. Umlaute sind egal:
|
Schlagworte und Inhalt. Titel zählen am stärksten. Umlaute sind egal:
|
||||||
„Uberblick“ findet auch „Überblick“. Ein Wortanfang reicht, „deploy“ findet
|
„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
|
- **Nach Schlagwort filtern:** In der Seitenleiste stehen die zehn häufigsten
|
||||||
Schlagworte des Projekts. Ein Klick filtert die Liste. Gibt es mehr, führt
|
Schlagworte des Projekts. Ein Klick filtert die Liste. Gibt es mehr, führt
|
||||||
*Alle Schlagworte…* zu einer Seite mit der vollständigen Liste, nach
|
*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
|
angesehen hast, wird nichts überschrieben; prüfe dann den neuen Stand und
|
||||||
versuche es erneut.
|
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
|
## Papierkorb
|
||||||
|
|
||||||
**Löschen** legt einen Eintrag in den Papierkorb, egal ob ein Mensch in der
|
**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
|
## Viele Einträge auf einmal
|
||||||
|
|
||||||
Mit Schreibrecht hat die Eintragsliste vor jedem Eintrag ein Auswahlkästchen.
|
Mit Schreibrecht hat die Eintragsliste vor jedem Eintrag ein Auswahlkästchen.
|
||||||
Unter der Liste lassen sich für alle ausgewählten Einträge Schlagworte
|
Unter der Liste gibt es für alle ausgewählten Einträge zwei Aktionen:
|
||||||
hinzufügen oder entfernen, mehrere durch Komma getrennt. Praktisch, um ein
|
|
||||||
Schlagwort umzubenennen: das neue hinzufügen, das alte entfernen.
|
- **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
|
## 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
|
zurückgezogen wurden. Einträge werden nach 90 Tagen gelöscht, weil sie Namen
|
||||||
und Adressen enthalten.
|
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
|
## Was man beim Teilen von Wissen bedenken sollte
|
||||||
|
|
||||||
**Keine Geheimnisse in Einträge.** Passwörter, Tokens und private Schlüssel
|
**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")
|
wort := fmt.Sprintf("marke%sx%s", buchstaben(runde*1000+a*100+i), "ende")
|
||||||
erg, err := client.CallTool(ctx, &mcp.CallToolParams{
|
erg, err := client.CallTool(ctx, &mcp.CallToolParams{
|
||||||
Name: "remember",
|
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{
|
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 {
|
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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
@ -292,7 +293,7 @@ func TestReadRoleCanRead(t *testing.T) {
|
||||||
|
|
||||||
// ── Suchen und Lesen ─────────────────────────────────────────────────────────
|
// ── 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,
|
// "Deployment-Prozess" finden. Ohne sie liefert die Suche ein leeres Ergebnis,
|
||||||
// und das Modell erfährt nie, dass es den Eintrag gäbe.
|
// und das Modell erfährt nie, dass es den Eintrag gäbe.
|
||||||
func TestRecallFindsByPrefix(t *testing.T) {
|
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) {
|
func TestRecallFiltersByTag(t *testing.T) {
|
||||||
h, ws, projekt := aufbau(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{
|
ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{
|
||||||
"slug": "b", "title": "Deployment B", "content": "deployment", "tags": []any{"docs"},
|
"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",
|
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)),
|
"slug": "eintrag-" + string(rune('a'+i%26)) + string(rune('a'+i/26)),
|
||||||
"title": "Eintrag",
|
"title": "Eintrag",
|
||||||
"content": "gemeinsames Stichwort",
|
"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++ {
|
for i := 0; i < grenze; i++ {
|
||||||
ergebnis := ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{
|
ergebnis := ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{
|
||||||
"slug": fmt.Sprintf("eintrag-%d", i), "title": "Eintrag", "content": "Inhalt",
|
"slug": fmt.Sprintf("eintrag-%d", i), "title": "Eintrag", "content": "Inhalt",
|
||||||
|
"force": true, // gleiche Titel sind hier gewollt, siehe dubletten.go
|
||||||
})
|
})
|
||||||
if ergebnis.IsError {
|
if ergebnis.IsError {
|
||||||
t.Fatalf("Aufruf %d von %d abgelehnt, obwohl im Budget: %s", i+1, grenze, text(ergebnis))
|
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{
|
ruf(t, h, ws, projekt, auth.RoleWrite, "remember", map[string]any{
|
||||||
"slug": "neues-deployment", "title": "Deployment neu", "content": "Deployment per Pipeline",
|
"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"}))
|
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)
|
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:
|
Vorgehen beim LESEN:
|
||||||
1. "recall" mit einer inhaltlichen Frage aufrufen (nicht mit Stichworten).
|
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.
|
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:
|
Vorgehen beim SCHREIBEN:
|
||||||
1. IMMER zuerst "recall" aufrufen, um zu prüfen, ob es zum Thema schon etwas gibt.
|
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
|
aufrufen — das aktualisiert ihn, statt eine zweite, widersprüchliche
|
||||||
Fassung daneben zu stellen.
|
Fassung daneben zu stellen.
|
||||||
3. Nur wenn es wirklich nichts Passendes gibt, einen neuen Bezeichner wählen.
|
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):
|
Zum Bezeichner (slug):
|
||||||
Kurz, sprechend, dauerhaft: "deployment-prozess", nicht "notiz-2026-01-15".
|
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
|
"related" liefert zu einem Eintrag die verknüpften Einträge, auch solche, die
|
||||||
"recall" nicht findet, weil kein Suchwort passt.
|
"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:
|
Alter von Wissen:
|
||||||
Einträge mit "stale": true sind seit über einem Jahr unverändert. Sie können
|
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
|
noch stimmen, sollten aber nicht ungeprüft als aktueller Stand weitergegeben
|
||||||
|
|
@ -153,6 +176,7 @@ func (h *Handler) Forget(projectID string) {
|
||||||
|
|
||||||
type recallInput struct {
|
type recallInput struct {
|
||||||
Query string `json:"query" jsonschema:"Inhaltliche Frage oder Suchbegriff"`
|
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"`
|
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)"`
|
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 {
|
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."`
|
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"`
|
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"`
|
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"`
|
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 {
|
type rememberOutput struct {
|
||||||
|
|
@ -207,6 +242,10 @@ type rememberOutput struct {
|
||||||
Version int `json:"version" jsonschema:"Neue Fassung nach dem Schreiben"`
|
Version int `json:"version" jsonschema:"Neue Fassung nach dem Schreiben"`
|
||||||
Created bool `json:"created" jsonschema:"true bei einem neuen Eintrag, false bei einer Aktualisierung"`
|
Created bool `json:"created" jsonschema:"true bei einem neuen Eintrag, false bei einer Aktualisierung"`
|
||||||
Message string `json:"message"`
|
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 {
|
type forgetInput struct {
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,7 @@ func workspaceFrom(ctx context.Context) (*workspace.Workspace, error) {
|
||||||
const (
|
const (
|
||||||
recallDefaultLimit = 10
|
recallDefaultLimit = 10
|
||||||
recallMaxLimit = 50
|
recallMaxLimit = 50
|
||||||
|
recallMaxSynonyms = 10 // mehr bringt nichts, verwässert aber die Bewertung
|
||||||
listDefaultLimit = 50
|
listDefaultLimit = 50
|
||||||
listMaxLimit = 200
|
listMaxLimit = 200
|
||||||
|
|
||||||
|
|
@ -143,7 +144,9 @@ func (h *Handler) addRecall(server *mcp.Server, project store.Project) {
|
||||||
&mcp.Tool{
|
&mcp.Tool{
|
||||||
Name: "recall",
|
Name: "recall",
|
||||||
Description: "Durchsucht das Gedächtnis dieses Projekts. IMMER zuerst aufrufen — " +
|
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) {
|
func(ctx context.Context, _ *mcp.CallToolRequest, in recallInput) (*mcp.CallToolResult, recallOutput, error) {
|
||||||
ws, err := workspaceFrom(ctx)
|
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
|
// Etwas großzügiger suchen als am Ende ausgegeben wird: Die
|
||||||
// Schlagwortfilterung unten entfernt womöglich noch Treffer.
|
// 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)
|
gesucht := store.NormalizeTags(in.Tags)
|
||||||
|
|
||||||
jetzt := time.Now()
|
jetzt := time.Now()
|
||||||
|
|
@ -202,7 +206,7 @@ func (h *Handler) addRecall(server *mcp.Server, project store.Project) {
|
||||||
ergebnisse = append(ergebnisse, recallMatch{
|
ergebnisse = append(ergebnisse, recallMatch{
|
||||||
Slug: e.Slug,
|
Slug: e.Slug,
|
||||||
Title: e.Title,
|
Title: e.Title,
|
||||||
Snippet: entry.Snippet(e.Content, in.Query),
|
Snippet: entry.Snippet(e.Content, mitSynonymen(in.Query, synonyme)),
|
||||||
Tags: e.Tags,
|
Tags: e.Tags,
|
||||||
Source: e.Source,
|
Source: e.Source,
|
||||||
Curated: e.Curated,
|
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)}
|
out := recallOutput{Results: ergebnisse, Total: len(ergebnisse)}
|
||||||
if len(ergebnisse) == 0 {
|
if len(ergebnisse) == 0 {
|
||||||
out.Hint = "Nichts gefunden. Mit \"list\" einen Überblick verschaffen, " +
|
out.Hint = "Nichts gefunden. Erst mit anderen Begriffen erneut suchen (Synonyme, Übersetzung, " +
|
||||||
"oder — falls etwas Neues festgehalten werden soll — mit \"remember\" einen Eintrag anlegen."
|
"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 {
|
} else {
|
||||||
out.Hint = "Für den vollständigen Inhalt \"get\" mit dem slug aufrufen."
|
out.Hint = "Für den vollständigen Inhalt \"get\" mit dem slug aufrufen."
|
||||||
// Veraltete Treffer bleiben in der Liste und rutschen auch
|
// 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.
|
// hatAlleTags prüft, ob alle gesuchten Schlagworte vorhanden sind.
|
||||||
func hatAlleTags(vorhanden, gesucht []string) bool {
|
func hatAlleTags(vorhanden, gesucht []string) bool {
|
||||||
if len(gesucht) == 0 {
|
if len(gesucht) == 0 {
|
||||||
|
|
@ -378,7 +428,10 @@ func (h *Handler) addRemember(server *mcp.Server, project store.Project) {
|
||||||
Name: "remember",
|
Name: "remember",
|
||||||
Description: "Legt einen Eintrag an oder aktualisiert ihn. Der slug entscheidet: " +
|
Description: "Legt einen Eintrag an oder aktualisiert ihn. Der slug entscheidet: " +
|
||||||
"Ein vorhandener Eintrag wird überschrieben, kein neuer danebengestellt. " +
|
"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) {
|
func(ctx context.Context, _ *mcp.CallToolRequest, in rememberInput) (*mcp.CallToolResult, rememberOutput, error) {
|
||||||
ws, err := workspaceFrom(ctx)
|
ws, err := workspaceFrom(ctx)
|
||||||
|
|
@ -391,6 +444,9 @@ func (h *Handler) addRemember(server *mcp.Server, project store.Project) {
|
||||||
if strings.TrimSpace(in.Content) == "" {
|
if strings.TrimSpace(in.Content) == "" {
|
||||||
return nil, rememberOutput{}, errors.New("content darf nicht leer sein")
|
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)
|
slug := strings.TrimSpace(in.Slug)
|
||||||
if slug == "" {
|
if slug == "" {
|
||||||
|
|
@ -407,6 +463,43 @@ func (h *Handler) addRemember(server *mcp.Server, project store.Project) {
|
||||||
titel = entry.TitleFromContent(in.Content)
|
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{
|
geschrieben, err := ws.Store.PutEntry(ctx, store.EntryWrite{
|
||||||
ProjectID: project.ID,
|
ProjectID: project.ID,
|
||||||
Slug: slug,
|
Slug: slug,
|
||||||
|
|
@ -429,11 +522,19 @@ func (h *Handler) addRemember(server *mcp.Server, project store.Project) {
|
||||||
|
|
||||||
ws.Search.Refresh(ctx, project.ID, geschrieben.Slug)
|
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)
|
sess, _ := web.SessionFromContext(ctx)
|
||||||
ws.Store.LogAudit(ctx, store.AuditEvent{
|
ws.Store.LogAudit(ctx, store.AuditEvent{
|
||||||
ProjectID: project.ID, APIKeyID: sess.Key.ID, Actor: actorName(ctx),
|
ProjectID: project.ID, APIKeyID: sess.Key.ID, Actor: actorName(ctx),
|
||||||
Action: store.ActionEntryWrite, EntrySlug: slug, Source: store.SourceMCP,
|
Action: store.ActionEntryWrite, EntrySlug: slug, Source: store.SourceMCP,
|
||||||
Detail: store.EntryWriteDetail(geschrieben),
|
Detail: detail,
|
||||||
})
|
})
|
||||||
|
|
||||||
// "angelegt" oder "aktualisiert"? Für das Modell ist dieser
|
// "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)
|
meldung := fmt.Sprintf("Eintrag %q aktualisiert (Fassung %d).", slug, geschrieben.Version)
|
||||||
switch {
|
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:
|
case istNeu:
|
||||||
meldung = fmt.Sprintf("Eintrag %q angelegt.", slug)
|
meldung = fmt.Sprintf("Eintrag %q angelegt.", slug)
|
||||||
case geschrieben.RestoredFromTrash:
|
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 {
|
type docMeta struct {
|
||||||
length int // Anzahl der Terme (nach Tokenisierung und Boost)
|
length int // Anzahl der Terme (nach Tokenisierung und Boost)
|
||||||
title string // Titel für die Ergebnisanzeige
|
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.
|
// Result ist ein einzelnes Suchergebnis mit BM25-Score.
|
||||||
|
|
@ -125,6 +126,22 @@ func NewIndex() *Index {
|
||||||
// title, body und tags werden getrennt übergeben um unterschiedliche
|
// title, body und tags werden getrennt übergeben um unterschiedliche
|
||||||
// Boost-Faktoren anwenden zu können.
|
// Boost-Faktoren anwenden zu können.
|
||||||
func (s *Index) Index(docID, title, body string, tags []string) {
|
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()
|
s.mu.Lock()
|
||||||
defer s.mu.Unlock()
|
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.
|
// 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.docTerms[docID] = termList
|
||||||
s.totalTerms += len(allTokens)
|
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.
|
// Search führt eine BM25-Suche für den übergebenen Suchbegriff durch.
|
||||||
// Die Ergebnisse sind absteigend nach Relevanz sortiert (bester Treffer zuerst).
|
// Die Ergebnisse sind absteigend nach Relevanz sortiert (bester Treffer zuerst).
|
||||||
// limit begrenzt die Anzahl der zurückgegebenen Ergebnisse.
|
// 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 {
|
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()
|
s.mu.RLock()
|
||||||
defer s.mu.RUnlock()
|
defer s.mu.RUnlock()
|
||||||
|
|
||||||
queryTokens := tokenize(queryStr)
|
queryTokens := ohneStoppwoerter(tokenize(queryStr))
|
||||||
if len(queryTokens) == 0 || len(s.docs) == 0 {
|
if len(queryTokens) == 0 || len(s.docs) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
@ -224,32 +262,20 @@ func (s *Index) Search(queryStr string, limit int) []Result {
|
||||||
scores := make(map[string]float64)
|
scores := make(map[string]float64)
|
||||||
|
|
||||||
for _, term := range queryTokens {
|
for _, term := range queryTokens {
|
||||||
postings, found := s.invertedIndex[term]
|
// Je Dokument zählt für dieses Suchwort nur die beste Variante —
|
||||||
if !found {
|
// ein exakter Treffer wird also nicht durch denselben Treffer als
|
||||||
// Rückfallebene: als Wortanfang suchen.
|
// Wortanfang noch einmal aufgewertet.
|
||||||
//
|
beste := make(map[string]float64)
|
||||||
// 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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
for _, v := range s.variantenFuer(term, tippfehler) {
|
||||||
// IDF (Inverse Document Frequency):
|
// IDF (Inverse Document Frequency):
|
||||||
// Seltene Terme sind aussagekräftiger als häufige (z.B. "und", "der").
|
// Seltene Terme sind aussagekräftiger als häufige (z.B. "und", "der").
|
||||||
// Formel: log((N - nq + 0.5) / (nq + 0.5) + 1)
|
// Formel: log((N - nq + 0.5) / (nq + 0.5) + 1)
|
||||||
// nq = Anzahl der Dokumente die diesen Term enthalten
|
// 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)
|
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]
|
meta := s.docs[p.docID]
|
||||||
docLen := float64(meta.length)
|
docLen := float64(meta.length)
|
||||||
|
|
||||||
|
|
@ -259,7 +285,15 @@ func (s *Index) Search(queryStr string, limit int) []Result {
|
||||||
tf := float64(p.freq)
|
tf := float64(p.freq)
|
||||||
tfNorm := tf * (bm25K1 + 1) / (tf + bm25K1*(1-bm25B+bm25B*docLen/avgDocLen))
|
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
|
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))
|
results := make([]Result, 0, len(scores))
|
||||||
for docID, score := range 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).
|
// Absteigend nach Score sortieren (bester Treffer zuerst).
|
||||||
|
|
@ -367,39 +402,3 @@ func repeatTokens(tokens []string, n int) []string {
|
||||||
}
|
}
|
||||||
return result
|
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
|
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
|
// NewRegistry erstellt eine leere Registry. Die Indizes entstehen beim ersten
|
||||||
// Zugriff auf ein Projekt (siehe For).
|
// Zugriff auf ein Projekt (siehe For).
|
||||||
func NewRegistry(s *store.Store) *Registry {
|
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 {
|
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
|
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)
|
log.Printf("Suchindex: %q in Projekt %s nicht lesbar, Index wird neu aufgebaut: %v", slug, projectID, err)
|
||||||
f.idx = nil
|
f.idx = nil
|
||||||
default:
|
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")
|
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
|
// TestSearchExactBeatsPrefix prüft, dass ein exakter Treffer vor einem Treffer
|
||||||
// keinen exakten Treffer gibt.
|
// über den Wortanfang steht.
|
||||||
func TestSearchExactBeatsPrefix(t *testing.T) {
|
func TestSearchExactBeatsPrefix(t *testing.T) {
|
||||||
idx := NewIndex()
|
idx := NewIndex()
|
||||||
idx.Index("exakt", "Test", "Das Wort ssh kommt hier vor.", nil)
|
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")
|
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)
|
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
|
// EntryWriteDetail sagt anhand des gespeicherten Eintrags, ob der
|
||||||
// Schreibvorgang ihn angelegt oder aktualisiert hat.
|
// 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"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -312,3 +313,59 @@ func (s *Store) RecentlyUsedKeys(ctx context.Context, projectID string, limit in
|
||||||
}
|
}
|
||||||
return keys, rows.Err()
|
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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
"unicode/utf8"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestUsageSummariesZaehltSchreibvorgaenge: Neuanlagen, Aktualisierungen und
|
// TestUsageSummariesZaehltSchreibvorgaenge: Neuanlagen, Aktualisierungen und
|
||||||
|
|
@ -148,3 +150,45 @@ func TestRecentlyUsedKeys(t *testing.T) {
|
||||||
t.Errorf("RecentlyUsedKeys = %v, erwartet nur „benutzt“", keys)
|
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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
@ -57,6 +58,50 @@ func (s *Store) MoveEntryToTrash(ctx context.Context, projectID, slug, geloescht
|
||||||
return requireOneRow(res)
|
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.
|
// 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
|
// 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")
|
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
|
Query string
|
||||||
Tag string
|
Tag string
|
||||||
Total int
|
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.
|
// 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"))
|
query := strings.TrimSpace(r.URL.Query().Get("q"))
|
||||||
tag := strings.TrimSpace(r.URL.Query().Get("tag"))
|
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
|
var err error
|
||||||
if query != "" {
|
if query != "" {
|
||||||
|
|
@ -83,7 +88,7 @@ func (s *Server) searchEntries(r *http.Request, project store.Project, query str
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
treffer := idx.Search(query, 30)
|
treffer := idx.SearchFuzzy(query, 30)
|
||||||
|
|
||||||
items := make([]entryListItem, 0, len(treffer))
|
items := make([]entryListItem, 0, len(treffer))
|
||||||
for _, t := range 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)
|
s.serverError(w, "Vorschläge konnten nicht geladen werden", err)
|
||||||
return
|
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)
|
e, err := ws.Store.EntryBySlug(r.Context(), project.ID, treffer.DocID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
continue // Index kann einen Eintrag kennen, den es nicht mehr gibt
|
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
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
ziel := "/p/" + project.Slug + "/"
|
ziel := sammelZiel(r, project)
|
||||||
if tag := r.FormValue("tag"); tag != "" {
|
|
||||||
ziel += "?tag=" + url.QueryEscape(tag)
|
|
||||||
}
|
|
||||||
|
|
||||||
ids := r.Form["ids"]
|
ids := r.Form["ids"]
|
||||||
add := splitTags(r.FormValue("add_tags"))
|
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))
|
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 := "?"
|
trenner := "?"
|
||||||
if strings.Contains(ziel, "?") {
|
if strings.Contains(ziel, "?") {
|
||||||
trenner = "&"
|
trenner = "&"
|
||||||
}
|
}
|
||||||
http.Redirect(w, r, ziel+trenner+"hinweis="+url.QueryEscape(meldung), http.StatusSeeOther)
|
return ziel + trenner + "hinweis=" + url.QueryEscape(meldung)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Änderungshistorie ────────────────────────────────────────────────────────
|
// ── Änderungshistorie ────────────────────────────────────────────────────────
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,13 @@ type usageData struct {
|
||||||
Missing []store.MissingLink
|
Missing []store.MissingLink
|
||||||
MissingMore int // wie viele Verweise ins Leere über die Liste hinaus
|
MissingMore int // wie viele Verweise ins Leere über die Liste hinaus
|
||||||
Keys []store.APIKey
|
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.
|
// handleProjectUsage zeigt die Nutzungsübersicht eines Projekts.
|
||||||
|
|
@ -61,6 +68,24 @@ func (s *Server) handleProjectUsage(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
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{
|
daten := usageData{
|
||||||
// Ein Projekt ohne Einträge und Schreibvorgänge fehlt in der map —
|
// Ein Projekt ohne Einträge und Schreibvorgänge fehlt in der map —
|
||||||
// dann liefert alle[project.ID] den Nullwert, also überall 0.
|
// 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,
|
Untagged: ohneSchlagwort,
|
||||||
Missing: insLeere,
|
Missing: insLeere,
|
||||||
Keys: keys,
|
Keys: keys,
|
||||||
|
|
||||||
|
CuratedOverrides: zurPruefung,
|
||||||
|
|
||||||
|
Searches: suchen,
|
||||||
|
EmptySearches: ohneTreffer,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Die Verweise ins Leere kommen vollständig (die Gesamtzahl ist selbst
|
// 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}/e/{slug}/delete", mitSchreibrecht(s.handleEntryDelete))
|
||||||
mux.Handle("POST /p/{project}/save", mitSchreibrecht(s.handleEntrySave))
|
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-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.
|
// Papierkorb: auch das Ansehen nur mit Schreibrecht — siehe handlers_papierkorb.go.
|
||||||
mux.Handle("GET /p/{project}/trash", mitSchreibrecht(s.handleTrashList))
|
mux.Handle("GET /p/{project}/trash", mitSchreibrecht(s.handleTrashList))
|
||||||
mux.Handle("POST /p/{project}/trash/restore", mitSchreibrecht(s.handleTrashRestore))
|
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
|
// TestEntryVersionsAndDiff prüft den Verlauf und den Vergleich zweier
|
||||||
// Fassungen — inklusive des Falls "Vergleich gegen die aktuelle Fassung",
|
// Fassungen — inklusive des Falls "Vergleich gegen die aktuelle Fassung",
|
||||||
// wenn es keine spätere abgelöste Fassung mehr gibt.
|
// 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
|
// TestNutzungsseiteLeeresProjekt: Ein Projekt ganz ohne Daten darf nicht
|
||||||
// stolpern (es fehlt dann in der map der Kennzahlen).
|
// stolpern (es fehlt dann in der map der Kennzahlen).
|
||||||
func TestNutzungsseiteLeeresProjekt(t *testing.T) {
|
func TestNutzungsseiteLeeresProjekt(t *testing.T) {
|
||||||
|
|
|
||||||
|
|
@ -235,10 +235,16 @@ func agent(ctx context.Context, httpTransport http.RoundTripper, url, key string
|
||||||
werkzeug = "remember"
|
werkzeug = "remember"
|
||||||
// Ein eindeutiges Wort je Schreibvorgang — daran erkennt
|
// Ein eindeutiges Wort je Schreibvorgang — daran erkennt
|
||||||
// pruefeBestand, ob der Suchindex den letzten Stand kennt.
|
// 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{
|
args = map[string]any{
|
||||||
"slug": fmt.Sprintf("gemeinsam-%02d", zufall.IntN(*lastSlugs)),
|
"slug": fmt.Sprintf("gemeinsam-%02d", zufall.IntN(*lastSlugs)),
|
||||||
"title": "Gemeinsamer Eintrag",
|
"title": "Gemeinsamer Eintrag",
|
||||||
"content": "Stand " + eindeutigesWort(nr, i) + "\n\n" + fuellText(i),
|
"content": "Stand " + eindeutigesWort(nr, i) + "\n\n" + fuellText(i),
|
||||||
|
"force": true,
|
||||||
}
|
}
|
||||||
case w < *lastSchreiben+(100-*lastSchreiben)*2/3:
|
case w < *lastSchreiben+(100-*lastSchreiben)*2/3:
|
||||||
werkzeug = "recall"
|
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
|
// 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
|
// 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
|
// 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",
|
log.Printf("Protokoll von %q aufgeräumt: %d Einträge älter als %d Tage entfernt",
|
||||||
w.Name, n, int(auditRetention.Hours()/24))
|
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
|
return nil
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,7 @@
|
||||||
<form method="POST" action="/p/{{.Project.Slug}}/entries/bulk-tags" id="sammelform">
|
<form method="POST" action="/p/{{.Project.Slug}}/entries/bulk-tags" id="sammelform">
|
||||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||||
{{if .Data.Tag}}<input type="hidden" name="tag" value="{{.Data.Tag}}">{{end}}
|
{{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}}
|
{{end}}
|
||||||
|
|
||||||
<ul class="eintraege">
|
<ul class="eintraege">
|
||||||
|
|
@ -76,6 +77,12 @@
|
||||||
<input type="text" name="add_tags" placeholder="Schlagworte hinzufügen, kommagetrennt" autocomplete="off">
|
<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">
|
<input type="text" name="remove_tags" placeholder="Schlagworte entfernen, kommagetrennt" autocomplete="off">
|
||||||
<button type="submit" class="zweitrangig">Anwenden</button>
|
<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>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
{{end}}
|
{{end}}
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,28 @@
|
||||||
</div>
|
</div>
|
||||||
</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 ─────────────────────────────────────────────────── */}}
|
{{/* ── Schreibvorgänge ─────────────────────────────────────────────────── */}}
|
||||||
<section class="abschnitt">
|
<section class="abschnitt">
|
||||||
<h2>Schreibvorgänge der letzten {{.Data.WindowDays}} Tage</h2>
|
<h2>Schreibvorgänge der letzten {{.Data.WindowDays}} Tage</h2>
|
||||||
|
|
@ -99,6 +121,49 @@
|
||||||
{{end}}
|
{{end}}
|
||||||
</section>
|
</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 ─────────────────────────────────────────────── */}}
|
{{/* ── Verweise ins Leere ─────────────────────────────────────────────── */}}
|
||||||
{{if .Data.Missing}}
|
{{if .Data.Missing}}
|
||||||
<section class="abschnitt">
|
<section class="abschnitt">
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue