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