// Package dubletten erkennt, ob ein neuer Eintrag einem vorhandenen ähnelt // (AUSBAUSTUFEN.md 3.3). // // Eigenes Paket, weil zwei Wege es brauchen: "remember" über MCP (fragt // zurück, statt anzulegen) und das Formular "Neuer Eintrag" der Weboberfläche // (zeigt beim Tippen „Gibt es schon?"). Beide sollen dieselbe Prüfung haben — // zwei getrennte würden mit der Zeit unterschiedlich urteilen. package dubletten // Dubletten über Ähnlichkeit erkennen. // // 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" "regexp" "sort" "strings" "munin/internal/entry" "munin/internal/search" "munin/internal/store" "munin/internal/workspace" ) const ( // Schwelle 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. Schwelle = 0.5 // suchtiefe: So viele Suchtreffer werden genauer verglichen. // Die Suche findet Kandidaten, die Überdeckung entscheidet. suchtiefe = 10 // maxKandidaten: So viele Kandidaten gehen höchstens zurück. maxKandidaten = 5 ) // Kandidat ist ein vorhandener Eintrag, der einem neu anzulegenden ähnelt. type Kandidat struct { Slug string Title string Overlap int // Übereinstimmung in Prozent // MatchedWords sind die Wörter, auf denen die Übereinstimmung beruht — // in der Form, die die Suche erzeugt (klein, Umlaute gefaltet). MatchedWords []string } // Pruefe 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. Ein leerer slug (das Formular, bevor // jemand einen Bezeichner eingetragen hat) überspringt diese Prüfung. // // 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. Ein // Suchbegriff zählt dabei nur wörtlich, ein Titelwort auch mit Varianten // (siehe search.UeberdeckungMitSuchbegriffen). func Pruefe(ctx context.Context, ws *workspace.Workspace, project store.Project, slug, titel, inhalt string) ([]Kandidat, error) { if strings.TrimSpace(titel) == "" { return nil, nil } if slug != "" { _, 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 []Kandidat for _, t := range idx.Search(neuerName, suchtiefe) { 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 } // Ein gemeinsames Präfix wie „Munin – " sagt nichts über das Thema, // siehe ohneGemeinsamesPraefix. themaNeu, themaVorhanden := ohneGemeinsamesPraefix(titel, e.Title) // 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, gemeinsam := search.UeberdeckungMitSuchbegriffen( themaNeu, themaVorhanden, entry.AuchGesuchtAls(e.Content)) rueckwaerts, gemeinsamRueckwaerts := search.UeberdeckungMitSuchbegriffen( themaVorhanden, themaNeu, entry.AuchGesuchtAls(inhalt)) if rueckwaerts > anteil { anteil, gemeinsam = rueckwaerts, gemeinsamRueckwaerts } if anteil < Schwelle { continue } kandidaten = append(kandidaten, Kandidat{ Slug: e.Slug, Title: e.Title, Overlap: int(math.Round(anteil * 100)), MatchedWords: gemeinsam, }) } // Ä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) > maxKandidaten { kandidaten = kandidaten[:maxKandidaten] } return kandidaten, nil } // titelTrenner trennt ein Präfix vom eigentlichen Thema eines Titels: // „Munin – Architektur", „Munin — Architektur", „Munin - Architektur", // „Munin: Architektur", „Munin | Architektur". // // Ein Bindestrich zählt nur mit Leerzeichen auf beiden Seiten. Ohne // Leerzeichen ist er Teil eines Worts — „Deployment-Ablauf" hat kein Präfix. var titelTrenner = regexp.MustCompile(`\s+[-–—|]\s+|:\s+`) // ohneGemeinsamesPraefix entfernt ein Präfix, das beide Titel gemeinsam // haben, und gibt die beiden Themen zurück. // // # Warum? // // Viele Teams benennen Einträge nach dem Muster „Projekt – Thema". Bei // kurzen Titeln entscheidet dann das gemeinsame Präfix allein: „Munin – // Architektur" und „Munin – Überblick" teilen ein Wort von zweien, also 50 % // — genau die Schwelle. Gemeldet wurde so jeder neue Eintrag des Projekts. // Das Präfix ist aber bei allen gleich und sagt deshalb nichts darüber, ob // es um dasselbe Thema geht. // // Entfernt wird es nur, wenn BEIDE Titel dasselbe Präfix haben. Haben sie // verschiedene („Munin – Deployment" und „Hugin – Deployment") oder nur einer // eines, bleibt der volle Titel im Vergleich — dann kann das Präfix etwas // unterscheiden. Bleibt nach dem Präfix nichts übrig, ebenfalls. func ohneGemeinsamesPraefix(neu, vorhanden string) (string, string) { praefixNeu, themaNeu, okNeu := titelTeilen(neu) praefixVorhanden, themaVorhanden, okVorhanden := titelTeilen(vorhanden) if !okNeu || !okVorhanden || praefixNeu != praefixVorhanden { return neu, vorhanden } return themaNeu, themaVorhanden } // titelTeilen trennt einen Titel am ersten Trenner in Präfix und Thema. Das // Präfix kommt vereinheitlicht zurück (klein, einfache Leerzeichen), damit // „Munin" und „munin" als dasselbe gelten. ok ist false, wenn es keinen // Trenner gibt oder eine Seite leer ist. func titelTeilen(titel string) (praefix, thema string, ok bool) { pos := titelTrenner.FindStringIndex(titel) if pos == nil { return "", titel, false } praefix = strings.Join(strings.Fields(strings.ToLower(titel[:pos[0]])), " ") thema = strings.TrimSpace(titel[pos[1]:]) if praefix == "" || thema == "" { return "", titel, false } return praefix, thema, true }