Merge commit 'cfaf67010017eab368216aded483a64126dbcb2e' as 'bahn/wissensdatenbank'

This commit is contained in:
2026-06-30 21:19:25 +02:00
4724 changed files with 667022 additions and 0 deletions
+131
View File
@@ -0,0 +1,131 @@
"""Review-Gate: schreibt Dokumente je nach Status in processed/ oder staging/.
Dedup (nur approved): Dieselbe Quelle-Seite (per page_identity, i.d.R. Confluence-Page-ID)
darf je <scope>/<domaene> nur EIN Dokument im Feed erzeugen - auch wenn mehrere Quellen
sie liefern (z.B. ein breiter confluence_tree UND eine dedizierte confluence_faq-Quelle).
Bei Konkurrenz gewinnt die SPEZIFISCHERE Aufbereitung (FAQ > Seite/PDF), sonst die zuerst
verarbeitete Quelle. So verschwinden die frueheren Hash-Suffix-Duplikate.
"""
from __future__ import annotations
import json
from pathlib import Path
from ..model import ComponentType, Document, ReviewStatus, page_identity
# Aufbereitungs-Prioritaet bei gleicher Seite: FAQ (Q/A-Bloecke) schlaegt die generische
# Seiten-/PDF-Darstellung (z.B. dieselbe FAQ-Seite als rohe Tabelle aus einem Tree).
_PRIORITY = {ComponentType.FAQ: 2, ComponentType.PAGE: 1, ComponentType.PDF: 1}
class ReviewGate:
"""Schreibt Dokumente je nach Status:
- approved -> `output_dir/processed/<scope>/<domaene>[/<tool>]/<slug>.md`
- pending -> `staging_dir/pending/<domaene>/<slug>.md`
Der Audit-Report (`review_report.json`) liegt im `staging_dir` (intern).
Konstruktor unterstuetzt zwei Aufrufformen:
ReviewGate(output_dir="output", staging_dir="staging") # explizit
ReviewGate(base) # Test-Convenience:
-> output_dir = base/'output', staging_dir = base/'staging'
"""
def __init__(self, output_dir: str | Path, staging_dir: str | Path | None = None):
if staging_dir is None:
# Test-Convenience: nur ein Pfad uebergeben -> staging unter <output>/staging
# (kompatibel mit altem Layout, in dem alles flach unter einem tmp-Verzeichnis lag)
self.output_dir = Path(output_dir)
self.staging_dir = self.output_dir / "staging"
else:
self.output_dir = Path(output_dir)
self.staging_dir = Path(staging_dir)
self.processed = self.output_dir / "processed"
self.staging = self.staging_dir # staging ist jetzt TOP-LEVEL (kein /staging-Suffix)
self.report: list[dict] = []
self._written: set[str] = set()
self._approved: dict[tuple[str, str, str], dict] = {}
def _report_entry(self, doc: Document, path: Path) -> dict:
# Pfad relativ zum jeweiligen Root abbilden, mit Prefix damit Konsumenten den
# Bereich erkennen ("output/..." vs "staging/..."). staging zuerst pruefen, damit
# auch ein verschachteltes Layout (staging unter output, z.B. in Tests) korrekt
# als "staging/..." erkannt wird statt faelschlich als "output/staging/...".
try:
rel = "staging/" + str(path.relative_to(self.staging_dir))
except ValueError:
try:
rel = "output/" + str(path.relative_to(self.output_dir))
except ValueError:
rel = str(path)
return {
"domain": doc.domain,
"tool": doc.tool,
"scope": doc.scope.value,
"title": doc.title,
"part": doc.part,
"url": doc.url,
"status": doc.review_status.value,
"notes": doc.review_notes,
"file": rel,
}
def write(self, doc: Document) -> Path:
if doc.review_status == ReviewStatus.APPROVED:
# Struktur: <scope>/<domaene>[/<tool>] -> Chatbot kann in extern/ ueber
# alle Domaenen/Tools suchen (extern/infraportal, extern/pathos, ...).
base = self.processed / doc.scope.value / doc.domain
if doc.tool and doc.tool not in ("allgemein", doc.domain):
base = base / doc.tool
# Dedup: gleiche Seite je scope/domaene nur einmal.
key = (doc.scope.value, doc.domain, page_identity(doc.url))
prio = _PRIORITY.get(doc.component_type, 1)
if page_identity(doc.url):
existing = self._approved.get(key)
if existing is not None:
prev_path = Path(existing["path"])
if prio > existing["priority"]:
# spezifischere Aufbereitung gewinnt -> bestehende Datei ersetzen
prev_path.write_text(doc.to_markdown(), encoding="utf-8")
existing["priority"] = prio
self.report[existing["idx"]] = self._report_entry(doc, prev_path)
# gleich/niedriger -> Duplikat verwerfen (kein zweites File)
return prev_path
target = base
target.mkdir(parents=True, exist_ok=True)
path = target / f"{doc.slug()}.md"
# Slug-Kollision NUR zwischen verschiedenen Seiten (gleiche Seite ist oben
# bereits dedupliziert) -> per content_hash disambiguieren.
if str(path) in self._written:
path = target / f"{doc.slug()}-{doc.content_hash}.md"
self._written.add(str(path))
path.write_text(doc.to_markdown(), encoding="utf-8")
self.report.append(self._report_entry(doc, path))
if page_identity(doc.url):
self._approved[key] = {"path": str(path), "priority": prio, "idx": len(self.report) - 1}
return path
# pending -> staging (kein Dedup; Transparenz)
target = self.staging / doc.review_status.value / doc.domain
target.mkdir(parents=True, exist_ok=True)
path = target / f"{doc.slug()}.md"
if str(path) in self._written:
path = target / f"{doc.slug()}-{doc.content_hash}.md"
self._written.add(str(path))
path.write_text(doc.to_markdown(), encoding="utf-8")
self.report.append(self._report_entry(doc, path))
return path
def write_report(self) -> Path:
# Audit-Log gehoert in den internen Bereich (staging), nicht in den output-Feed.
self.staging_dir.mkdir(parents=True, exist_ok=True)
path = self.staging_dir / "review_report.json"
path.write_text(json.dumps(self.report, indent=2, ensure_ascii=False), encoding="utf-8")
return path
def summary(self) -> dict[str, int]:
out: dict[str, int] = {}
for r in self.report:
out[r["status"]] = out.get(r["status"], 0) + 1
return out