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
+321
View File
@@ -0,0 +1,321 @@
"""Standalone-Chunking: erzeugt aus dem freigegebenen Bestand (output/processed)
zusaetzliche Chunk-Dateien unter output/chunks/.
Laeuft OFFLINE (kein Netz) auf dem bereits abgelegten Markdown. Das Voll-Dokument
in output/processed bleibt unangetastet - Chunks sind ein ZUSAETZLICHES, abgeleitetes
Artefakt (Parent-Document-Muster). Wir stellen die Vektor-DB nicht; ein Anschliesser
kann das Voll-Dokument ODER die Chunks ODER beides nutzen.
Default ist AUS: nur Quellen mit options.chunk != off (siehe tools.yaml/general.yaml
+ config/chunking.yaml) werden gechunkt. Aufgeloest wird pro Ablage-Ort
(<scope>/<domaene>[/<tool>]); per-Quelle-Optionen ueberschreiben die zentralen
Defaults aus config/chunking.yaml.
INKREMENTELL: pro Dokument wird nur dann neu gechunkt, wenn sich der Inhalt
(`parent_hash`) ODER die wirksamen Chunk-Optionen (`chunk_fingerprint`, z.B. die
Strategie) geaendert haben. Unveraenderte Dokumente werden uebersprungen (kein
taeglicher Git-Churn). Verwaiste Chunks (Voll-Dokument geloescht) und Chunks
deaktivierter Orte (chunk wieder auf off) werden automatisch entfernt.
python -m src.chunk --data output
python -m src.chunk --data output --only regulierung # Vorschau einer Ablage
"""
from __future__ import annotations
import argparse
import shutil
from pathlib import Path
from .config_loader import load_chunking_defaults, load_tools
from .model import ComponentType, Document, ReviewStatus, Scope, Source, Tool
from .store import parse_frontmatter
from .transformers.chunker import _is_off, chunk_document, chunk_options_fingerprint, effective_opts
Location = tuple[str, str, str]
def _location(scope: str, domain: str, tool: Tool | None) -> Location:
"""Ablage-Ort wie in ReviewGate.write: <scope>/<domaene>[/<tool>]."""
tool_id = ""
if tool and tool.id and tool.id not in ("allgemein", domain):
tool_id = tool.id
return (scope, domain, tool_id)
def collect_locations(tools: list[Tool], general: list[Source], defaults: dict) -> dict[Location, list[dict]]:
"""Sammelt alle Ablage-Orte mit aktiviertem Chunking -> {(scope,domain,tool): [opts, ...]}.
Pro Ort koennen MEHRERE Chunk-Konfigurationen gelten (z.B. Anhang-PDFs via
`chunk_kind: attachment` UND FAQ-Seiten via `chunk_component: faq`). Pro Dokument
waehlt `chunk_location` die erste passende Konfiguration (siehe `_match_opts`).
"""
out: dict[Location, list[dict]] = {}
def add(source: Source, tool: Tool | None):
opts = effective_opts(source.options, defaults)
if _is_off(opts.get("chunk", "off")):
return
for sc in source.scopes:
loc = _location(sc.value, source.domain, tool)
bucket = out.setdefault(loc, [])
# identische Konfigurationen am selben Ort nicht doppelt fuehren
if not any(chunk_options_fingerprint(o) == chunk_options_fingerprint(opts) for o in bucket):
bucket.append(opts)
for tool in tools:
for source in tool.sources:
add(source, tool)
for source in general:
add(source, None)
return out
def _match_opts(doc: Document, opts_list: list[dict]) -> dict | None:
"""Waehlt die erste Chunk-Konfiguration, deren kind-/component-Filter zum Dokument passt.
So gilt am selben Ort z.B. `chunk_kind: attachment` fuer Anhang-PDFs und
`chunk_component: faq` fuer FAQ-Seiten, ohne sich zu ueberschreiben. Dokumente, die
zu keiner Konfiguration passen, werden nicht gechunkt (None)."""
kind = doc.kind or "document"
comp = getattr(doc, "component_type", "")
comp = comp.value if hasattr(comp, "value") else str(comp)
for o in opts_list:
wk = str(o.get("chunk_kind", "") or "").strip()
wc = str(o.get("chunk_component", "") or "").strip()
if wk and kind != wk:
continue
if wc and comp != wc:
continue
return o
return None
def _doc_from_md(meta: dict, body: str, fallback_title: str = "") -> Document:
"""Rekonstruiert ein minimales Voll-Dokument aus Frontmatter + Body.
Die abgelegten .md tragen kein eigenes 'title:'-Feld -> fuer den Kontext-Vorspann
der Chunks nutzen wir 'part' bzw. den (humanisierten) Dateinamen als Fallback.
`last_updated` wird vom Voll-Dokument GEERBT (nicht 'heute'), damit ein Chunk
voll deterministisch aus Parent + Optionen entsteht (kein Datums-Churn).
"""
try:
scope = Scope(meta.get("scope", "intern"))
except ValueError:
scope = Scope.INTERN
try:
ctype = ComponentType(meta.get("component_type", "page"))
except ValueError:
ctype = ComponentType.PAGE
doc = Document(
tool=meta.get("tool", "allgemein"),
scope=scope,
title=meta.get("title") or meta.get("part") or fallback_title,
body_md=body,
url=meta.get("url", ""),
domain=meta.get("domain", "allgemein"),
component_type=ctype,
tags=list(meta.get("tags", [])),
owners=list(meta.get("owners", [])),
contact=meta.get("contact", ""),
source_system=meta.get("source", "web"),
part=meta.get("part", ""),
review_status=ReviewStatus.APPROVED,
)
if meta.get("last_updated"):
doc.last_updated = meta["last_updated"]
# kind/parent_url/attachment_name MUSS vom Frontmatter uebernommen werden, damit
# Filter wie `chunk_kind: attachment` greifen (sonst bleibt der Klassen-Default
# "document" und ein chunk_kind-Filter waere effektiv tot).
doc.kind = meta.get("kind", "document") or "document"
doc.parent_url = meta.get("parent_url", "")
doc.attachment_name = meta.get("attachment_name", "")
return doc
def _humanize_stem(stem: str) -> str:
"""'inb-2026-anlage-1-0-data' -> 'inb 2026 anlage 1 0' (Titel-Fallback)."""
s = stem
for suffix in ("-data", "-data-pdf"):
if s.endswith(suffix):
s = s[: -len(suffix)]
return s.replace("-", " ").strip()
def _chunk_slug(ch: Document) -> str:
slug = ch.ziffer or ch.section[:60] or f"chunk-{ch.chunk_index}"
slug = "".join(c.lower() if c.isalnum() or c == "." else "-" for c in slug)
while "--" in slug:
slug = slug.replace("--", "-")
return slug.strip("-.") or f"chunk-{ch.chunk_index}"
def _existing_signature(doc_dir: Path) -> tuple[str, str] | None:
"""(parent_hash, chunk_fingerprint) aus einer vorhandenen Chunk-Datei, sonst None."""
files = sorted(doc_dir.glob("*.md"))
if not files:
return None
try:
meta, _ = parse_frontmatter(files[0].read_text(encoding="utf-8"))
except OSError:
return None
return (meta.get("parent_hash", ""), meta.get("chunk_fingerprint", ""))
def _out_base(data_dir: Path, loc: Location) -> Path:
scope, domain, tool_id = loc
base = data_dir / "chunks" / scope / domain
return base / tool_id if tool_id else base
def chunk_location(data_dir: Path, loc: Location, opts) -> tuple[int, int, int]:
"""Chunkt die Voll-Dokumente eines Ablage-Orts inkrementell.
`opts` ist eine einzelne Konfiguration ODER eine Liste von Konfigurationen
(mehrere am selben Ort, z.B. Anhang-PDFs + FAQ-Seiten). Pro Dokument wird die
erste passende Konfiguration gewaehlt (`_match_opts`).
Rueckgabe: (geschriebene_docs, geschriebene_chunks, uebersprungene_docs).
"""
opts_list = [opts] if isinstance(opts, dict) else list(opts)
scope, domain, tool_id = loc
src_base = data_dir / "processed" / scope / domain
if tool_id:
src_base = src_base / tool_id
if not src_base.exists():
return (0, 0, 0)
out_base = _out_base(data_dir, loc)
written_docs = 0
written_chunks = 0
skipped = 0
for md in sorted(src_base.glob("*.md")):
meta, body = parse_frontmatter(md.read_text(encoding="utf-8"))
if meta.get("kind") == "chunk":
continue
doc = _doc_from_md(meta, body, fallback_title=_humanize_stem(md.stem))
# parent_hash = gespeicherter content_hash des Voll-Dokuments (sauberes Linking).
parent_hash = meta.get("content_hash") or doc.content_hash
doc_dir = out_base / md.stem
opts_doc = _match_opts(doc, opts_list)
fp = chunk_options_fingerprint(opts_doc) if opts_doc else None
# Inkrementell: unveraendert (gleicher Inhalt + gleiche Optionen) -> ueberspringen.
sig = _existing_signature(doc_dir) if doc_dir.exists() else None
if opts_doc is not None and sig == (parent_hash, fp):
skipped += 1
continue
chunks = chunk_document(doc, opts_doc, parent_hash=parent_hash) if opts_doc else []
if doc_dir.exists():
shutil.rmtree(doc_dir)
if not chunks:
continue
doc_dir.mkdir(parents=True, exist_ok=True)
written_docs += 1
for ch in chunks:
fname = f"{ch.chunk_index:03d}-{_chunk_slug(ch)[:100]}.md"
(doc_dir / fname).write_text(ch.to_markdown(), encoding="utf-8")
written_chunks += 1
return (written_docs, written_chunks, skipped)
def _leaf_doc_dirs(chunks_root: Path):
"""Liefert alle Blatt-Verzeichnisse unter output/chunks, die direkt .md enthalten."""
if not chunks_root.exists():
return
for d in chunks_root.rglob("*"):
if d.is_dir() and any(d.glob("*.md")):
yield d
def _loc_and_slug(doc_dir: Path, chunks_root: Path) -> tuple[Location, str] | None:
"""Leitet (scope,domain,tool) + docslug aus dem Pfad eines Blatt-Verzeichnisses ab."""
parts = doc_dir.relative_to(chunks_root).parts
if len(parts) == 3: # <scope>/<domain>/<docslug>
return ((parts[0], parts[1], ""), parts[2])
if len(parts) == 4: # <scope>/<domain>/<tool>/<docslug>
return ((parts[0], parts[1], parts[2]), parts[3])
return None
def prune_orphans(data_dir: Path, locations: dict[Location, dict]) -> list[str]:
"""Entfernt Chunk-Ordner, deren Ort deaktiviert wurde ODER deren Voll-Dokument fehlt."""
chunks_root = data_dir / "chunks"
removed: list[str] = []
for doc_dir in list(_leaf_doc_dirs(chunks_root)):
info = _loc_and_slug(doc_dir, chunks_root)
if info is None:
continue
loc, slug = info
deactivated = loc not in locations
scope, domain, tool_id = loc
parent = data_dir / "processed" / scope / domain
if tool_id:
parent = parent / tool_id
parent_md = parent / f"{slug}.md"
if deactivated or not parent_md.exists():
shutil.rmtree(doc_dir)
removed.append(str(doc_dir.relative_to(data_dir)))
return removed
def run(config_path: str, data_dir: str, only: str | None = None) -> tuple[int, int]:
tools, general = load_tools(config_path)
defaults = load_chunking_defaults(Path(config_path).parent / "chunking.yaml")
locations = collect_locations(tools, general, defaults)
data = Path(data_dir)
total_docs = 0
total_chunks = 0
total_skipped = 0
for loc, opts_list in sorted(locations.items()):
scope, domain, tool_id = loc
label = f"{scope}/{domain}" + (f"/{tool_id}" if tool_id else "")
if only and only.lower() not in label.lower():
continue
d, c, s = chunk_location(data, loc, opts_list)
total_docs += d
total_chunks += c
total_skipped += s
modes = ",".join(str(o.get("chunk")) for o in opts_list)
print(f"-> {label} | chunk={modes} | {d} neu/geaendert ({c} Chunks), {s} unveraendert")
# Aufraeumen: deaktivierte Orte + verwaiste Dokumente (nur im Voll-Lauf, nicht bei --only).
removed: list[str] = []
if not only:
removed = prune_orphans(data, locations)
for r in removed:
print(f" [prune] entfernt: {r}")
# Katalog ueber den Bestand (Voll-Dokumente + zugehoerige Chunks) aktualisieren.
from .store import write_index
write_index(data_dir)
if not locations:
print("Kein Ablage-Ort mit aktiviertem Chunking (Default = off).")
print("\n=== Chunking-Zusammenfassung ===")
print(f"Dokumente neu/geaendert: {total_docs}")
print(f"Chunks geschrieben: {total_chunks}")
print(f"Unveraendert (skip): {total_skipped}")
print(f"Entfernt (prune): {len(removed)}")
return (total_docs, total_chunks)
def main() -> None:
parser = argparse.ArgumentParser(description="Chunking (offline, auf output/processed)")
parser.add_argument("--config", default="config/tools.yaml")
parser.add_argument("--data", default="output")
parser.add_argument("--only", default=None,
help="nur Ablage-Orte verarbeiten, deren <scope>/<domaene>[/<tool>] diesen Text enthaelt")
args = parser.parse_args()
if not Path(args.config).exists():
raise SystemExit(f"Config nicht gefunden: {args.config}")
run(args.config, args.data, only=args.only)
if __name__ == "__main__":
main()
@@ -0,0 +1,65 @@
"""Laedt die Konfiguration (tools.yaml, filter_rules.json)."""
from __future__ import annotations
import json
from pathlib import Path
import yaml
from .model import Source, Tool
def load_tools(path: str | Path) -> tuple[list[Tool], list[Source]]:
"""Liest tools.yaml (Tools) und general.yaml (allgemeines Wissen).
general-Quellen werden bevorzugt aus config/general.yaml geladen; als Fallback
aus einem 'general:'-Abschnitt in tools.yaml (Rueckwaertskompatibilitaet).
Rueckgabe: (tools, general_sources)
"""
path = Path(path)
data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
tools = [Tool.from_dict(t) for t in data.get("tools", [])]
general_file = path.parent / "general.yaml"
if general_file.exists():
gdata = yaml.safe_load(general_file.read_text(encoding="utf-8")) or {}
general = [Source.from_dict(s) for s in gdata.get("general", [])]
else:
general = [Source.from_dict(s) for s in data.get("general", [])]
return tools, general
def load_chunking_defaults(path: str | Path) -> dict:
"""Liest config/chunking.yaml -> defaults-dict (gemeinsame Chunk-Optionen).
Fehlt die Datei, wird ein leeres dict geliefert; die endgueltigen Optionen
entstehen ohnehin in chunker.effective_opts (DEFAULTS < diese < per-Quelle).
"""
p = Path(path)
if not p.exists():
return {}
data = yaml.safe_load(p.read_text(encoding="utf-8")) or {}
return dict(data.get("defaults", {}) or {})
def load_filter_rules(path: str | Path) -> dict:
p = Path(path)
if not p.exists():
return {"blacklist_keywords": [], "drop_css_classes": [], "regex_redact": []}
return json.loads(p.read_text(encoding="utf-8"))
def load_approvals(path: str | Path) -> dict:
"""Liest config/approvals.yaml -> {'urls': set, 'hashes': set}."""
p = Path(path)
urls: set[str] = set()
hashes: set[str] = set()
if p.exists():
data = yaml.safe_load(p.read_text(encoding="utf-8")) or {}
for entry in data.get("approved", []) or []:
e = str(entry).strip()
if e.startswith("hash:"):
hashes.add(e[5:].strip())
elif e:
urls.add(e)
return {"urls": urls, "hashes": hashes}
@@ -0,0 +1,46 @@
"""Connector-Registry: ordnet jede Strategie einem Connector zu."""
from __future__ import annotations
from ..model import Strategy
from .base import Connector
from .confluence import ConfluenceConnector
from .file_source import FileConnector
from .gitlab_md import GitlabMdConnector
from .pdf_parser import PdfConnector
from .web_crawler import WebCrawlerConnector
# Eine Instanz pro Connector reicht (zustandslos).
_web = WebCrawlerConnector()
_confluence = ConfluenceConnector()
_pdf = PdfConnector()
_gitlab = GitlabMdConnector()
_file = FileConnector()
REGISTRY: dict[Strategy, Connector] = {
Strategy.CRAWLER: _web,
Strategy.SITEMAP: _web,
Strategy.CONFLUENCE_PAGE: _confluence,
Strategy.CONFLUENCE_TREE: _confluence,
Strategy.CONFLUENCE_FAQ: _confluence,
Strategy.GITLAB_MD: _gitlab,
Strategy.FILE: _file,
Strategy.PDF: _pdf,
}
def get_connector(strategy: Strategy) -> Connector:
if strategy not in REGISTRY:
raise KeyError(f"Keine Connector-Zuordnung fuer Strategie {strategy}")
return REGISTRY[strategy]
def set_data_dir(data_dir: str, staging_dir: str = "staging") -> None:
"""Setzt die Verzeichnisse: data_dir = Feed-Wurzel (output) fuer inkrementelles
Auffinden bereits verarbeiteter Dokumente; staging_dir = interne Ablage, in der
Roh-PDFs unter staging/raw landen (nicht im Konsumenten-Feed)."""
from pathlib import Path
_pdf.raw_dir = Path(staging_dir) / "raw"
_pdf.data_dir = Path(data_dir)
_web.data_dir = data_dir
_confluence.data_dir = data_dir
@@ -0,0 +1,22 @@
"""Basis-Interface fuer alle Connectors."""
from __future__ import annotations
from abc import ABC, abstractmethod
from ..model import Document, Source, Tool
class Connector(ABC):
"""Ein Connector holt fuer eine Quelle 0..n Dokumente."""
name: str = "base"
@abstractmethod
def fetch(self, source: Source, tool: Tool | None) -> list[Document]:
"""Holt Rohdaten und liefert (noch ungefilterte) Dokumente.
tool ist None bei tool-uebergreifenden 'general'-Quellen.
Bei Fehlern (z.B. fehlende Credentials) sollte eine leere Liste
zurueckgegeben und geloggt werden, damit der lokale Lauf nicht bricht.
"""
raise NotImplementedError
@@ -0,0 +1,497 @@
"""Confluence-Connector: confluence_page / confluence_tree / confluence_faq.
Benoetigt CONFLUENCE_URL, CONFLUENCE_USER, CONFLUENCE_TOKEN als Env-Variablen
sowie das Paket 'atlassian-python-api'. Fehlt eines davon, wird die Quelle
sauber uebersprungen, damit lokale Laeufe ohne Credentials funktionieren.
"""
from __future__ import annotations
import os
import re
from urllib.parse import parse_qs, urlparse
from ..model import ComponentType, Document, Source, Strategy, Tool, source_meta_fingerprint
from ..store import reconcile_meta
from .base import Connector
class ConfluenceConnector(Connector):
name = "confluence"
def __init__(self, data_dir: str | None = None):
self.data_dir = data_dir
self._att_known: dict[tuple[str, str], str] = {} # {(url, scope): version} fuer inkrementelles Skip
def _known_versions(self, domain: str) -> dict[str, dict]:
"""{page_id: {"version", "files":[{"scope","path","fp"}]}} der Domaene.
files = alle vorhandenen Dateien zu dieser Seite (je Scope eine), damit ein
Scope-Wechsel (z.B. intern -> extern) erkannt wird. Domaenen-gefiltert, weil
dieselbe URL legitim unter mehreren Domaenen liegen kann.
"""
import re as _re
from pathlib import Path
from ..store import parse_frontmatter
known: dict[str, dict] = {}
if not self.data_dir:
return known
base = Path(self.data_dir) / "processed"
if not base.exists():
return known
for md in base.rglob("*.md"):
try:
meta, _ = parse_frontmatter(md.read_text(encoding="utf-8"))
except Exception:
continue
if meta.get("domain") != domain:
continue
# Anhang-Dateien gehoeren zu ihrer Elternseite -> ueber parent_url einsortieren,
# damit reconcile_meta sie beim Re-Tagging miterfasst (sonst stale Frontmatter).
url_for_id = meta.get("url", "")
if meta.get("kind") == "attachment":
url_for_id = meta.get("parent_url", "")
m = _re.search(r"/pages/(\d+)", url_for_id)
if m and meta.get("source_version"):
entry = known.setdefault(m.group(1), {"version": str(meta["source_version"]), "files": []})
# Page-Version aus der Seite (nicht Anhang) ableiten
if meta.get("kind") != "attachment":
entry["version"] = str(meta["source_version"])
entry["files"].append({
"scope": meta.get("scope", ""),
"path": str(md),
"fp": meta.get("meta_fingerprint", ""),
"comp": meta.get("component_type", "page"),
})
return known
@staticmethod
def _filter_excluded(page_ids: list[str], options: dict) -> list[str]:
"""Entfernt explizit ausgeschlossene Seiten-IDs (options.exclude_pages).
Nuetzlich, wenn eine Seite von einem breiten confluence_tree erfasst WUERDE,
aber gezielt anders aufbereitet werden soll (z.B. als confluence_faq). So
produziert der Tree die Seite nicht als generische Seite und die FAQ-Quelle
bleibt die einzige (und damit massgebliche) Aufbereitung."""
exclude = {str(x) for x in (options.get("exclude_pages") or [])}
if not exclude:
return page_ids
return [p for p in page_ids if str(p) not in exclude]
def fetch(self, source: Source, tool: Tool | None) -> list[Document]:
base = os.environ.get("CONFLUENCE_URL")
user = os.environ.get("CONFLUENCE_USER")
token = os.environ.get("CONFLUENCE_TOKEN")
cloud = os.environ.get("CONFLUENCE_CLOUD", "false").lower() == "true"
if not (base and token):
print(" [confluence] keine Credentials (CONFLUENCE_URL/TOKEN) -> uebersprungen")
return []
try:
from atlassian import Confluence # lazy
except ImportError:
print(" [confluence] 'atlassian-python-api' nicht installiert -> uebersprungen")
return []
# PAT (Server/DC) -> Bearer-Token; mit User -> Basic-Auth (Cloud)
if user:
client = Confluence(url=base, username=user, password=token, cloud=cloud)
else:
client = Confluence(url=base, token=token, cloud=cloud)
page_id = self._extract_page_id(source.url)
if not page_id:
print(f" [confluence] keine page id in {source.url} -> uebersprungen")
return []
page_ids = [page_id]
if source.strategy == Strategy.CONFLUENCE_TREE:
max_pages = int(source.options.get("max_pages", 300))
# max_depth: -1 = unbegrenzt, 0 = nur diese Seite, 1 = +direkte Kinder ...
max_depth = int(source.options.get("max_depth", -1))
page_ids += self._descendants(client, page_id, max_pages=max_pages, max_depth=max_depth)
print(f" [confluence] Tree {page_id} (max_depth={max_depth}): {len(page_ids)} Seite(n)")
page_ids = self._filter_excluded(page_ids, source.options)
incremental = bool(source.options.get("incremental"))
known = self._known_versions(source.domain) if incremental else {}
self._att_known = self._known_attachment_versions(source.domain) if incremental else {}
current_fp = source_meta_fingerprint(source)
docs: list[Document] = []
skipped = 0
retagged = 0
for pid in page_ids:
ver = None
entry = None
if incremental:
try:
info = client.get_page_by_id(pid, expand="version")
ver = str(info.get("version", {}).get("number", ""))
except Exception as e:
print(f" [confluence] Version von {pid} nicht ladbar: {e}")
entry = known.get(str(pid))
if entry and ver and entry["version"] == ver:
# Inhalt unveraendert. Scope-Set UND Komponente vergleichen:
desired = {s.value for s in source.scopes}
existing = {f["scope"] for f in entry["files"]}
# Diese Quelle produziert eine bestimmte Komponente (FAQ vs. Seite).
# Nur ueberspringen, wenn die vorhandene Ablage AUCH diese Komponente
# hat - sonst hat z.B. ein Tree die Seite als 'page' abgelegt und die
# FAQ-Quelle muss sie erst noch als 'faq' aufbereiten.
want_comp = "faq" if source.strategy == Strategy.CONFLUENCE_FAQ else "page"
comp_by_scope = {f["scope"]: f.get("comp", "page") for f in entry["files"]}
comp_ok = all(comp_by_scope.get(s) == want_comp for s in desired)
if desired == existing and comp_ok:
# gleiche Ablage -> nur Metadaten ggf. guenstig in-place angleichen
stale = [f["path"] for f in entry["files"] if f["fp"] != current_fp]
if stale:
retagged += reconcile_meta(stale, source, current_fp)
# Anhaenge trotzdem pruefen: Anhang-Version steigt unabhaengig von der
# Seitenversion, neue eingebundene PDFs muessen erkannt werden.
try:
page = client.get_page_by_id(pid, expand="body.storage,version")
html = page.get("body", {}).get("storage", {}).get("value", "")
links = page.get("_links", {}) or {}
env_base = os.environ.get("CONFLUENCE_URL", "").rstrip("/")
base_url = (links.get("base") or env_base).rstrip("/")
webui = links.get("webui") or f"/pages/viewpage.action?pageId={pid}"
page_url = f"{base_url}{webui}"
title = page.get("title", str(pid))
docs.extend(self._attachment_docs(client, pid, page_url, title, html, source, tool))
except Exception as e:
print(f" [confluence] Anhang-Check {pid} fehlgeschlagen: {e}")
skipped += 1
continue
# Scope-Wechsel -> neu verarbeiten (neue Ablage entsteht; alte
# wird nach erfolgreichem Schreiben in main.py entfernt)
docs.extend(self._page_to_doc(client, pid, source, tool, version=ver))
if incremental:
print(f" [confluence] incremental: {skipped} unveraendert uebersprungen "
f"({retagged} re-tagged), {len(page_ids) - skipped} (neu)verarbeitet")
return docs
# -- intern -------------------------------------------------------------
@staticmethod
def _extract_page_id(url: str) -> str | None:
# .../pages/428909879/Titel oder ?pageId=12345
m = re.search(r"/pages/(\d+)", url)
if m:
return m.group(1)
qs = parse_qs(urlparse(url).query)
if "pageId" in qs:
return qs["pageId"][0]
return None
def _descendants(self, client, page_id: str, max_pages: int = 300, max_depth: int = -1) -> list[str]:
out: list[str] = []
def walk(pid: str, depth: int):
if len(out) >= max_pages:
return
if max_depth >= 0 and depth >= max_depth:
return
try:
children = client.get_child_pages(pid) or []
except Exception as e:
print(f" [confluence] Unterseiten von {pid} nicht ladbar: {e}")
return
for child in children:
if len(out) >= max_pages:
return
cid = str(child.get("id"))
out.append(cid)
walk(cid, depth + 1)
walk(page_id, 0)
return out
def _page_to_doc(self, client, page_id: str, source: Source, tool: Tool | None, version=None):
try:
page = client.get_page_by_id(page_id, expand="body.storage,version")
except Exception as e:
print(f" [confluence] Seite {page_id} nicht ladbar: {e}")
return []
title = page.get("title", page_id)
html = page.get("body", {}).get("storage", {}).get("value", "")
# Korrekte, aufloesbare URL aus den API-_links bauen. Das blanke
# "<base>/pages/<id>" funktioniert auf Confluence Server/DC NICHT
# ("Seite nicht gefunden") -> webui (/spaces/<KEY>/pages/<id>/<Titel>)
# bzw. Fallback viewpage.action?pageId=<id>.
links = page.get("_links", {}) or {}
env_base = os.environ.get("CONFLUENCE_URL", "").rstrip("/")
base = (links.get("base") or env_base).rstrip("/")
webui = links.get("webui") or f"/pages/viewpage.action?pageId={page_id}"
url = f"{base}{webui}"
if version is None:
version = str(page.get("version", {}).get("number", ""))
from ..transformers.md_converter import storage_to_markdown
# FAQ-Sonderfall: Q/A aus Tabellen
if source.strategy == Strategy.CONFLUENCE_FAQ:
body_md = self._to_faq(html, source)
return [self._mk_doc(title, body_md, url, source, tool, ComponentType.FAQ, sc,
version=version)
for sc in source.scopes]
body_md = storage_to_markdown(html)
# ein Dokument je Scope der Quelle (intern,extern => fuer beide, ohne Trennung)
docs = [self._mk_doc(title, body_md, url, source, tool, ComponentType.PAGE, sc,
version=version)
for sc in source.scopes]
# eingebundene PDF-Anhaenge als eigene Dokumente (Option A)
docs += self._attachment_docs(client, page_id, url, title, html, source, tool)
return docs
def _mk_doc(self, title, body_md, url, source, tool, comp, scope, part="", version=""):
return Document(
tool=tool.id if tool else "allgemein",
scope=scope,
domain=source.domain,
title=title,
part=part,
body_md=body_md,
url=url,
component_type=comp,
tags=list(source.tags),
owners=list(source.owners),
contact=source.contact_address,
source_system="confluence",
source_version=str(version or ""),
)
# -- Anhaenge (eingebundene PDFs als eigene Dokumente, Option A) ---------
_FILE_MACROS = ("view-file", "viewpdf", "viewdoc", "viewxls", "viewppt")
@staticmethod
def _embedded_attachments(html: str) -> list[str]:
"""Dateinamen der im Seitentext EINGEBUNDENEN Anhaenge (view-file/viewpdf)."""
from bs4 import BeautifulSoup
soup = BeautifulSoup(html, "html.parser")
names: list[str] = []
for macro in soup.find_all("ac:structured-macro"):
if (macro.get("ac:name") or "").lower() in ConfluenceConnector._FILE_MACROS:
ra = macro.find("ri:attachment")
fn = ra.get("ri:filename") if ra else ""
if fn and fn not in names:
names.append(fn)
return names
def _known_attachment_versions(self, domain: str) -> dict[tuple[str, str], str]:
"""{(attachment_url, scope): version} bereits abgelegter Anhang-Dokumente.
Scope-aware: bei einer Scope-Erweiterung (intern -> intern,extern) wird der
Anhang fuer den neuen Scope erneut erzeugt; gleicher Scope+gleiche Version
bleibt skip.
"""
from pathlib import Path
from ..store import parse_frontmatter
known: dict[tuple[str, str], str] = {}
if not self.data_dir:
return known
base = Path(self.data_dir) / "processed"
if not base.exists():
return known
for md in base.rglob("*.md"):
try:
meta, _ = parse_frontmatter(md.read_text(encoding="utf-8"))
except Exception:
continue
if (meta.get("kind") == "attachment" and meta.get("domain") == domain
and meta.get("source_version") and meta.get("url")):
known[(meta["url"], meta.get("scope", ""))] = str(meta["source_version"])
return known
@staticmethod
def _download_attachment(client, link: str) -> bytes:
"""Laedt den Anhang als Bytes (relativer download-Link aus der API)."""
return client.get(link, not_json_response=True)
def _attachment_docs(self, client, page_id, page_url, page_title, html, source, tool):
"""Erzeugt aus eingebundenen PDF-Anhaengen je ein eigenes Dokument (kind=attachment).
Default an; pro Quelle ueber options.attachments=false abschaltbar. Nur fuer
Seiten-/Tree-Strategien (nicht FAQ). Nicht-PDF-Anhaenge werden uebersprungen.
"""
if source.strategy == Strategy.CONFLUENCE_FAQ:
return []
if not source.options.get("attachments", True):
return []
embedded = self._embedded_attachments(html)
if not embedded:
return []
try:
meta = client.get_attachments_from_content(page_id)
results = meta.get("results", []) if isinstance(meta, dict) else (meta or [])
except Exception as e:
print(f" [confluence] Anhaenge von {page_id} nicht ladbar: {e}")
return []
by_name = {r.get("title", ""): r for r in results}
env_base = os.environ.get("CONFLUENCE_URL", "").rstrip("/")
from .pdf_parser import pymupdf_markdown
docs: list[Document] = []
for fn in embedded:
info = by_name.get(fn)
if not info:
continue
media = ((info.get("extensions", {}) or {}).get("mediaType", "")
or (info.get("metadata", {}) or {}).get("mediaType", ""))
if "pdf" not in media.lower() and not fn.lower().endswith(".pdf"):
continue # nur PDFs (Bilder etc. via alt-Text im Seitentext)
link = (info.get("_links", {}) or {}).get("download", "")
if not link:
continue
# link kann relativ ('/download/...') ODER absolut ('https://.../download/...') sein
stable_url = link if link.startswith(("http://", "https://")) else (env_base + link)
stable_url = stable_url.split("?", 1)[0]
ver = str((info.get("version", {}) or {}).get("number", ""))
when = str((info.get("version", {}) or {}).get("when", ""))
# inkrementell: unveraendert je Scope -> nicht erneut laden/parsen.
# Nur skippen, wenn ALLE Ziel-Scopes bereits die aktuelle Version haben.
if ver and all(self._att_known.get((stable_url, sc.value)) == ver for sc in source.scopes):
continue
try:
content = self._download_attachment(client, link)
except Exception as e:
print(f" [confluence] Anhang-Download {fn} fehlgeschlagen: {e}")
continue
body = pymupdf_markdown(content or b"")
if len(body.strip()) < 30:
continue
for sc in source.scopes:
# je Scope einzeln pruefen (gemischte Scopes sauber behandeln)
if ver and self._att_known.get((stable_url, sc.value)) == ver:
continue
d = self._mk_doc(f"{page_title} - {fn}", body, stable_url, source, tool,
ComponentType.PDF, sc, version=ver)
d.kind = "attachment"
d.parent_url = page_url
d.attachment_name = fn
d.tags = list(source.tags) + ["anhang"]
if when[:4].isdigit():
d.last_updated = when[:10] # deterministisch -> kein taeglicher Churn
elif ver:
d.last_updated = f"v{ver}" # Fallback ohne 'when': stabil je Version
else:
# Letzter Fallback: stabil aus Dateinamen ableiten (kein heute-Churn)
d.last_updated = "unknown"
docs.append(d)
print(f" [confluence] Anhang geparst: {fn} ({len(body)} Zeichen)")
return docs
# Spaltennamen (lowercase, Teilstring-Match) -> logischer Schluessel.
_FAQ_COLS = {
"frage": "frage",
"antwort": "antwort",
"ansprechpartner": "ansprechpartner",
"kontakt": "ansprechpartner",
"cluster": "cluster",
"thema": "thema",
"themen": "thema",
}
@staticmethod
def _cell_text(cell) -> str:
"""Zelltext mit erhaltener Grundstruktur: <br> und Absaetze werden zu
Zeilenumbruechen, Listeneintraege zu '- ...'. So bleiben Aufzaehlungen und
mehrzeilige Antworten lesbar (statt mit Leerzeichen plattgedrueckt)."""
from bs4 import NavigableString, Tag
if cell is None:
return ""
parts: list[str] = []
def walk(node):
for child in node.children:
if isinstance(child, NavigableString):
txt = re.sub(r"\s+", " ", str(child))
if txt.strip():
parts.append(txt)
elif isinstance(child, Tag):
name = (child.name or "").lower()
if name == "br":
parts.append("\n")
elif name in ("p", "div", "ul", "ol"):
parts.append("\n")
walk(child)
parts.append("\n")
elif name == "li":
parts.append("\n- ")
walk(child)
else:
walk(child)
walk(cell)
text = "".join(parts)
text = re.sub(r"[ \t]+", " ", text)
text = re.sub(r" *\n *", "\n", text)
text = re.sub(r"\n{3,}", "\n\n", text)
return text.strip()
def _to_faq(self, html: str, source: Source) -> str:
"""Erzeugt Q/A-Bloecke aus FAQ-Tabellen.
Erkennt die Kopfzeile und ordnet Spalten ueber ihre Namen zu (Frage, Antwort und
optional Ansprechpartner, Cluster, Thema). Die Frage wird zur Ueberschrift (###),
die Antwort zum Text; Metadaten (Thema/Cluster/Ansprechpartner) stehen separat
unter der Antwort, statt in den Antworttext gemischt zu werden - so passen
Ueberschrift und Inhalt je Block sauber zusammen. Grundstruktur (Listen,
Zeilenumbrueche) bleibt erhalten. Faellt auf Spalte0=Frage / Spalte1=Antwort
zurueck, wenn keine benannte Kopfzeile erkennbar ist.
"""
from bs4 import BeautifulSoup
soup = BeautifulSoup(html, "html.parser")
out: list[str] = []
for table in soup.find_all("table"):
rows = table.find_all("tr")
if not rows:
continue
# Kopfzeile -> Spaltenindex je logischem Schluessel.
headers = [c.get_text(" ", strip=True).lower() for c in rows[0].find_all(["th", "td"])]
col: dict[str, int] = {}
for i, h in enumerate(headers):
for alias, key in self._FAQ_COLS.items():
if alias in h and key not in col:
col[key] = i
named = "frage" in col and "antwort" in col
data_rows = rows[1:] if named else rows
for row in data_rows:
cells = row.find_all(["td", "th"])
if len(cells) < 2:
continue
if named:
if col["frage"] >= len(cells) or col["antwort"] >= len(cells):
continue
q = self._cell_text(cells[col["frage"]])
a = self._cell_text(cells[col["antwort"]])
meta_lines = []
for key, label in (("thema", "Thema"), ("cluster", "Cluster"),
("ansprechpartner", "Ansprechpartner")):
ci = col.get(key)
if ci is not None and ci < len(cells):
val = " ".join(self._cell_text(cells[ci]).split())
if val:
meta_lines.append(f"*{label}:* {val}")
else:
q = self._cell_text(cells[0])
a = "\n".join(self._cell_text(c) for c in cells[1:])
meta_lines = []
q = " ".join(q.split()) # Ueberschrift einzeilig halten
if not (q and a.strip()):
continue
block = f"### {q}\n\n{a.strip()}\n"
if meta_lines:
block += "\n" + "\n".join(meta_lines) + "\n"
out.append(block)
if not out:
return "_Keine FAQ-faehigen Tabellen gefunden._"
return "\n".join(out)
@@ -0,0 +1,87 @@
"""File-Connector: liest Dateien (Markdown/PDF) direkt aus dem Repo.
Strategie 'file' fuer Handbuecher etc., die man einfach ins Repo pusht
(z.B. nach files/<tool>/handbuch.pdf), statt sie extern zu verlinken.
source.url ist ein REPO-RELATIVER Pfad (Datei oder Ordner), z.B.
"files/mein-tool/handbuch.pdf" (eine Datei)
"files/mein-tool" (ganzer Ordner -> alle .md/.pdf)
options:
max_depth: Tiefe bei Ordnern (-1 = alle, 0 = nur direkt im Ordner)
.md wird direkt uebernommen, .pdf wird zu Text extrahiert.
"""
from __future__ import annotations
from pathlib import Path
from ..model import ComponentType, Document, Source, Tool
from .base import Connector
class FileConnector(Connector):
name = "file"
def fetch(self, source: Source, tool: Tool | None) -> list[Document]:
base = Path(source.url)
if not base.exists():
print(f" [file] Pfad nicht gefunden: {source.url}")
return []
max_depth = int(source.options.get("max_depth", -1))
files: list[Path] = []
if base.is_file():
files = [base]
else:
for f in sorted(base.rglob("*")):
if f.suffix.lower() not in (".md", ".pdf"):
continue
if max_depth >= 0:
rel = f.relative_to(base)
if len(rel.parts) - 1 > max_depth:
continue
files.append(f)
if not files:
print(f" [file] keine .md/.pdf unter {source.url}")
return []
print(f" [file] {len(files)} Datei(en) aus {source.url}")
docs: list[Document] = []
for f in files:
body = self._read(f)
if not body or len(body.strip()) < 1:
continue
comp = ComponentType.PDF if f.suffix.lower() == ".pdf" else ComponentType.PAGE
title = f.stem.replace("_", " ").replace("-", " ").strip()
for sc in source.scopes:
docs.append(Document(
tool=tool.id if tool else "allgemein",
scope=sc, domain=source.domain, title=title, body_md=body,
url=str(f), component_type=comp,
tags=list(source.tags), owners=list(source.owners),
contact=source.contact_address,
source_system="file",
))
return docs
def _read(self, path: Path) -> str:
if path.suffix.lower() == ".md":
try:
return path.read_text(encoding="utf-8")
except Exception as e:
print(f" [file] {path} nicht lesbar: {e}")
return ""
# PDF
try:
from pypdf import PdfReader
except ImportError:
print(" [file] 'pypdf' fehlt -> PDF uebersprungen")
return ""
try:
reader = PdfReader(str(path))
parts = [(p.extract_text() or "").strip() for p in reader.pages]
return "\n\n".join(p for p in parts if p)
except Exception as e:
print(f" [file] PDF nicht lesbar {path}: {e}")
return ""
@@ -0,0 +1,136 @@
"""GitLab-Markdown-Connector: laedt .md-Dateien aus einem GitLab-Repo.
Strategie 'gitlab_md':
- source.url: Projekt-URL, optional mit Ordner/Branch, z.B.
https://git.tech.rz.db.de/gruppe/projekt
https://git.tech.rz.db.de/gruppe/projekt/-/tree/main/docs
- options:
ref: Branch/Tag (default: aus URL oder 'main')
path: Unterordner (default: aus URL oder '' = ganzes Repo)
max_depth: Tiefe relativ zu path (-1 = alle, 0 = nur direkt in path)
- Token: env GITLAB_TOKEN (PRIVATE-TOKEN). Fuer oeffentliche Projekte optional.
Jede .md-Datei wird ein Dokument (Inhalt ist bereits Markdown).
"""
from __future__ import annotations
import os
from urllib.parse import quote, urlparse
from ..model import ComponentType, Document, Source, Tool
from .base import Connector
USER_AGENT = "wissensdatenbank-etl/0.1"
TIMEOUT = 30
def parse_gitlab_url(url: str) -> dict:
"""Zerlegt eine GitLab-URL in host/project/ref/path."""
p = urlparse(url)
host = p.netloc
path = p.path.strip("/")
ref = ""
subpath = ""
if "/-/tree/" in path:
project, rest = path.split("/-/tree/", 1)
parts = rest.split("/", 1)
ref = parts[0]
subpath = parts[1] if len(parts) > 1 else ""
elif "/-/blob/" in path:
project, rest = path.split("/-/blob/", 1)
parts = rest.split("/", 1)
ref = parts[0]
subpath = parts[1] if len(parts) > 1 else ""
else:
project = path
return {"host": host, "project": project, "ref": ref, "path": subpath}
class GitlabMdConnector(Connector):
name = "gitlab_md"
def fetch(self, source: Source, tool: Tool | None) -> list[Document]:
try:
import requests
except ImportError:
print(" [gitlab_md] 'requests' fehlt -> uebersprungen")
return []
info = parse_gitlab_url(source.url)
host = source.options.get("gitlab_host", info["host"])
project = source.options.get("project", info["project"])
ref = source.options.get("ref", info["ref"]) or "main"
base_path = source.options.get("path", info["path"]) or ""
max_depth = int(source.options.get("max_depth", -1))
if not host or not project:
print(f" [gitlab_md] keine Projekt-URL erkennbar: {source.url}")
return []
token = os.environ.get("GITLAB_TOKEN")
headers = {"User-Agent": USER_AGENT}
if token:
headers["PRIVATE-TOKEN"] = token
pid = quote(project, safe="")
api = f"https://{host}/api/v4/projects/{pid}/repository"
md_files = self._list_md(requests, api, ref, base_path, max_depth, headers)
if not md_files:
print(f" [gitlab_md] keine .md in {project}@{ref}/{base_path}")
return []
print(f" [gitlab_md] {len(md_files)} Markdown-Datei(en) in {project}")
docs: list[Document] = []
for fpath in md_files:
body = self._raw(requests, api, ref, fpath, headers)
if not body or len(body.strip()) < 1:
continue
title = fpath.rsplit("/", 1)[-1].rsplit(".md", 1)[0]
web = f"https://{host}/{project}/-/blob/{ref}/{fpath}"
for sc in source.scopes:
docs.append(Document(
tool=tool.id if tool else "allgemein",
scope=sc, domain=source.domain, title=title, body_md=body,
url=web, component_type=ComponentType.PAGE,
tags=list(source.tags), owners=list(source.owners),
contact=source.contact_address,
source_system=host,
))
return docs
def _list_md(self, requests, api, ref, base_path, max_depth, headers) -> list[str]:
out: list[str] = []
page = 1
while True:
params = {"ref": ref, "recursive": "true", "per_page": 100, "page": page}
if base_path:
params["path"] = base_path
try:
r = requests.get(f"{api}/tree", params=params, headers=headers, timeout=TIMEOUT)
r.raise_for_status()
except Exception as e:
print(f" [gitlab_md] Tree-Fehler: {e}")
break
items = r.json()
for it in items:
if it.get("type") == "blob" and it.get("path", "").lower().endswith(".md"):
p = it["path"]
if max_depth >= 0:
rel = p[len(base_path):].strip("/") if base_path else p
if rel.count("/") > max_depth:
continue
out.append(p)
if len(items) < 100:
break
page += 1
return out
def _raw(self, requests, api, ref, fpath, headers) -> str:
url = f"{api}/files/{quote(fpath, safe='')}/raw"
try:
r = requests.get(url, params={"ref": ref}, headers=headers, timeout=TIMEOUT)
r.raise_for_status()
return r.text
except Exception as e:
print(f" [gitlab_md] Datei nicht ladbar {fpath}: {e}")
return ""
@@ -0,0 +1,347 @@
"""PDF-Connector: sammelt PDF-Links einer Seite und wandelt sie in Markdown.
Strategie 'pdf':
- source.url: Index-/Uebersichtsseite ODER direkter Link auf ein PDF.
- Findet alle .pdf-Links, laedt sie, speichert das Roh-PDF (-> Git) und
extrahiert den Text.
- Splitting (1 PDF -> n Markdowns) ueber options.split:
none : ein Dokument pro PDF (default)
pages : ein Dokument pro Seite
headings : Split an Ueberschriften (options.heading_pattern)
Speziell fuer INB (Regulierungs-/Rechtswissen) ist 'headings' sinnvoll, damit
jeder Abschnitt/jede Anlage als eigenes, gut chunkbares Dokument vorliegt.
Nutzt 'pypdf' (lazy import). Fehlt das Paket, wird sauber uebersprungen.
"""
from __future__ import annotations
import io
import re
from pathlib import Path
from urllib.parse import unquote, urljoin, urlparse
from ..model import ComponentType, Document, Source, Tool, source_meta_fingerprint
from ..store import reconcile_meta
from .base import Connector
USER_AGENT = "wissensdatenbank-etl/0.1 (+local-test)"
TIMEOUT = 60
# Default-Heading-Erkennung fuer INB-aehnliche Dokumente:
# "Anlage 1.0", "Abschnitt 3", oder nummerierte Gliederung am Zeilenanfang.
DEFAULT_HEADING_PATTERN = r"(?m)^\s*(?:Anlage|Abschnitt|Kapitel|Teil)\s+[\dIVX]+.*$"
def pymupdf_markdown(content: bytes) -> str:
"""Strukturiertes Markdown (inkl. Tabellen) aus PDF-Bytes via pymupdf4llm.
Wiederverwendbar (z.B. fuer Confluence-Anhaenge). Leerer String bei Fehler/fehlendem Paket.
"""
try:
import pymupdf
import pymupdf4llm
except ImportError:
print(" [pdf] 'pymupdf4llm' fehlt -> parser pymupdf nicht moeglich")
return ""
try:
doc = pymupdf.open(stream=content, filetype="pdf")
return pymupdf4llm.to_markdown(doc, show_progress=False).strip()
except Exception as e:
print(f" [pdf] pymupdf-Fehler: {e}")
return ""
class PdfConnector(Connector):
name = "pdf"
def __init__(self, raw_dir: str | Path = "staging/raw", data_dir: str | Path = "output"):
# raw_dir = Ablage der Roh-PDFs (intern, staging); data_dir = Feed-Wurzel
# (output) zum Auffinden bereits verarbeiteter Dokumente (inkrementell).
self.raw_dir = Path(raw_dir)
self.data_dir = Path(data_dir)
def fetch(self, source: Source, tool: Tool | None) -> list[Document]:
try:
import requests # noqa: F401
except ImportError as e: # pragma: no cover
print(f" [pdf] Abhaengigkeit fehlt: {e} -> uebersprungen")
return []
links = self._discover_pdfs(source)
if not links:
print(f" [pdf] keine PDF-Links gefunden unter {source.url}")
return []
# inkrementell: bekannte PDFs ueberspringen. Scope-Wechsel erzwingt
# Neuverarbeitung; reine Metadaten-Aenderung -> guenstiges In-Place-Re-Tagging
if source.options.get("incremental"):
known = self._known_urls(source.domain)
current_fp = source_meta_fingerprint(source)
desired = {s.value for s in source.scopes}
before = len(links)
fresh: list[str] = []
retagged = 0
for u in links:
entry = known.get(u)
if entry is None:
fresh.append(u)
continue
existing = {f["scope"] for f in entry["files"]}
if existing != desired:
fresh.append(u) # Scope-Wechsel -> neu holen
continue
stale = [f["path"] for f in entry["files"] if f["fp"] != current_fp]
if stale:
retagged += reconcile_meta(stale, source, current_fp)
links = fresh
print(f" [pdf] inkrementell: {before - len(links)} bekannt "
f"({retagged} re-tagged), {len(links)} offen")
# max_pdfs = Batchgroesse pro Lauf (grosse Sammlungen ueber mehrere Laeufe)
batch = int(source.options.get("max_pdfs", 50))
links = links[:batch]
print(f" [pdf] {len(links)} PDF(s) in diesem Lauf")
docs: list[Document] = []
for link in links:
docs.extend(self._pdf_to_docs(link, source, tool))
return docs
def _known_urls(self, domain: str) -> dict[str, dict]:
"""{url: {"files":[{"scope","path","fp"}]}} bereits verarbeiteter PDFs."""
from ..store import parse_frontmatter
known: dict[str, dict] = {}
base = Path(self.data_dir) / "processed"
if not base.exists():
return known
for md in base.rglob("*.md"):
try:
meta, _ = parse_frontmatter(md.read_text(encoding="utf-8"))
except Exception:
continue
if meta.get("domain") == domain and meta.get("url"):
entry = known.setdefault(meta["url"], {"files": []})
entry["files"].append({
"scope": meta.get("scope", ""),
"path": str(md),
"fp": meta.get("meta_fingerprint", ""),
})
return known
# -- Discovery ----------------------------------------------------------
def _discover_pdfs(self, source: Source) -> list[str]:
if source.url.lower().endswith(".pdf"):
return [source.url]
# Sitemap-Discovery: PDFs aus einer (gz-)Sitemap holen, optional per Regex
# gefiltert. So wird immer die aktuell veroeffentlichte Version gefunden (die
# blob-URL/Version aendert sich, das Regex auf den Dateinamen bleibt stabil).
sm = source.options.get("sitemap_url")
if not sm and source.url.lower().endswith((".xml", ".xml.gz")):
sm = source.url
if sm:
return self._discover_from_sitemap(
sm, source.options.get("url_pattern", ""), source.options.get("max_pdfs"))
import requests
from bs4 import BeautifulSoup
try:
resp = requests.get(source.url, headers={"User-Agent": USER_AGENT}, timeout=TIMEOUT)
resp.raise_for_status()
except Exception as e:
print(f" [pdf] Seite nicht erreichbar {source.url}: {e}")
return []
soup = BeautifulSoup(resp.text, "html.parser")
links: list[str] = []
seen: set[str] = set()
for a in soup.find_all("a", href=True):
full = urljoin(source.url, a["href"])
if ".pdf" not in full.lower():
continue
if full in seen:
continue
seen.add(full)
links.append(full)
return links
def _read_sitemap_locs(self, url: str, depth: int = 2) -> list[str]:
"""Liest <loc>-Eintraege; folgt Sitemap-Indizes rekursiv und entpackt .gz."""
import gzip
import requests
try:
raw = requests.get(url, headers={"User-Agent": USER_AGENT}, timeout=TIMEOUT).content
except Exception as e:
print(f" [pdf] Sitemap nicht erreichbar {url}: {e}")
return []
if url.endswith(".gz") or raw[:2] == b"\x1f\x8b":
try:
raw = gzip.decompress(raw)
except Exception:
pass
txt = raw.decode("utf-8", "ignore")
locs = re.findall(r"<loc>(.*?)</loc>", txt)
out: list[str] = []
for loc in locs:
loc = loc.strip()
if depth > 0 and (loc.endswith(".xml") or loc.endswith(".xml.gz")):
out.extend(self._read_sitemap_locs(loc, depth - 1))
else:
out.append(loc)
return out
def _discover_from_sitemap(self, sitemap_url: str, pattern: str, max_pdfs=None) -> list[str]:
"""PDF-URLs aus einer Sitemap, optional per Regex gefiltert, neueste zuerst.
'Neueste zuerst' anhand der blob-ID (`/resource/blob/<id>/`, hoehere ID = juenger)
als Heuristik - so liefert ein Pattern wie 'Handbuch-pathOS-Webportal' bei mehreren
Treffern die aktuellste Version oben. limit (max_pdfs) kappt die Anzahl."""
locs = self._read_sitemap_locs(sitemap_url, depth=2)
rx = re.compile(pattern, re.IGNORECASE) if pattern else None
matched = [u for u in locs if ".pdf" in u.lower() and (rx.search(u) if rx else True)]
def _blob_id(u: str) -> int:
m = re.search(r"/resource/blob/(\d+)/", u)
return int(m.group(1)) if m else 0
ordered = sorted(set(matched), key=lambda u: (_blob_id(u), u), reverse=True)
if max_pdfs:
ordered = ordered[: int(max_pdfs)]
print(f" [pdf] Sitemap {sitemap_url}: {len(ordered)} PDF(s) "
f"(Pattern '{pattern or '*'}')")
return ordered
# -- Download + Extraktion ---------------------------------------------
def _pdf_to_docs(self, url: str, source: Source, tool: Tool | None) -> list[Document]:
import requests
try:
resp = requests.get(url, headers={"User-Agent": USER_AGENT}, timeout=TIMEOUT)
resp.raise_for_status()
except Exception as e:
print(f" [pdf] Download fehlgeschlagen {url}: {e}")
return []
filename = unquote(urlparse(url).path.split("/")[-1]) or "dokument.pdf"
# Default: KEIN Roh-PDF speichern. Das geparste Markdown reicht fuer den Feed;
# das Original-PDF ist ueber die `url:` im Frontmatter weiterhin auffindbar.
# Per Quelle ueber `options: { keep_raw: true }` aktivierbar (z.B. Compliance/Audit).
# Falls aktiv, landet das PDF in `staging/raw/<domaene>/` (intern, nicht im output-Feed).
if source.options.get("keep_raw", False):
self._save_raw(resp.content, source.domain, filename)
base_title = filename.rsplit(".pdf", 1)[0].replace("_", " ").replace("-", " ").strip()
host = urlparse(url).netloc or "pdf"
# Parser: 'pymupdf' liefert strukturiertes Markdown (Tabellen!) - z.B. fuer INB
if source.options.get("parser") == "pymupdf":
body = self._pymupdf_markdown(resp.content)
if not body:
return []
segments = [("", body)]
else:
try:
from pypdf import PdfReader
except ImportError:
print(" [pdf] 'pypdf' nicht installiert -> uebersprungen")
return []
try:
reader = PdfReader(io.BytesIO(resp.content))
pages = [(page.extract_text() or "").strip() for page in reader.pages]
except Exception as e:
print(f" [pdf] Konnte PDF nicht lesen {url}: {e}")
return []
split = source.options.get("split", "none")
if split == "pages":
segments = [(f"Seite {i + 1}", txt) for i, txt in enumerate(pages) if txt]
elif split == "headings":
full = self._clean("\n\n".join(p for p in pages if p))
full = self._strip_noise(full, source.options.get("strip_patterns", []))
segments = self._split_by_headings(full, source.options.get("heading_pattern"))
else:
segments = [("", self._clean("\n\n".join(p for p in pages if p)))]
docs: list[Document] = []
for part_title, text in segments:
if len(text.strip()) < 30:
continue
for sc in source.scopes:
docs.append(
Document(
tool=tool.id if tool else "allgemein",
scope=sc,
domain=source.domain,
title=base_title,
part=part_title,
body_md=text,
url=url,
component_type=ComponentType.PDF,
tags=list(source.tags),
owners=list(source.owners),
contact=source.contact_address,
source_system=host,
)
)
return docs
# -- Helpers ------------------------------------------------------------
def _pymupdf_markdown(self, content: bytes) -> str:
"""Strukturiertes Markdown (inkl. Tabellen) via pymupdf4llm."""
return pymupdf_markdown(content)
def _save_raw(self, content: bytes, domain: str, filename: str) -> Path:
target = self.raw_dir / domain
target.mkdir(parents=True, exist_ok=True)
path = target / filename
path.write_bytes(content)
return path
@staticmethod
def _clean(text: str) -> str:
return re.sub(r"\n{3,}", "\n\n", text).strip()
@staticmethod
def _strip_noise(text: str, patterns: list[str]) -> str:
"""Entfernt wiederkehrende Kopf-/Fusszeilen und reine Seitenzahlen."""
# Default: reine Seitenzahl-Zeilen
compiled = [re.compile(r"^\s*\d{1,4}\s*$")]
for p in patterns:
try:
compiled.append(re.compile(p))
except re.error:
pass
out = []
for line in text.split("\n"):
if any(c.search(line) for c in compiled):
continue
out.append(line)
return "\n".join(out)
def _split_by_headings(self, text: str, pattern: str | None) -> list[tuple[str, str]]:
pat = re.compile(pattern or DEFAULT_HEADING_PATTERN)
matches = list(pat.finditer(text))
if not matches:
return [("", text)]
segments: list[tuple[str, str]] = []
# Vorspann vor der ersten Ueberschrift
if matches[0].start() > 0:
pre = text[: matches[0].start()].strip()
if pre:
segments.append(("Einleitung", pre))
for i, m in enumerate(matches):
start = m.start()
end = matches[i + 1].start() if i + 1 < len(matches) else len(text)
heading = m.group(0).strip()
body = text[start:end].strip()
segments.append((heading[:80], body))
return segments
@@ -0,0 +1,246 @@
"""Web-Connector: deckt die Strategien 'sitemap' und 'crawler' ab.
- sitemap: liest eine (gz-)Sitemap und holt viele Detailseiten (neueste zuerst,
inkrementell). So werden z.B. die Kundeninfos aufgenommen.
- crawler: laedt eine Index-Seite, sammelt alle Detail-Links (per
detail_pattern) und holt jede Detailseite als eigenes Dokument.
Funktioniert lokal ohne Credentials gegen oeffentliche Seiten.
"""
from __future__ import annotations
import re
from urllib.parse import urljoin, urlparse
from ..model import ComponentType, Document, Source, Strategy, Tool, source_meta_fingerprint
from ..store import reconcile_meta
from .base import Connector
USER_AGENT = "wissensdatenbank-etl/0.1 (+local-test)"
TIMEOUT = 20
class WebCrawlerConnector(Connector):
name = "web"
def __init__(self, data_dir: str | None = None):
self.data_dir = data_dir
def fetch(self, source: Source, tool: Tool | None) -> list[Document]:
try:
import requests # noqa: F401 (lazy Verfuegbarkeitspruefung)
from bs4 import BeautifulSoup # noqa: F401
except ImportError as e: # pragma: no cover
print(f" [web] Abhaengigkeit fehlt: {e} -> uebersprungen")
return []
if source.strategy == Strategy.SITEMAP:
return self._crawl_sitemap(source, tool)
return self._crawl_index(source, tool) # CRAWLER (Default)
# -- intern -------------------------------------------------------------
def _get(self, url: str):
import requests
resp = requests.get(url, headers={"User-Agent": USER_AGENT}, timeout=TIMEOUT)
resp.raise_for_status()
return resp.text
def _crawl_index(self, source: Source, tool: Tool | None) -> list[Document]:
try:
html = self._get(source.url)
except Exception as e:
print(f" [web] Index nicht erreichbar {source.url}: {e}")
return []
from bs4 import BeautifulSoup
soup = BeautifulSoup(html, "html.parser")
pattern = source.options.get("detail_pattern")
max_pages = int(source.options.get("max_pages", 25))
links: list[str] = []
seen: set[str] = set()
for a in soup.find_all("a", href=True):
full = urljoin(source.url, a["href"])
if pattern and not re.search(pattern, full):
continue
if full in seen:
continue
seen.add(full)
links.append(full)
if len(links) >= max_pages:
break
print(f" [web] Index {source.url}: {len(links)} Detailseiten gefunden")
docs: list[Document] = []
for link in links:
docs.extend(self._fetch_detail(link, source, tool))
return docs
def _crawl_sitemap(self, source: Source, tool: Tool | None) -> list[Document]:
"""Holt Detailseiten aus einer (ggf. gzip-)Sitemap.
Options:
sitemap_url : URL der Sitemap oder des Sitemap-Index
url_pattern : Regex, das Detail-URLs matcht
limit : optional, max. Anzahl (neueste zuerst, nach Trailing-ID).
Fehlt limit -> ALLE Treffer des Patterns werden geholt.
selector : CSS-Selector fuer den Hauptinhalt
"""
sitemap_url = source.options.get("sitemap_url", source.url)
pattern = source.options.get("url_pattern", "")
limit_opt = source.options.get("limit")
limit = int(limit_opt) if limit_opt else None # None = alle
locs = self._read_sitemap(sitemap_url, depth=2)
details = self._select_details(locs, pattern, limit)
# Inkrementell: bekannte Detailseiten nicht erneut crawlen. Scope-Wechsel
# erzwingt Neuverarbeitung; reine Metadaten-Aenderung -> guenstiges In-Place-Re-Tagging
if source.options.get("incremental") and self.data_dir:
known = self._known_urls(source.domain)
current_fp = source_meta_fingerprint(source)
desired = {s.value for s in source.scopes}
before = len(details)
fresh: list[str] = []
retagged = 0
for u in details:
entry = known.get(u)
if entry is None:
fresh.append(u)
continue
existing = {f["scope"] for f in entry["files"]}
if existing != desired:
fresh.append(u) # Scope-Wechsel -> neu holen
continue
stale = [f["path"] for f in entry["files"] if f["fp"] != current_fp]
if stale:
retagged += reconcile_meta(stale, source, current_fp)
details = fresh
print(f" [web] inkrementell: {before - len(details)} bekannt "
f"({retagged} re-tagged), {len(details)} neu")
print(f" [web] Sitemap {sitemap_url}: {len(details)} Detailseiten "
f"(limit {limit if limit is not None else 'alle'})")
docs: list[Document] = []
for link in details:
docs.extend(self._fetch_detail(link, source, tool))
return docs
def _known_urls(self, domain: str) -> dict[str, dict]:
"""{url: {"files":[{"scope","path","fp"}]}} bereits verarbeiteter Dokumente."""
from pathlib import Path
from ..store import parse_frontmatter
known: dict[str, dict] = {}
base = Path(self.data_dir) / "processed"
if not base.exists():
return known
for md in base.rglob("*.md"):
try:
meta, _ = parse_frontmatter(md.read_text(encoding="utf-8"))
except Exception:
continue
if meta.get("domain") == domain and meta.get("url"):
entry = known.setdefault(meta["url"], {"files": []})
entry["files"].append({
"scope": meta.get("scope", ""),
"path": str(md),
"fp": meta.get("meta_fingerprint", ""),
})
return known
@staticmethod
def _select_details(locs: list[str], pattern: str, limit: int | None = None) -> list[str]:
"""Filtert nach pattern und liefert die neuesten (Trailing-ID desc); limit=None => alle."""
matched = [u for u in locs if not pattern or re.search(pattern, u)]
def _key(u: str) -> int:
m = re.search(r"(\d+)\s*$", u)
return int(m.group(1)) if m else 0
# Tiebreaker nach URL -> bei gleicher Trailing-ID deterministische Reihenfolge
# (set()-Iteration ist sonst zufaellig -> Git-Churn bei gesetztem limit).
return sorted(set(matched), key=lambda u: (_key(u), u), reverse=True)[:limit]
def _read_sitemap(self, url: str, depth: int = 2) -> list[str]:
"""Liest <loc>-Eintraege; folgt Sitemap-Indizes rekursiv und entpackt .gz."""
import gzip
import requests
try:
raw = requests.get(url, headers={"User-Agent": USER_AGENT}, timeout=TIMEOUT).content
except Exception as e:
print(f" [web] Sitemap nicht erreichbar {url}: {e}")
return []
if url.endswith(".gz") or raw[:2] == b"\x1f\x8b":
try:
raw = gzip.decompress(raw)
except Exception:
pass
txt = raw.decode("utf-8", "ignore")
locs = re.findall(r"<loc>(.*?)</loc>", txt)
out: list[str] = []
for loc in locs:
if depth > 0 and (loc.endswith(".xml") or loc.endswith(".xml.gz")):
out.extend(self._read_sitemap(loc, depth - 1))
else:
out.append(loc)
return out
def _fetch_detail(self, url: str, source: Source, tool: Tool | None) -> list[Document]:
try:
html = self._get(url)
except Exception as e:
print(f" [web] Seite nicht erreichbar {url}: {e}")
return []
import html2text
from bs4 import BeautifulSoup
soup = BeautifulSoup(html, "html.parser")
title = ""
if soup.title and soup.title.string:
title = soup.title.string.strip()
h1 = soup.find("h1")
if h1:
title = h1.get_text(strip=True) or title
selector = source.options.get("selector")
node = soup.select_one(selector) if selector else None
if node is None:
node = soup.find("main") or soup.find("article") or soup.body or soup
# Stoer-Elemente entfernen
for tag in node.find_all(["script", "style", "nav", "footer", "header"]):
tag.decompose()
h = html2text.HTML2Text()
h.body_width = 0
h.ignore_images = True
body_md = h.handle(str(node)).strip()
host = urlparse(url).netloc
return [
Document(
tool=tool.id if tool else "allgemein",
scope=sc,
domain=source.domain,
title=title or url,
body_md=body_md,
url=url,
component_type=ComponentType.PAGE,
tags=list(source.tags),
owners=list(source.owners),
contact=source.contact_address,
source_system=host or "web",
)
for sc in source.scopes
]
+158
View File
@@ -0,0 +1,158 @@
"""Orchestrator der Wissens-ETL-Pipeline.
Ablauf: Config laden -> pro Tool/General-Quelle Connector ausfuehren ->
Tags vergeben -> filtern -> Review-Gate (processed/ vs staging/).
Lokaler Lauf:
python -m src.main --config config/tools.yaml --data output --staging staging
"""
from __future__ import annotations
import argparse
from pathlib import Path
from .config_loader import load_approvals, load_filter_rules, load_tools
from .connectors import get_connector, set_data_dir
from .model import Document, ReviewStatus, Source, Tool, source_meta_fingerprint
from .review.staging import ReviewGate
from .store import append_run_log, prune_duplicate_files, prune_scope_orphans, write_run_meta
from .transformers.content_filter import filter_document
from .transformers.tagger import apply_tags
def process_source(source: Source, tool: Tool | None, rules: dict, gate: ReviewGate,
approvals: dict | None = None) -> dict:
"""Verarbeitet eine Quelle. Rueckgabe: Ergebnis-Dict fuers Lauf-Log."""
label = f"{source.domain}/{tool.id if tool else 'allgemein'}"
print(f"-> {label} | {source.strategy.value} | {source.scope.value} | {source.url}")
result = {"source": label, "strategy": source.strategy.value, "scope": source.scope.value,
"url": source.url, "docs": 0, "doc_errors": 0, "error": None}
try:
connector = get_connector(source.strategy)
docs: list[Document] = connector.fetch(source, tool)
except Exception as e: # noqa: BLE001
print(f" Fehler beim Abruf: {e}")
result["error"] = str(e)
return result
fingerprint = source_meta_fingerprint(source)
produced: dict[tuple[str, str], set[str]] = {}
doc_errors = 0
for doc in docs:
try:
apply_tags(doc)
doc.meta_fingerprint = fingerprint
filter_document(doc, rules, trusted=source.trusted, approvals=approvals,
redact=source.options.get("redact", True))
gate.write(doc)
if doc.review_status == ReviewStatus.APPROVED:
produced.setdefault((doc.domain, doc.url), set()).add(doc.scope.value)
except Exception as e: # noqa: BLE001 - ein defektes Dokument darf den Lauf nicht stoppen
print(f" Fehler bei Dokument ({getattr(doc, 'url', '?')}): {e} -> uebersprungen")
doc_errors += 1
print(f" {len(docs)} Dokument(e) verarbeitet")
# Nach erfolgreichem Schreiben: alte Ablage entfernen, wenn der Scope gewechselt hat
if produced:
removed = prune_scope_orphans(gate.output_dir, produced)
for p in removed:
print(f" [orphan] entfernt (Scope-Wechsel): {p}")
result["docs"] = len(docs)
result["doc_errors"] = doc_errors
return result
def run(config_path: str, data_dir: str, filter_path: str, only: str | None = None,
staging_dir: str = "staging") -> int:
tools, general = load_tools(config_path)
rules = load_filter_rules(filter_path)
approvals = load_approvals(Path(config_path).parent / "approvals.yaml")
set_data_dir(data_dir, staging_dir)
gate = ReviewGate(data_dir, staging_dir)
def matches(source: Source, tool: Tool | None) -> bool:
if not only:
return True
hay = f"{tool.id if tool else 'allgemein'} {source.domain} {source.url}"
return only.lower() in hay.lower()
results: list[dict] = []
for tool in tools:
for source in tool.sources:
if matches(source, tool):
results.append(process_source(source, tool, rules, gate, approvals))
for source in general:
if matches(source, None):
results.append(process_source(source, None, rules, gate, approvals))
total = sum(r["docs"] for r in results)
# Alt-Duplikate derselben Seite (Hash-Suffix) aufraeumen; im Voll-Lauf, da es den
# Gesamtbestand betrifft (das Gate verhindert NEUE Duplikate bereits beim Schreiben).
if not only:
for p in prune_duplicate_files(data_dir):
print(f" [dedup] entferntes Duplikat: {p}")
report = gate.write_report()
meta_path = write_run_meta(data_dir, gate.summary())
# Lauf-Log: append-only Historie (output/run_log.jsonl) inkl. Fehler je Quelle,
# damit man (auch nachtraeglich) sieht, ob ein Lauf sauber durchlief.
errors = [{"source": r["source"], "strategy": r["strategy"], "error": r["error"]}
for r in results if r["error"]]
log_entry = {
"documents": total,
"by_status": gate.summary(),
"sources_total": len(results),
"sources_failed": len(errors),
"doc_errors": sum(r["doc_errors"] for r in results),
"errors": errors,
}
log_path = append_run_log(data_dir, log_entry)
# Chunking (inkrementell, offline): haelt output/chunks synchron zum freigegebenen
# Bestand. Default aus - nur Quellen mit options.chunk != off. Bei Vorschaulaeufen
# (--only) uebersprungen, da nur ein Teilbestand vorliegt. Fehler hier duerfen den
# ETL-Lauf NICHT abbrechen (Chunks sind ein abgeleitetes Zusatz-Artefakt).
if not only:
try:
from .chunk import run as chunk_run
print("\n=== Chunking ===")
chunk_run(config_path, data_dir)
except Exception as e: # noqa: BLE001 - bewusst breit: Chunking ist nicht kritisch
print(f" Chunking uebersprungen (Fehler, nicht kritisch): {e}")
print("\n=== Zusammenfassung ===")
print(f"Dokumente gesamt: {total}")
for status, count in sorted(gate.summary().items()):
print(f" {status:10s}: {count}")
if errors:
print(f"Quellen mit Fehler: {len(errors)}")
for e in errors:
print(f" ! {e['source']} ({e['strategy']}): {e['error']}")
print(f"Report: {report}")
print(f"Meta: {meta_path}")
print(f"Log: {log_path}")
return total
def main() -> None:
parser = argparse.ArgumentParser(description="Wissens-ETL-Pipeline")
parser.add_argument("--config", default="config/tools.yaml")
parser.add_argument("--data", default="output",
help="Output-Verzeichnis (Konsumenten-Feed): processed/, chunks/, _index.json, _meta.json, run_log.jsonl")
parser.add_argument("--staging", default="staging",
help="Staging-Verzeichnis (intern): pending/, review_report.json")
parser.add_argument("--filter", default="config/filter_rules.json")
parser.add_argument("--only", default=None,
help="nur Quellen verarbeiten, die diesen Text (Tool/Domaene/URL) enthalten (Vorschau)")
args = parser.parse_args()
if not Path(args.config).exists():
raise SystemExit(f"Config nicht gefunden: {args.config}")
run(args.config, args.data, args.filter, only=args.only, staging_dir=args.staging)
if __name__ == "__main__":
main()
+284
View File
@@ -0,0 +1,284 @@
"""Zentrales Datenmodell der Wissens-Pipeline.
Alles dreht sich um: Tool -> Quelle(n) mit Strategie + Scope -> Dokument(e).
"""
from __future__ import annotations
import hashlib
import json
import re
from dataclasses import dataclass, field
from datetime import date
from enum import Enum
# Default-Kontaktadresse (Ansprechpartner), die herausgegeben werden darf.
# Bewusst getrennt von 'owners' (= intern Verantwortliche).
DEFAULT_CONTACT = "einfachbahn@deutschebahn.com"
_PAGE_ID_RE = re.compile(r"/pages/(\d+)|pageId=(\d+)")
def page_identity(url: str) -> str:
"""Stabile Identitaet einer Quelle fuer Dedup.
Dieselbe Confluence-Seite kann aus mehreren Quellen kommen (z.B. ein breiter
confluence_tree UND eine dedizierte confluence_faq-Quelle) und in unterschiedlichen
URL-Schreibweisen (`/spaces/.../pages/<id>/...` vs. `pageId=<id>`). Wir reduzieren
daher auf die Confluence-Page-ID; sonst auf die normalisierte URL. So zaehlt eine
Seite als EINE Einheit -> Dedup im Review-Gate.
"""
if not url:
return ""
m = _PAGE_ID_RE.search(url)
if m:
return "confluence:" + (m.group(1) or m.group(2))
return url.rstrip("/")
class Scope(str, Enum):
"""Sichtbarkeit eines Wissensdokuments.
ALLGEMEIN gilt sowohl fuer intern als auch extern (Schnittmenge).
"""
INTERN = "intern"
EXTERN = "extern"
ALLGEMEIN = "allgemein"
@property
def visible_extern(self) -> bool:
return self in (Scope.EXTERN, Scope.ALLGEMEIN)
@property
def visible_intern(self) -> bool:
return self in (Scope.INTERN, Scope.ALLGEMEIN)
class Strategy(str, Enum):
"""Herangehensweise pro Quelle (austauschbar)."""
CONFLUENCE_PAGE = "confluence_page" # nur genau diese Seite
CONFLUENCE_TREE = "confluence_tree" # Seite inkl. aller Unterseiten
CONFLUENCE_FAQ = "confluence_faq" # FAQs erzeugen (z.B. aus Tabellen)
CRAWLER = "crawler" # allgemeiner rekursiver Web-Crawl
SITEMAP = "sitemap" # Detailseiten aus einer (gz-)Sitemap
GITLAB_MD = "gitlab_md" # Markdown-Dateien aus einem GitLab-Repo
FILE = "file" # Dateien (md/pdf) aus dem Repo-Ordner files/
PDF = "pdf" # PDF-Links einer Seite -> Markdown
class ComponentType(str, Enum):
PAGE = "page"
FAQ = "faq"
PDF = "pdf"
@dataclass
class Source:
"""Eine einzelne Wissensquelle eines Tools (1..n pro Tool).
scopes: eine oder mehrere Sichtbarkeiten. 'intern,extern' bedeutet: die ganze
Seite wird fuer BEIDE genutzt (dupliziert, KEINE Inhaltstrennung pro Seite).
"""
url: str
strategy: Strategy
scopes: list[Scope] = field(default_factory=lambda: [Scope.INTERN])
tags: list[str] = field(default_factory=list)
options: dict = field(default_factory=dict)
domain: str = "allgemein"
trusted: bool = False
owners: list[str] = field(default_factory=list) # intern Verantwortliche (Accountability)
contact: str = "" # herausgebbare Kontaktadresse (Ansprechpartner)
@property
def scope(self) -> Scope:
return self.scopes[0]
@property
def contact_address(self) -> str:
"""Kontaktadresse mit Default einfachbahn@deutschebahn.com."""
return self.contact or DEFAULT_CONTACT
@staticmethod
def _parse_scopes(raw) -> list[Scope]:
if raw is None:
return [Scope.INTERN]
items = raw if isinstance(raw, list) else str(raw).split(",")
scopes = [Scope(s.strip()) for s in items if str(s).strip()]
return scopes or [Scope.INTERN]
@staticmethod
def _parse_list(raw) -> list[str]:
if not raw:
return []
items = raw if isinstance(raw, list) else str(raw).split(",")
return [str(s).strip() for s in items if str(s).strip()]
@staticmethod
def from_dict(d: dict) -> "Source":
return Source(
url=d["url"],
strategy=Strategy(d["strategy"]),
scopes=Source._parse_scopes(d.get("scope")),
tags=list(d.get("tags", [])),
options=dict(d.get("options", {})),
domain=d.get("domain", "allgemein"),
trusted=bool(d.get("trusted", False)),
owners=Source._parse_list(d.get("owners")),
contact=str(d.get("contact", "")).strip(),
)
@dataclass
class Tool:
"""Eine Fachanwendung / ein Thema im 1st-Level-Support."""
id: str
name: str
domain: str = "allgemein"
scope: str = "intern" # tool-weit: intern|extern|allgemein|mixed
owners: list[str] = field(default_factory=list) # intern Verantwortliche
contact: str = "" # herausgebbare Kontaktadresse
sources: list[Source] = field(default_factory=list)
@staticmethod
def from_dict(d: dict) -> "Tool":
domain = d.get("domain", "allgemein")
tool_scope = d.get("scope", "intern") # intern|extern|allgemein|mixed
tool_owners = Source._parse_list(d.get("owners"))
tool_contact = str(d.get("contact", "")).strip()
sources: list[Source] = []
for sd in d.get("sources", []):
s = Source.from_dict(sd)
if not s.domain or s.domain == "allgemein":
s.domain = domain
if "scope" not in sd and tool_scope in ("intern", "extern", "allgemein"):
s.scopes = [Scope(tool_scope)]
if not s.owners:
s.owners = list(tool_owners)
if not s.contact:
s.contact = tool_contact
sources.append(s)
return Tool(id=d["id"], name=d.get("name", d["id"]), domain=domain,
scope=tool_scope, owners=tool_owners, contact=tool_contact,
sources=sources)
class ReviewStatus(str, Enum):
PENDING = "pending"
APPROVED = "approved"
@dataclass
class Document:
"""Ein aufbereitetes Wissensdokument (wird zu einer Markdown-Datei)."""
tool: str # Tool-Id oder "allgemein"
scope: Scope
title: str
body_md: str
url: str
domain: str = "allgemein"
component_type: ComponentType = ComponentType.PAGE
tags: list[str] = field(default_factory=list)
owners: list[str] = field(default_factory=list) # intern Verantwortliche
contact: str = DEFAULT_CONTACT # herausgebbare Kontaktadresse
source_system: str = "web"
source_version: str = "" # z.B. Confluence-Version (fuer inkrementelles Crawlen)
meta_fingerprint: str = "" # Hash der Metadaten (tags/owners/contact) -> inkrementelles Re-Tagging
part: str = "" # optionaler Teil-Bezeichner (z.B. INB-Abschnitt)
last_updated: str = field(default_factory=lambda: date.today().isoformat())
review_status: ReviewStatus = ReviewStatus.PENDING
review_notes: list[str] = field(default_factory=list)
# Chunking (nur bei Chunks gesetzt; Voll-Dokument: kind="document")
kind: str = "document" # "document" | "chunk"
parent_url: str = "" # URL/Quelle des Voll-Dokuments
parent_hash: str = "" # content_hash des Voll-Dokuments
section: str = "" # Abschnitts-Ueberschrift des Chunks
ziffer: str = "" # erkannte Gliederungsziffer (z.B. 7.3.1.1.1.1)
chunk_index: int = 0 # laufende Nummer innerhalb des Voll-Dokuments
chunk_fingerprint: str = "" # Hash der wirksamen Chunk-Optionen (-> inkrementelles Re-Chunking)
attachment_name: str = "" # Dateiname des Anhangs (nur bei kind="attachment")
@property
def content_hash(self) -> str:
return hashlib.sha256(self.body_md.encode("utf-8")).hexdigest()[:16]
def slug(self) -> str:
base = f"{self.title} {self.part}".strip() or self.url
keep = [c.lower() if c.isalnum() else "-" for c in base]
s = "".join(keep)
while "--" in s:
s = s.replace("--", "-")
return s.strip("-")[:80] or self.content_hash
def to_markdown(self) -> str:
fm = [
"---",
f'domain: "{self.domain}"',
f'tool: "{self.tool}"',
f'scope: "{self.scope.value}"',
f"tags: [{', '.join(self._q(t) for t in self.tags)}]",
f"owners: [{', '.join(self._q(o) for o in self.owners)}]",
f'contact: "{self.contact}"',
f'component_type: "{self.component_type.value}"',
f'source: "{self.source_system}"',
f'source_version: "{self.source_version}"',
f'meta_fingerprint: "{self.meta_fingerprint}"',
f'url: "{self.url}"',
]
if self.part:
fm.append(f'part: "{self.part}"')
if self.kind == "chunk":
fm += [
'kind: "chunk"',
f'parent_url: "{self.parent_url}"',
f'parent_hash: "{self.parent_hash}"',
f'section: "{self.section}"',
f'ziffer: "{self.ziffer}"',
f'chunk_index: {self.chunk_index}',
f'chunk_fingerprint: "{self.chunk_fingerprint}"',
]
if self.kind == "attachment":
fm += [
'kind: "attachment"',
f'parent_url: "{self.parent_url}"',
f'attachment_name: "{self.attachment_name}"',
]
fm += [
f'last_updated: "{self.last_updated}"',
f'review_status: "{self.review_status.value}"',
f'review_notes: "{self._notes_str()}"',
f'content_hash: "{self.content_hash}"',
"---",
"",
self.body_md.strip(),
"",
]
return "\n".join(fm)
def _notes_str(self) -> str:
joined = " | ".join(self.review_notes)
return joined.replace('"', "'")
@staticmethod
def _q(s: str) -> str:
return f'"{s}"'
def source_meta_fingerprint(source: "Source") -> str:
"""Stabiler Hash der herausgebbaren Metadaten einer Quelle.
Bewusst NUR Felder, die NICHT den Ablagepfad bestimmen: tags, owners, contact.
(scope/domain steuern den Pfad und werden separat behandelt.) Aendert sich einer
dieser Werte in der Config, weicht der Fingerprint vom gespeicherten ab und die
Pipeline aktualisiert die betroffenen Dokumente inkrementell.
"""
payload = {
"tags": sorted(t.strip().lower() for t in source.tags if t.strip()),
"owners": sorted(o.strip().lower() for o in source.owners if o.strip()),
"contact": source.contact_address.strip().lower(),
}
raw = json.dumps(payload, sort_keys=True, ensure_ascii=False)
return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:16]
+42
View File
@@ -0,0 +1,42 @@
"""Einfacher Qualitaets-Score (0-100) fuer ein Wissensdokument.
Heuristik (bewusst simpel, erweiterbar): Laenge, Wortanzahl, Struktur
(Ueberschriften/Listen/Tabellen). Dient als Orientierung auf der Pages-Seite;
spaeter optional als Gate-Schwelle (niedrig -> pending) nutzbar.
`quality_breakdown` liefert die Einzelpunkte transparent (fuer die Anzeige),
`quality_score` den gerundeten Gesamtwert.
"""
from __future__ import annotations
# Maximalpunkte je Kriterium (Summe = 100) - zentrale, transparente Definition.
WEIGHTS = {
"laenge": 45, # bis 1000 Zeichen
"woerter": 20, # bis 80 Woerter
"headings": 15, # mind. eine Ueberschrift (#)
"listen": 10, # mind. eine Liste (-/*/Nummer)
"tabellen": 10, # mind. eine Tabelle (|)
}
def quality_breakdown(text: str) -> dict:
"""Punkte je Kriterium + Gesamt (0-100), transparent nachvollziehbar."""
t = (text or "").strip()
if not t:
return {**{k: 0 for k in WEIGHTS}, "total": 0}
lines = t.splitlines()
parts = {
"laenge": round(min(len(t) / 1000.0, 1.0) * WEIGHTS["laenge"]),
"woerter": round(min(len(t.split()) / 80.0, 1.0) * WEIGHTS["woerter"]),
"headings": WEIGHTS["headings"] if any(ln.lstrip().startswith("#") for ln in lines) else 0,
"listen": WEIGHTS["listen"] if any(
ln.lstrip().startswith(("-", "*")) or ln.lstrip()[:2].isdigit() for ln in lines
) else 0,
"tabellen": WEIGHTS["tabellen"] if "|" in t else 0,
}
parts["total"] = int(min(sum(parts.values()), 100))
return parts
def quality_score(text: str) -> int:
return quality_breakdown(text)["total"]
+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
File diff suppressed because it is too large Load Diff
+509
View File
@@ -0,0 +1,509 @@
"""Liest den vorhandenen Wissensbestand vom Datentraeger (statt aus dem Run-Report).
Wichtig fuer inkrementelle Laeufe: der review_report.json enthaelt nur die im
letzten Lauf verarbeiteten Dokumente. Die GitLab-Pages-Seite soll aber den GESAMTEN
Bestand sehen -> hier aus output/processed (approved) und staging/pending
(pending) laden.
"""
from __future__ import annotations
import hashlib
import json
from datetime import datetime, timezone
from pathlib import Path
def parse_frontmatter(text: str) -> tuple[dict, str]:
"""Einfacher YAML-Frontmatter-Parser (key: value, plus [..]-Listen)."""
if not text.startswith("---"):
return {}, text
end = text.find("\n---", 3)
if end == -1:
return {}, text
fm_raw = text[3:end].strip("\n")
body = text[end + 4:].lstrip("\n")
meta: dict = {}
for line in fm_raw.split("\n"):
if ":" not in line:
continue
key, _, val = line.partition(":")
key = key.strip()
val = val.strip()
if val.startswith("[") and val.endswith("]"):
items = [v.strip().strip('"') for v in val[1:-1].split(",") if v.strip()]
meta[key] = items
else:
meta[key] = val.strip('"')
return meta, body
def _fmt_list(items) -> str:
return "[" + ", ".join(f'"{str(i)}"' for i in items) + "]"
def patch_frontmatter_file(path, *, tags=None, owners=None, contact=None,
meta_fingerprint=None) -> bool:
"""Aktualisiert einzelne Frontmatter-Felder einer bestehenden .md IN-PLACE.
Aendert nur die angegebenen Zeilen (Rest des Dokuments bleibt unangetastet).
Genutzt fuer inkrementelles Re-Tagging: aendern sich Metadaten in der Config,
werden bestehende Dateien guenstig angepasst statt neu geladen.
Felder, die den Ablagepfad bestimmen (scope/domain), werden bewusst NICHT
angefasst. Gibt True zurueck, wenn die Datei geschrieben wurde.
"""
from pathlib import Path as _Path
p = _Path(path)
text = p.read_text(encoding="utf-8")
if not text.startswith("---"):
return False
end = text.find("\n---", 3)
if end == -1:
return False
head = text[:end]
rest = text[end:] # beginnt mit "\n---" (schliessender Block + Body)
lines = head.split("\n")
updates: dict[str, str] = {}
if tags is not None:
updates["tags"] = f"tags: {_fmt_list(tags)}"
if owners is not None:
updates["owners"] = f"owners: {_fmt_list(owners)}"
if contact is not None:
updates["contact"] = f'contact: "{contact}"'
if meta_fingerprint is not None:
updates["meta_fingerprint"] = f'meta_fingerprint: "{meta_fingerprint}"'
for key, rendered in updates.items():
prefix = f"{key}:"
for i, ln in enumerate(lines):
if ln.startswith(prefix):
lines[i] = rendered
break
else:
lines.append(rendered)
p.write_text("\n".join(lines) + rest, encoding="utf-8")
return True
def reconcile_meta(paths, source, current_fp: str) -> int:
"""Bringt die Metadaten bestehender Dateien auf den Stand der Quelle.
Pro Datei werden Tags (domain:/tool:/scope: + Quell-Tags), owners, contact und
der meta_fingerprint neu geschrieben. Die Tags werden mit der jeweils im File
hinterlegten domain/tool/scope rekonstruiert (identisch zu apply_tags).
"""
from .transformers.tagger import compute_tags
n = 0
for path in paths:
try:
meta, _ = parse_frontmatter(Path(path).read_text(encoding="utf-8"))
except Exception:
continue
tags = compute_tags(
meta.get("domain", source.domain),
meta.get("tool", "allgemein"),
meta.get("scope", source.scope.value),
list(source.tags),
)
if patch_frontmatter_file(
path,
tags=tags,
owners=list(source.owners),
contact=source.contact_address,
meta_fingerprint=current_fp,
):
n += 1
return n
def prune_scope_orphans(data_dir, produced: dict) -> list[str]:
"""Loescht verwaiste processed-Dateien nach einem Scope-Wechsel.
produced: {(domain, url): {scopes...}} = die in DIESEM Lauf erzeugten
approved-Scopes je (Domaene, URL). Eine bestehende Datei wird nur geloescht,
wenn ihr (domain, url) gerade neu erzeugt wurde, ihr Scope aber NICHT mehr
dabei ist (also die Ablage gewechselt hat). Domaenen-intern -> kollisionssicher;
laeuft erst NACH erfolgreichem Schreiben -> kein Datenverlust bei Abruffehlern.
"""
base = Path(data_dir) / "processed"
deleted: list[str] = []
if not base.exists():
return deleted
domains = {dom for (dom, _) in produced}
seen: set = set()
for dom in domains:
for md in base.glob(f"*/{dom}/**/*.md"):
if md in seen:
continue
seen.add(md)
try:
meta, _ = parse_frontmatter(md.read_text(encoding="utf-8"))
except Exception:
continue
scopes = produced.get((meta.get("domain", ""), meta.get("url", "")))
if not scopes:
continue
if meta.get("scope", "") not in scopes:
try:
md.unlink()
except OSError:
continue
deleted.append(str(md))
return deleted
def processed_signature(data_dir) -> tuple[str, int, dict]:
"""Inhalts-Signatur des freigegebenen Bestands (CI-sicher, ohne Datei-mtimes).
Hasht je Dokument (relativer Pfad + content_hash + meta_fingerprint), damit
JEDE Aenderung erkannt wird: neue/entfernte Datei, geaenderter Inhalt ODER
geaenderte Metadaten (Re-Tagging). Liefert (signature, anzahl, je_scope).
"""
base = Path(data_dir) / "processed"
items: list[str] = []
by_scope: dict[str, int] = {}
count = 0
if base.exists():
for md in sorted(base.rglob("*.md")):
try:
meta, _ = parse_frontmatter(md.read_text(encoding="utf-8"))
except Exception:
continue
rel = str(md.relative_to(base))
items.append(f"{rel}|{meta.get('content_hash', '')}|{meta.get('meta_fingerprint', '')}")
sc = meta.get("scope", "")
by_scope[sc] = by_scope.get(sc, 0) + 1
count += 1
sig = hashlib.sha256("\n".join(items).encode("utf-8")).hexdigest()[:16]
return sig, count, by_scope
def write_run_meta(data_dir, summary: dict | None = None) -> Path:
"""Schreibt `output/_meta.json` fuer nachgelagerte Systeme.
Liegt bewusst auf `output/`-Ebene (nicht im `processed/`-Feed): globale Status-Info
ueber den GESAMTEN Bestand, kein Wissensdokument.
Felder: `last_run` (dieser Lauf), `last_change` (wann sich der Bestand zuletzt
inhaltlich/metadatenseitig geaendert hat bleibt stehen, wenn der Lauf nichts
geaendert hat), `documents`, `by_scope`, `content_signature` und optional die
Status-Zusammenfassung des letzten Laufs.
"""
root = Path(data_dir)
meta_path = root / "_meta.json"
prev: dict = {}
if meta_path.exists():
try:
prev = json.loads(meta_path.read_text(encoding="utf-8"))
except Exception:
prev = {}
sig, docs, by_scope = processed_signature(data_dir)
now = datetime.now(timezone.utc).isoformat(timespec="seconds")
if prev.get("content_signature") == sig and prev.get("last_change"):
last_change = prev["last_change"]
else:
last_change = now
payload = {
"schema": 1,
"last_run": now,
"last_change": last_change,
"documents": docs,
"by_scope": by_scope,
"content_signature": sig,
}
if summary is not None:
payload["last_run_summary"] = summary
root.mkdir(parents=True, exist_ok=True)
meta_path.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
return meta_path
def build_index(data_dir) -> dict:
"""Katalog ueber den freigegebenen Bestand: pro Voll-Dokument (output/processed)
Herkunft, Aenderungsstand und - falls vorhanden - die zugehoerigen Chunks und
Anhang-Dokumente (eingebundene PDFs). Auch gechunkte Anhang-PDFs
(`chunk_kind: attachment`) erhalten ihren `chunks`-Block und zaehlen in `chunks_total`.
Beantwortet fuer nachgelagerte Systeme (und Menschen): was liegt pro Domaene/Tool
wo, wann zuletzt geaendert, und gibt es das Dokument zusaetzlich als Chunks/Anhaenge?
Pfade sind relativ zum Output-Root (`processed/...`, `chunks/...`).
"""
from .model import page_identity
base = Path(data_dir)
processed = base / "processed"
chunks_root = base / "chunks"
# 1) Alle Dokumente einmal einlesen (Chunks ausgenommen)
items: list[tuple] = [] # (md, meta)
if processed.exists():
for md in sorted(processed.rglob("*.md")):
try:
meta, _ = parse_frontmatter(md.read_text(encoding="utf-8"))
except OSError:
continue
if meta.get("kind") == "chunk":
continue
items.append((md, meta))
# 2) Anhang-Dokumente nach Elternseite gruppieren ((scope, domaene, parent-identity))
attachments_by_parent: dict[tuple, list[dict]] = {}
for md, meta in items:
if meta.get("kind") != "attachment":
continue
key = (meta.get("scope", ""), meta.get("domain", ""), page_identity(meta.get("parent_url", "")))
attachments_by_parent.setdefault(key, []).append({
"name": meta.get("attachment_name", "") or md.stem,
"path": str(md.relative_to(base)),
})
documents: list[dict] = []
by_domain: dict[str, dict] = {}
by_scope: dict[str, int] = {}
total_chunks = 0
total_attachments = 0
for md, meta in items:
scope = meta.get("scope", "")
domain = meta.get("domain", "")
tool = meta.get("tool", "")
kind = meta.get("kind", "document") or "document"
entry = {
"scope": scope,
"domain": domain,
"tool": tool,
"kind": kind,
"title": meta.get("part") or meta.get("title") or md.stem,
"url": meta.get("url", ""),
"path": str(md.relative_to(base)),
"content_hash": meta.get("content_hash", ""),
"last_updated": meta.get("last_updated", ""),
}
# Chunks dieser Datei (gleicher relativer Pfad in output/chunks/<...>/<stem>/).
# Gilt fuer Voll-Dokumente UND gechunkte Anhang-PDFs (chunk_kind: attachment) -
# daher vor der kind-Fallunterscheidung, damit auch Anhaenge ihren chunks-Block
# erhalten und in chunks_total mitzaehlen.
rel_parent = md.relative_to(processed).parent
chunk_dir = chunks_root / rel_parent / md.stem
chunk_files = sorted(chunk_dir.glob("*.md")) if chunk_dir.is_dir() else []
chunks_info = None
if chunk_files:
try:
cmeta, _ = parse_frontmatter(chunk_files[0].read_text(encoding="utf-8"))
except OSError:
cmeta = {}
chunks_info = {
"count": len(chunk_files),
"path": str(chunk_dir.relative_to(base)),
"fingerprint": cmeta.get("chunk_fingerprint", ""),
}
total_chunks += len(chunk_files)
entry["chunks"] = chunks_info
if kind == "attachment":
entry["parent_url"] = meta.get("parent_url", "")
total_attachments += 1
else:
# zugehoerige Anhang-Dokumente (eingebundene PDFs)
akey = (scope, domain, page_identity(meta.get("url", "")))
entry["attachments"] = attachments_by_parent.get(akey) or None
documents.append(entry)
d = by_domain.setdefault(domain, {"documents": 0, "chunks": 0, "attachments": 0,
"scopes": set(), "tools": set()})
d["documents"] += 1
if kind == "attachment":
d["attachments"] += 1
if entry.get("chunks"):
d["chunks"] += entry["chunks"]["count"]
d["scopes"].add(scope)
d["tools"].add(tool)
by_scope[scope] = by_scope.get(scope, 0) + 1
by_domain_out = {
dom: {
"documents": v["documents"],
"chunks": v["chunks"],
"attachments": v["attachments"],
"scopes": sorted(v["scopes"]),
"tools": sorted(v["tools"]),
}
for dom, v in sorted(by_domain.items())
}
return {
"schema": 1,
"documents_total": len(documents),
"chunks_total": total_chunks,
"attachments_total": total_attachments,
"by_scope": dict(sorted(by_scope.items())),
"by_domain": by_domain_out,
"documents": documents,
}
def write_index(data_dir) -> Path:
"""Schreibt `output/_index.json` (dokument-genauer Katalog inkl. Chunks).
Liegt bewusst auf `output/`-Ebene (nicht im `processed/`-Feed): Katalog ueber den
gesamten Bestand, kein Wissensdokument.
"""
root = Path(data_dir)
root.mkdir(parents=True, exist_ok=True)
path = root / "_index.json"
payload = build_index(data_dir)
path.write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
return path
def append_run_log(data_dir, entry: dict, cap: int = 500) -> Path:
"""Haengt einen Lauf-Eintrag an `output/run_log.jsonl` an (append-only Historie).
Bewusst auf `output/`-Ebene, eine JSON-Zeile pro ETL-Lauf. Im Gegensatz zu
`_meta.json`/`_index.json` ist dies KEIN deterministischer Snapshot, sondern ein
Verlauf (jeder Lauf = ein Ereignis mit Zeitstempel) -> so sieht man historisch, ob
Laeufe sauber durchliefen und welche Quellen Fehler hatten. Auf die letzten `cap`
Eintraege gekappt, damit die Datei nicht unbegrenzt waechst.
"""
root = Path(data_dir)
root.mkdir(parents=True, exist_ok=True)
path = root / "run_log.jsonl"
record = {"ts": datetime.now(timezone.utc).isoformat(timespec="seconds"), **entry}
lines = path.read_text(encoding="utf-8").splitlines() if path.exists() else []
lines.append(json.dumps(record, ensure_ascii=False))
if cap and len(lines) > cap:
lines = lines[-cap:]
path.write_text("\n".join(lines) + "\n", encoding="utf-8")
return path
def read_last_run(data_dir) -> dict:
"""Liefert den letzten Eintrag aus `output/run_log.jsonl` (oder {} wenn keiner)."""
path = Path(data_dir) / "run_log.jsonl"
if not path.exists():
return {}
last = ""
for line in path.read_text(encoding="utf-8").splitlines():
if line.strip():
last = line
try:
return json.loads(last) if last else {}
except json.JSONDecodeError:
return {}
def prune_duplicate_files(data_dir) -> list[str]:
"""Entfernt verwaiste Hash-Suffix-Duplikate im Feed.
Frueher konnten mehrere Quellen DIESELBE Seite liefern -> `<slug>.md` plus
`<slug>-<hash>.md`. Mit dem Dedup im Review-Gate entsteht das nicht mehr neu; diese
Funktion raeumt Altbestaende auf: Ein `<slug>-<hash>.md` wird geloescht, wenn die
Basisdatei `<slug>.md` im selben Ordner DIESELBE Seite referenziert (page_identity).
Verschiedene Seiten mit zufaellig gleichem Titel (unterschiedliche page_identity)
bleiben erhalten.
"""
import re as _re
from .model import page_identity
base = Path(data_dir) / "processed"
removed: list[str] = []
if not base.exists():
return removed
suffix_re = _re.compile(r"^(?P<slug>.+)-[0-9a-f]{16}\.md$")
for md in sorted(base.rglob("*.md")):
m = suffix_re.match(md.name)
if not m:
continue
sibling = md.with_name(m.group("slug") + ".md")
if not sibling.exists():
continue
try:
meta_a, _ = parse_frontmatter(md.read_text(encoding="utf-8"))
meta_b, _ = parse_frontmatter(sibling.read_text(encoding="utf-8"))
except OSError:
continue
id_a = page_identity(meta_a.get("url", ""))
id_b = page_identity(meta_b.get("url", ""))
if id_a and id_a == id_b:
try:
md.unlink()
except OSError:
continue
removed.append(str(md.relative_to(Path(data_dir))))
return removed
def load_documents(data_dir: str, staging_dir: str | None = None) -> list[dict]:
"""Liefert alle Dokumente aus `output/processed/` und `staging/pending/` (intern)
als Liste von dicts.
Jedes dict: domain, tool, scope, tags, title, part, url, status, path, text.
Der Status kommt aus dem Frontmatter (review_status) bzw. dem staging-Ordner.
`data_dir`: Output-Verzeichnis (Feed). `staging_dir`: top-level Staging-Verzeichnis.
Wenn nicht angegeben, wird `<data_dir>/staging` als Fallback geprueft (Test-Convenience).
"""
base = Path(data_dir)
staging_root = Path(staging_dir) if staging_dir else (base / "staging")
docs: list[dict] = []
for root, default_status in ((base / "processed", "approved"), (staging_root, None)):
if not root.exists():
continue
for md in sorted(root.rglob("*.md")):
try:
meta, body = parse_frontmatter(md.read_text(encoding="utf-8"))
except Exception:
continue
status = meta.get("review_status") or default_status or "pending"
from .quality import quality_breakdown
qb = quality_breakdown(body)
docs.append(
{
"domain": meta.get("domain", ""),
"tool": meta.get("tool", ""),
"scope": meta.get("scope", "intern"),
"kind": meta.get("kind", "document") or "document",
"tags": meta.get("tags", []),
"owners": meta.get("owners", []),
"contact": meta.get("contact", ""),
"title": meta.get("part") or meta.get("title") or md.stem,
"part": meta.get("part", ""),
"url": meta.get("url", ""),
"hash": meta.get("content_hash", ""),
"notes": meta.get("review_notes", ""),
"quality": qb["total"],
"quality_parts": qb,
"status": status,
"path": _relpath_from_roots(md, base, staging_root),
"text": body.strip(),
}
)
return docs
def _relpath_from_roots(md, output_root, staging_root) -> str:
"""Liefert einen verstaendlichen relativen Pfad: 'output/...' oder 'staging/...'.
staging zuerst pruefen, damit ein verschachteltes Layout (staging unter output,
z.B. in Tests) korrekt als 'staging/...' erkannt wird.
Faellt zurueck auf den absoluten String, falls die Datei in keinem der Roots liegt."""
try:
return "staging/" + str(md.relative_to(staging_root))
except ValueError:
pass
try:
return "output/" + str(md.relative_to(output_root))
except ValueError:
return str(md)
@@ -0,0 +1,82 @@
"""Erkennt zu einer URL die passende Verarbeitungs-Strategie (frueh, beim Issue).
So sieht man schon beim Anlegen, ob eine URL in unsere bekannten Strategien passt
oder ob eine NEUE Strategie gebaut werden muss. Funktioniert pro URL (ein Tool kann
mehrere URLs mit je eigener Strategie haben).
"""
from __future__ import annotations
import re
KNOWN = {"confluence_page", "confluence_tree", "confluence_faq", "crawler", "sitemap", "pdf", "gitlab_md", "file"}
def detect_strategy(url: str) -> str | None:
"""Heuristik anhand der URL. None => unbekannt -> neue Strategie noetig."""
u = url.strip().lower()
if not u.startswith(("http://", "https://")):
# anderes Schema (ftp://, sharepoint://, ...) -> unbekannt
if "://" in u:
return None
# repo-relativer Pfad -> Datei(en) aus dem Repo
return "file" if u else None
if u.endswith(".pdf"):
return "pdf"
if "sitemap" in u and u.endswith((".xml", ".xml.gz")):
return "sitemap"
# GitLab-Projekt -> Markdown-Dateien
host = u.split("/")[2] if "//" in u else ""
if host.startswith("git.") or "gitlab" in host:
return "gitlab_md"
# DB-Confluence (jaas) bzw. generische Confluence-Seiten
if "arija-confluence" in u or re.search(r"/spaces/[^/]+/pages/\d+", u) or "/wiki/" in u:
return "confluence_tree"
# bekannte oeffentliche Webquelle -> Crawler (Detailseiten/Index)
if u.startswith(("http://", "https://")):
return "crawler"
return None
def validate_urls(urls: list[str]) -> list[dict]:
"""Liefert pro URL die erkannte Strategie + ob sie bekannt ist."""
out = []
for url in urls:
strat = detect_strategy(url)
out.append({
"url": url,
"strategy": strat or "UNBEKANNT",
"known": strat is not None,
"note": "" if strat else "Keine passende Strategie -> neue Strategie pruefen/bauen",
})
return out
def sniff_strategy(url: str, timeout: int = 10) -> str | None:
"""Verfeinert die Erkennung per Netzwerk (HEAD/GET).
- Content-Type application/pdf -> 'pdf'
- HTML-Seite mit >=3 PDF-Links -> 'pdf' (PDF-Sammelseite, z.B. INB/Regelwerk)
- sonst Fallback auf detect_strategy().
Netzwerk-Fehler -> Fallback auf detect_strategy().
"""
base = detect_strategy(url)
try:
import requests
except ImportError:
return base
ua = {"User-Agent": "wissensdatenbank/0.1"}
try:
h = requests.head(url, allow_redirects=True, timeout=timeout, headers=ua)
ct = h.headers.get("Content-Type", "").lower()
if "application/pdf" in ct:
return "pdf"
if "text/html" in ct or not ct:
from bs4 import BeautifulSoup
r = requests.get(url, timeout=timeout, headers=ua)
soup = BeautifulSoup(r.text, "html.parser")
pdfs = [a for a in soup.find_all("a", href=True) if ".pdf" in a["href"].lower()]
if len(pdfs) >= 3:
return "pdf"
except Exception:
pass
return base
@@ -0,0 +1,257 @@
"""Heading-basiertes Chunking (deterministisch, ohne Embeddings).
Erzeugt aus einem Voll-Dokument (Markdown) eine Liste von Abschnitts-Chunks entlang
der Markdown-Ueberschriften. Das Voll-Dokument bleibt unangetastet; Chunks sind ein
ZUSAETZLICHES Artefakt (output/chunks/...). Es werden bewusst nur deterministische
Verfahren genutzt (kein Embedding/LLM) - semantisches Chunking ist Sache des
anschliessenden Systems.
Contextual Retrieval (vereinfacht, deterministisch): jeder Chunk bekommt optional einen
kurzen Kontext-Vorspann ("Kontext: <Dokumenttitel> > <Abschnitt>"), damit ein isoliert
abgerufener Chunk weiss, wozu er gehoert.
"""
from __future__ import annotations
import hashlib
import json
import re
from dataclasses import replace
from ..model import Document
# Default-Chunk-Optionen (werden von config/chunking.yaml + Quelle ueberschrieben).
DEFAULTS = {
"chunk": "off",
"chunk_level": "auto",
"chunk_target_tokens": 500,
"chunk_max_tokens": 1800,
"chunk_min_chars": 200,
"chunk_overlap": 0,
"chunk_context_header": True,
"chunk_min_doc_chars": 0, # Dokumente kleiner als dies bleiben GANZ (0 = aus)
"chunk_kind": "", # nur Dokumente dieses kind chunken ("" = alle)
"chunk_component": "", # nur Dokumente dieses component_type chunken ("" = alle)
}
# Bekannte Chunk-Modi (alles andere wird als "headings" behandelt, ausser off).
KNOWN_MODES = ("headings", "faq", "recursive")
# Version der Chunking-LOGIK. Erhoehen, wenn sich der Algorithmus so aendert, dass
# vorhandene Chunks neu erzeugt werden sollen (fliesst in chunk_options_fingerprint
# ein -> der naechste Lauf re-chunkt automatisch alle aktiven Quellen).
# 1: initiale Heading-Logik
# 2: Hard-Split ueberlanger Bloecke (bounded max_tokens), Modi faq/recursive
# 3: parent_hash = content_hash des Voll-Dokuments (sauberes Parent-Linking)
# 4: chunk_min_doc_chars + chunk_kind (kleine Docs ganz lassen, nur passende kinds chunken)
_CHUNKER_VERSION = 4
_HEAD_RE = re.compile(r"^(#{1,6})\s+(.*)$")
_ZIFFER_RE = re.compile(r"^\**\s*(\d+(?:\.\d+)+)")
def _is_off(value) -> bool:
"""True, wenn Chunking deaktiviert ist. Robust gegen YAML 'off' (-> False),
None, Leerstring und Schreibweisen; bewusst KEIN Match auf int 0 (0 == False)."""
if value is False or value is None:
return True
return isinstance(value, str) and value.strip().lower() in ("", "off", "false", "no")
def effective_opts(source_options: dict, defaults: dict | None = None) -> dict:
"""Defaults < config/chunking.yaml-Defaults < per-source options."""
out = dict(DEFAULTS)
if defaults:
out.update({k: v for k, v in defaults.items() if k in DEFAULTS})
for k in DEFAULTS:
if k in (source_options or {}):
out[k] = source_options[k]
return out
def chunk_options_fingerprint(opts: dict) -> str:
"""Stabiler Hash der wirksamen Chunk-Optionen.
Aendert sich die Strategie ODER eine groessen-/kontextrelevante Option, weicht der
Fingerprint vom in den vorhandenen Chunks gespeicherten ab -> nur die betroffenen
Dokumente werden neu gechunkt (kein Voll-Rebuild, kein taeglicher Git-Churn).
"""
payload = {k: opts.get(k, DEFAULTS[k]) for k in DEFAULTS if k != "chunk_component"}
# 'off'/False normalisieren, damit gleichbedeutende Werte denselben Hash ergeben
if _is_off(payload.get("chunk")):
payload["chunk"] = "off"
# chunk_component nur einfliessen lassen, wenn gesetzt -> bestehende Chunks ohne diese
# Option behalten ihren Fingerprint (kein einmaliger Voll-Rebuild/Churn).
cc = str(opts.get("chunk_component", "") or "").strip()
if cc:
payload["chunk_component"] = cc
payload["_v"] = _CHUNKER_VERSION # Logik-Version -> erzwingt Rebuild bei Algo-Aenderung
raw = json.dumps(payload, sort_keys=True, ensure_ascii=False)
return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:16]
def _tokens(text: str) -> int:
return max(1, len(text) // 4) # grobe Schaetzung: ~4 Zeichen je Token
def _clean_heading(raw: str) -> str:
return re.sub(r"[#*]", "", raw).strip()
def _extract_ziffer(title: str) -> str:
m = _ZIFFER_RE.match(title)
return m.group(1) if m else ""
def _detect_level(lines: list[str]) -> int:
levels = [len(m.group(1)) for ln in lines if (m := _HEAD_RE.match(ln))]
return min(levels) if levels else 2
def split_sections(md: str, level: str | int = "auto") -> list[dict]:
"""Teilt Markdown an Ueberschriften der gewaehlten Ebene in Abschnitte.
Rueckgabe: [{title, ziffer, text}] - text enthaelt die Ueberschrift.
"""
lines = md.splitlines()
lvl = _detect_level(lines) if level == "auto" else int(level)
sections: list[dict] = []
cur_title = ""
buf: list[str] = []
def flush():
text = "\n".join(buf).strip()
if text:
sections.append({"title": cur_title, "ziffer": _extract_ziffer(cur_title), "text": text})
for ln in lines:
m = _HEAD_RE.match(ln)
if m and len(m.group(1)) == lvl:
flush()
cur_title = _clean_heading(m.group(2))
buf = [ln]
else:
buf.append(ln)
flush()
return sections
def _merge_small(sections: list[dict], min_chars: int) -> list[dict]:
out: list[dict] = []
for s in sections:
if out and len(s["text"]) < min_chars:
out[-1]["text"] += "\n\n" + s["text"]
else:
out.append(dict(s))
return out
def _split_large(text: str, target_tokens: int, max_tokens: int) -> list[str]:
if _tokens(text) <= max_tokens:
return [text]
paras = re.split(r"\n\s*\n", text)
parts: list[str] = []
cur = ""
for p in paras:
if cur and _tokens(cur + "\n\n" + p) > target_tokens:
parts.append(cur.strip())
cur = p
else:
cur = (cur + "\n\n" + p) if cur else p
if cur.strip():
parts.append(cur.strip())
# Robustheit: einzelne ueberlange Bloecke (z.B. eine Zeile ohne Absatztrenner,
# etwa OCR-„picture text" oder eine riesige Tabelle) lassen sich nicht an Absaetzen
# teilen. Als letzte Stufe hart auf das Token-Budget begrenzen, damit KEIN Chunk
# max_tokens unkontrolliert sprengt.
max_chars = max(1, max_tokens) * 4
bounded: list[str] = []
for part in (parts or [text]):
if _tokens(part) > max_tokens:
bounded.extend(part[i:i + max_chars] for i in range(0, len(part), max_chars))
else:
bounded.append(part)
return bounded
def chunk_document(doc: Document, opts: dict, parent_hash: str | None = None) -> list[Document]:
"""Erzeugt Chunk-Documents aus einem Voll-Dokument. Leere Liste, wenn chunk=off.
parent_hash: content_hash des Voll-Dokuments. Wird i.d.R. von aussen aus dem
Frontmatter der Quelldatei uebergeben, damit der Chunk exakt auf die gespeicherte
Version verweist. Fallback: aus dem (rekonstruierten) Body berechnet.
Modi:
- headings (Default fuer alles Aktive): Split an Markdown-Ueberschriften.
- faq: ein Chunk je Frage/Antwort (Split an Ebene 3, ###).
- recursive: kein Heading-Split, nur groessenbasiert (Absatz -> Zielband).
"""
mode = (opts or {}).get("chunk", "off")
if _is_off(mode):
return []
# Nur Dokumente eines bestimmten kind chunken (z.B. nur Anhang-PDFs).
want_kind = str(opts.get("chunk_kind", "") or "").strip()
if want_kind and (doc.kind or "document") != want_kind:
return []
# Nur Dokumente eines bestimmten component_type chunken (z.B. nur FAQ-Seiten).
want_comp = str(opts.get("chunk_component", "") or "").strip()
if want_comp:
comp = getattr(doc, "component_type", "")
comp = comp.value if hasattr(comp, "value") else str(comp)
if comp != want_comp:
return []
# Kleine Dokumente bleiben ganz (Schwelle = mindestens N Zeichen Body).
min_doc_chars = int(opts.get("chunk_min_doc_chars", 0) or 0)
if min_doc_chars > 0 and len(doc.body_md or "") < min_doc_chars:
return []
target = int(opts.get("chunk_target_tokens", 500))
mx = int(opts.get("chunk_max_tokens", 1800))
min_chars = int(opts.get("chunk_min_chars", 200))
ctx = bool(opts.get("chunk_context_header", True))
fp = chunk_options_fingerprint(opts)
if mode == "recursive":
sections = [{"title": "", "ziffer": "", "text": doc.body_md.strip()}]
elif mode == "faq":
sections = split_sections(doc.body_md, 3)
sections = _merge_small(sections, min_chars)
else: # headings (Default fuer jeden aktiven, nicht weiter bekannten Wert)
sections = split_sections(doc.body_md, opts.get("chunk_level", "auto"))
sections = _merge_small(sections, min_chars)
parent_hash = parent_hash or doc.content_hash
overlap_tokens = int(opts.get("chunk_overlap", 0) or 0)
# 1) Roh-Teile in Reihenfolge sammeln (Abschnitt + Text).
emitted: list[tuple[dict, str]] = []
for s in sections:
for part_text in _split_large(s["text"], target, mx):
emitted.append((s, part_text))
# 2) Zu Chunk-Documents wrappen; optional Overlap (Tail des Vorgaengers) voranstellen.
chunks: list[Document] = []
for idx, (s, part_text) in enumerate(emitted):
body = part_text
if overlap_tokens > 0 and idx > 0:
tail = emitted[idx - 1][1][-(overlap_tokens * 4):].lstrip()
if tail:
body = f"{tail}\n\n{part_text}"
if ctx:
header = f"> Kontext: {doc.title}"
if s["title"]:
header += f" > {s['title']}"
body = header + "\n\n" + body
chunks.append(replace(
doc,
body_md=body,
kind="chunk",
parent_url=doc.url,
parent_hash=parent_hash,
section=s["title"].replace('"', "'"),
ziffer=s["ziffer"],
chunk_index=idx,
chunk_fingerprint=fp,
part=s["ziffer"] or s["title"][:60] or f"chunk-{idx}",
))
return chunks
@@ -0,0 +1,56 @@
"""Sanitization + Scope-Enforcement + Review-Status setzen.
Sicherheitsnetz: interne/vertrauliche Inhalte duerfen nie in extern-
faehiges Wissen gelangen. Alles was nicht approved ist, wird PENDING
(mit Grund) es gibt kein separates 'rejected' mehr.
"""
from __future__ import annotations
import re
from ..model import Document, ReviewStatus
def filter_document(doc: Document, rules: dict, trusted: bool = False,
approvals: dict | None = None, redact: bool = True) -> Document:
notes: list[str] = []
# 1) Regex-Redaction (z.B. IP-Adressen, E-Mail-Adressen) - per Quelle abschaltbar
if redact:
for pattern in rules.get("regex_redact", []):
try:
if re.search(pattern, doc.body_md):
doc.body_md = re.sub(pattern, "[REDACTED]", doc.body_md)
notes.append(f"redigiert: {pattern}")
except re.error as e:
notes.append(f"ungueltiges Redaction-Pattern uebersprungen: {pattern} ({e})")
# 2) Blacklist-Keywords (z.B. 'Vertraulich', 'Nur fuer internen Gebrauch')
body_lower = doc.body_md.lower()
hits = [kw for kw in rules.get("blacklist_keywords", []) if kw.lower() in body_lower]
if hits:
notes.append("Blacklist-Treffer: " + ", ".join(hits))
if trusted:
doc.review_status = ReviewStatus.APPROVED
notes.append("Quelle als vertrauenswuerdig markiert -> freigegeben")
else:
doc.review_status = ReviewStatus.PENDING
if doc.scope.visible_extern:
notes.append("Scope-Warnung: vertraulicher Inhalt in extern/allgemein")
else:
doc.review_status = ReviewStatus.APPROVED
# 3) Leere/zu kurze Dokumente -> pending
if len(doc.body_md.strip()) < 50:
doc.review_status = ReviewStatus.PENDING
notes.append("Inhalt zu kurz / leer")
# 4) Manuelle Freigabe per Allowlist (ueberschreibt pending)
if approvals and doc.review_status != ReviewStatus.APPROVED:
if doc.url in approvals.get("urls", set()) or doc.content_hash in approvals.get("hashes", set()):
doc.review_status = ReviewStatus.APPROVED
notes.append("manuell freigegeben (approvals.yaml)")
doc.review_notes = notes
return doc
@@ -0,0 +1,81 @@
"""Wandelt Confluence-Storage-HTML in sauberes Markdown.
Entfernt Confluence-Makro-Metadaten (ac:/ri:-Namespaces, Makro-IDs,
ac:parameter), die sonst als Muell im Text landen (z.B. UUID-artige IDs).
Nutzbare Makro-Inhalte (expand/panel/info/code) bleiben erhalten.
Bilder (Variante A): kein Verwerfen mehr, sondern der alt-Text/Dateiname bleibt als
`[Bild: ...]` im Markdown - gibt dem RAG Kontext ohne Binaerdaten.
Datei-Embeds (view-file/viewpdf): werden zu `[Anhang: <datei>]` - der eigentliche
Inhalt wird vom Confluence-Connector als eigenes Dokument geparst (PDF).
"""
from __future__ import annotations
# Makros, deren sichtbarer Rich-Text-Inhalt erhalten bleiben soll
_KEEP_BODY = {"expand", "panel", "info", "note", "warning", "tip", "section", "column"}
# Makros, die komplett entfernt werden (reine Navigation/Layout-Metadaten)
_DROP = {"toc", "children", "pagetree", "anchor"}
# Datei-Embed-Makros -> Marker (Inhalt kommt als eigenes Dokument)
_FILE_MACROS = {"view-file", "viewpdf", "viewdoc", "viewxls", "viewppt"}
def _attachment_name(tag) -> str:
"""Liest den Datei-/Bildnamen aus einem ri:attachment bzw. ri:url unterhalb von tag."""
ra = tag.find("ri:attachment")
if ra and ra.get("ri:filename"):
return ra.get("ri:filename")
ru = tag.find("ri:url")
if ru and ru.get("ri:value"):
return ru.get("ri:value")
return ""
def storage_to_markdown(html: str) -> str:
import html2text
from bs4 import BeautifulSoup
soup = BeautifulSoup(html, "html.parser")
for macro in soup.find_all("ac:structured-macro"):
name = (macro.get("ac:name") or "").lower()
if name in _DROP:
macro.decompose()
continue
if name in _FILE_MACROS:
fn = _attachment_name(macro)
macro.replace_with(soup.new_string(f"[Anhang: {fn}]" if fn else "[Anhang]"))
continue
if name == "code":
body = macro.find("ac:plain-text-body")
pre = soup.new_tag("pre")
pre.string = body.get_text() if body else ""
macro.replace_with(pre)
continue
# Default + _KEEP_BODY: nur den sichtbaren Rich-Text-Inhalt behalten
body = macro.find("ac:rich-text-body")
if body is not None:
body.name = "div"
macro.replace_with(body)
else:
macro.decompose()
# Bilder (Variante A): alt-Text/Dateiname behalten statt verwerfen
for img in soup.find_all("ac:image"):
fn = _attachment_name(img)
img.replace_with(soup.new_string(f"[Bild: {fn}]" if fn else "[Bild]"))
for img in soup.find_all("img"):
alt = (img.get("alt") or "").strip()
img.replace_with(soup.new_string(f"[Bild: {alt}]" if alt else "[Bild]"))
# Makro-Parameter (enthalten IDs/Config) und Ressourcen-Verweise entfernen
for tag in soup.find_all(["ac:parameter", "ri:attachment", "ri:url", "ri:page"]):
tag.decompose()
# uebrige Namespace-Tags (ac:*, ri:*) aufloesen, Textinhalt behalten
for tag in list(soup.find_all(lambda t: t.name and ":" in t.name)):
tag.unwrap()
h = html2text.HTML2Text()
h.body_width = 0
h.ignore_images = True
return h.handle(str(soup)).strip()
@@ -0,0 +1,26 @@
"""Vergibt/normalisiert Tags an Dokumenten."""
from __future__ import annotations
from ..model import Document
def compute_tags(domain: str, tool: str, scope: str, extra: list[str]) -> list[str]:
"""Baut die normalisierte Tag-Liste (domain:/tool:/scope: + Quell-Tags).
Zentrale Stelle, damit Erst-Tagging (apply_tags) und das inkrementelle
Re-Tagging bestehender Dateien identische Tags erzeugen.
"""
tags = [f"domain:{domain}", f"tool:{tool}", f"scope:{scope}", *extra]
seen: set[str] = set()
norm: list[str] = []
for t in tags:
key = t.strip().lower()
if key and key not in seen:
seen.add(key)
norm.append(key)
return norm
def apply_tags(doc: Document) -> Document:
doc.tags = compute_tags(doc.domain, doc.tool, doc.scope.value, doc.tags)
return doc