202 lines
7.6 KiB
Go
202 lines
7.6 KiB
Go
// 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
|
||
}
|