118 lines
4.1 KiB
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
|
|
}
|