bifroest/app/help/service.py
2026-10-27 08:34:17 +01:00

159 lines
5.6 KiB
Python

"""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]