"""Erzeugt die statische GitLab-Pages-Seite (DB-UX-Look).
Schreibt:
public/index.html -> DB-gestylte Oberflaeche mit Filter (extern/intern/allgemein),
Suche und Klick-in-Dokument (Modal mit dem Markdown-Inhalt)
public/data.json -> alle Dokumente (Metadaten + Text), per fetch() geladen
Aufruf:
python -m src.site --data output --staging staging --out public
"""
from __future__ import annotations
import argparse
import html as _html
import json
import re
from pathlib import Path
def _md_inline(s: str) -> str:
"""Minimal: escape + **bold**, `code`, [text](url)."""
s = _html.escape(s)
s = re.sub(r"\*\*(.+?)\*\*", r"\1", s)
s = re.sub(r"`(.+?)`", r"\1", s)
s = re.sub(r"\[(.+?)\]\((.+?)\)", r'\1', s)
return s
def _md_to_html(md: str) -> str:
"""Sehr kleiner Markdown->HTML-Renderer (Headings, Listen, Absaetze) fuer das Changelog."""
out: list[str] = []
in_list = False
def close_list():
nonlocal in_list
if in_list:
out.append("")
in_list = False
for raw in md.splitlines():
line = raw.rstrip()
if not line.strip():
close_list()
continue
if line.startswith("### "):
close_list()
out.append(f"
{_md_inline(line)}
") close_list() return "\n".join(out) # --- Gemeinsame Navigation (auf jeder Seite, sticky) ------------------------- NAV_CSS = """""" _NAV_ITEMS = [ ("index.html", "Uebersicht", "📊", "docs", False), # Balkendiagramm ("config.html", "Wissensquellen", "📚", "sources", True), # Buecher ("chatbot.html", "Chatbot-Anschluss", "🤖", None, False), # Roboter ("hilfe.html", "Hilfe", "❓", None, False), # Fragezeichen ("changelog.html", "Changelog", "📝", None, False), # Notizzettel ] def _nav(active: str, counts: dict, project_url: str, version: str = "") -> str: items = [] for href, label, icon, ckey, feat in _NAV_ITEMS: cls = [] if href == active: cls.append("active") if feat: cls.append("feat") badge = "" if ckey and counts.get(ckey) is not None: badge = f'{counts[ckey]}' cl = f' class="{" ".join(cls)}"' if cls else "" items.append(f'{icon}{label}{badge}') cta = "" if project_url: cta = (f'+ Wissen anfordern') vtxt = f" · v{_html.escape(version)}" if version else "" return (NAV_CSS + 'Hier sammeln wir das Wissen rund um DB InfraGO an einem Ort und legen es versioniert im Git-Repo ab. Dieses Repo ist die Single Source of Truth: was hier steht und freigegeben ist, gilt - und genau das nutzen die Chatbots. So ist klar definiert, welches Wissen in Chatbots darf und welches nicht.
Jedes Wissen durchlaeuft bei der Aufnahme drei einfache Schritte:
| Schritt | Was passiert |
|---|---|
| Extract = Sammeln | Wissen aus den Quellen holen (Confluence, Webseiten, PDFs) |
| Transform = Aufbereiten | in einheitliches Markdown umwandeln, Schlagworte/Scope vergeben, vertrauliche Inhalte herausfiltern |
| Load = Ablegen | nur geprueftes Wissen versioniert nach output/processed/ schreiben - die zentrale Wahrheit, aus der Chatbots lesen |
Kurz: Quellen rein → aufbereiten/pruefen → als Single Source of Truth ablegen. Den vollstaendigen Fluss zeigt die Architektur unten.
Vom Quellsystem bis zum Chatbot - vier Stationen:
output/processed/Nur freigegebenes Wissen (review_status: approved) verlaesst die
Pipeline. Der Scope steckt im Ordnerpfad und im Frontmatter - ein externer Bot
liest nur extern/ + allgemein/, ein interner zusaetzlich
intern/.
Die eigentliche Freigabe ist der Merge Request. Wer eine Quelle (Link + Strategie
+ Scope) in config/tools.yaml/general.yaml eintraegt, stellt einen
MR. Beim Merge (4-Augen via CODEOWNERS + protected main)
wird entschieden, welche Quelle mit welchem Scope aufgenommen wird.
Der Auto-Filter ist ein Sicherheitsnetz (kein Freigabe-Knopf):
ohne Blacklist-Treffer und mit genug Inhalt → approved
→ landet direkt in output/processed/ (live fuer den Bot). Blacklist-Treffer
oder zu kurz (<50 Zeichen) → pending (Scope
und Grund klar ersichtlich); trusted: true (z.B. INB) bleibt approved.
config/approvals.yaml ist nur die nachtraegliche Korrektur, um einzelne
pending-Dokumente doch freizugeben.
| Scope | Bedeutung |
|---|---|
| allgemein | darf intern UND extern genutzt werden; nicht toolspezifisch (z.B. Regulierung/INB, Kundeninfos) |
| intern | nur DB InfraGO intern |
| extern | public im Internet bzw. fuer EVUs/EIUs |
Eine „Strategie" sagt der Pipeline, wie sie eine Quelle einliest. Pro URL wird sie automatisch erkannt. Es gibt drei Gruppen:
| Strategie | Einfach erklaert |
|---|---|
confluence_page | genau eine Wiki-Seite |
confluence_tree | eine Seite inkl. aller Unterseiten (Tiefe ueber max_depth) |
confluence_faq | macht aus einer Frage/Antwort-Tabelle einer Seite einzelne FAQ-Eintraege |
Anhaenge: bei confluence_page/confluence_tree werden eingebundene PDF-Anhaenge
(view-file/viewpdf-Makros) automatisch geparst und als eigene Dokumente
abgelegt (Frontmatter kind: attachment, parent_url verweist auf die Seite).
Per Quelle ueber options: { attachments: false } abschaltbar.
Bilder: Binaerdaten werden nicht uebernommen, der alt-Text bleibt als
[Bild: ...] im Markdown erhalten - gibt dem RAG Kontext ohne Volumen.
| Strategie | Einfach erklaert |
|---|---|
sitemap | viele Detailseiten einer Website auf einmal (liest die Sitemap, neueste zuerst, inkrementell). So werden die Kundeninfos geholt. |
crawler | geht von einer Uebersichtsseite alle Detail-Links durch - wenn es keine Sitemap gibt |
| Strategie | Einfach erklaert |
|---|---|
pdf | liest PDF-Handbuecher (direkte URL, ganze PDF-Sammelseiten oder per Sitemap+Regex die jeweils neueste Version) und wandelt sie in Markdown inkl. Tabellen |
file | nimmt Dateien (md/pdf), die jemand direkt ins Repo nach files/ legt |
gitlab_md | holt Markdown aus einem GitLab-Repo (Ordner/Tiefe waehlbar) |
Passt zu einer neuen URL keine Strategie, wird sie als „neue Strategie noetig" markiert - dann bauen wir eine. Die konkret konfigurierten Quellen je Tool stehen auf der Seite Konfiguration.
| Ursache | Was tun? |
|---|---|
| Blacklist-Treffer (z.B. „Vertraulich") | Inhaltlich pruefen; wenn ok: URL oder hash:<content_hash> in config/approvals.yaml unter approved: eintragen |
| Inhalt zu kurz/leer (<50 Zeichen, Stub-Seite) | Meist ignorieren; ggf. Quelle mit Inhalt fuellen |
| Scope-Warnung (Blacklist in extern/allgemein) | Quelle auf intern stellen, trennen, oder bewusst in approvals.yaml freigeben |
Der genaue Grund + der Scope stehen an jeder Zeile in der Hauptansicht und im Detail-Fenster.
Die aktiven Regeln (Blacklist, Redaction-Muster) stehen weiter unten unter „Transparenz: Filter-Regeln".
Jedes Dokument bekommt einen groben Score als Orientierung, wie „gehaltvoll" der
extrahierte Inhalt ist. Er wird aus dem Text berechnet (in src/quality.py):
| Kriterium | max. Punkte |
|---|---|
| Laenge (bis ~1000 Zeichen) | 45 |
| Wortanzahl (ab ~80 Woertern voll) | 20 |
Ueberschriften vorhanden (#) | 15 |
Listen vorhanden (-/*/Nummern) | 10 |
Tabelle(n) vorhanden (|) | 10 |
Bedeutung: hoch = strukturiert/umfangreich; niedrig = sehr kurz/unstrukturiert (z.B. Stub-Seite). Aktuell rein informativ (im Detail-Fenster sichtbar). Geplant: optionale Schwelle, unter der ein Dokument auf pending geht.
Grosse, stark gegliederte Dokumente (v.a. die INB) sind als eine Markdown-Datei fuer Retrieval und Zitate unhandlich. Zusaetzlich zur Voll-Datei koennen daher Chunks entlang der Ueberschriften erzeugt werden. Default ist AUS - aktuell nur fuer die INB aktiv.
output/processed/. Chunks sind
ein zusaetzliches, abgeleitetes Artefakt unter output/chunks/<scope>/<domaene>/
(jederzeit neu erzeugbar). So hat ein Anschliesser die Wahl: Voll-Dokument, Chunks oder beides.kind: "chunk", parent_url,
parent_hash, section und (falls erkannt) die ziffer
(z.B. 7.3.1.1) - klassisches Parent-Document-Muster.> Kontext: <Dokument> > <Abschnitt>),
damit ein isoliert abgerufener Chunk weiss, wozu er gehoert - ganz ohne Embedding/LLM.Pro Quelle waehlbar ueber options.chunk (gemeinsame Defaults in
config/chunking.yaml, pro Quelle ueberschreibbar):
chunk: | Was es tut | Wann nutzen |
|---|---|---|
off (Default) | nur Voll-Dokument | kurze Seiten, Standard |
headings | schneidet an Markdown-Ueberschriften; zu kleine mergen, zu grosse weiter teilen | INB, Regelwerk, lange Confluence-Baeume |
faq | ein Chunk je Frage/Antwort | confluence_faq |
recursive | groessenbasiert (Absatz/Satz) bis Zielband | lange Doks ohne saubere Gliederung |
Erzeugt wird offline aus dem Bestand: python -m src.chunk --data output
(laeuft auch automatisch am Ende jedes ETL-Laufs). Inkrementell: nur Dokumente mit
geaendertem Inhalt (parent_hash) oder geaenderter Strategie/Option
(chunk_fingerprint) werden neu gechunkt - der Rest bleibt unangetastet (kein
Git-Churn). Gemeinsame Optionen: chunk_target_tokens (~500),
chunk_max_tokens (~1800), chunk_min_chars (~200),
chunk_overlap (0), chunk_context_header (an),
chunk_min_doc_chars (0 = aus; kleine Dokumente bleiben ganz),
chunk_kind ("" = alle; attachment = nur Anhang-PDFs chunken).
config/tools.yaml bzw. config/general.yaml und oeffnet einen Merge Request (mit Pages-Preview).- id: mein-tool
name: "Mein Tool"
domain: mein-tool
scope: mixed # intern | extern | allgemein | mixed
owners: ["vorname.nachname@deutschebahn.com"] # intern verantwortlich
contact: "einfachbahn@deutschebahn.com" # herausgebbarer Kontakt (Default)
sources:
- url: "https://arija-confluence.../pages/123/Tool+X"
strategy: confluence_tree
scope: intern
tags: ["mein-tool"]
- url: "https://www.example.com/public-faq"
strategy: crawler
scope: extern
tags: ["mein-tool", "faq"]
- url: "files/mein-tool/handbuch.pdf"
strategy: file
scope: intern
# Datei(en) nach files/mein-tool/ legen:
files/mein-tool/handbuch.pdf
files/mein-tool/schnellstart.md
# In config/tools.yaml:
sources:
- url: "files/mein-tool"
strategy: file
scope: intern
Gleicher Weg wie beim Hinzufuegen - per Merge Request:
config/tools.yaml oder
general.yaml loeschen und die zugehoerigen Dateien in
output/processed/ (+ ggf. staging/) im selben MR
mit entfernen. Nach Merge ist das Wissen weg..md-Datei unter
output/processed/ im MR loeschen. Solange die Quelle konfiguriert bleibt,
wird sie beim naechsten ETL-Lauf neu erzeugt - also ggf. die Quelle anpassen
(z.B. Scope aendern, URL entfernen) oder per approvals.yaml explizit
auf pending halten.Wird geladen ...
Diese Regeln stehen in config/filter_rules.json. Dokumente unter
50 Zeichen werden ausserdem auf pending gesetzt.
Diese Seite richtet sich an Chatbot-/RAG-Anschliesser: wo liegt welches Wissen, welcher Pfad fuer welchen Bot, und wie ist es zu interpretieren.
review_status: approved) ausgeliefert - aber Extraktion
kann unsauber sein (Tabellen, PDF-Layout). Verantwortung fuer die Veroeffentlichung bleibt
beim anschliessenden System.So haengt alles zusammen - von den Quellsystemen ueber die Wissensdatenbank
(diese Pipeline) bis zum LLM-Chatbot (z.B. Cognigy), der sein Wissen aus
output/processed/ bezieht:
files/output/processed/<scope>/approved Markdown = der Feedurl) & KontaktHinweis: Spalte 3 (Chatbot/Cognigy, Vektor-DB, RAG) ist nur ein
Vorschlag, wie ein anschliessendes System das Wissen nutzen koennte - sie ist
nicht Teil dieser Wissensdatenbank. Diese Pipeline endet bei
output/processed/. Die Trennung extern / intern / allgemein steckt im Ordnerpfad
& Frontmatter - der externe Bot bekommt so nie internes Wissen.
Im Git-Repo unter output/processed/<scope>/<domaene>/[<tool>]/*.md.
Der Scope steckt im Ordnerpfad und im Frontmatter jeder Datei.
| Bot-Typ | Welche Pfade verwenden |
|---|---|
| Externer Chatbot (Kund:innen, EVU/EIU) | extern output/processed/extern/**+ allgemein output/processed/allgemein/**NIEMALS intern/ |
| Interner Chatbot (DB InfraGO intern) | intern output/processed/intern/**+ allgemein output/processed/allgemein/**(extern bei Bedarf zusaetzlich) |
--- domain: "pathos" # fachlicher Bereich tool: "pathos" # Tool (oder "allgemein") scope: "extern" # intern | extern | allgemein tags: ["domain:pathos", "tool:pathos", "scope:extern", "faq"] owners: ["name@deutschebahn.com"] # intern verantwortlich (Accountability) contact: "einfachbahn@deutschebahn.com" # herausgebbarer Ansprechpartner/Kontakt component_type: "page" # page | faq | pdf source: "confluence" url: "https://..." # Originalquelle (fuer Zitate/Verweise) review_status: "approved" # NUR approved nutzen content_hash: "..." # Identitaet/Dedupe --- # ... eigentlicher Markdown-Inhalt ...
review_status: approved einbetten.scope hart filtern (extern-Bot: nur extern+allgemein).url als Quellenangabe/Deep-Link in Antworten nutzen.tags/domain/tool als Metadaten-Filter im Vektorindex.output/processed/ einlesen (empfohlen - versioniert, nachvollziehbar).output/processed/ als Artefakt ab.Zwei maschinenlesbare Dateien helfen beim Anschluss, ohne den ganzen Baum zu scannen:
| Datei | Wofuer |
|---|---|
output/_meta.json |
globaler Status: last_run, last_change, documents,
by_scope, content_signature - sagt, ob sich ueberhaupt etwas geaendert hat. |
output/_index.json |
dokument-genauer Katalog: pro Voll-Dokument domain/tool/scope,
url, path, content_hash, last_updated, das Feld
kind (document/attachment) und - falls vorhanden -
die zugehoerigen chunks (Anzahl + Pfad) und attachments
(Anhang-Dokumente: Name + Pfad). Aggregate: documents_total,
chunks_total, attachments_total,
by_domain/by_scope. So sieht man, was pro Domaene/Tool wo liegt
und welche Dokumente es zusaetzlich als Chunks oder Anhaenge gibt
(z.B. INB = Voll-Dokument und Chunks; pathOS-Seite = Voll-Dokument und Anhang-PDF). |
output/run_log.jsonl |
Lauf-Historie (append-only, 1 Zeile je ETL-Lauf): Zeitstempel, verarbeitete Dokumente, Status-Counts und Fehler je Quelle. So sieht man (auch historisch), ob ein Lauf sauber durchlief - z.B. fehlgeschlagene Confluence-Abrufe. |
1. output/processed/<erlaubte scopes>/ einlesen (oder gezielt ueber _index.json)
2. Frontmatter parsen -> Metadaten (scope, domain, tool, tags, url, owners, kind)
3. nur review_status == approved
4. Voll-Dokument einbetten ODER fertige Chunks aus output/chunks/ nutzen (s. _index.json)
- Beachten: 'kind' kann 'document' (Seite/PDF) ODER 'attachment' (geparster Confluence-PDF-Anhang
mit 'parent_url' auf die Elternseite) sein - beides ist normaler Feed
5. Retrieval IMMER mit scope-Filter (extern-Index nie intern)
content_hash (nur Geaendertes neu).url) + Owner in der Bot-Antwort zitieren -> Vertrauen/Feedbackweg.last_updated gewichten.Das Wissen wird stuendlich Mo-Fr 8-17 Uhr aktualisiert (Cron 0 8-17 * * 1-5).
Der letzte Lauf hat folgende Dokumente verarbeitet:
Wird geladen ...
Alle aktuell aufgenommenen Quellen - transparente, geparste Ansicht von
config/tools.yaml, config/general.yaml und
config/approvals.yaml. So ist jederzeit nachvollziehbar, welches Wissen mit
welchem Scope und welcher Strategie in die Chatbots fliesst und was manuell freigegeben ist.
Aktuelle Version: v__VERSION__ · Versionierung nach SemVer, Format nach „Keep a Changelog".
Hier sammeln wir das Wissen rund um DB InfraGO an einem Ort und legen es versioniert ab - dieses Repo ist die Single Source of Truth: was hier steht und freigegeben ist, gilt und wird von den Chatbots genutzt.
Bei der Aufnahme laeuft eine ETL-Pipeline - kurz: Sammeln (aus Confluence, Webseiten, PDFs) → Aufbereiten (einheitliches Markdown, Schlagworte, vertrauliche Inhalte herausfiltern) → Ablegen (nur geprueftes Wissen). So ist klar definiert, welches Wissen Chatbots nutzen duerfen - und welches nicht.
Kein CHANGELOG.md gefunden.
" changelog = page(CHANGELOG_HTML, "changelog.html").replace("__CHANGELOG__", cl_html).replace("__VERSION__", _html.escape(version or "?")) (out / "changelog.html").write_text(changelog, encoding="utf-8") # Review-Report (letzter ETL-Lauf) -> liegt im internen staging/ report_path = Path(staging_dir) / "review_report.json" if report_path.exists(): import shutil as _shutil _shutil.copy(report_path, out / "report.json") def _config_payload() -> dict: """Parst tools.yaml/general.yaml/approvals.yaml fuer die Transparenzseite. Degradiert sauber, falls PyYAML im Build (z.B. Pages-Stage) fehlt. """ import os try: from .config_loader import load_approvals, load_tools tools, general = load_tools("config/tools.yaml") approvals = load_approvals("config/approvals.yaml") except Exception as e: # pragma: no cover - nur Schutz im Build return {"available": False, "error": str(e), "tools": [], "general": [], "approvals": {"urls": [], "hashes": []}} def src(s): return { "url": s.url, "strategy": s.strategy.value, "scopes": [sc.value for sc in s.scopes], "tags": list(s.tags), "options": dict(s.options), "domain": s.domain, "trusted": s.trusted, "contact": s.contact_address, } return { "available": True, "project_url": os.environ.get("CI_PROJECT_URL", "https://git.tech.rz.db.de/einfachbahn-lab/tools/wissensdatenbank"), "tools": [ {"id": t.id, "name": t.name, "domain": t.domain, "scope": t.scope, "owners": list(t.owners), "contact": t.contact or "einfachbahn@deutschebahn.com", "sources": [src(s) for s in t.sources]} for t in tools ], "general": [src(s) for s in general], "approvals": {"urls": sorted(approvals["urls"]), "hashes": sorted(approvals["hashes"])}, } def main() -> None: parser = argparse.ArgumentParser(description="GitLab-Pages-Site (DB-UX)") parser.add_argument("--data", default="output") parser.add_argument("--staging", default="staging") parser.add_argument("--out", default="public") args = parser.parse_args() build(args.data, args.out, staging_dir=args.staging) if __name__ == "__main__": main()