1140 lines
62 KiB
Python
1140 lines
62 KiB
Python
"""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"<b>\1</b>", s)
|
|
s = re.sub(r"`(.+?)`", r"<code>\1</code>", s)
|
|
s = re.sub(r"\[(.+?)\]\((.+?)\)", r'<a href="\2">\1</a>', 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("</ul>")
|
|
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"<h3>{_md_inline(line[4:])}</h3>")
|
|
elif line.startswith("## "):
|
|
close_list()
|
|
out.append(f"<h2>{_md_inline(line[3:])}</h2>")
|
|
elif line.startswith("# "):
|
|
close_list()
|
|
out.append(f"<h1>{_md_inline(line[2:])}</h1>")
|
|
elif line.lstrip().startswith(("- ", "* ")):
|
|
if not in_list:
|
|
out.append("<ul>")
|
|
in_list = True
|
|
out.append(f"<li>{_md_inline(line.lstrip()[2:])}</li>")
|
|
else:
|
|
close_list()
|
|
out.append(f"<p>{_md_inline(line)}</p>")
|
|
close_list()
|
|
return "\n".join(out)
|
|
|
|
# --- Gemeinsame Navigation (auf jeder Seite, sticky) -------------------------
|
|
NAV_CSS = """<style>
|
|
header.topbar{position:sticky;top:0;z-index:50;background:#fff;border-bottom:3px solid var(--db-red);
|
|
box-shadow:0 2px 8px rgba(0,0,0,.07);padding:0;display:block;gap:0}
|
|
.topbar .bar{max-width:1100px;margin:0 auto;display:flex;align-items:center;gap:10px;
|
|
padding:10px 24px;flex-wrap:wrap}
|
|
.topbar .brand{display:flex;align-items:center;gap:11px;text-decoration:none;color:var(--db-dark)}
|
|
.topbar .brand img{height:30px;width:auto}
|
|
.topbar .brand b{font-size:16px;font-weight:700;line-height:1}
|
|
.topbar .brand small{display:block;font-size:10.5px;color:#8a929c;font-weight:500;margin-top:2px}
|
|
.topbar nav{display:flex;gap:4px;flex-wrap:wrap;align-items:center;margin-left:10px}
|
|
.topbar nav a{display:inline-flex;align-items:center;gap:7px;text-decoration:none;color:#3a424d;
|
|
font-size:14px;font-weight:600;padding:8px 13px;border-radius:9px;transition:background .12s}
|
|
.topbar nav a:hover{background:var(--db-cool-100)}
|
|
.topbar nav a.active{background:var(--db-dark);color:#fff}
|
|
.topbar nav a .ic{font-size:15px;line-height:1}
|
|
.topbar nav a .n{font-size:11px;font-weight:800;padding:1px 8px;border-radius:11px;
|
|
background:var(--db-cool-100);color:var(--db-dark)}
|
|
.topbar nav a.active .n{background:rgba(255,255,255,.22);color:#fff}
|
|
.topbar nav a.feat{color:var(--db-red)}
|
|
.topbar nav a.feat.active{color:#fff}
|
|
.topbar .spacer{margin-left:auto}
|
|
.topbar .cta-btn{background:var(--db-red);color:#fff;border-radius:9px;padding:9px 15px;
|
|
font-weight:700;text-decoration:none;font-size:14px;white-space:nowrap}
|
|
.topbar .cta-btn:hover{background:#c40013}
|
|
@media(max-width:720px){.topbar .brand small{display:none}.topbar nav a{padding:7px 10px}}
|
|
</style>"""
|
|
|
|
_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'<span class="n">{counts[ckey]}</span>'
|
|
cl = f' class="{" ".join(cls)}"' if cls else ""
|
|
items.append(f'<a{cl} href="{href}"><span class="ic">{icon}</span>{label}{badge}</a>')
|
|
cta = ""
|
|
if project_url:
|
|
cta = (f'<a class="cta-btn" target="_blank" '
|
|
f'href="{project_url}/-/issues/new?issuable_template=Neues_Wissen">+ Wissen anfordern</a>')
|
|
vtxt = f" · v{_html.escape(version)}" if version else ""
|
|
return (NAV_CSS
|
|
+ '<header class="topbar"><div class="bar">'
|
|
+ '<a class="brand" href="index.html">'
|
|
+ '<img src="DB-Infrago-Logo.png" alt="DB InfraGO AG">'
|
|
+ f'<span><b>Wissensdatenbank</b><small>DB InfraGO · #Einfachbahn{vtxt}</small></span></a>'
|
|
+ f'<nav>{"".join(items)}</nav>'
|
|
+ f'<span class="spacer"></span>{cta}'
|
|
+ '</div></header>')
|
|
|
|
|
|
HELP_HTML = """<!doctype html>
|
|
<html lang="de">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>Wissensdatenbank - Hilfe</title>
|
|
<style>
|
|
:root{--db-red:#EC0016;--db-dark:#131821;--db-cool-100:#F0F3F5;--db-cool-200:#D7DCE1;--db-blue:#0A6CFF;--db-orange:#A9430E;--db-green:#2A7230}
|
|
*{box-sizing:border-box}
|
|
body{margin:0;font-family:-apple-system,'Segoe UI',system-ui,Roboto,Arial,sans-serif;background:var(--db-cool-100);color:var(--db-dark)}
|
|
header{background:#fff;color:var(--db-dark);padding:12px 24px;display:flex;align-items:center;gap:16px;border-bottom:3px solid var(--db-red)}
|
|
header .logo{height:34px;width:auto} header h1{font-size:18px;margin:0;font-weight:600}
|
|
.wrap{max-width:900px;margin:0 auto;padding:24px}
|
|
h2{border-bottom:2px solid var(--db-cool-200);padding-bottom:6px;margin-top:32px}
|
|
table{width:100%;border-collapse:collapse;background:#fff;border-radius:8px;overflow:hidden;margin:12px 0}
|
|
th,td{text-align:left;padding:8px 10px;border-bottom:1px solid var(--db-cool-200);font-size:14px}
|
|
th{background:var(--db-cool-100)}
|
|
code{background:var(--db-cool-100);padding:1px 5px;border-radius:3px}
|
|
pre{background:#fff;border:1px solid var(--db-cool-200);padding:14px;border-radius:8px;overflow:auto;font-size:13px}
|
|
.badge{font-size:11px;font-weight:700;color:#fff;padding:2px 9px;border-radius:11px}
|
|
.badge.extern{background:var(--db-green)} .badge.intern{background:var(--db-red)} .badge.allgemein{background:var(--db-blue)}
|
|
.badge.pending{background:var(--db-orange)} .badge.rejected{background:#6c757d}
|
|
.flow{display:flex;gap:8px;flex-wrap:wrap;align-items:center;font-size:13px;margin:12px 0}
|
|
.flow .step{background:#fff;border:1px solid var(--db-cool-200);border-radius:6px;padding:4px 10px}
|
|
.arch{display:flex;gap:8px;align-items:stretch;flex-wrap:wrap;margin:14px 0}
|
|
.arch-col{flex:1;min-width:155px;background:var(--db-cool-100);border:1px solid var(--db-cool-200);border-radius:8px;padding:10px 12px}
|
|
.arch-h{font-weight:700;font-size:13px;margin-bottom:8px}
|
|
.arch-box{background:#fff;border:1px solid var(--db-cool-200);border-radius:6px;padding:7px 9px;font-size:12.5px;margin:5px 0}
|
|
.arch-arrow{display:flex;align-items:center;justify-content:center;color:var(--db-red);font-size:22px;font-weight:700;min-width:18px}
|
|
@media(max-width:760px){.arch-arrow{transform:rotate(90deg);min-height:18px}}
|
|
.muted{color:#656d76;font-size:13px}
|
|
footer{text-align:center;color:#8a929c;font-size:11px;padding:18px 24px 28px}
|
|
a{color:var(--db-red)}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
__NAV__
|
|
<div class="wrap">
|
|
|
|
<h2>Ziel</h2>
|
|
<p><b>Hier sammeln wir das Wissen</b> rund um DB InfraGO an einem Ort und legen es
|
|
versioniert im Git-Repo ab. Dieses Repo ist die <b>Single Source of Truth</b>: was hier
|
|
steht und freigegeben ist, gilt - und genau das nutzen die Chatbots. So ist klar definiert,
|
|
<b>welches Wissen in Chatbots darf und welches nicht</b>.</p>
|
|
|
|
<h2>Was ist „ETL"? (in einfach)</h2>
|
|
<p>Jedes Wissen durchlaeuft bei der Aufnahme drei einfache Schritte:</p>
|
|
<table>
|
|
<tr><th>Schritt</th><th>Was passiert</th></tr>
|
|
<tr><td><b>Extract</b> = Sammeln</td><td>Wissen aus den Quellen holen (Confluence, Webseiten, PDFs)</td></tr>
|
|
<tr><td><b>Transform</b> = Aufbereiten</td><td>in einheitliches Markdown umwandeln, Schlagworte/Scope vergeben, vertrauliche Inhalte herausfiltern</td></tr>
|
|
<tr><td><b>Load</b> = Ablegen</td><td>nur geprueftes Wissen versioniert nach <code>output/processed/</code> schreiben - die zentrale Wahrheit, aus der Chatbots lesen</td></tr>
|
|
</table>
|
|
<p class="muted">Kurz: <b>Quellen rein → aufbereiten/pruefen → als Single Source of
|
|
Truth ablegen.</b> Den vollstaendigen Fluss zeigt die Architektur unten.</p>
|
|
|
|
<h2>Architektur & Wissensfluss</h2>
|
|
<p>Vom Quellsystem bis zum Chatbot - vier Stationen:</p>
|
|
<div class="arch">
|
|
<div class="arch-col">
|
|
<div class="arch-h">1. Quellen</div>
|
|
<div class="arch-box">Confluence (BES)</div>
|
|
<div class="arch-box">Webseiten / Kundeninfos</div>
|
|
<div class="arch-box">PDFs (INB, Regelwerk)</div>
|
|
<div class="arch-box">GitLab-Repos / Dateien</div>
|
|
</div>
|
|
<div class="arch-arrow">→</div>
|
|
<div class="arch-col">
|
|
<div class="arch-h">2. ETL-Pipeline</div>
|
|
<div class="arch-box">Extract (Connector je Strategie)</div>
|
|
<div class="arch-box">Transform (Markdown, Tags, Owner)</div>
|
|
<div class="arch-box">Filter + Review-Gate</div>
|
|
</div>
|
|
<div class="arch-arrow">→</div>
|
|
<div class="arch-col">
|
|
<div class="arch-h">3. Git-Repo (Single Source of Truth)</div>
|
|
<div class="arch-box"><b>output/processed/<scope>/</b> = freigegeben</div>
|
|
<div class="arch-box">staging/ = pending (Sichtung noetig)</div>
|
|
<div class="arch-box">staging/raw/ = Roh-PDFs</div>
|
|
</div>
|
|
<div class="arch-arrow">→</div>
|
|
<div class="arch-col">
|
|
<div class="arch-h">4. Nutzung</div>
|
|
<div class="arch-box">Vektor-DB / RAG liest <code>output/processed/</code></div>
|
|
<div class="arch-box">Chatbots intern & extern</div>
|
|
</div>
|
|
</div>
|
|
<p class="muted">Nur freigegebenes Wissen (<code>review_status: approved</code>) verlaesst die
|
|
Pipeline. Der Scope steckt im Ordnerpfad <b>und</b> im Frontmatter - ein externer Bot
|
|
liest nur <code>extern/</code> + <code>allgemein/</code>, ein interner zusaetzlich
|
|
<code>intern/</code>.</p>
|
|
|
|
<h3>Freigabe & Auto-Filter (wann wird was live?)</h3>
|
|
<p><b>Die eigentliche Freigabe ist der Merge Request.</b> Wer eine Quelle (Link + Strategie
|
|
+ Scope) in <code>config/tools.yaml</code>/<code>general.yaml</code> eintraegt, stellt einen
|
|
MR. Beim <b>Merge</b> (4-Augen via <code>CODEOWNERS</code> + protected <code>main</code>)
|
|
wird entschieden, welche Quelle mit welchem Scope aufgenommen wird.</p>
|
|
<div class="flow">
|
|
<span class="step">MR mergen (Mensch)</span> →
|
|
<span class="step">ETL-Lauf</span> →
|
|
<span class="step">Auto-Filter</span> →
|
|
<span class="badge allgemein">approved</span> /
|
|
<span class="badge pending">pending</span>
|
|
</div>
|
|
<p class="muted">Der <b>Auto-Filter</b> ist ein <b>Sicherheitsnetz</b> (kein Freigabe-Knopf):
|
|
ohne Blacklist-Treffer und mit genug Inhalt → <span class="badge allgemein">approved</span>
|
|
→ landet <b>direkt</b> in <code>output/processed/</code> (live fuer den Bot). Blacklist-Treffer
|
|
oder zu kurz (<50 Zeichen) → <span class="badge pending">pending</span> (Scope
|
|
und Grund klar ersichtlich); <code>trusted: true</code> (z.B. INB) bleibt approved.
|
|
<br><code>config/approvals.yaml</code> ist nur die <b>nachtraegliche Korrektur</b>, um einzelne
|
|
<span class="badge pending">pending</span>-Dokumente doch freizugeben.</p>
|
|
|
|
<h2>Klassifikation (Scope)</h2>
|
|
<table>
|
|
<tr><th>Scope</th><th>Bedeutung</th></tr>
|
|
<tr><td><span class="badge allgemein">allgemein</span></td><td>darf intern UND extern genutzt werden; nicht toolspezifisch (z.B. Regulierung/INB, Kundeninfos)</td></tr>
|
|
<tr><td><span class="badge intern">intern</span></td><td>nur DB InfraGO intern</td></tr>
|
|
<tr><td><span class="badge extern">extern</span></td><td>public im Internet bzw. fuer EVUs/EIUs</td></tr>
|
|
</table>
|
|
|
|
<h2>Strategien (wie eine Quelle aufgenommen wird)</h2>
|
|
<p>Eine „Strategie" sagt der Pipeline, <b>wie</b> sie eine Quelle einliest. Pro URL wird
|
|
sie automatisch erkannt. Es gibt drei Gruppen:</p>
|
|
|
|
<h3>1. Confluence - internes Wiki (BES)</h3>
|
|
<table>
|
|
<tr><th>Strategie</th><th>Einfach erklaert</th></tr>
|
|
<tr><td><code>confluence_page</code></td><td>genau <b>eine</b> Wiki-Seite</td></tr>
|
|
<tr><td><code>confluence_tree</code></td><td>eine Seite <b>inkl. aller Unterseiten</b> (Tiefe ueber <code>max_depth</code>)</td></tr>
|
|
<tr><td><code>confluence_faq</code></td><td>macht aus einer <b>Frage/Antwort-Tabelle</b> einer Seite einzelne FAQ-Eintraege</td></tr>
|
|
</table>
|
|
<p class="muted"><b>Anhaenge:</b> bei <code>confluence_page</code>/<code>confluence_tree</code> werden eingebundene PDF-Anhaenge
|
|
(<code>view-file</code>/<code>viewpdf</code>-Makros) automatisch geparst und als <b>eigene Dokumente</b>
|
|
abgelegt (Frontmatter <code>kind: attachment</code>, <code>parent_url</code> verweist auf die Seite).
|
|
Per Quelle ueber <code>options: { attachments: false }</code> abschaltbar.<br>
|
|
<b>Bilder:</b> Binaerdaten werden nicht uebernommen, der <i>alt-Text</i> bleibt als
|
|
<code>[Bild: ...]</code> im Markdown erhalten - gibt dem RAG Kontext ohne Volumen.</p>
|
|
|
|
<h3>2. Webseiten - oeffentliches Internet</h3>
|
|
<table>
|
|
<tr><th>Strategie</th><th>Einfach erklaert</th></tr>
|
|
<tr><td><code>sitemap</code></td><td><b>viele</b> Detailseiten einer Website auf einmal (liest die Sitemap, neueste zuerst, inkrementell). <b>So werden die Kundeninfos geholt.</b></td></tr>
|
|
<tr><td><code>crawler</code></td><td>geht von einer Uebersichtsseite alle Detail-Links durch - wenn es <b>keine</b> Sitemap gibt</td></tr>
|
|
</table>
|
|
|
|
<h3>3. Dokumente & Repos</h3>
|
|
<table>
|
|
<tr><th>Strategie</th><th>Einfach erklaert</th></tr>
|
|
<tr><td><code>pdf</code></td><td>liest <b>PDF-Handbuecher</b> (direkte URL, ganze PDF-Sammelseiten oder per <b>Sitemap+Regex die jeweils neueste Version</b>) und wandelt sie in Markdown inkl. Tabellen</td></tr>
|
|
<tr><td><code>file</code></td><td>nimmt Dateien (md/pdf), die jemand <b>direkt ins Repo</b> nach <code>files/</code> legt</td></tr>
|
|
<tr><td><code>gitlab_md</code></td><td>holt <b>Markdown aus einem GitLab-Repo</b> (Ordner/Tiefe waehlbar)</td></tr>
|
|
</table>
|
|
<p class="muted">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 <a href="config.html">Konfiguration</a>.</p>
|
|
|
|
<h2>Warum ist etwas pending?</h2>
|
|
<table>
|
|
<tr><th>Ursache</th><th>Was tun?</th></tr>
|
|
<tr><td>Blacklist-Treffer (z.B. „Vertraulich")</td>
|
|
<td>Inhaltlich pruefen; wenn ok: URL oder <code>hash:<content_hash></code> in <code>config/approvals.yaml</code> unter <code>approved:</code> eintragen</td></tr>
|
|
<tr><td>Inhalt zu kurz/leer (<50 Zeichen, Stub-Seite)</td><td>Meist ignorieren; ggf. Quelle mit Inhalt fuellen</td></tr>
|
|
<tr><td>Scope-Warnung (Blacklist in extern/allgemein)</td><td>Quelle auf <code>intern</code> stellen, trennen, oder bewusst in <code>approvals.yaml</code> freigeben</td></tr>
|
|
</table>
|
|
<p>Der genaue Grund + der <b>Scope</b> stehen an jeder Zeile in der Hauptansicht und im Detail-Fenster.<br>
|
|
Die aktiven Regeln (Blacklist, Redaction-Muster) stehen weiter unten unter „Transparenz: Filter-Regeln".</p>
|
|
|
|
<h2>Qualitaets-Score (0-100)</h2>
|
|
<p>Jedes Dokument bekommt einen groben Score als Orientierung, wie „gehaltvoll" der
|
|
extrahierte Inhalt ist. Er wird aus dem Text berechnet (in <code>src/quality.py</code>):</p>
|
|
<table>
|
|
<tr><th>Kriterium</th><th>max. Punkte</th></tr>
|
|
<tr><td>Laenge (bis ~1000 Zeichen)</td><td>45</td></tr>
|
|
<tr><td>Wortanzahl (ab ~80 Woertern voll)</td><td>20</td></tr>
|
|
<tr><td>Ueberschriften vorhanden (<code>#</code>)</td><td>15</td></tr>
|
|
<tr><td>Listen vorhanden (<code>-</code>/<code>*</code>/Nummern)</td><td>10</td></tr>
|
|
<tr><td>Tabelle(n) vorhanden (<code>|</code>)</td><td>10</td></tr>
|
|
</table>
|
|
<p><b>Bedeutung:</b> 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 <span class="badge pending">pending</span> geht.</p>
|
|
|
|
<h2>Chunking (optional, fuer grosse Dokumente)</h2>
|
|
<p>Grosse, stark gegliederte Dokumente (v.a. die <b>INB</b>) sind als <b>eine</b> Markdown-Datei
|
|
fuer Retrieval und Zitate unhandlich. Zusaetzlich zur Voll-Datei koennen daher
|
|
<b>Chunks entlang der Ueberschriften</b> erzeugt werden. <b>Default ist AUS</b> - aktuell
|
|
nur fuer die INB aktiv.</p>
|
|
<ul>
|
|
<li><b>Voll-Dokument bleibt immer erhalten</b> in <code>output/processed/</code>. Chunks sind
|
|
ein <b>zusaetzliches, abgeleitetes</b> Artefakt unter <code>output/chunks/<scope>/<domaene>/</code>
|
|
(jederzeit neu erzeugbar). So hat ein Anschliesser die Wahl: Voll-Dokument, Chunks oder beides.</li>
|
|
<li>Jeder Chunk traegt im Frontmatter <code>kind: "chunk"</code>, <code>parent_url</code>,
|
|
<code>parent_hash</code>, <code>section</code> und (falls erkannt) die <code>ziffer</code>
|
|
(z.B. <code>7.3.1.1</code>) - klassisches <b>Parent-Document-Muster</b>.</li>
|
|
<li><b>Contextual Retrieval (Anthropic, deterministisch):</b> jedem Chunk wird eine kurze
|
|
Kontextzeile vorangestellt (<code>> Kontext: <Dokument> > <Abschnitt></code>),
|
|
damit ein isoliert abgerufener Chunk weiss, wozu er gehoert - ganz ohne Embedding/LLM.</li>
|
|
<li>Wir geben bewusst nur <b>deterministische, embedding-freie</b> Strategien vor.
|
|
Semantisches Chunking gehoert in die Vektor-DB des Anschliessers (die wir nicht stellen).</li>
|
|
</ul>
|
|
<p>Pro Quelle waehlbar ueber <code>options.chunk</code> (gemeinsame Defaults in
|
|
<code>config/chunking.yaml</code>, pro Quelle ueberschreibbar):</p>
|
|
<table>
|
|
<tr><th><code>chunk:</code></th><th>Was es tut</th><th>Wann nutzen</th></tr>
|
|
<tr><td><code>off</code> (Default)</td><td>nur Voll-Dokument</td><td>kurze Seiten, Standard</td></tr>
|
|
<tr><td><code>headings</code></td><td>schneidet an Markdown-Ueberschriften; zu kleine mergen, zu grosse weiter teilen</td><td>INB, Regelwerk, lange Confluence-Baeume</td></tr>
|
|
<tr><td><code>faq</code></td><td>ein Chunk je Frage/Antwort</td><td><code>confluence_faq</code></td></tr>
|
|
<tr><td><code>recursive</code></td><td>groessenbasiert (Absatz/Satz) bis Zielband</td><td>lange Doks ohne saubere Gliederung</td></tr>
|
|
</table>
|
|
<p class="muted">Erzeugt wird offline aus dem Bestand: <code>python -m src.chunk --data output</code>
|
|
(laeuft auch automatisch am Ende jedes ETL-Laufs). <b>Inkrementell:</b> nur Dokumente mit
|
|
geaendertem Inhalt (<code>parent_hash</code>) oder geaenderter Strategie/Option
|
|
(<code>chunk_fingerprint</code>) werden neu gechunkt - der Rest bleibt unangetastet (kein
|
|
Git-Churn). Gemeinsame Optionen: <code>chunk_target_tokens</code> (~500),
|
|
<code>chunk_max_tokens</code> (~1800), <code>chunk_min_chars</code> (~200),
|
|
<code>chunk_overlap</code> (0), <code>chunk_context_header</code> (an),
|
|
<code>chunk_min_doc_chars</code> (0 = aus; kleine Dokumente bleiben ganz),
|
|
<code>chunk_kind</code> ("" = alle; <code>attachment</code> = nur Anhang-PDFs chunken).</p>
|
|
|
|
<h2>Neues Wissen hinzufuegen</h2>
|
|
<ol>
|
|
<li>GitLab-<b>Issue</b> mit Vorlage „Neues Wissen" anlegen: Tool/Domaene, Link(s), Strategie, Scope, <b>Ansprechpartner</b>.</li>
|
|
<li>Ein Reviewer uebernimmt es in <code>config/tools.yaml</code> bzw. <code>config/general.yaml</code> und oeffnet einen <b>Merge Request</b> (mit Pages-Preview).</li>
|
|
<li><b>Merge = Freigabe</b> (4-Augen via CODEOWNERS). Der ETL-Schedule (stuendlich Mo-Fr 8-17 Uhr) verarbeitet die Quelle.</li>
|
|
</ol>
|
|
|
|
<h3>Beispiel: Neues Tool mit mehreren Quellen</h3>
|
|
<pre>- 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</pre>
|
|
|
|
<h3>Beispiel: Handbuch ins Repo pushen</h3>
|
|
<pre># 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</pre>
|
|
|
|
<h2>Wissen entfernen / aktualisieren</h2>
|
|
<p>Gleicher Weg wie beim Hinzufuegen - per <b>Merge Request</b>:</p>
|
|
<ul>
|
|
<li><b>Quelle entfernen:</b> den Eintrag aus <code>config/tools.yaml</code> oder
|
|
<code>general.yaml</code> loeschen und die zugehoerigen Dateien in
|
|
<code>output/processed/</code> (+ ggf. <code>staging/</code>) im selben MR
|
|
mit entfernen. Nach Merge ist das Wissen weg.</li>
|
|
<li><b>Einzelnes Dokument entfernen:</b> die <code>.md</code>-Datei unter
|
|
<code>output/processed/</code> 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 <code>approvals.yaml</code> explizit
|
|
auf pending halten.</li>
|
|
<li><b>Aktualisierung:</b> passiert automatisch bei jedem ETL-Lauf (Quelle wird neu
|
|
geholt, Datei ueberschrieben). Fuer sofortige Aktualisierung: Schedule manuell
|
|
starten (Pipelines → Schedules → Run).</li>
|
|
</ul>
|
|
|
|
<h2>Transparenz: Filter-Regeln (was wird gefiltert?)</h2>
|
|
<div id="rules"><p class="muted">Wird geladen ...</p></div>
|
|
<p class="muted">Diese Regeln stehen in <code>config/filter_rules.json</code>. Dokumente unter
|
|
50 Zeichen werden ausserdem auf <span class="badge pending">pending</span> gesetzt.</p>
|
|
|
|
</div>
|
|
<footer>Ansprechpartner: Sebastian Reinig · V.IWF 91 · #Einfachbahn</footer>
|
|
<script>
|
|
function esc(s){return (s||'').toString().replace(/[&<>]/g,c=>({'&':'&','<':'<','>':'>'}[c]));}
|
|
fetch('data.json').then(r=>r.json()).then(p=>{
|
|
const rules=(p&&p.rules)||{};
|
|
const bl=(rules.blacklist_keywords||[]).map(esc).map(k=>'<span class="badge pending">'+k+'</span>').join(' ');
|
|
const rd=(rules.regex_redact||[]).map(esc).map(k=>'<code>'+k+'</code>').join('<br>');
|
|
document.querySelector('#rules').innerHTML=
|
|
'<p><b>Blacklist-Keywords</b> (Treffer -> pending, ausser <code>trusted</code>):<br>'+(bl||'-')+'</p>'
|
|
+'<p><b>Redaction-Muster</b> (werden zu [REDACTED], ausser <code>redact: false</code>):<br>'+(rd||'-')+'</p>';
|
|
}).catch(()=>{document.querySelector('#rules').innerHTML='<p class="muted">Regeln nicht ladbar.</p>';});
|
|
</script>
|
|
</body></html>
|
|
"""
|
|
|
|
CHATBOT_HTML = """<!doctype html>
|
|
<html lang="de">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>Wissensdatenbank - Chatbot-Anschluss</title>
|
|
<style>
|
|
:root{--db-red:#EC0016;--db-dark:#131821;--db-cool-100:#F0F3F5;--db-cool-200:#D7DCE1;--db-blue:#0A6CFF;--db-green:#2A7230;--db-orange:#A9430E}
|
|
*{box-sizing:border-box}
|
|
body{margin:0;font-family:-apple-system,'Segoe UI',system-ui,Roboto,Arial,sans-serif;background:var(--db-cool-100);color:var(--db-dark)}
|
|
header{background:#fff;padding:12px 24px;display:flex;align-items:center;gap:16px;border-bottom:3px solid var(--db-red)}
|
|
header .logo{height:34px} header h1{font-size:18px;margin:0}
|
|
.wrap{max-width:900px;margin:0 auto;padding:24px}
|
|
h2{border-bottom:2px solid var(--db-cool-200);padding-bottom:6px;margin-top:32px}
|
|
table{width:100%;border-collapse:collapse;background:#fff;border-radius:8px;overflow:hidden;margin:12px 0}
|
|
th,td{text-align:left;padding:8px 10px;border-bottom:1px solid var(--db-cool-200);font-size:14px;vertical-align:top}
|
|
th{background:var(--db-cool-100)}
|
|
code{background:var(--db-cool-100);padding:1px 5px;border-radius:3px}
|
|
pre{background:#fff;border:1px solid var(--db-cool-200);padding:14px;border-radius:8px;overflow:auto;font-size:13px}
|
|
.warn{background:#fff;border-left:4px solid var(--db-orange);padding:10px 14px;border-radius:6px}
|
|
.badge{font-size:11px;font-weight:700;color:#fff;padding:2px 9px;border-radius:11px}
|
|
.badge.extern{background:var(--db-green)} .badge.intern{background:var(--db-red)} .badge.allgemein{background:var(--db-blue)}
|
|
.bigflow{display:flex;align-items:stretch;flex-wrap:wrap;margin:18px 0;gap:0}
|
|
.bigflow .col{flex:1 1 230px;background:#fff;border:1px solid var(--db-cool-200);border-radius:10px;padding:14px 16px}
|
|
.bigflow .col h3{margin:0 0 2px;font-size:15px}
|
|
.bigflow .col .cap{font-size:11.5px;color:#5a6470;margin-bottom:10px;display:block}
|
|
.bigflow .box{background:var(--db-cool-100);border:1px solid var(--db-cool-200);border-radius:7px;padding:7px 10px;font-size:12.5px;margin:6px 0}
|
|
.bigflow .box b{display:block}
|
|
.bigflow .box small{color:#5a6470}
|
|
.bigflow .arrow{display:flex;align-items:center;justify-content:center;color:var(--db-red);font-size:26px;font-weight:800;flex:0 0 30px}
|
|
.flowcap{text-align:center;color:#5a6470;font-size:12px;margin:-6px 0 18px}
|
|
@media(max-width:820px){.bigflow .col{flex-basis:100%}.bigflow .arrow{flex-basis:100%;transform:rotate(90deg);padding:4px 0}}
|
|
footer{text-align:center;color:#8a929c;font-size:11px;padding:18px 24px 28px}
|
|
a{color:var(--db-red)}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
__NAV__
|
|
<div class="wrap">
|
|
|
|
<p>Diese Seite richtet sich an <b>Chatbot-/RAG-Anschliesser</b>: wo liegt welches Wissen,
|
|
welcher Pfad fuer welchen Bot, und wie ist es zu interpretieren.</p>
|
|
|
|
<div class="warn"><b>Wichtig:</b> Die Inhalte werden automatisiert extrahiert. Vor der
|
|
Nutzung im Chatbot <b>inhaltlich pruefen</b> (Stichprobe je Quelle). Es wird ausschliesslich
|
|
freigegebenes Wissen (<code>review_status: approved</code>) ausgeliefert - aber Extraktion
|
|
kann unsauber sein (Tabellen, PDF-Layout). Verantwortung fuer die Veroeffentlichung bleibt
|
|
beim anschliessenden System.</p></div>
|
|
|
|
<h2>Gesamtbild: Quellen → Wissensdatenbank → Chatbot</h2>
|
|
<p>So haengt alles zusammen - von den Quellsystemen ueber die Wissensdatenbank
|
|
(diese Pipeline) bis zum LLM-Chatbot (z.B. Cognigy), der sein Wissen aus
|
|
<code>output/processed/</code> bezieht:</p>
|
|
<div class="bigflow">
|
|
<div class="col">
|
|
<h3>1. Quellsysteme</h3><span class="cap">wo das Wissen entsteht</span>
|
|
<div class="box"><b>Confluence (BES)</b><small>internes Wiki: Tool-Seiten, FAQ</small></div>
|
|
<div class="box"><b>dbinfrago.com</b><small>Kundeninfos (Sitemap)</small></div>
|
|
<div class="box"><b>PDFs</b><small>INB, Regelwerke</small></div>
|
|
<div class="box"><b>GitLab / Dateien</b><small>Markdown-Repos, <code>files/</code></small></div>
|
|
</div>
|
|
<div class="arrow">→</div>
|
|
<div class="col">
|
|
<h3>2. Wissensdatenbank</h3><span class="cap">diese ETL-Pipeline</span>
|
|
<div class="box"><b>Extract</b><small>Connector je Strategie</small></div>
|
|
<div class="box"><b>Transform</b><small>Markdown, Tags, Scope, owners/contact</small></div>
|
|
<div class="box"><b>Auto-Filter</b><small>Blacklist/Redaction (Sicherheitsnetz)</small></div>
|
|
<div class="box"><b>Freigabe = Merge Request</b><small>4-Augen, CODEOWNERS</small></div>
|
|
<div class="box"><b><code>output/processed/<scope>/</code></b><small>approved Markdown = der Feed</small></div>
|
|
</div>
|
|
<div class="arrow">→</div>
|
|
<div class="col">
|
|
<h3>3. Chatbot (z.B. Cognigy)</h3><span class="cap">Vorschlag - nicht Teil dieses Repos</span>
|
|
<div class="box"><b>Holt nur erlaubte Scopes</b><small>extern-Bot: <span class="badge extern">extern</span>+<span class="badge allgemein">allgemein</span></small></div>
|
|
<div class="box"><b>Vektor-DB</b><small>Chunks + Embeddings (Index je Scope)</small></div>
|
|
<div class="box"><b>RAG / Retrieval</b><small>passende Stellen zur Frage</small></div>
|
|
<div class="box"><b>LLM-Antwort</b><small>mit Quelle (<code>url</code>) & Kontakt</small></div>
|
|
</div>
|
|
</div>
|
|
<p class="flowcap"><b>Hinweis:</b> Spalte 3 (Chatbot/Cognigy, Vektor-DB, RAG) ist nur ein
|
|
<b>Vorschlag</b>, wie ein anschliessendes System das Wissen nutzen koennte - sie ist
|
|
<b>nicht Teil dieser Wissensdatenbank</b>. Diese Pipeline endet bei
|
|
<code>output/processed/</code>. Die Trennung extern / intern / allgemein steckt im Ordnerpfad
|
|
& Frontmatter - der externe Bot bekommt so nie internes Wissen.</p>
|
|
|
|
<h2>Wo liegt das Wissen?</h2>
|
|
<p>Im Git-Repo unter <code>output/processed/<scope>/<domaene>/[<tool>]/*.md</code>.
|
|
Der Scope steckt im Ordnerpfad <b>und</b> im Frontmatter jeder Datei.</p>
|
|
<table>
|
|
<tr><th>Bot-Typ</th><th>Welche Pfade verwenden</th></tr>
|
|
<tr><td><b>Externer Chatbot</b> (Kund:innen, EVU/EIU)</td>
|
|
<td><span class="badge extern">extern</span> <code>output/processed/extern/**</code><br>
|
|
+ <span class="badge allgemein">allgemein</span> <code>output/processed/allgemein/**</code><br>
|
|
<b>NIEMALS</b> <code>intern/</code></td></tr>
|
|
<tr><td><b>Interner Chatbot</b> (DB InfraGO intern)</td>
|
|
<td><span class="badge intern">intern</span> <code>output/processed/intern/**</code><br>
|
|
+ <span class="badge allgemein">allgemein</span> <code>output/processed/allgemein/**</code><br>
|
|
(extern bei Bedarf zusaetzlich)</td></tr>
|
|
</table>
|
|
|
|
<h2>Wie interpretieren? (Frontmatter je Datei)</h2>
|
|
<pre>---
|
|
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 ...</pre>
|
|
<ul>
|
|
<li>Nur Dateien mit <code>review_status: approved</code> einbetten.</li>
|
|
<li><code>scope</code> hart filtern (extern-Bot: nur extern+allgemein).</li>
|
|
<li><code>url</code> als Quellenangabe/Deep-Link in Antworten nutzen.</li>
|
|
<li><code>tags</code>/<code>domain</code>/<code>tool</code> als Metadaten-Filter im Vektorindex.</li>
|
|
</ul>
|
|
|
|
<h2>Wie zugreifen?</h2>
|
|
<ul>
|
|
<li><b>Git</b>: Repo klonen/pullen, <code>output/processed/</code> einlesen (empfohlen - versioniert, nachvollziehbar).</li>
|
|
<li><b>CI-Artefakt</b>: der ETL-Job (stuendlich Mo-Fr) legt <code>output/processed/</code> als Artefakt ab.</li>
|
|
<li><b>Raw-Datei</b>: einzelne Datei via GitLab Raw-URL.</li>
|
|
</ul>
|
|
|
|
<h2>Katalog & Aktualitaet (Manifest-Dateien)</h2>
|
|
<p>Zwei maschinenlesbare Dateien helfen beim Anschluss, ohne den ganzen Baum zu scannen:</p>
|
|
<table>
|
|
<tr><th>Datei</th><th>Wofuer</th></tr>
|
|
<tr><td><code>output/_meta.json</code></td>
|
|
<td>globaler Status: <code>last_run</code>, <code>last_change</code>, <code>documents</code>,
|
|
<code>by_scope</code>, <code>content_signature</code> - sagt, <b>ob</b> sich ueberhaupt etwas geaendert hat.</td></tr>
|
|
<tr><td><code>output/_index.json</code></td>
|
|
<td>dokument-genauer <b>Katalog</b>: pro Voll-Dokument <code>domain</code>/<code>tool</code>/<code>scope</code>,
|
|
<code>url</code>, <code>path</code>, <code>content_hash</code>, <code>last_updated</code>, das Feld
|
|
<code>kind</code> (<code>document</code>/<code>attachment</code>) und - falls vorhanden -
|
|
die zugehoerigen <code>chunks</code> (Anzahl + Pfad) und <code>attachments</code>
|
|
(Anhang-Dokumente: Name + Pfad). Aggregate: <code>documents_total</code>,
|
|
<code>chunks_total</code>, <code>attachments_total</code>,
|
|
<code>by_domain</code>/<code>by_scope</code>. So sieht man, <b>was pro Domaene/Tool wo liegt</b>
|
|
und welche Dokumente es zusaetzlich als Chunks oder Anhaenge gibt
|
|
(z.B. INB = Voll-Dokument <b>und</b> Chunks; pathOS-Seite = Voll-Dokument <b>und</b> Anhang-PDF).</td></tr>
|
|
<tr><td><code>output/run_log.jsonl</code></td>
|
|
<td><b>Lauf-Historie</b> (append-only, 1 Zeile je ETL-Lauf): Zeitstempel, verarbeitete
|
|
Dokumente, Status-Counts und <b>Fehler je Quelle</b>. So sieht man (auch historisch),
|
|
ob ein Lauf sauber durchlief - z.B. fehlgeschlagene Confluence-Abrufe.</td></tr>
|
|
</table>
|
|
|
|
<h2>Empfohlener Ingestion-Ablauf (RAG)</h2>
|
|
<pre>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)</pre>
|
|
|
|
<h2>Weitere Ideen</h2>
|
|
<ul>
|
|
<li>Inkrementell einbetten ueber <code>content_hash</code> (nur Geaendertes neu).</li>
|
|
<li>Quelle (<code>url</code>) + Owner in der Bot-Antwort zitieren -> Vertrauen/Feedbackweg.</li>
|
|
<li>Aktualitaet via <code>last_updated</code> gewichten.</li>
|
|
<li>Separate Vektor-Indizes je Scope (harte technische Trennung extern/intern).</li>
|
|
</ul>
|
|
|
|
<h2>Aktualitaet: letzter ETL-Lauf</h2>
|
|
<p>Das Wissen wird <b>stuendlich Mo-Fr 8-17 Uhr</b> aktualisiert (Cron <code>0 8-17 * * 1-5</code>).
|
|
Der letzte Lauf hat folgende Dokumente verarbeitet:</p>
|
|
<div id="report"><p class="muted">Wird geladen ...</p></div>
|
|
|
|
</div>
|
|
<footer>Ansprechpartner: Sebastian Reinig · V.IWF 91 · #Einfachbahn</footer>
|
|
<script>
|
|
function esc(s){return (s||'').toString().replace(/[&<>]/g,c=>({'&':'&','<':'<','>':'>'}[c]));}
|
|
fetch('report.json').then(r=>r.ok?r.json():Promise.reject()).then(rpt=>{
|
|
const el=document.querySelector('#report');
|
|
if(!rpt||!rpt.length){el.innerHTML='<p class="muted">Kein Lauf-Bericht (noch kein ETL-Lauf oder 0 neue Dokumente).</p>';return;}
|
|
let h='<p class="muted">'+rpt.length+' Dokument(e) im letzten Lauf:</p>';
|
|
h+='<table><tr><th>Status</th><th>Titel</th><th>Domaene</th><th>Hinweise</th></tr>';
|
|
for(const d of rpt.slice(0,50)){
|
|
const n=(d.notes||[]).join('; ');
|
|
h+='<tr><td>'+esc(d.status)+'</td><td>'+esc(d.title).slice(0,60)+'</td><td>'+esc(d.domain)+'</td><td>'+esc(n)+'</td></tr>';
|
|
}
|
|
h+='</table>';
|
|
if(rpt.length>50) h+='<p class="muted">... und '+(rpt.length-50)+' weitere.</p>';
|
|
el.innerHTML=h;
|
|
}).catch(()=>{document.querySelector('#report').innerHTML='<p class="muted">Kein report.json vorhanden.</p>';});
|
|
</script>
|
|
</body></html>
|
|
"""
|
|
|
|
CONFIG_HTML = """<!doctype html>
|
|
<html lang="de">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>Wissensdatenbank - Konfiguration</title>
|
|
<style>
|
|
:root{--db-red:#EC0016;--db-dark:#131821;--db-cool-100:#F0F3F5;--db-cool-200:#D7DCE1;--db-blue:#0A6CFF;--db-green:#2A7230;--db-orange:#A9430E}
|
|
*{box-sizing:border-box}
|
|
body{margin:0;font-family:-apple-system,'Segoe UI',system-ui,Roboto,Arial,sans-serif;background:var(--db-cool-100);color:var(--db-dark)}
|
|
header{background:#fff;padding:12px 24px;display:flex;align-items:center;gap:16px;border-bottom:3px solid var(--db-red)}
|
|
header .logo{height:34px} header h1{font-size:18px;margin:0}
|
|
header nav{margin-left:auto;font-size:13px;display:flex;gap:14px;flex-wrap:wrap}
|
|
.wrap{max-width:1100px;margin:0 auto;padding:24px}
|
|
h2{border-bottom:2px solid var(--db-cool-200);padding-bottom:6px;margin-top:34px}
|
|
table{width:100%;border-collapse:collapse;background:#fff;border-radius:8px;overflow:hidden;margin:10px 0}
|
|
th,td{text-align:left;padding:8px 10px;border-bottom:1px solid var(--db-cool-200);font-size:13px;vertical-align:top}
|
|
th{background:var(--db-cool-100)}
|
|
code{background:var(--db-cool-100);padding:1px 5px;border-radius:3px;font-size:12px;word-break:break-all}
|
|
.badge{font-size:11px;font-weight:700;color:#fff;padding:2px 9px;border-radius:11px;white-space:nowrap;margin-right:3px}
|
|
.badge.extern{background:var(--db-green)} .badge.intern{background:var(--db-red)} .badge.allgemein{background:var(--db-blue)}
|
|
.badge.mixed{background:#5a6470}
|
|
.tag{display:inline-block;background:var(--db-cool-100);border:1px solid var(--db-cool-200);border-radius:4px;font-size:11px;padding:1px 6px;margin:1px;color:#3a424d}
|
|
.tool{background:#fff;border-radius:8px;box-shadow:0 1px 2px rgba(0,0,0,.08);padding:14px 18px;margin:14px 0}
|
|
.tool h3{margin:0 0 4px;font-size:16px}
|
|
.muted{color:#5a6470;font-size:13px}
|
|
.sumcards{display:flex;gap:12px;flex-wrap:wrap;margin:16px 0 6px}
|
|
.sumcards .c{background:#fff;border-radius:10px;padding:14px 20px;box-shadow:0 1px 3px rgba(0,0,0,.1);min-width:120px}
|
|
.sumcards .c .n{font-size:26px;font-weight:800}
|
|
.sumcards .c.s .n{color:var(--db-red)} .sumcards .c.t .n{color:var(--db-dark)}
|
|
.sumcards .c.g .n{color:var(--db-blue)} .sumcards .c.a .n{color:var(--db-green)}
|
|
.sumcards .c small{color:#5a6470;font-size:12px}
|
|
footer{text-align:center;color:#8a929c;font-size:11px;padding:18px 24px 28px}
|
|
a{color:var(--db-red)}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
__NAV__
|
|
<div class="wrap">
|
|
<h1 style="font-size:22px;margin:18px 0 4px">Wissensquellen</h1>
|
|
<p class="muted">Alle aktuell aufgenommenen Quellen - transparente, geparste Ansicht von
|
|
<code>config/tools.yaml</code>, <code>config/general.yaml</code> und
|
|
<code>config/approvals.yaml</code>. So ist jederzeit nachvollziehbar, welches Wissen mit
|
|
welchem Scope und welcher Strategie in die Chatbots fliesst und was manuell freigegeben ist.</p>
|
|
<div id="summary" class="sumcards"></div>
|
|
<label class="muted" style="display:inline-flex;gap:8px;align-items:center;margin:16px 0 0">Domaene filtern:
|
|
<select id="domf" style="padding:7px 10px;border:1px solid var(--db-cool-200);border-radius:6px;font-size:13px;background:#fff;cursor:pointer">
|
|
<option value="">alle</option>
|
|
</select>
|
|
</label>
|
|
<div id="content"></div>
|
|
</div>
|
|
<footer>Ansprechpartner: Sebastian Reinig · V.IWF 91 · #Einfachbahn</footer>
|
|
<script>
|
|
const $=s=>document.querySelector(s);
|
|
function esc(s){return (s||'').toString().replace(/[&<>]/g,c=>({'&':'&','<':'<','>':'>'}[c]));}
|
|
function scopes(arr){return (arr||[]).map(s=>'<span class="badge '+esc(s)+'">'+esc(s)+'</span>').join('');}
|
|
function tags(arr){return (arr||[]).map(t=>'<span class="tag">'+esc(t)+'</span>').join('');}
|
|
function opts(o){
|
|
const keys=Object.keys(o||{});
|
|
if(!keys.length) return '<span class=muted>-</span>';
|
|
return keys.map(k=>'<code>'+esc(k)+': '+esc(JSON.stringify(o[k]))+'</code>').join(' ');
|
|
}
|
|
function srcTable(sources){
|
|
let h='<table><tr><th>Quelle (URL)</th><th>Strategie</th><th>Scope</th><th>Tags</th><th>Optionen</th><th></th></tr>';
|
|
for(const s of sources){
|
|
const issueTitle=encodeURIComponent('Quelle anpassen/loeschen: '+s.url.slice(0,60));
|
|
const editLink=PROJ+'/-/edit/main/config/'+(s.domain==='kundeninfo'||s.domain==='regulierung'||s.domain==='regelwerk'?'general.yaml':'tools.yaml');
|
|
const issueBody=encodeURIComponent('Bitte diese Quelle anpassen oder entfernen:\\n\\nURL: '+s.url+'\\nStrategie: '+s.strategy+'\\nScope: '+(s.scopes||[]).join(', ')+'\\nDomaene: '+s.domain+'\\n\\nDirekt bearbeiten: '+editLink+'\\n\\nWas soll passieren? (bitte beschreiben)\\n- [ ] Scope aendern\\n- [ ] Quelle entfernen\\n- [ ] Sonstiges');
|
|
const link=PROJ?('<a href="'+PROJ+'/-/issues/new?issue[title]='+issueTitle+'&issue[description]='+issueBody+'" target=_blank title="Issue: Quelle anpassen/loeschen">✎</a>'):'';
|
|
h+='<tr><td><code>'+esc(s.url)+'</code></td><td><code>'+esc(s.strategy)+'</code></td>'
|
|
+'<td>'+scopes(s.scopes)+'</td><td>'+tags(s.tags)+'</td><td>'+opts(s.options)+'</td><td>'+link+'</td></tr>';
|
|
}
|
|
return h+'</table>';
|
|
}
|
|
let CFG=null, fdom='', PROJ='';
|
|
function renderConfig(){
|
|
const c=CFG, root=$('#content');
|
|
const tools=fdom?c.tools.filter(t=>t.domain===fdom):c.tools;
|
|
const general=fdom?c.general.filter(s=>s.domain===fdom):c.general;
|
|
let h='<h2>Tools ('+tools.length+')</h2>';
|
|
if(!tools.length) h+='<p class="muted">Keine Tools in dieser Domaene.</p>';
|
|
for(const t of tools){
|
|
h+='<div class="tool"><h3>'+esc(t.name)+' <span class="muted">('+esc(t.id)+')</span></h3>'
|
|
+'<p class="muted">Domaene: <code>'+esc(t.domain)+'</code> · Tool-Scope: <span class="badge '+esc(t.scope)+'">'+esc(t.scope)+'</span>'
|
|
+(t.owners&&t.owners.length?(' · Verantwortlich: '+t.owners.map(esc).join(', ')):'')
|
|
+(t.contact?(' · Kontakt: '+esc(t.contact)):'')+'</p>'
|
|
+(t.sources.length?srcTable(t.sources):'<p class="muted">Platzhalter - noch keine Quellen hinterlegt.</p>')+'</div>';
|
|
}
|
|
h+='<h2>Allgemeines Wissen ('+general.length+')</h2>';
|
|
h+='<p class="muted">scope: allgemein (intern UND extern), nicht toolspezifisch.</p>';
|
|
h+=general.length?srcTable(general):'<p class="muted">Keine allgemeinen Quellen in dieser Domaene.</p>';
|
|
if(!fdom){
|
|
h+='<h2>Manuelle Freigaben (approvals.yaml)</h2>';
|
|
if((!c.approvals.urls||!c.approvals.urls.length)&&(!c.approvals.hashes||!c.approvals.hashes.length)){
|
|
h+='<p class="muted">Keine manuellen Freigaben eingetragen.</p>';
|
|
}else{
|
|
h+='<p class="muted">Diese Eintraege heben ein vom Auto-Filter auf pending gesetztes Dokument auf <span class="badge allgemein">approved</span>.</p>';
|
|
h+='<table><tr><th>Typ</th><th>Wert</th></tr>';
|
|
for(const u of (c.approvals.urls||[])) h+='<tr><td>URL</td><td><code>'+esc(u)+'</code></td></tr>';
|
|
for(const x of (c.approvals.hashes||[])) h+='<tr><td>content_hash</td><td><code>'+esc(x)+'</code></td></tr>';
|
|
h+='</table>';
|
|
}
|
|
}
|
|
root.innerHTML=h;
|
|
}
|
|
fetch('config.json').then(r=>r.json()).then(c=>{
|
|
CFG=c;
|
|
PROJ=c.project_url||'';
|
|
const root=$('#content');
|
|
if(!c.available){root.innerHTML='<p class=muted>Konfiguration konnte nicht geparst werden (PyYAML fehlt im Build).</p>';return;}
|
|
const nSources=c.tools.reduce((a,t)=>a+t.sources.length,0)+c.general.length;
|
|
const nAppr=(c.approvals.urls||[]).length+(c.approvals.hashes||[]).length;
|
|
$('#summary').innerHTML=
|
|
'<div class="c s"><div class="n">'+nSources+'</div><small>Wissensquellen gesamt</small></div>'
|
|
+'<div class="c t"><div class="n">'+c.tools.length+'</div><small>Tools</small></div>'
|
|
+'<div class="c g"><div class="n">'+c.general.length+'</div><small>allgemeine Quellen</small></div>'
|
|
+'<div class="c a"><div class="n">'+nAppr+'</div><small>manuelle Freigaben</small></div>';
|
|
const doms=[...new Set([...c.tools.map(t=>t.domain),...c.general.map(s=>s.domain)].filter(Boolean))].sort();
|
|
$('#domf').innerHTML='<option value="">alle Domaenen</option>'
|
|
+ doms.map(d=>'<option value="'+esc(d)+'">'+esc(d)+'</option>').join('');
|
|
$('#domf').onchange=e=>{fdom=e.target.value;renderConfig();};
|
|
renderConfig();
|
|
});
|
|
</script>
|
|
</body></html>
|
|
"""
|
|
|
|
CHANGELOG_HTML = """<!doctype html>
|
|
<html lang="de">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>Wissensdatenbank - Changelog</title>
|
|
<style>
|
|
:root{--db-red:#EC0016;--db-dark:#131821;--db-cool-100:#F0F3F5;--db-cool-200:#D7DCE1;--db-blue:#0A6CFF;--db-green:#2A7230;--db-orange:#A9430E}
|
|
*{box-sizing:border-box}
|
|
body{margin:0;font-family:-apple-system,'Segoe UI',system-ui,Roboto,Arial,sans-serif;background:var(--db-cool-100);color:var(--db-dark)}
|
|
.wrap{max-width:860px;margin:0 auto;padding:24px}
|
|
h1{font-size:22px;margin:6px 0 2px}
|
|
h2{border-bottom:2px solid var(--db-cool-200);padding-bottom:6px;margin-top:30px;font-size:18px}
|
|
h3{margin:16px 0 4px;font-size:14px;color:#3a424d;text-transform:uppercase;letter-spacing:.04em}
|
|
ul{margin:6px 0 12px;padding-left:22px} li{margin:3px 0;font-size:14px;line-height:1.5}
|
|
p{font-size:14px;line-height:1.55} code{background:#fff;border:1px solid var(--db-cool-200);padding:1px 5px;border-radius:3px;font-size:12.5px}
|
|
a{color:var(--db-red)}
|
|
.vbadge{display:inline-block;background:var(--db-dark);color:#fff;font-weight:700;font-size:13px;padding:3px 10px;border-radius:12px}
|
|
.content{background:#fff;border-radius:10px;padding:8px 22px 18px;box-shadow:0 1px 3px rgba(0,0,0,.08);margin-top:12px}
|
|
footer{text-align:center;color:#8a929c;font-size:11px;padding:18px 24px 28px}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
__NAV__
|
|
<div class="wrap">
|
|
<h1>Changelog</h1>
|
|
<p>Aktuelle Version: <span class="vbadge">v__VERSION__</span>
|
|
· Versionierung nach SemVer, Format nach „Keep a Changelog".</p>
|
|
<div class="content">
|
|
__CHANGELOG__
|
|
</div>
|
|
</div>
|
|
<footer>Ansprechpartner: Sebastian Reinig · V.IWF 91 · #Einfachbahn</footer>
|
|
</body></html>
|
|
"""
|
|
|
|
INDEX_HTML = """<!doctype html>
|
|
<html lang="de">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>Wissensdatenbank - Uebersicht</title>
|
|
<style>
|
|
:root{
|
|
--db-red:#EC0016; --db-dark:#131821; --db-cool-800:#282D37; --db-cool-200:#D7DCE1;
|
|
--db-cool-100:#F0F3F5; --db-white:#fff; --db-green:#2A7230; --db-blue:#0A6CFF;
|
|
--db-orange:#A9430E;
|
|
}
|
|
*{box-sizing:border-box}
|
|
body{margin:0;font-family:-apple-system,'Segoe UI',system-ui,Roboto,Arial,sans-serif;
|
|
background:var(--db-cool-100);color:var(--db-dark)}
|
|
header{background:#fff;color:var(--db-dark);padding:12px 24px;display:flex;align-items:center;
|
|
gap:16px;border-bottom:3px solid var(--db-red)}
|
|
header .logo{height:34px;width:auto}
|
|
header h1{font-size:18px;margin:0;font-weight:600}
|
|
footer{text-align:center;color:#8a929c;font-size:11px;padding:18px 24px 28px}
|
|
.wrap{max-width:1100px;margin:0 auto;padding:20px 24px}
|
|
.cards{display:flex;gap:12px;flex-wrap:wrap;margin-bottom:18px}
|
|
.card{background:#fff;border-radius:8px;padding:14px 18px;box-shadow:0 1px 3px rgba(0,0,0,.12);min-width:130px}
|
|
.card .n{font-size:26px;font-weight:700}
|
|
.ok .n{color:var(--db-green)} .warn .n{color:var(--db-orange)} .bad .n{color:var(--db-red)}
|
|
.toolbar{display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin:14px 0}
|
|
.chip{border:1px solid var(--db-cool-200);background:#fff;border-radius:20px;padding:6px 14px;
|
|
cursor:pointer;font-size:14px}
|
|
.chip.active{background:var(--db-dark);color:#fff;border-color:var(--db-dark)}
|
|
#q{flex:1;min-width:200px;padding:9px 12px;border:1px solid var(--db-cool-200);border-radius:6px;font-size:14px}
|
|
#dom{padding:9px 12px;border:1px solid var(--db-cool-200);border-radius:6px;font-size:14px;background:#fff;cursor:pointer;max-width:230px}
|
|
.row{background:#fff;border-radius:8px;box-shadow:0 1px 2px rgba(0,0,0,.08);padding:12px 16px;
|
|
margin:8px 0;cursor:pointer;display:flex;gap:10px;align-items:center}
|
|
.row:hover{box-shadow:0 2px 8px rgba(0,0,0,.15)}
|
|
.row .meta{flex:1;min-width:0}
|
|
.row .title{font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
.row .sub{font-size:12px;color:#5a6470;margin-top:2px}
|
|
.badge{font-size:11px;font-weight:700;color:#fff;padding:2px 9px;border-radius:11px;white-space:nowrap}
|
|
.badge.extern{background:var(--db-green)} .badge.intern{background:var(--db-red)}
|
|
.badge.allgemein{background:var(--db-blue)}
|
|
.badge.pending{background:var(--db-orange)} .badge.rejected{background:#6c757d}
|
|
.tag{display:inline-block;background:var(--db-cool-100);border:1px solid var(--db-cool-200);
|
|
border-radius:4px;font-size:11px;padding:1px 6px;margin-right:4px;color:#3a424d}
|
|
.qual{flex:0 0 auto;align-self:center;min-width:34px;text-align:center;font-weight:700;font-size:13px;
|
|
padding:3px 8px;border-radius:11px}
|
|
.qual.q-hi{background:#e3f1e4;color:var(--db-green)}
|
|
.qual.q-mid{background:#fbeede;color:var(--db-orange)}
|
|
.qual.q-lo{background:#f6dada;color:var(--db-red)}
|
|
/* Modal */
|
|
.ov{position:fixed;inset:0;background:rgba(19,24,33,.55);display:none;align-items:flex-start;
|
|
justify-content:center;padding:40px 16px;overflow:auto}
|
|
.ov.open{display:flex}
|
|
.modal{background:#fff;border-radius:10px;max-width:820px;width:100%;padding:0 0 20px}
|
|
.modal h2{margin:0;padding:16px 22px;border-bottom:1px solid var(--db-cool-200);font-size:17px}
|
|
.modal .body{padding:18px 22px}
|
|
.modal pre{white-space:pre-wrap;word-wrap:break-word;font-family:ui-monospace,Menlo,Consolas,monospace;
|
|
font-size:13px;line-height:1.5;background:var(--db-cool-100);padding:14px;border-radius:6px}
|
|
.modal .close{float:right;cursor:pointer;border:none;background:none;font-size:22px;color:#5a6470}
|
|
a{color:var(--db-red)}
|
|
.muted{color:#5a6470;font-size:13px}
|
|
.reason{color:var(--db-orange);font-weight:600}
|
|
.intro{background:#fff;border-radius:8px;padding:16px 20px;margin:0 0 14px;border-left:4px solid var(--db-red)}
|
|
.intro h2{margin:0 0 6px;font-size:18px;border:0;padding:0}
|
|
.intro p{margin:6px 0;font-size:14px;line-height:1.5}
|
|
.scopes-mini{display:flex;gap:18px;flex-wrap:wrap;margin-top:10px;font-size:13px}
|
|
.sec{font-size:13px;margin:22px 0 8px;color:#5a6470;text-transform:uppercase;letter-spacing:.05em;font-weight:700}
|
|
</style>
|
|
</head>
|
|
<body>
|
|
__NAV__
|
|
<div class="wrap">
|
|
<section class="intro">
|
|
<h2>Ziel dieser Wissensdatenbank</h2>
|
|
<p><b>Hier sammeln wir das Wissen</b> rund um DB InfraGO an einem Ort und legen es
|
|
versioniert ab - dieses Repo ist die <b>Single Source of Truth</b>: was hier steht
|
|
und freigegeben ist, gilt und wird von den Chatbots genutzt.</p>
|
|
<p>Bei der Aufnahme laeuft eine <b>ETL-Pipeline</b> - kurz: <b>Sammeln</b> (aus
|
|
Confluence, Webseiten, PDFs) → <b>Aufbereiten</b> (einheitliches Markdown,
|
|
Schlagworte, vertrauliche Inhalte herausfiltern) → <b>Ablegen</b> (nur
|
|
geprueftes Wissen). So ist klar definiert, <b>welches Wissen Chatbots nutzen duerfen
|
|
- und welches nicht.</b></p>
|
|
<div class="scopes-mini">
|
|
<div><span class="badge allgemein">allgemein</span> intern UND extern, nicht toolspezifisch</div>
|
|
<div><span class="badge intern">intern</span> nur DB InfraGO intern</div>
|
|
<div><span class="badge extern">extern</span> public / fuer EVUs & EIUs</div>
|
|
</div>
|
|
</section>
|
|
|
|
<div class="sec">Bestand</div>
|
|
<div class="cards" id="stats"></div>
|
|
|
|
<div class="sec">Wissen durchsuchen</div>
|
|
<div class="toolbar">
|
|
<span class="chip active" data-f="all">Alle</span>
|
|
<span class="chip" data-f="extern">extern</span>
|
|
<span class="chip" data-f="intern">intern</span>
|
|
<span class="chip" data-f="allgemein">allgemein</span>
|
|
<span class="chip" data-f="pending">pending</span>
|
|
<select id="dom" title="Nach Domaene filtern"><option value="">Alle Domaenen</option></select>
|
|
<input id="q" placeholder="Suche in Titel, Domaene, Tags ...">
|
|
</div>
|
|
<p class="muted" id="count"></p>
|
|
<div id="list"></div>
|
|
</div>
|
|
|
|
<footer>Ansprechpartner: Sebastian Reinig · V.IWF 91 · #Einfachbahn<span id="lastrun"></span></footer>
|
|
|
|
<div class="ov" id="ov"><div class="modal">
|
|
<h2><button class="close" id="x">×</button><span id="mt"></span></h2>
|
|
<div class="body"><p class="muted" id="mm"></p><pre id="mc"></pre></div>
|
|
</div></div>
|
|
|
|
<script>
|
|
let DOCS=[], filter='all', q='', dom='', PROJECT_URL='';
|
|
const $=s=>document.querySelector(s);
|
|
function scopeOf(d){return d.status==='approved'?d.scope:'pending';}
|
|
function render(){
|
|
const list=$('#list'); list.innerHTML='';
|
|
const items=DOCS.filter(d=>{
|
|
let f;
|
|
if(filter==='all') f=true;
|
|
else if(filter==='pending') f=d.status==='pending';
|
|
else f=(d.status==='approved'&&d.scope===filter);
|
|
const hay=(d.title+' '+d.domain+' '+d.tool+' '+(d.tags||[]).join(' ')).toLowerCase();
|
|
return f && (!dom || d.domain===dom) && (!q || hay.includes(q));
|
|
});
|
|
$('#count').textContent=items.length+' Dokument(e)';
|
|
for(const d of items.slice(0,1000)){
|
|
const sc=scopeOf(d);
|
|
const row=document.createElement('div'); row.className='row';
|
|
const reason=(d.status!=='approved'&&d.notes)?(' <span class="reason">· pending: '+esc(d.notes)+'</span>'):'';
|
|
const q=d.quality||0;
|
|
const qcls=q>=70?'q-hi':q>=40?'q-mid':'q-lo';
|
|
row.innerHTML=`<span class="badge ${sc}">${sc}</span>
|
|
<div class="meta"><div class="title">${esc(d.title)}</div>
|
|
<div class="sub">${esc(d.domain)}${d.tool&&d.tool!==d.domain?(' / '+esc(d.tool)):''}
|
|
· ${(d.tags||[]).slice(0,6).map(t=>'<span class=tag>'+esc(t)+'</span>').join('')}${reason}</div></div>
|
|
<span class="qual ${qcls}" title="Qualitaets-Score 0-100">${q}</span>`;
|
|
row.onclick=()=>open(d);
|
|
list.appendChild(row);
|
|
}
|
|
}
|
|
function open(d){
|
|
$('#mt').textContent=d.title;
|
|
let html=`<span class="badge ${d.scope}">${d.scope}</span>`
|
|
+ (d.status!=='approved'?' <span class="badge pending">pending</span>':'')
|
|
+ ` · ${esc(d.domain)}`
|
|
+ ` · Qualitaet: <b>${d.quality||0}/100</b>`
|
|
+ (d.url?(' · <a href="'+d.url+'" target=_blank>Quelle</a>'):'');
|
|
if(PROJECT_URL && d.path) html += ` · <a href="${PROJECT_URL}/-/blob/main/${encodeURI(d.path)}" target=_blank>Datei im Repo</a>`;
|
|
if(d.owners&&d.owners.length) html += '<br>Verantwortlich: '+d.owners.map(esc).join(', ');
|
|
if(d.contact) html += '<br>Kontakt: '+esc(d.contact);
|
|
if(d.notes) html += '<br>Hinweis: '+esc(d.notes);
|
|
const qp=d.qparts||{};
|
|
if(qp.total!==undefined){
|
|
const part=(k,lbl,max)=>'<span class=tag title="'+lbl+'">'+lbl+': '+(qp[k]||0)+'/'+max+'</span>';
|
|
html += '<br><span class=muted>Qualitaet zusammengesetzt aus:</span> '
|
|
+ part('laenge','Laenge',45)+part('woerter','Woerter',20)
|
|
+ part('headings','Ueberschriften',15)+part('listen','Listen',10)
|
|
+ part('tabellen','Tabellen',10);
|
|
}
|
|
if(d.status!=='approved' && PROJECT_URL){
|
|
const title=encodeURIComponent('Freigabe: '+d.title);
|
|
const body=encodeURIComponent('Bitte freigeben.\\n\\nScope: '+d.scope+'\\nQuelle: '+d.url+'\\nhash: '+d.hash+'\\n\\nIn config/approvals.yaml unter approved: hinzufuegen:\\n- "hash:'+d.hash+'"');
|
|
html += `<br><a href="${PROJECT_URL}/-/issues/new?issue[title]=${title}&issue[description]=${body}" target=_blank>Freigabe beantragen (Issue)</a>`
|
|
+ ` · <a href="${PROJECT_URL}/-/edit/main/config/approvals.yaml" target=_blank>approvals.yaml bearbeiten</a>`;
|
|
}
|
|
$('#mm').innerHTML=html;
|
|
$('#mc').textContent=d.text||'(kein Inhalt)';
|
|
$('#ov').classList.add('open');
|
|
}
|
|
function esc(s){return (s||'').replace(/[&<>]/g,c=>({'&':'&','<':'<','>':'>'}[c]));}
|
|
$('#x').onclick=()=>$('#ov').classList.remove('open');
|
|
$('#ov').onclick=e=>{if(e.target===$('#ov'))$('#ov').classList.remove('open');};
|
|
$('#q').oninput=e=>{q=e.target.value.toLowerCase();render();};
|
|
$('#dom').onchange=e=>{dom=e.target.value;render();};
|
|
document.querySelectorAll('.chip').forEach(c=>c.onclick=()=>{
|
|
document.querySelectorAll('.chip').forEach(x=>x.classList.remove('active'));
|
|
c.classList.add('active'); filter=c.dataset.f; render();
|
|
});
|
|
fetch('data.json').then(r=>r.json()).then(payload=>{
|
|
DOCS=Array.isArray(payload)?payload:(payload.docs||[]);
|
|
PROJECT_URL=(payload.meta&&payload.meta.project_url)||'';
|
|
PROJECT_URL=(payload.meta&&payload.meta.project_url)||'';
|
|
const M=(!Array.isArray(payload)&&payload.meta)||{};
|
|
if(M.last_run){
|
|
const fmt=s=>{try{return new Date(s).toLocaleString('de-DE',{dateStyle:'medium',timeStyle:'short'});}catch(e){return s;}};
|
|
let t=' \u00b7 Stand: '+fmt(M.last_run);
|
|
if(M.last_change) t+=' (letzte \u00c4nderung: '+fmt(M.last_change)+')';
|
|
if(M.last_run_logged){
|
|
t+=' \u00b7 '+(M.last_run_documents||0)+' Dok verarbeitet';
|
|
const f=(M.last_run_sources_failed||0);
|
|
if(f>0) t+=' \u00b7 \u26a0 '+f+' Quelle(n) mit Fehler';
|
|
}
|
|
const el=$('#lastrun'); if(el) el.textContent=t;
|
|
}
|
|
const doms=[...new Set(DOCS.map(d=>d.domain).filter(Boolean))].sort();
|
|
$('#dom').innerHTML='<option value="">Alle Domaenen ('+DOCS.length+')</option>'
|
|
+ doms.map(dm=>{const n=DOCS.filter(d=>d.domain===dm).length;
|
|
return '<option value="'+esc(dm)+'">'+esc(dm)+' ('+n+')</option>';}).join('');
|
|
const a=DOCS.filter(d=>d.status==='approved');
|
|
const by=s=>a.filter(d=>d.scope===s).length;
|
|
const cnt=s=>DOCS.filter(d=>d.status===s).length;
|
|
$('#stats').innerHTML=`
|
|
<div class="card ok"><div class="n">${by('extern')}</div>extern</div>
|
|
<div class="card bad"><div class="n">${by('intern')}</div>intern</div>
|
|
<div class="card"><div class="n">${by('allgemein')}</div>allgemein</div>
|
|
<div class="card warn"><div class="n">${cnt('pending')}</div>pending</div>`;
|
|
render();
|
|
});
|
|
</script>
|
|
</body></html>
|
|
"""
|
|
|
|
|
|
def build(data_dir: str, out_dir: str, staging_dir: str = "staging") -> None:
|
|
import os
|
|
|
|
from .store import load_documents
|
|
|
|
docs = load_documents(data_dir, staging_dir)
|
|
payload_docs = [
|
|
{
|
|
"title": d["title"], "domain": d["domain"], "tool": d["tool"],
|
|
"scope": d["scope"], "status": d["status"], "tags": d["tags"],
|
|
"owners": d.get("owners", []), "notes": d.get("notes", ""),
|
|
"contact": d.get("contact", ""),
|
|
"hash": d.get("hash", ""), "quality": d.get("quality", 0),
|
|
"qparts": d.get("quality_parts", {}),
|
|
"path": d["path"],
|
|
"url": d["url"], "text": d["text"],
|
|
}
|
|
for d in docs
|
|
]
|
|
rules = {}
|
|
rules_path = Path("config/filter_rules.json")
|
|
if rules_path.exists():
|
|
try:
|
|
rules = json.loads(rules_path.read_text(encoding="utf-8"))
|
|
except Exception:
|
|
rules = {}
|
|
project_url = os.environ.get("CI_PROJECT_URL", "https://git.tech.rz.db.de/einfachbahn-lab/tools/wissensdatenbank")
|
|
# globale Metadatei (last_run/last_change) fuer eine dezente Fusszeile
|
|
run_meta = {}
|
|
meta_path = Path(data_dir) / "_meta.json"
|
|
if meta_path.exists():
|
|
try:
|
|
run_meta = json.loads(meta_path.read_text(encoding="utf-8"))
|
|
except Exception:
|
|
run_meta = {}
|
|
# letzter Lauf aus dem Run-Log (Health: verarbeitet + Fehler je Quelle)
|
|
from .store import read_last_run
|
|
last = read_last_run(data_dir)
|
|
payload = {"docs": payload_docs, "rules": rules,
|
|
"meta": {"project_url": project_url,
|
|
"last_run": run_meta.get("last_run", ""),
|
|
"last_change": run_meta.get("last_change", ""),
|
|
"last_run_logged": bool(last),
|
|
"last_run_documents": last.get("documents", 0),
|
|
"last_run_sources_failed": last.get("sources_failed", 0),
|
|
"last_run_doc_errors": last.get("doc_errors", 0),
|
|
"last_run_errors": last.get("errors", [])}}
|
|
|
|
# Konfiguration einmal parsen (fuer config.html + Quellen-Zaehler in der Nav)
|
|
cfg = _config_payload()
|
|
n_sources = (sum(len(t["sources"]) for t in cfg["tools"]) + len(cfg["general"])) if cfg["available"] else None
|
|
approved = sum(1 for d in docs if d["status"] == "approved")
|
|
counts = {"docs": approved, "sources": n_sources}
|
|
|
|
version = ""
|
|
vfile = Path("VERSION")
|
|
if vfile.exists():
|
|
version = vfile.read_text(encoding="utf-8").strip()
|
|
|
|
def page(html: str, active: str) -> str:
|
|
return html.replace("__NAV__", _nav(active, counts, project_url, version))
|
|
|
|
out = Path(out_dir)
|
|
out.mkdir(parents=True, exist_ok=True)
|
|
(out / "data.json").write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8")
|
|
(out / "index.html").write_text(page(INDEX_HTML, "index.html"), encoding="utf-8")
|
|
# DB-InfraGO-Logo mit ausliefern (flach nach public/)
|
|
logo = Path("assets/DB-Infrago-Logo.png")
|
|
if logo.exists():
|
|
import shutil
|
|
shutil.copy(logo, out / "DB-Infrago-Logo.png")
|
|
print(f"-> {out}/index.html + data.json ({len(docs)} Dokumente, {approved} approved)")
|
|
|
|
# Hilfe-Seite
|
|
(out / "hilfe.html").write_text(page(HELP_HTML, "hilfe.html"), encoding="utf-8")
|
|
# Chatbot-Anschluss-Seite
|
|
(out / "chatbot.html").write_text(page(CHATBOT_HTML, "chatbot.html"), encoding="utf-8")
|
|
# Konfigurations-/Wissensquellen-Transparenzseite
|
|
(out / "config.json").write_text(json.dumps(cfg, ensure_ascii=False), encoding="utf-8")
|
|
(out / "config.html").write_text(page(CONFIG_HTML, "config.html"), encoding="utf-8")
|
|
# Changelog-Seite (aus CHANGELOG.md gerendert)
|
|
cl_path = Path("CHANGELOG.md")
|
|
cl_html = _md_to_html(cl_path.read_text(encoding="utf-8")) if cl_path.exists() else "<p>Kein CHANGELOG.md gefunden.</p>"
|
|
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()
|