munin/internal/mcpserver/dubletten.go

118 lines
4.1 KiB
Go

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
}