"""Online-Hilfe: Anleitungen je Rolle, Begriffe und häufige Fragen. Die Seiten liegen als Markdown in app/help/pages und werden mit der Anwendung ausgeliefert, damit sie zum Stand des Codes passen. Sie stammen von uns, nicht vom Modell: Rohes HTML bleibt trotzdem aus, Überschriften bekommen Anker für Links und das Inhaltsverzeichnis. """ import re from dataclasses import dataclass, field from functools import cache from pathlib import Path from markdown_it import MarkdownIt from markupsafe import Markup PAGES_DIR = Path(__file__).parent / "pages" @dataclass(frozen=True) class Page: slug: str title: str audience: str summary: str PAGES = ( Page("start", "Überblick", "Alle", "Was Bifröst ist, Rollen und die ersten Schritte."), Page("nutzer", "Für Nutzer", "Mitglieder", "Chatten, Daten analysieren, Code ändern lassen, größere Aufgaben."), Page("freigeben", "Für Freigeber und Prüfer", "Freigeber, globale Approver, Reviewer", "Freigabeanträge prüfen und entscheiden, KI-Änderungen im Pull Request reviewen."), Page("workspace-admin", "Für Workspace-Admins", "Workspace-Admins", "Mitglieder, Datenquellen, Tools und Regeln, Freigaben, Kosten und Audit eines Workspace."), Page("plattform-admin", "Für Plattform-Admins", "Plattform-Admins", "Teams, Nutzer, Modelle, Tool-Einstufung, MCP-Server und Betrieb."), Page("begriffe", "Begriffe", "Alle", "Die wichtigsten Begriffe von A bis Z."), Page("faq", "Häufige Fragen", "Alle", "Probleme und ihre Lösung."), ) BY_SLUG = {p.slug: p for p in PAGES} # Wo man gerade ist -> passende Stelle der Hilfe (längster Präfix gewinnt) CONTEXT = ( ("/chats/", "nutzer#chatten"), ("/approvals", "freigeben"), ("/admin/tools", "plattform-admin#tools-einstufen"), ("/admin/models", "plattform-admin#modelle-und-zugaenge"), ("/admin/mcp", "plattform-admin#mcp-server"), ("/admin/users", "plattform-admin#nutzer"), ("/admin/teams", "plattform-admin#teams-und-speicherkontingent"), ("/admin/", "plattform-admin"), ("/profile", "nutzer#anmelden-und-profil"), ) WORKSPACE_CONTEXT = ( ("/settings", "workspace-admin"), ("/data", "nutzer#dateien-analysieren"), ("/memory", "nutzer#was-die-ki-sehen-kann"), ("/usage", "workspace-admin#nutzung-runs-und-audit-log"), ("/runs", "workspace-admin#nutzung-runs-und-audit-log"), ("/audit", "workspace-admin#nutzung-runs-und-audit-log"), ) def link_for(path: str) -> str: for prefix, target in CONTEXT: if path.startswith(prefix): return f"/hilfe/{target}" match = re.match(r"^/workspaces/\d+(/.*)?$", path) if match: rest = match.group(1) or "" for prefix, target in WORKSPACE_CONTEXT: if rest.startswith(prefix): return f"/hilfe/{target}" return "/hilfe/nutzer" return "/hilfe" def slug(text: str) -> str: text = text.lower() for a, b in (("ä", "ae"), ("ö", "oe"), ("ü", "ue"), ("ß", "ss")): text = text.replace(a, b) return re.sub(r"[^a-z0-9]+", "-", text).strip("-") or "abschnitt" @dataclass class Heading: level: int text: str anchor: str @dataclass class Rendered: page: Page html: Markup toc: list[Heading] = field(default_factory=list) sections: list[tuple[Heading, str]] = field(default_factory=list) # für die Suche: Überschrift, Klartext _md = MarkdownIt("commonmark", {"html": False, "linkify": False}).enable(["table", "strikethrough"]) @cache def render(page_slug: str) -> Rendered: page = BY_SLUG[page_slug] text = (PAGES_DIR / f"{page_slug}.md").read_text(encoding="utf-8") tokens = _md.parse(text) toc: list[Heading] = [] used: set[str] = set() for i, token in enumerate(tokens): if token.type == "heading_open": title = tokens[i + 1].content anchor = base = slug(title) n = 2 while anchor in used: anchor, n = f"{base}-{n}", n + 1 used.add(anchor) token.attrSet("id", anchor) if token.tag in ("h2", "h3"): toc.append(Heading(int(token.tag[1]), title, anchor)) html = _md.renderer.render(tokens, _md.options, {}) # Abschnitte (ab h2) als Klartext für die Suche sections: list[tuple[Heading, str]] = [] current, lines = Heading(1, page.title, ""), [] for line in text.splitlines(): if line.startswith("## ") or line.startswith("### "): sections.append((current, " ".join(lines))) title = line.lstrip("#").strip() current = next((h for h in toc if h.text == title), Heading(2, title, slug(title))) lines = [] else: lines.append(re.sub(r"[*`_\[\]]|\(/hilfe[^)]*\)", "", line)) sections.append((current, " ".join(lines))) return Rendered(page, Markup(html), toc, sections) @dataclass class Hit: page: Page heading: Heading snippet: str def search(query: str, limit: int = 30) -> list[Hit]: words = [w for w in query.lower().split() if len(w) >= 2] if not words: return [] hits = [] for page in PAGES: for heading, body in render(page.slug).sections: haystack = f"{heading.text} {body}".lower() if all(w in haystack for w in words): position = haystack.find(words[0]) start = max(0, position - len(heading.text) - 60) snippet = body[start:start + 220].strip() hits.append(Hit(page, heading, ("… " if start else "") + snippet + " …")) # Treffer in Überschriften zuerst hits.sort(key=lambda h: not all(w in h.heading.text.lower() for w in words)) return hits[:limit]