feat(stage13): implement Iterative Research / Gap Analysis\n\nImplement Gap Analysis Engine (Stage 13):\n- GapAnalysisEngine: detect single-source claims, contradictions,\n missing primary sources, weak evidence\n- IterationReport: structured gap findings with severity & target\n- GapSearchQuery: derived search queries per gap finding\n- Integration into ResearchOrchestrator: runs gap analysis after\n extracting, then executes gap searches iteratively\n- 17 tests covering all analysis categories and edge cases
This commit is contained in:
472
tests/test_stage13_gap_analysis.py
Normal file
472
tests/test_stage13_gap_analysis.py
Normal file
@@ -0,0 +1,472 @@
|
||||
"""Tests für die Gap Analysis Engine (Stage 13: Iterative Research / Gap Analysis)."""
|
||||
|
||||
import pytest
|
||||
from uuid import uuid4
|
||||
|
||||
from nsct.config import AppSettings
|
||||
from nsct.models.claim import Claim, ClaimType
|
||||
from nsct.models.gap_analysis import (
|
||||
GapCategory,
|
||||
GapFinding,
|
||||
GapSearchQuery,
|
||||
GapSeverity,
|
||||
GapTarget,
|
||||
IterationReport,
|
||||
)
|
||||
from nsct.stages.stage13_gap_analysis import GapAnalysisEngine
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_claim(source_id: str, text: str, confidence: float = 1.0) -> Claim:
|
||||
"""Helper: Erstelle einen einfachen Claim."""
|
||||
return Claim(
|
||||
id=uuid4(),
|
||||
research_run_id=uuid4(),
|
||||
source_id=uuid4(),
|
||||
claim_text=text,
|
||||
evidence_span=f"Evidenz für: {text}",
|
||||
claim_type=ClaimType.CLAIM,
|
||||
source_url="https://example.com",
|
||||
confidence=confidence,
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def config() -> AppSettings:
|
||||
return AppSettings.from_env()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def engine(config: AppSettings) -> GapAnalysisEngine:
|
||||
return GapAnalysisEngine(config=config, max_iterations=3)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: Single Source Claims
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestSingleSourceClaims:
|
||||
"""Tests: Claims mit nur einer Quelle."""
|
||||
|
||||
def test_single_source_claim_detected(self, engine: GapAnalysisEngine):
|
||||
"""Ein Claim mit nur einer Quelle soll als Lücke erkannt werden."""
|
||||
claim = _make_claim(
|
||||
source_id="s1",
|
||||
text="Die CO2-Emissionen sind um 10% gestiegen.",
|
||||
)
|
||||
sources = [
|
||||
{"id": "s1", "url": "https://example.com", "title": "Example", "source_type": "secondary"},
|
||||
]
|
||||
|
||||
report = engine.analyze(
|
||||
claims=[claim],
|
||||
sources=sources,
|
||||
iteration_number=1,
|
||||
research_run_id=str(uuid4()),
|
||||
)
|
||||
|
||||
single_source = [f for f in report.findings if f.category == GapCategory.SINGLE_SOURCE_CLAIM]
|
||||
assert len(single_source) >= 1
|
||||
|
||||
def test_multi_source_claim_not_flagged(self, engine: GapAnalysisEngine):
|
||||
"""Zwei Claims aus derselben Quelle sollen beide als single source flagged werden (jeder hat nur 1 Quelle)."""
|
||||
source1 = uuid4()
|
||||
source2 = uuid4()
|
||||
claim1 = Claim(
|
||||
id=uuid4(),
|
||||
research_run_id=uuid4(),
|
||||
source_id=source1,
|
||||
claim_text="Die Emissionen sind gesunken.",
|
||||
evidence_span="Evidenz 1",
|
||||
claim_type=ClaimType.CLAIM,
|
||||
source_url="https://source1.com",
|
||||
confidence=0.9,
|
||||
)
|
||||
claim2 = Claim(
|
||||
id=uuid4(),
|
||||
research_run_id=uuid4(),
|
||||
source_id=source1, # Gleiche Quelle!
|
||||
claim_text="Die Emissionen sind gesunken.",
|
||||
evidence_span="Evidenz 2",
|
||||
claim_type=ClaimType.CLAIM,
|
||||
source_url="https://source1.com",
|
||||
confidence=0.85,
|
||||
)
|
||||
|
||||
sources = [
|
||||
{"id": str(source1), "url": "https://source1.com", "title": "Source 1", "source_type": "primary"},
|
||||
{"id": str(source2), "url": "https://source2.com", "title": "Source 2", "source_type": "primary"},
|
||||
]
|
||||
|
||||
report = engine.analyze(
|
||||
claims=[claim1, claim2],
|
||||
sources=sources,
|
||||
iteration_number=1,
|
||||
research_run_id=str(uuid4()),
|
||||
)
|
||||
|
||||
single_source = [f for f in report.findings if f.category == GapCategory.SINGLE_SOURCE_CLAIM]
|
||||
# Beide Claims haben nur 1 Quelle (source1) → beide als single source flagged
|
||||
assert len(single_source) == 2
|
||||
|
||||
def test_multiple_single_source_claims(self, engine: GapAnalysisEngine):
|
||||
"""Mehrere Claims mit jeweils nur einer Quelle."""
|
||||
claims = [
|
||||
_make_claim(source_id="s1", text="Behauptung A"),
|
||||
_make_claim(source_id="s1", text="Behauptung B"),
|
||||
_make_claim(source_id="s2", text="Behauptung C"),
|
||||
]
|
||||
sources = [
|
||||
{"id": "s1", "url": "https://a.com", "title": "A", "source_type": "secondary"},
|
||||
{"id": "s2", "url": "https://b.com", "title": "B", "source_type": "secondary"},
|
||||
]
|
||||
|
||||
report = engine.analyze(
|
||||
claims=claims,
|
||||
sources=sources,
|
||||
iteration_number=1,
|
||||
research_run_id=str(uuid4()),
|
||||
)
|
||||
|
||||
single_source = [f for f in report.findings if f.category == GapCategory.SINGLE_SOURCE_CLAIM]
|
||||
assert len(single_source) == 3
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: Contradiction Gaps
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestContradictionGaps:
|
||||
"""Tests: Widersprüchliche Claims."""
|
||||
|
||||
def test_contradiction_detected(self, engine: GapAnalysisEngine):
|
||||
"""Widersprüchliche Claims sollen erkannt werden."""
|
||||
claims = [
|
||||
Claim(
|
||||
id=uuid4(),
|
||||
research_run_id=uuid4(),
|
||||
source_id=uuid4(),
|
||||
claim_text="Die Regierung hat die Steuern gesenkt.",
|
||||
evidence_span="Evidenz 1",
|
||||
claim_type=ClaimType.CLAIM,
|
||||
source_url="https://a.com",
|
||||
confidence=0.8,
|
||||
),
|
||||
Claim(
|
||||
id=uuid4(),
|
||||
research_run_id=uuid4(),
|
||||
source_id=uuid4(),
|
||||
claim_text="Die Regierung hat die Steuern nicht gesenkt.",
|
||||
evidence_span="Evidenz 2",
|
||||
claim_type=ClaimType.CLAIM,
|
||||
source_url="https://b.com",
|
||||
confidence=0.75,
|
||||
),
|
||||
]
|
||||
sources = [
|
||||
{"id": "s1", "url": "https://a.com", "title": "A", "source_type": "secondary"},
|
||||
{"id": "s2", "url": "https://b.com", "title": "B", "source_type": "secondary"},
|
||||
]
|
||||
|
||||
report = engine.analyze(
|
||||
claims=claims,
|
||||
sources=sources,
|
||||
iteration_number=1,
|
||||
research_run_id=str(uuid4()),
|
||||
)
|
||||
|
||||
contradictions = [f for f in report.findings if f.category == GapCategory.UNRESOLVED_CONTRADICTION]
|
||||
assert len(contradictions) >= 1
|
||||
|
||||
def test_no_contradiction_same_claim(self, engine: GapAnalysisEngine):
|
||||
"""Zwei identische Claims sollen kein Contradiction ergeben."""
|
||||
claims = [
|
||||
_make_claim(source_id="s1", text="Die Emissionen sind gesunken."),
|
||||
_make_claim(source_id="s2", text="Die Emissionen sind gesunken."),
|
||||
]
|
||||
sources = [
|
||||
{"id": "s1", "url": "https://a.com", "title": "A", "source_type": "primary"},
|
||||
{"id": "s2", "url": "https://b.com", "title": "B", "source_type": "primary"},
|
||||
]
|
||||
|
||||
report = engine.analyze(
|
||||
claims=claims,
|
||||
sources=sources,
|
||||
iteration_number=1,
|
||||
research_run_id=str(uuid4()),
|
||||
)
|
||||
|
||||
contradictions = [f for f in report.findings if f.category == GapCategory.UNRESOLVED_CONTRADICTION]
|
||||
assert len(contradictions) == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: Missing Primary Sources
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestMissingPrimarySources:
|
||||
"""Tests: Fehlende Primärquellen."""
|
||||
|
||||
def test_too_few_primary_sources(self, engine: GapAnalysisEngine):
|
||||
"""Wenn < 25% der Quellen Primärquellen sind, soll eine Lücke erkannt werden."""
|
||||
sources = [
|
||||
{"id": f"s{i}", "url": f"https://news{i}.com", "title": f"News {i}", "source_type": "secondary"}
|
||||
for i in range(6)
|
||||
]
|
||||
|
||||
report = engine.analyze(
|
||||
claims=[],
|
||||
sources=sources,
|
||||
iteration_number=1,
|
||||
research_run_id=str(uuid4()),
|
||||
)
|
||||
|
||||
missing = [f for f in report.findings if f.category == GapCategory.MISSING_PRIMARY_SOURCE]
|
||||
assert len(missing) >= 1
|
||||
|
||||
def test_enough_primary_sources(self, engine: GapAnalysisEngine):
|
||||
"""Wenn >= 25% Primärquellen, keine Lücke."""
|
||||
sources = [
|
||||
{"id": f"s{i}", "url": f"https://source{i}.gov", "title": f"Source {i}", "source_type": "primary"}
|
||||
for i in range(4)
|
||||
] + [
|
||||
{"id": f"s{i}", "url": f"https://news{i}.com", "title": f"News {i}", "source_type": "secondary"}
|
||||
for i in range(2)
|
||||
]
|
||||
|
||||
report = engine.analyze(
|
||||
claims=[],
|
||||
sources=sources,
|
||||
iteration_number=1,
|
||||
research_run_id=str(uuid4()),
|
||||
)
|
||||
|
||||
missing = [f for f in report.findings if f.category == GapCategory.MISSING_PRIMARY_SOURCE]
|
||||
assert len(missing) == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: Weak Evidence
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestWeakEvidence:
|
||||
"""Tests: Claims mit niedriger Confidence."""
|
||||
|
||||
def test_weak_evidence_detected(self, engine: GapAnalysisEngine):
|
||||
"""Claims mit confidence < 0.5 sollen als weak evidence flagged werden."""
|
||||
claims = [
|
||||
_make_claim(source_id="s1", text="Starke Behauptung", confidence=0.9),
|
||||
_make_claim(source_id="s1", text="Schwache Behauptung", confidence=0.3),
|
||||
]
|
||||
sources = [
|
||||
{"id": "s1", "url": "https://example.com", "title": "Example", "source_type": "secondary"},
|
||||
]
|
||||
|
||||
report = engine.analyze(
|
||||
claims=claims,
|
||||
sources=sources,
|
||||
iteration_number=1,
|
||||
research_run_id=str(uuid4()),
|
||||
)
|
||||
|
||||
weak = [f for f in report.findings if f.category == GapCategory.WEAK_EVIDENCE]
|
||||
assert len(weak) >= 1
|
||||
|
||||
def test_no_weak_evidence(self, engine: GapAnalysisEngine):
|
||||
"""Alle Claims mit hohem Confidence: keine Lücke."""
|
||||
claims = [
|
||||
_make_claim(source_id="s1", text="Behauptung A", confidence=0.9),
|
||||
_make_claim(source_id="s1", text="Behauptung B", confidence=0.95),
|
||||
]
|
||||
sources = [
|
||||
{"id": "s1", "url": "https://example.com", "title": "Example", "source_type": "primary"},
|
||||
]
|
||||
|
||||
report = engine.analyze(
|
||||
claims=claims,
|
||||
sources=sources,
|
||||
iteration_number=1,
|
||||
research_run_id=str(uuid4()),
|
||||
)
|
||||
|
||||
weak = [f for f in report.findings if f.category == GapCategory.WEAK_EVIDENCE]
|
||||
assert len(weak) == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: Iteration Report
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestIterationReport:
|
||||
"""Tests: IterationReport Struktur."""
|
||||
|
||||
def test_has_gaps_true(self, engine: GapAnalysisEngine):
|
||||
"""has_gaps soll True sein wenn Lücken gefunden wurden und Iteration < max."""
|
||||
claims = [_make_claim(source_id="s1", text="Nur eine Quelle", confidence=0.3)]
|
||||
sources = [
|
||||
{"id": "s1", "url": "https://a.com", "title": "A", "source_type": "secondary"},
|
||||
]
|
||||
|
||||
report = engine.analyze(
|
||||
claims=claims,
|
||||
sources=sources,
|
||||
iteration_number=1,
|
||||
research_run_id=str(uuid4()),
|
||||
)
|
||||
|
||||
assert report.has_gaps is True
|
||||
assert report.research_run_id
|
||||
assert report.iteration_number == 1
|
||||
assert report.max_iterations == 3
|
||||
assert len(report.findings) > 0
|
||||
assert len(report.gap_search_queries) > 0
|
||||
|
||||
def test_has_gaps_false_at_max(self, engine: GapAnalysisEngine):
|
||||
"""has_gaps soll False sein wenn max_iterations erreicht."""
|
||||
claims = [_make_claim(source_id="s1", text="Test", confidence=0.3)]
|
||||
sources = [
|
||||
{"id": "s1", "url": "https://a.com", "title": "A", "source_type": "secondary"},
|
||||
]
|
||||
|
||||
report = engine.analyze(
|
||||
claims=claims,
|
||||
sources=sources,
|
||||
iteration_number=3,
|
||||
research_run_id=str(uuid4()),
|
||||
)
|
||||
|
||||
# Auch wenn Lücken gefunden, ist bei max iteration has_gaps=False
|
||||
assert report.iteration_number == 3
|
||||
assert report.max_iterations == 3
|
||||
# hat_gaps = has_findings AND iteration < max_iterations
|
||||
assert report.has_gaps is False
|
||||
|
||||
def test_has_gaps_false_no_findings(self, engine: GapAnalysisEngine):
|
||||
"""Keine Lücken = has_gaps False."""
|
||||
sources = [
|
||||
{"id": f"s{i}", "url": f"https://i.com", "title": f"S{i}", "source_type": "primary"}
|
||||
for i in range(10)
|
||||
]
|
||||
report = engine.analyze(
|
||||
claims=[],
|
||||
sources=sources,
|
||||
iteration_number=1,
|
||||
research_run_id=str(uuid4()),
|
||||
)
|
||||
|
||||
assert report.has_gaps is False
|
||||
assert len(report.findings) == 0
|
||||
assert len(report.gap_search_queries) == 0
|
||||
|
||||
def test_gap_search_query_format(self, engine: GapAnalysisEngine):
|
||||
"""Jede GapSearchQuery hat alle required Felder."""
|
||||
claims = [_make_claim(source_id="s1", text="Test", confidence=0.3)]
|
||||
sources = [
|
||||
{"id": "s1", "url": "https://a.com", "title": "A", "source_type": "secondary"},
|
||||
]
|
||||
|
||||
report = engine.analyze(
|
||||
claims=claims,
|
||||
sources=sources,
|
||||
iteration_number=1,
|
||||
research_run_id=str(uuid4()),
|
||||
)
|
||||
|
||||
for q in report.gap_search_queries:
|
||||
assert q.query
|
||||
assert len(q.query) > 0
|
||||
assert q.reason
|
||||
assert len(q.reason) > 0
|
||||
assert q.purpose
|
||||
assert q.target
|
||||
assert isinstance(q, GapSearchQuery)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Tests: Edge Cases
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestEdgeCases:
|
||||
"""Tests: Randfälle."""
|
||||
|
||||
def test_empty_claims(self, engine: GapAnalysisEngine):
|
||||
"""Keine Claims: nur general gaps (z.B. fehlende Primärquellen)."""
|
||||
sources = [
|
||||
{"id": "s1", "url": "https://a.com", "title": "A", "source_type": "secondary"},
|
||||
]
|
||||
|
||||
report = engine.analyze(
|
||||
claims=[],
|
||||
sources=sources,
|
||||
iteration_number=1,
|
||||
research_run_id=str(uuid4()),
|
||||
)
|
||||
|
||||
assert isinstance(report, IterationReport)
|
||||
assert report.iteration_number == 1
|
||||
|
||||
def test_empty_sources(self, engine: GapAnalysisEngine):
|
||||
"""Keine Quellen: Single-source detection schlägt still durch."""
|
||||
report = engine.analyze(
|
||||
claims=[],
|
||||
sources=[],
|
||||
iteration_number=1,
|
||||
research_run_id=str(uuid4()),
|
||||
)
|
||||
|
||||
assert isinstance(report, IterationReport)
|
||||
assert report.research_statistics["total_claims"] == 0
|
||||
assert report.research_statistics["total_sources"] == 0
|
||||
|
||||
def test_max_iterations_custom(self, engine: GapAnalysisEngine):
|
||||
"""Max iterations = 1."""
|
||||
engine2 = GapAnalysisEngine(config=AppSettings.from_env(), max_iterations=1)
|
||||
|
||||
claims = [_make_claim(source_id="s1", text="Test", confidence=0.3)]
|
||||
sources = [
|
||||
{"id": "s1", "url": "https://a.com", "title": "A", "source_type": "secondary"},
|
||||
]
|
||||
|
||||
report = engine2.analyze(
|
||||
claims=claims,
|
||||
sources=sources,
|
||||
iteration_number=1,
|
||||
research_run_id=str(uuid4()),
|
||||
)
|
||||
|
||||
assert report.has_gaps is False # iteration 1 == max 1
|
||||
|
||||
def test_research_statistics(self, engine: GapAnalysisEngine):
|
||||
"""Research statistics enthalten alle wichtigen Keys."""
|
||||
claims = [
|
||||
_make_claim(source_id="s1", text="Test A", confidence=0.9),
|
||||
_make_claim(source_id="s2", text="Test B", confidence=0.2),
|
||||
]
|
||||
sources = [
|
||||
{"id": "s1", "url": "https://a.com", "title": "A", "source_type": "primary"},
|
||||
{"id": "s2", "url": "https://b.com", "title": "B", "source_type": "secondary"},
|
||||
]
|
||||
|
||||
report = engine.analyze(
|
||||
claims=claims,
|
||||
sources=sources,
|
||||
iteration_number=2,
|
||||
research_run_id=str(uuid4()),
|
||||
)
|
||||
|
||||
stats = report.research_statistics
|
||||
assert stats["total_claims"] == 2
|
||||
assert stats["total_sources"] == 2
|
||||
assert "findings_count" in stats
|
||||
assert "has_single_source_claims" in stats
|
||||
assert "has_contradictions" in stats
|
||||
Reference in New Issue
Block a user