159 lines
6.8 KiB
Python
159 lines
6.8 KiB
Python
"""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()
|