159 lines
5.6 KiB
Python
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]
|