// 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 -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] + "" + safe[idx:idx+len(needle)] + "" + 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" }