Initial monorepo structure

This commit is contained in:
2026-06-30 20:37:40 +02:00
commit 2f2b295531
121 changed files with 39171 additions and 0 deletions
@@ -0,0 +1,556 @@
"""Tests für Volltextsuche und Scope-basierte Filterung im KnowledgeStore.
Validiert Task 6.4:
- SearchResult Dataclass mit entry, relevance_score, partial_results
- Relevanz-Scoring: title > tags > summary > id
- Sortierung nach Relevanz (höchster Score zuerst)
- Scope-Filterung: Nur Ergebnisse aus autorisierten Scopes
- Keine Offenlegung nicht-autorisierter Artefakte
- Timeout-Handling: Abbruch nach 5 Sekunden mit Teilergebnis-Warnung
Requirements: 3.3, 3.7, 3.9
"""
from __future__ import annotations
import time
from pathlib import Path
from unittest.mock import patch
import pytest
from monorepo.knowledge.index import IndexEntry, YAMLIndex
from monorepo.knowledge.store import (
SEARCH_TIMEOUT_SECONDS,
KnowledgeStore,
SearchResult,
_compute_relevance,
)
from monorepo.models import ScopeConfig
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def scope_config() -> ScopeConfig:
"""Beispiel-ScopeConfig."""
return ScopeConfig(scopes={
"privat": {"scope": "privat", "paths": ["privat/"]},
"dhive": {"scope": "dhive", "paths": ["dhive/"]},
"bahn": {"scope": "bahn", "paths": ["bahn/"]},
"shared": {"scope": "shared", "paths": ["shared/"]},
})
@pytest.fixture
def sample_entries() -> list[IndexEntry]:
"""Vielfältige Einträge zum Testen der Relevanz-Sortierung."""
return [
IndexEntry(
id="bahn/decisions/api-design",
title="API-Designprinzipien für Microservices",
type="decision",
tags=["api", "microservices", "architecture"],
scope="bahn",
summary="REST-API-Konventionen für DB-InfraGO-Dienste",
path="bahn/decisions/api-design.md",
content_hash="sha256:abc123",
links=[],
),
IndexEntry(
id="privat/notes/rest-patterns",
title="REST-Patterns Notizen",
type="note",
tags=["api", "rest", "patterns"],
scope="privat",
summary="Sammlung bewährter REST-Patterns",
path="privat/notes/rest-patterns.md",
content_hash="sha256:def456",
links=[],
),
IndexEntry(
id="dhive/reference/cloud-native",
title="Cloud-Native Architektur Guide",
type="reference",
tags=["cloud", "kubernetes", "architecture"],
scope="dhive",
summary="Leitfaden für cloud-native Anwendungen bei dhive",
path="dhive/reference/cloud-native.md",
content_hash="sha256:ghi789",
links=[],
),
IndexEntry(
id="shared/patterns/api-versioning",
title="API Versioning",
type="pattern",
tags=["api", "versioning", "best-practices"],
scope="shared",
summary="Best Practices für API-Versionierung",
path="shared/patterns/api-versioning.md",
content_hash="sha256:jkl012",
links=[],
),
IndexEntry(
id="bahn/notes/kubernetes-deployment",
title="Kubernetes Deployment Notizen",
type="note",
tags=["kubernetes", "deployment", "infrastructure"],
scope="bahn",
summary="Notizen zum Kubernetes-Deployment bei DB-InfraGO",
path="bahn/notes/kubernetes-deployment.md",
content_hash="sha256:mno345",
links=[],
),
]
@pytest.fixture
def store_with_entries(
tmp_path: Path, scope_config: ScopeConfig, sample_entries: list[IndexEntry]
) -> KnowledgeStore:
"""KnowledgeStore mit vorgeladenen Beispiel-Einträgen."""
store = KnowledgeStore(tmp_path, scope_config)
for entry in sample_entries:
store.index.update_entry(entry)
return store
# ---------------------------------------------------------------------------
# SearchResult Dataclass Tests
# ---------------------------------------------------------------------------
class TestSearchResult:
"""Tests für die SearchResult Dataclass."""
def test_default_values(self) -> None:
"""SearchResult hat korrekte Standardwerte."""
entry = IndexEntry(id="test", title="Test", type="note", scope="privat")
result = SearchResult(entry=entry)
assert result.entry is entry
assert result.relevance_score == 0.0
assert result.partial_results is False
def test_custom_values(self) -> None:
"""SearchResult akzeptiert benutzerdefinierte Werte."""
entry = IndexEntry(id="test", title="Test", type="note", scope="privat")
result = SearchResult(entry=entry, relevance_score=0.85, partial_results=True)
assert result.relevance_score == 0.85
assert result.partial_results is True
def test_relevance_score_range(self) -> None:
"""relevance_score liegt zwischen 0.0 und 1.0."""
entry = IndexEntry(id="test", title="Test", type="note", scope="privat")
# Grenzwerte
result_zero = SearchResult(entry=entry, relevance_score=0.0)
result_one = SearchResult(entry=entry, relevance_score=1.0)
assert result_zero.relevance_score == 0.0
assert result_one.relevance_score == 1.0
# ---------------------------------------------------------------------------
# Relevanz-Scoring Tests
# ---------------------------------------------------------------------------
class TestRelevanceScoring:
"""Tests für die Relevanz-Score-Berechnung."""
def test_title_match_highest_score(self) -> None:
"""Title-Match ergibt höchsten Score (>= 0.8)."""
entry = IndexEntry(
id="test/doc",
title="API Design",
type="note",
tags=["documentation"],
scope="bahn",
summary="Allgemeine Informationen",
)
score = _compute_relevance("api design", entry)
assert score >= 0.8
def test_exact_title_match_maximum_score(self) -> None:
"""Exakter Title-Match ergibt Score 1.0."""
entry = IndexEntry(
id="test/doc",
title="API Design",
type="note",
tags=["misc"],
scope="bahn",
summary="Irgendwas",
)
score = _compute_relevance("api design", entry)
assert score == 1.0
def test_tag_match_medium_score(self) -> None:
"""Tag-Match ergibt mittleren Score (0.6 - 0.79)."""
entry = IndexEntry(
id="test/doc",
title="Allgemeines Dokument",
type="note",
tags=["kubernetes", "deployment"],
scope="bahn",
summary="Nicht relevanter Text",
)
score = _compute_relevance("kubernetes", entry)
assert 0.6 <= score <= 0.79
def test_summary_match_lower_score(self) -> None:
"""Summary-Match ergibt niedrigeren Score (0.4 - 0.59)."""
entry = IndexEntry(
id="test/doc",
title="Allgemeines Dokument",
type="note",
tags=["misc"],
scope="bahn",
summary="Informationen über Kubernetes-Cluster",
)
score = _compute_relevance("kubernetes", entry)
assert 0.4 <= score <= 0.59
def test_id_match_lowest_score(self) -> None:
"""ID-Match ergibt niedrigsten Score (0.2 - 0.39)."""
entry = IndexEntry(
id="bahn/notes/kubernetes-setup",
title="Allgemeines Dokument",
type="note",
tags=["misc"],
scope="bahn",
summary="Nicht relevanter Text",
)
score = _compute_relevance("kubernetes", entry)
assert 0.2 <= score <= 0.39
def test_no_match_zero_score(self) -> None:
"""Kein Match ergibt Score 0.0."""
entry = IndexEntry(
id="test/doc",
title="Allgemeines Dokument",
type="note",
tags=["misc"],
scope="bahn",
summary="Nicht relevanter Text",
)
score = _compute_relevance("zyxwv", entry)
assert score == 0.0
def test_title_beats_tags(self) -> None:
"""Title-Match hat Vorrang vor Tag-Match."""
entry_title = IndexEntry(
id="test/a", title="Kubernetes", type="note",
tags=["misc"], scope="bahn", summary="Anderes",
)
entry_tag = IndexEntry(
id="test/b", title="Anderes Dokument", type="note",
tags=["kubernetes"], scope="bahn", summary="Anderes",
)
score_title = _compute_relevance("kubernetes", entry_title)
score_tag = _compute_relevance("kubernetes", entry_tag)
assert score_title > score_tag
def test_tags_beat_summary(self) -> None:
"""Tag-Match hat Vorrang vor Summary-Match."""
entry_tag = IndexEntry(
id="test/a", title="Anderes", type="note",
tags=["kubernetes"], scope="bahn", summary="Anderer Text",
)
entry_summary = IndexEntry(
id="test/b", title="Anderes", type="note",
tags=["misc"], scope="bahn", summary="Kubernetes ist wichtig",
)
score_tag = _compute_relevance("kubernetes", entry_tag)
score_summary = _compute_relevance("kubernetes", entry_summary)
assert score_tag > score_summary
def test_summary_beats_id(self) -> None:
"""Summary-Match hat Vorrang vor ID-Match."""
entry_summary = IndexEntry(
id="test/other", title="Anderes", type="note",
tags=["misc"], scope="bahn", summary="Kubernetes Deployment Guide",
)
entry_id = IndexEntry(
id="bahn/kubernetes/setup", title="Anderes", type="note",
tags=["misc"], scope="bahn", summary="Anderer Text",
)
score_summary = _compute_relevance("kubernetes", entry_summary)
score_id = _compute_relevance("kubernetes", entry_id)
assert score_summary > score_id
def test_case_insensitive(self) -> None:
"""Relevanz-Berechnung ist case-insensitive bezüglich Entry-Feldern.
Die Funktion erwartet query_lower als bereits lowercase Input.
Die Entry-Felder werden intern lowercase verglichen.
"""
entry_upper = IndexEntry(
id="test/doc", title="API DESIGN", type="note",
tags=[], scope="bahn", summary="",
)
entry_lower = IndexEntry(
id="test/doc", title="api design", type="note",
tags=[], scope="bahn", summary="",
)
# Gleicher Query (lowercase) gegen verschiedene Entry-Schreibweisen
score_upper_entry = _compute_relevance("api design", entry_upper)
score_lower_entry = _compute_relevance("api design", entry_lower)
assert score_upper_entry == score_lower_entry
# ---------------------------------------------------------------------------
# KnowledgeStore.search() Tests
# ---------------------------------------------------------------------------
class TestKnowledgeStoreSearch:
"""Tests für KnowledgeStore.search() mit Relevanz-Sortierung."""
def test_returns_search_results(self, store_with_entries: KnowledgeStore) -> None:
"""search() liefert SearchResult-Objekte."""
results = store_with_entries.search("api", ["bahn", "privat", "shared"])
assert len(results) > 0
for r in results:
assert isinstance(r, SearchResult)
assert isinstance(r.entry, IndexEntry)
assert isinstance(r.relevance_score, float)
assert isinstance(r.partial_results, bool)
def test_sorted_by_relevance_descending(self, store_with_entries: KnowledgeStore) -> None:
"""Ergebnisse sind nach Relevanz absteigend sortiert."""
results = store_with_entries.search("api", ["bahn", "privat", "shared", "dhive"])
scores = [r.relevance_score for r in results]
assert scores == sorted(scores, reverse=True)
def test_title_match_ranked_higher(self, store_with_entries: KnowledgeStore) -> None:
"""Einträge mit Title-Match werden höher gerankt als Tag-Matches."""
results = store_with_entries.search(
"api versioning", ["bahn", "privat", "shared", "dhive"]
)
# "API Versioning" hat exakten Title-Match
if results:
assert results[0].entry.id == "shared/patterns/api-versioning"
def test_empty_query_returns_empty(self, store_with_entries: KnowledgeStore) -> None:
"""Leerer Query liefert leere Ergebnisliste."""
results = store_with_entries.search("", ["bahn", "privat", "shared", "dhive"])
assert results == []
def test_no_match_returns_empty(self, store_with_entries: KnowledgeStore) -> None:
"""Query ohne Treffer liefert leere Liste."""
results = store_with_entries.search("zyxwvutsrq", ["bahn", "privat", "shared", "dhive"])
assert results == []
def test_relevance_scores_within_range(self, store_with_entries: KnowledgeStore) -> None:
"""Alle Relevanz-Scores liegen zwischen 0.0 und 1.0."""
results = store_with_entries.search("api", ["bahn", "privat", "shared", "dhive"])
for r in results:
assert 0.0 <= r.relevance_score <= 1.0
# ---------------------------------------------------------------------------
# Scope-Filterung Tests
# ---------------------------------------------------------------------------
class TestSearchScopeFiltering:
"""Tests für Scope-basierte Filterung in der Suche."""
def test_only_authorized_scopes_returned(self, store_with_entries: KnowledgeStore) -> None:
"""Nur Ergebnisse aus autorisierten Scopes werden zurückgegeben."""
results = store_with_entries.search("api", ["bahn"])
for r in results:
assert r.entry.scope == "bahn"
def test_unauthorized_scope_not_in_results(
self, store_with_entries: KnowledgeStore
) -> None:
"""Artefakte aus nicht-autorisierten Scopes erscheinen nicht."""
# dhive-Scope nicht autorisiert, aber "cloud" existiert dort
results = store_with_entries.search("cloud", ["bahn", "privat"])
for r in results:
assert r.entry.scope != "dhive"
def test_existence_not_revealed(self, store_with_entries: KnowledgeStore) -> None:
"""Existenz nicht-autorisierter Artefakte wird nicht offengelegt.
Requirements: 3.9 unberechtigte Artefakte aus Suchergebnissen
ausschließen, ohne deren Existenz offenzulegen.
"""
# "cloud-native" existiert im dhive-Scope
results_restricted = store_with_entries.search("cloud-native", ["bahn", "privat"])
results_all = store_with_entries.search("cloud-native", ["bahn", "privat", "dhive"])
# Ohne dhive-Berechtigung: Kein Hinweis auf Existenz
assert len(results_restricted) == 0
# Mit dhive-Berechtigung: Ergebnis vorhanden
assert len(results_all) >= 1
def test_empty_scopes_returns_empty(self, store_with_entries: KnowledgeStore) -> None:
"""Leere Scope-Liste liefert keine Ergebnisse."""
results = store_with_entries.search("api", [])
assert results == []
def test_multiple_scopes(self, store_with_entries: KnowledgeStore) -> None:
"""Suche über mehrere Scopes liefert kombinierte Ergebnisse."""
results = store_with_entries.search("api", ["bahn", "shared"])
scopes_found = {r.entry.scope for r in results}
# Beide Scopes sollten vertreten sein
assert "bahn" in scopes_found or "shared" in scopes_found
# ---------------------------------------------------------------------------
# Timeout-Handling Tests
# ---------------------------------------------------------------------------
class TestSearchTimeout:
"""Tests für das Timeout-Handling der Suche."""
def test_default_timeout_is_5_seconds(self) -> None:
"""Standard-Timeout ist 5 Sekunden."""
assert SEARCH_TIMEOUT_SECONDS == 5.0
def test_no_timeout_under_normal_conditions(
self, store_with_entries: KnowledgeStore
) -> None:
"""Unter normalen Bedingungen kein Timeout (partial_results=False)."""
results = store_with_entries.search("api", ["bahn", "privat", "shared", "dhive"])
for r in results:
assert r.partial_results is False
def test_timeout_marks_partial_results(
self, tmp_path: Path, scope_config: ScopeConfig
) -> None:
"""Bei Timeout werden alle Ergebnisse als partial markiert."""
store = KnowledgeStore(tmp_path, scope_config)
# Viele Einträge erstellen
for i in range(100):
entry = IndexEntry(
id=f"bahn/test/entry-{i}",
title=f"Testartikel {i} über API",
type="note",
tags=["api", "test"],
scope="bahn",
summary=f"Zusammenfassung für Entry {i}",
path=f"bahn/test/entry-{i}.md",
content_hash=f"sha256:hash{i}",
)
store.index.update_entry(entry)
# Timeout auf 0 Sekunden simulieren
results = store.search("api", ["bahn"], timeout=0.0)
# Entweder keine Ergebnisse (sofort Timeout) oder alle partial
if results:
for r in results:
assert r.partial_results is True
def test_custom_timeout_parameter(
self, tmp_path: Path, scope_config: ScopeConfig
) -> None:
"""Benutzerdefinierter Timeout wird respektiert."""
store = KnowledgeStore(tmp_path, scope_config)
entry = IndexEntry(
id="bahn/test/entry",
title="API Test",
type="note",
tags=["api"],
scope="bahn",
summary="Test",
path="bahn/test/entry.md",
content_hash="sha256:test",
)
store.index.update_entry(entry)
# Großzügiger Timeout: Normale Ergebnisse
results = store.search("api", ["bahn"], timeout=10.0)
assert len(results) == 1
assert results[0].partial_results is False
def test_timeout_preserves_found_results(
self, tmp_path: Path, scope_config: ScopeConfig
) -> None:
"""Bei Timeout werden bereits gefundene Teilergebnisse beibehalten."""
store = KnowledgeStore(tmp_path, scope_config)
# Viele Einträge erstellen
for i in range(1000):
entry = IndexEntry(
id=f"bahn/test/entry-{i}",
title=f"Testartikel {i} über API",
type="note",
tags=["api", "test"],
scope="bahn",
summary=f"Zusammenfassung für Entry {i}",
path=f"bahn/test/entry-{i}.md",
content_hash=f"sha256:hash{i}",
)
store.index.update_entry(entry)
# Minimaler Timeout: Sollte Teilergebnisse liefern
# Da die Iteration auf einer modernen Maschine schnell ist,
# mocken wir time.monotonic für einen kontrollierten Test
call_count = 0
real_monotonic = time.monotonic
def fake_monotonic() -> float:
nonlocal call_count
call_count += 1
# Nach 5 Aufrufen "Timeout" simulieren
if call_count > 5:
return real_monotonic() + 10.0
return real_monotonic()
with patch("monorepo.knowledge.store.time.monotonic", side_effect=fake_monotonic):
results = store.search("api", ["bahn"], timeout=5.0)
# Sollte Teilergebnisse haben (nicht alle 1000)
if results:
assert all(r.partial_results is True for r in results)
assert len(results) < 1000
# ---------------------------------------------------------------------------
# Integration Tests
# ---------------------------------------------------------------------------
class TestSearchIntegration:
"""Integrationstests für die Volltextsuche."""
def test_search_finds_by_title(self, store_with_entries: KnowledgeStore) -> None:
"""Findet Einträge über Titel-Suche."""
results = store_with_entries.search(
"Cloud-Native Architektur", ["dhive"]
)
assert len(results) >= 1
assert results[0].entry.id == "dhive/reference/cloud-native"
assert results[0].relevance_score >= 0.8 # Title match
def test_search_finds_by_tag(self, store_with_entries: KnowledgeStore) -> None:
"""Findet Einträge über Tag-Suche."""
results = store_with_entries.search("deployment", ["bahn"])
assert len(results) >= 1
assert any(r.entry.id == "bahn/notes/kubernetes-deployment" for r in results)
def test_search_finds_by_summary(self, store_with_entries: KnowledgeStore) -> None:
"""Findet Einträge über Summary-Suche."""
results = store_with_entries.search("bewährter", ["privat"])
assert len(results) == 1
assert results[0].entry.id == "privat/notes/rest-patterns"
def test_case_insensitive_search(self, store_with_entries: KnowledgeStore) -> None:
"""Suche ist case-insensitive."""
results_lower = store_with_entries.search("kubernetes", ["bahn", "dhive"])
results_upper = store_with_entries.search("KUBERNETES", ["bahn", "dhive"])
assert len(results_lower) == len(results_upper)
def test_search_with_all_scopes(self, store_with_entries: KnowledgeStore) -> None:
"""Suche über alle Scopes liefert alle Treffer."""
all_scopes = ["bahn", "privat", "dhive", "shared"]
results = store_with_entries.search("api", all_scopes)
# api kommt in mehreren Einträgen vor (title, tags)
assert len(results) >= 3