feat: implement knowledge management system (spec complete, all 53 tasks done)

This commit is contained in:
2026-07-01 14:14:16 +02:00
parent e4256d3222
commit 8ac664d33d
89 changed files with 19759 additions and 72 deletions
@@ -0,0 +1,456 @@
"""Unit-Tests für die WissensdatenbankSource.
Testet den Adapter für bahn/wissensdatenbank/output/ Read-Only-Referenzen
im bahn/knowledge/ YAML-Index ohne Modifikation der Originaldateien.
Requirements: 13.1, 13.2, 13.3, 13.4, 13.5
"""
from __future__ import annotations
from datetime import date
from pathlib import Path
import pytest
from monorepo.knowledge.sources.base import ExtractionResult, SourceConfig
from monorepo.knowledge.sources.wissensdatenbank import (
WissensdatenbankSource,
_compute_file_hash,
)
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def wdb_dir(tmp_path: Path) -> Path:
"""Erstellt ein temporäres Wissensdatenbank-Output-Verzeichnis mit Testdaten."""
output = tmp_path / "wissensdatenbank" / "output"
output.mkdir(parents=True)
# Einige Markdown-Dateien erstellen
(output / "inb-2026-kapitel-1.md").write_text(
"# INB 2026 Kapitel 1\n\nInhalt des ersten Kapitels.",
encoding="utf-8",
)
(output / "inb-2026-kapitel-2.md").write_text(
"# INB 2026 Kapitel 2\n\nInhalt des zweiten Kapitels.",
encoding="utf-8",
)
# Unterverzeichnis mit weiterer Datei
sub = output / "themen"
sub.mkdir()
(sub / "signaltechnik.md").write_text(
"# Signaltechnik\n\nBeschreibung der Signaltechnik.",
encoding="utf-8",
)
# Eine .txt-Datei
(output / "notizen.txt").write_text(
"Allgemeine Notizen zur Wissensdatenbank.",
encoding="utf-8",
)
return output
@pytest.fixture
def source_config(wdb_dir: Path) -> SourceConfig:
"""Erstellt eine SourceConfig für die Wissensdatenbank."""
return SourceConfig(
type="wissensdatenbank",
name="Wissensdatenbank",
params={"output_path": str(wdb_dir)},
target_folder="references",
sync_frequency="manual",
enabled=True,
)
@pytest.fixture
def source() -> WissensdatenbankSource:
"""Erstellt eine WissensdatenbankSource-Instanz."""
return WissensdatenbankSource()
# ---------------------------------------------------------------------------
# Tests: Grundlegende Extraktion (Req 13.1)
# ---------------------------------------------------------------------------
class TestBasicExtraction:
"""Tests für die grundlegende Extraktion von Wissensdatenbank-Artefakten."""
def test_extracts_all_markdown_files(
self, source: WissensdatenbankSource, source_config: SourceConfig
) -> None:
"""Alle .md- und .txt-Dateien im Output-Verzeichnis werden extrahiert."""
result = source.extract(source_config, "bahn")
assert isinstance(result, ExtractionResult)
assert len(result.artifacts) == 4 # 2 .md + 1 subdir .md + 1 .txt
assert result.skipped == 0
assert len(result.errors) == 0
def test_extracts_recursively(
self, source: WissensdatenbankSource, source_config: SourceConfig
) -> None:
"""Dateien in Unterverzeichnissen werden ebenfalls extrahiert."""
result = source.extract(source_config, "bahn")
file_paths = [str(a.file_path) for a in result.artifacts]
# signaltechnik.md aus themen/ sollte enthalten sein
assert any("signaltechnik" in p for p in file_paths)
def test_ignores_non_supported_extensions(
self,
source: WissensdatenbankSource,
source_config: SourceConfig,
wdb_dir: Path,
) -> None:
"""Dateien mit nicht-unterstützten Endungen werden ignoriert."""
(wdb_dir / "bild.png").write_bytes(b"\x89PNG...")
(wdb_dir / "dokument.pdf").write_bytes(b"%PDF-1.4")
result = source.extract(source_config, "bahn")
# Nur .md und .txt werden verarbeitet
assert len(result.artifacts) == 4
# ---------------------------------------------------------------------------
# Tests: Read-Only-Referenz-Metadaten (Req 13.3)
# ---------------------------------------------------------------------------
class TestReadOnlyMetadata:
"""Tests für korrekte source-Metadaten der Wissensdatenbank-Artefakte."""
def test_source_type_is_wissensdatenbank(
self, source: WissensdatenbankSource, source_config: SourceConfig
) -> None:
"""Artefakte haben source.type == 'wissensdatenbank'."""
result = source.extract(source_config, "bahn")
for artifact in result.artifacts:
assert artifact.metadata.source["type"] == "wissensdatenbank"
def test_source_path_references_original(
self,
source: WissensdatenbankSource,
source_config: SourceConfig,
wdb_dir: Path,
) -> None:
"""Artefakte enthalten den Originalpfad in source.path."""
result = source.extract(source_config, "bahn")
for artifact in result.artifacts:
assert "path" in artifact.metadata.source
# Der Pfad sollte auf eine existierende Datei zeigen
original_path = Path(artifact.metadata.source["path"])
assert original_path.exists()
def test_source_context_is_bahn(
self, source: WissensdatenbankSource, source_config: SourceConfig
) -> None:
"""Artefakte haben source_context == 'bahn' (übergebener Kontext)."""
result = source.extract(source_config, "bahn")
for artifact in result.artifacts:
assert artifact.metadata.source_context == "bahn"
def test_artifact_type_is_reference(
self, source: WissensdatenbankSource, source_config: SourceConfig
) -> None:
"""Artefakte sind vom Typ 'reference'."""
result = source.extract(source_config, "bahn")
for artifact in result.artifacts:
assert artifact.metadata.type == "reference"
def test_category_is_references(
self, source: WissensdatenbankSource, source_config: SourceConfig
) -> None:
"""Artefakte haben category == 'references'."""
result = source.extract(source_config, "bahn")
for artifact in result.artifacts:
assert artifact.metadata.category == "references"
def test_content_hash_is_set(
self, source: WissensdatenbankSource, source_config: SourceConfig
) -> None:
"""Artefakte haben einen nicht-leeren content_hash."""
result = source.extract(source_config, "bahn")
for artifact in result.artifacts:
assert artifact.metadata.content_hash.startswith("sha256:")
def test_title_derived_from_filename(
self, source: WissensdatenbankSource, source_config: SourceConfig
) -> None:
"""Titel wird aus dem Dateinamen abgeleitet."""
result = source.extract(source_config, "bahn")
titles = [a.metadata.title for a in result.artifacts]
assert "Inb 2026 Kapitel 1" in titles
assert "Signaltechnik" in titles
# ---------------------------------------------------------------------------
# Tests: Originaldateien unverändert (Req 13.2, 13.4)
# ---------------------------------------------------------------------------
class TestReadOnlyBehavior:
"""Tests, dass die Originaldateien nicht modifiziert werden."""
def test_original_files_unchanged(
self,
source: WissensdatenbankSource,
source_config: SourceConfig,
wdb_dir: Path,
) -> None:
"""Originaldateien werden nicht verändert gleicher Hash vor/nach Extraktion."""
# Hashes vor Extraktion berechnen
files = list(wdb_dir.rglob("*"))
hashes_before = {
str(f): _compute_file_hash(f) for f in files if f.is_file()
}
# Extraktion ausführen
source.extract(source_config, "bahn")
# Hashes nach Extraktion prüfen
hashes_after = {
str(f): _compute_file_hash(f) for f in files if f.is_file()
}
assert hashes_before == hashes_after
def test_no_new_files_created_in_source_dir(
self,
source: WissensdatenbankSource,
source_config: SourceConfig,
wdb_dir: Path,
) -> None:
"""Keine neuen Dateien werden im Quellverzeichnis erstellt."""
files_before = set(str(f) for f in wdb_dir.rglob("*") if f.is_file())
source.extract(source_config, "bahn")
files_after = set(str(f) for f in wdb_dir.rglob("*") if f.is_file())
assert files_before == files_after
def test_artifact_file_path_points_to_original(
self, source: WissensdatenbankSource, source_config: SourceConfig
) -> None:
"""Artifact.file_path zeigt auf die Originaldatei (Read-Only-Referenz)."""
result = source.extract(source_config, "bahn")
for artifact in result.artifacts:
# file_path zeigt auf die originale Datei
assert artifact.file_path.exists()
assert artifact.file_path.suffix.lower() in {".md", ".txt"}
# ---------------------------------------------------------------------------
# Tests: Inkrementelle Verarbeitung (Req 13.2)
# ---------------------------------------------------------------------------
class TestIncrementalProcessing:
"""Tests für inkrementelle Verarbeitung via Content-Hash."""
def test_supports_incremental(
self, source: WissensdatenbankSource
) -> None:
"""WissensdatenbankSource unterstützt inkrementelle Verarbeitung."""
assert source.supports_incremental() is True
def test_skips_unchanged_files(
self,
source: WissensdatenbankSource,
source_config: SourceConfig,
wdb_dir: Path,
) -> None:
"""Dateien mit bekanntem Hash werden übersprungen."""
# Erste Extraktion alles neu
result1 = source.extract(source_config, "bahn")
assert result1.skipped == 0
assert len(result1.artifacts) == 4
# Hashes der extrahierten Dateien merken
known = {}
for f in wdb_dir.rglob("*"):
if f.is_file() and f.suffix.lower() in {".md", ".txt"}:
known[str(f)] = _compute_file_hash(f)
source.set_known_hashes(known)
# Zweite Extraktion alles unverändert → alles geskippt
result2 = source.extract(source_config, "bahn")
assert result2.skipped == 4
assert len(result2.artifacts) == 0
def test_processes_changed_files(
self,
source: WissensdatenbankSource,
source_config: SourceConfig,
wdb_dir: Path,
) -> None:
"""Geänderte Dateien werden erneut verarbeitet."""
# Erste Extraktion
source.extract(source_config, "bahn")
# Hashes merken
known = {}
for f in wdb_dir.rglob("*"):
if f.is_file() and f.suffix.lower() in {".md", ".txt"}:
known[str(f)] = _compute_file_hash(f)
source.set_known_hashes(known)
# Eine Datei ändern
changed_file = wdb_dir / "inb-2026-kapitel-1.md"
changed_file.write_text(
"# INB 2026 Kapitel 1 (aktualisiert)\n\nNeuer Inhalt.",
encoding="utf-8",
)
# Zweite Extraktion nur die geänderte Datei wird verarbeitet
result = source.extract(source_config, "bahn")
assert len(result.artifacts) == 1
assert result.skipped == 3
assert "inb-2026-kapitel-1" in str(result.artifacts[0].file_path)
# ---------------------------------------------------------------------------
# Tests: Fehlerbehandlung
# ---------------------------------------------------------------------------
class TestErrorHandling:
"""Tests für die Fehlerbehandlung."""
def test_missing_output_path_param(
self, source: WissensdatenbankSource
) -> None:
"""Fehlende output_path-Konfiguration erzeugt einen Fehler."""
config = SourceConfig(
type="wissensdatenbank",
name="Test",
params={},
)
result = source.extract(config, "bahn")
assert len(result.errors) == 1
assert "output_path" in result.errors[0].message
def test_nonexistent_directory(
self, source: WissensdatenbankSource, tmp_path: Path
) -> None:
"""Nicht-existierendes Verzeichnis erzeugt einen Fehler."""
config = SourceConfig(
type="wissensdatenbank",
name="Test",
params={"output_path": str(tmp_path / "nonexistent")},
)
result = source.extract(config, "bahn")
assert len(result.errors) == 1
assert result.errors[0].error_type == "connection"
assert result.errors[0].retry is True
def test_path_is_file_not_directory(
self, source: WissensdatenbankSource, tmp_path: Path
) -> None:
"""Pfad zu einer Datei (statt Verzeichnis) erzeugt einen Fehler."""
file = tmp_path / "file.md"
file.write_text("content", encoding="utf-8")
config = SourceConfig(
type="wissensdatenbank",
name="Test",
params={"output_path": str(file)},
)
result = source.extract(config, "bahn")
assert len(result.errors) == 1
assert result.errors[0].error_type == "parse"
assert result.errors[0].retry is False
def test_empty_directory(
self, source: WissensdatenbankSource, tmp_path: Path
) -> None:
"""Leeres Verzeichnis erzeugt leeres Ergebnis ohne Fehler."""
empty_dir = tmp_path / "empty"
empty_dir.mkdir()
config = SourceConfig(
type="wissensdatenbank",
name="Test",
params={"output_path": str(empty_dir)},
)
result = source.extract(config, "bahn")
assert len(result.artifacts) == 0
assert len(result.errors) == 0
assert result.skipped == 0
# ---------------------------------------------------------------------------
# Tests: Kein OKF-Reformat (Req 13.4)
# ---------------------------------------------------------------------------
class TestNoOkfReformat:
"""Tests, dass kein Google-OKF-Reformat erzwungen wird."""
def test_content_preserved_exactly(
self,
source: WissensdatenbankSource,
source_config: SourceConfig,
wdb_dir: Path,
) -> None:
"""Dateiinhalte werden unverändert als Content übernommen."""
result = source.extract(source_config, "bahn")
# Finde das Artefakt für kapitel-1
kapitel1 = next(
a for a in result.artifacts if "kapitel-1" in str(a.file_path)
)
expected_content = "# INB 2026 Kapitel 1\n\nInhalt des ersten Kapitels."
assert kapitel1.content == expected_content
def test_no_frontmatter_injection_in_original(
self,
source: WissensdatenbankSource,
source_config: SourceConfig,
wdb_dir: Path,
) -> None:
"""Originaldateien erhalten KEIN YAML-Frontmatter."""
source.extract(source_config, "bahn")
# Originaldatei prüfen darf kein '---' am Anfang haben
original = wdb_dir / "inb-2026-kapitel-1.md"
content = original.read_text(encoding="utf-8")
assert not content.startswith("---")
# ---------------------------------------------------------------------------
# Tests: Import von __init__.py
# ---------------------------------------------------------------------------
class TestModuleExport:
"""Tests, dass WissensdatenbankSource aus dem Modul exportiert wird."""
def test_importable_from_sources_package(self) -> None:
"""WissensdatenbankSource ist über das sources-Package importierbar."""
from monorepo.knowledge.sources import WissensdatenbankSource as WDB
assert WDB is WissensdatenbankSource