munin/internal/entry/entry.go

220 lines
7.6 KiB
Go

// Package entry enthält die Fachlogik rund um einen Wissenseintrag: die
// Darstellung als HTML, Textausschnitte für Suchergebnisse und die Auflösung
// der [[slug]]-Verweise.
//
// Bewusst getrennt von internal/store: Dort geht es darum, wie ein Eintrag
// gespeichert wird, hier darum, wie er aussieht. Beides ändert sich aus
// unterschiedlichen Gründen.
package entry
import (
"fmt"
"html"
"sort"
"strings"
"unicode"
"unicode/utf8"
"munin/internal/markdown"
"munin/internal/store"
)
// RenderHTML wandelt den Markdown-Inhalt eines Eintrags in HTML.
//
// Das eigentliche Umwandeln übernimmt internal/markdown — derselbe Renderer
// wie für die Hilfe, damit dort beschriebenes Markdown (Tabellen, Checklisten,
// Durchstreichen) im Eintrag genauso aussieht. Dort steht auch der
// Sicherheitshinweis: Roh-HTML wird nicht durchgelassen, und genau darauf
// verlässt sich die Weboberfläche, wenn sie das Ergebnis ohne weiteres
// Escaping einsetzt.
func RenderHTML(quelle string, linkBase string) (string, error) {
// Verweise vor dem Rendern in Markdown-Links umschreiben. Danach wäre es zu
// spät: goldmark hätte die eckigen Klammern dann schon als Text behandelt.
//
// (Der Parameter heißt "quelle" statt "markdown", weil "markdown" jetzt der
// Name des importierten Pakets ist — sonst wäre das Paket in dieser
// Funktion verdeckt.)
vorbereitet := store.ReplaceLinks(quelle, func(slug string) string {
return fmt.Sprintf("[%s](%s%s)", slug, linkBase, slug)
})
return markdown.Render(vorbereitet)
}
// RenderPlain gibt den Inhalt als reinen Text zurück — Verweise werden auf ihren
// Slug reduziert. Gedacht für Textausschnitte, wo Markdown-Zeichen nur stören.
func RenderPlain(markdown string) string {
return store.ReplaceLinks(markdown, func(slug string) string { return slug })
}
// snippetRadius bestimmt, wie viel Text vor und nach einem Treffer im Ausschnitt steht.
const snippetRadius = 120
// Snippet gibt einen Textausschnitt um den ersten Treffer des Suchbegriffs zurück.
//
// Der Ausschnitt ist bewusst kurz: Er dient dazu, einen Treffer einzuordnen —
// bei einer KI-Anfrage entscheidet er darüber, ob der ganze Eintrag nachgeladen
// wird. Wer den vollen Inhalt will, holt ihn gezielt.
func Snippet(content, query string) string {
text := RenderPlain(content)
nadel := trefferwort(text, query)
if nadel == "" {
// Kein Wort der Anfrage steht wörtlich im Text (z.B. weil der Fund über
// eine Endung oder ein Synonym kam) — dann der Anfang des Texts als
// Vorschau.
return kuerzen(text, snippetRadius+snippetRadius)
}
idx := strings.Index(strings.ToLower(text), strings.ToLower(nadel))
start := max(idx-snippetRadius, 0)
ende := min(idx+len(nadel)+snippetRadius, len(text))
ausschnitt := strings.TrimSpace(text[start:ende])
if start > 0 {
ausschnitt = "…" + ausschnitt
}
if ende < len(text) {
ausschnitt += "…"
}
return ausschnitt
}
// minTrefferwort ist die Mindestlänge eines einzelnen Worts der Anfrage, an
// dem sich der Ausschnitt ausrichten darf. Kürzere Wörter („wie", „an") stehen
// in fast jedem Text und sagen nichts über den Treffer.
const minTrefferwort = 4
// trefferwort bestimmt, an welcher Stelle der Ausschnitt ansetzt: das Stück
// der Anfrage, das im Text vorkommt. "" heißt: nichts davon kommt vor.
//
// # Warum nicht einfach die ganze Anfrage?
//
// So war es früher. Agenten stellen aber ganze Fragen („wie deployen wir den
// Dienst"), und die stehen nie wörtlich im Text. Der Ausschnitt zeigte dann
// immer nur den Textanfang — auch wenn die eigentliche Fundstelle weiter
// hinten lag.
//
// Deshalb zuerst die ganze Anfrage (in der Weboberfläche tippen Menschen oft
// genau eine Wortfolge), danach die einzelnen Wörter, längste zuerst. Die
// Länge ist ein einfacher Ersatz für „aussagekräftig": „Zertifikatswechsel"
// verrät mehr über die Fundstelle als „Dienst".
func trefferwort(text, query string) string {
query = strings.TrimSpace(query)
if query == "" {
return ""
}
klein := strings.ToLower(text)
if strings.Contains(klein, strings.ToLower(query)) {
return query
}
// FieldsFunc zerlegt an jedem Zeichen, für das die Funktion true liefert —
// hier an allem, was kein Buchstabe und keine Ziffer ist.
woerter := strings.FieldsFunc(query, func(r rune) bool {
return !unicode.IsLetter(r) && !unicode.IsDigit(r)
})
// Längste zuerst. SliceStable lässt gleich lange Wörter in der Reihenfolge
// der Anfrage stehen — so ist das Ergebnis immer dasselbe.
sort.SliceStable(woerter, func(i, j int) bool {
return utf8.RuneCountInString(woerter[i]) > utf8.RuneCountInString(woerter[j])
})
for _, w := range woerter {
if utf8.RuneCountInString(w) < minTrefferwort {
break // sortiert: alle folgenden sind noch kürzer
}
if strings.Contains(klein, strings.ToLower(w)) {
return w
}
}
return ""
}
// kuerzen schneidet einen Text auf höchstens n Zeichen.
func kuerzen(text string, n int) string {
text = strings.TrimSpace(text)
if len(text) <= n {
return text
}
return strings.TrimSpace(text[:n]) + "…"
}
// HighlightSnippet baut einen Textausschnitt mit hervorgehobenem Treffer als
// fertiges HTML.
//
// Wichtig: Erst wird der Text escaped, dann werden die <mark>-Auszeichnungen
// eingesetzt. Andersherum könnte ein Eintragsinhalt eigenes HTML einschleusen.
func HighlightSnippet(content, query string) string {
ausschnitt := Snippet(content, query)
sicher := html.EscapeString(ausschnitt)
if query == "" {
return sicher
}
// Hervorgehoben wird dasselbe Stück der Anfrage, an dem sich der
// Ausschnitt ausgerichtet hat. Gesucht wird im escapten Text nach dem
// ebenfalls escapten Stück, damit die Positionen zusammenpassen.
treffer := trefferwort(ausschnitt, query)
if treffer == "" {
return sicher
}
nadel := html.EscapeString(treffer)
idx := strings.Index(strings.ToLower(sicher), strings.ToLower(nadel))
if idx == -1 {
return sicher
}
return sicher[:idx] + "<mark>" + sicher[idx:idx+len(nadel)] + "</mark>" + sicher[idx+len(nadel):]
}
// auchGesuchtAlsPraefix leitet die Zeile ein, in der ein Eintrag weitere
// Suchbegriffe nennt (siehe instructions in internal/mcpserver).
const auchGesuchtAlsPraefix = "auch gesucht als"
// AuchGesuchtAls gibt die Begriffe aus der Zeile „Auch gesucht als: …" eines
// Eintrags zurück, so wie sie dort stehen. Ohne eine solche Zeile: "".
//
// Agenten schreiben die Zeile nicht immer gleich. Erkannt werden deshalb auch
// Hervorhebungen und Aufzählungszeichen davor:
//
// Auch gesucht als: release, rollout
// *Auch gesucht als:* release, rollout
// - **Auch gesucht als:** release, rollout
//
// Gibt es mehrere solcher Zeilen, zählt die letzte — die Anleitung bittet
// darum, die Zeile ans Ende zu stellen.
func AuchGesuchtAls(content string) string {
ergebnis := ""
for _, zeile := range strings.Split(content, "\n") {
// Aufzählungszeichen, Zitatzeichen und Hervorhebungen am Anfang weg.
zeile = strings.TrimLeft(strings.TrimSpace(zeile), "-*_> ")
if !strings.HasPrefix(strings.ToLower(zeile), auchGesuchtAlsPraefix) {
continue
}
rest := zeile[len(auchGesuchtAlsPraefix):]
rest = strings.TrimLeft(rest, ":*_ ")
ergebnis = strings.TrimSpace(rest)
}
return ergebnis
}
// TitleFromContent rät einen Titel aus dem Inhalt — die erste Überschrift oder
// die erste Zeile. Wird verwendet, wenn ein Agent keinen Titel mitgeschickt hat.
func TitleFromContent(content string) string {
for _, zeile := range strings.Split(content, "\n") {
zeile = strings.TrimSpace(zeile)
if zeile == "" {
continue
}
zeile = strings.TrimLeft(zeile, "#")
zeile = strings.TrimSpace(zeile)
if zeile != "" {
return kuerzen(zeile, 120)
}
}
return "Ohne Titel"
}