220 lines
7.5 KiB
Go
220 lines
7.5 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(source 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 "source" statt "markdown", weil "markdown" jetzt der
|
|
// Name des importierten Pakets ist — sonst wäre das Paket in dieser
|
|
// Funktion verdeckt.)
|
|
prepared := store.ReplaceLinks(source, func(slug string) string {
|
|
return fmt.Sprintf("[%s](%s%s)", slug, linkBase, slug)
|
|
})
|
|
|
|
return markdown.Render(prepared)
|
|
}
|
|
|
|
// 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)
|
|
|
|
needle := hitWord(text, query)
|
|
if needle == "" {
|
|
// 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 truncate(text, snippetRadius+snippetRadius)
|
|
}
|
|
idx := strings.Index(strings.ToLower(text), strings.ToLower(needle))
|
|
|
|
start := max(idx-snippetRadius, 0)
|
|
end := min(idx+len(needle)+snippetRadius, len(text))
|
|
|
|
excerpt := strings.TrimSpace(text[start:end])
|
|
if start > 0 {
|
|
excerpt = "…" + excerpt
|
|
}
|
|
if end < len(text) {
|
|
excerpt += "…"
|
|
}
|
|
return excerpt
|
|
}
|
|
|
|
// minHitWord 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 minHitWord = 4
|
|
|
|
// hitWord 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 hitWord(text, query string) string {
|
|
query = strings.TrimSpace(query)
|
|
if query == "" {
|
|
return ""
|
|
}
|
|
|
|
lower := strings.ToLower(text)
|
|
if strings.Contains(lower, 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.
|
|
words := 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(words, func(i, j int) bool {
|
|
return utf8.RuneCountInString(words[i]) > utf8.RuneCountInString(words[j])
|
|
})
|
|
|
|
for _, w := range words {
|
|
if utf8.RuneCountInString(w) < minHitWord {
|
|
break // sortiert: alle folgenden sind noch kürzer
|
|
}
|
|
if strings.Contains(lower, strings.ToLower(w)) {
|
|
return w
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// truncate schneidet einen Text auf höchstens n Zeichen.
|
|
func truncate(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 {
|
|
excerpt := Snippet(content, query)
|
|
safe := html.EscapeString(excerpt)
|
|
|
|
if query == "" {
|
|
return safe
|
|
}
|
|
|
|
// 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.
|
|
hits := hitWord(excerpt, query)
|
|
if hits == "" {
|
|
return safe
|
|
}
|
|
needle := html.EscapeString(hits)
|
|
idx := strings.Index(strings.ToLower(safe), strings.ToLower(needle))
|
|
if idx == -1 {
|
|
return safe
|
|
}
|
|
|
|
return safe[:idx] + "<mark>" + safe[idx:idx+len(needle)] + "</mark>" + safe[idx+len(needle):]
|
|
}
|
|
|
|
// alsoSearchedAsPrefix leitet die Zeile ein, in der ein Eintrag weitere
|
|
// Suchbegriffe nennt (siehe instructions in internal/mcpserver).
|
|
const alsoSearchedAsPrefix = "auch gesucht als"
|
|
|
|
// AlsoSearchedAs 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 AlsoSearchedAs(content string) string {
|
|
result := ""
|
|
for _, line := range strings.Split(content, "\n") {
|
|
// Aufzählungszeichen, Zitatzeichen und Hervorhebungen am Anfang weg.
|
|
line = strings.TrimLeft(strings.TrimSpace(line), "-*_> ")
|
|
if !strings.HasPrefix(strings.ToLower(line), alsoSearchedAsPrefix) {
|
|
continue
|
|
}
|
|
rest := line[len(alsoSearchedAsPrefix):]
|
|
rest = strings.TrimLeft(rest, ":*_ ")
|
|
result = strings.TrimSpace(rest)
|
|
}
|
|
return result
|
|
}
|
|
|
|
// 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 _, line := range strings.Split(content, "\n") {
|
|
line = strings.TrimSpace(line)
|
|
if line == "" {
|
|
continue
|
|
}
|
|
line = strings.TrimLeft(line, "#")
|
|
line = strings.TrimSpace(line)
|
|
if line != "" {
|
|
return truncate(line, 120)
|
|
}
|
|
}
|
|
return "Ohne Titel"
|
|
}
|