Merge commit 'cfaf67010017eab368216aded483a64126dbcb2e' as 'bahn/wissensdatenbank'
This commit is contained in:
@@ -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
|
||||
]
|
||||
@@ -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()
|
||||
@@ -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]
|
||||
@@ -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"]
|
||||
@@ -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
@@ -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
|
||||
Reference in New Issue
Block a user