munin/internal/dubletten/dubletten.go

202 lines
7.6 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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
}