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 }