// Package duplicates 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 duplicates // 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 ( // Threshold ist der Mindestanteil gemeinsamer Titelwörter, ab dem // ein vorhandener Eintrag als Kandidat gilt (siehe search.Coverage). // // 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. Threshold = 0.5 // searchDepth: So viele Suchtreffer werden genauer verglichen. // Die Suche findet Kandidaten, die Überdeckung entscheidet. searchDepth = 10 // maxCandidates: So viele Kandidaten gehen höchstens zurück. maxCandidates = 5 ) // Candidate ist ein vorhandener Eintrag, der einem neu anzulegenden ähnelt. type Candidate 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 } // Check 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.CoverageWithSearchTerms). func Check(ctx context.Context, ws *workspace.Workspace, project store.Project, slug, title, content string) ([]Candidate, error) { if strings.TrimSpace(title) == "" { 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 } newName := title + " " + entry.AlsoSearchedAs(content) var candidates []Candidate for _, t := range idx.Search(newName, searchDepth) { 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 withoutCommonPrefix. topicNew, topicExisting := withoutCommonPrefix(title, 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. share, shared := search.CoverageWithSearchTerms( topicNew, topicExisting, entry.AlsoSearchedAs(e.Content)) backwards, sharedBackwards := search.CoverageWithSearchTerms( topicExisting, topicNew, entry.AlsoSearchedAs(content)) if backwards > share { share, shared = backwards, sharedBackwards } if share < Threshold { continue } candidates = append(candidates, Candidate{ Slug: e.Slug, Title: e.Title, Overlap: int(math.Round(share * 100)), MatchedWords: shared, }) } // Ähnlichste zuerst. SliceStable lässt gleich ähnliche in der Reihenfolge // der Suche stehen — die ist der bessere Gleichstandsbrecher als Zufall. sort.SliceStable(candidates, func(i, j int) bool { return candidates[i].Overlap > candidates[j].Overlap }) if len(candidates) > maxCandidates { candidates = candidates[:maxCandidates] } return candidates, nil } // titleSeparator 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 titleSeparator = regexp.MustCompile(`\s+[-–—|]\s+|:\s+`) // withoutCommonPrefix 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 withoutCommonPrefix(newer, existing string) (string, string) { prefixNew, topicNew, okNew := titleParts(newer) prefixExisting, topicExisting, okExisting := titleParts(existing) if !okNew || !okExisting || prefixNew != prefixExisting { return newer, existing } return topicNew, topicExisting } // titleParts 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 titleParts(title string) (prefix, topic string, ok bool) { pos := titleSeparator.FindStringIndex(title) if pos == nil { return "", title, false } prefix = strings.Join(strings.Fields(strings.ToLower(title[:pos[0]])), " ") topic = strings.TrimSpace(title[pos[1]:]) if prefix == "" || topic == "" { return "", title, false } return prefix, topic, true }