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:
Frerk Campen
2026-08-26 11:19:35 +00:00
committed by NSCT Agent
parent 2ef7b67002
commit 0b7a624bc8
4 changed files with 1196 additions and 2 deletions

View 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