feat(stage7): claim clustering & contradiction candidates — semantic grouping, numeric normalization, pairwise analysis
- ClaimClusterModel: LLM-basierte semantische Gruppierung von Claims - ClaimRelationModel: SUPPORTS, CONTRADICTS, DUPLICATE, UNCERTAIN pairwise relations - ClaimNLUModel: numerische Normalisierung (%, Währungen, deutsche/englische Wörter) - stage7_normalize_numerics.py: Regex-basiert mit 200+ deutschen/englischen Zahlenwörtern - stage7_clustering.py: LLM-Clustering + pairwise claim-relation analysis - API: POST cluster-claims, GET clusters, GET claim-relations - 79 tests: numerische Normalisierung, LLM-Parsing, Clustering, Relationen, Edge-Cases - Dedup: claims mit gleichen numerischen Werten werden zusammengefasst
This commit is contained in:
866
tests/stages/test_stage7_clustering.py
Normal file
866
tests/stages/test_stage7_clustering.py
Normal file
@@ -0,0 +1,866 @@
|
||||
"""Umfassende Tests für Stage 7: Claim Clustering & Contradiction Candidates.
|
||||
|
||||
Abdeckungen:
|
||||
- Numerische Normalisierung: Prozent, Währungen, deutsche/englische Wörter
|
||||
- LLM-Response-Parsing für Cluster und Relation
|
||||
- Clustering: gleiche Themen, verschiedene Themen
|
||||
- Pairwise Relation: SUPPORTS, CONTRADICTS, DUPLICATE, UNCERTAIN
|
||||
- Edge Cases: 1 Claim, 2 Claims, viele Claims, leere Claims
|
||||
- DB-Integration: Cluster + Relation creation
|
||||
- Key-Phrase-Extraktion
|
||||
- API-Request/Response-Schemas
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
import pytest
|
||||
|
||||
from nsct.models.claim import Claim, ClaimType
|
||||
from nsct.stages.stage7_clustering import (
|
||||
Stage7Clustering,
|
||||
CLUSTER_SYSTEM_PROMPT,
|
||||
CLUSTER_USER_PROMPT,
|
||||
RELATION_SYSTEM_PROMPT,
|
||||
RELATION_USER_PROMPT,
|
||||
_extract_key_phrases,
|
||||
_claim_text_hash,
|
||||
parse_cluster_response,
|
||||
parse_relation_response,
|
||||
)
|
||||
from nsct.stages.stage7_normalize_numerics import (
|
||||
NumericExtractionResult,
|
||||
_normalize_number,
|
||||
_parse_word_number,
|
||||
extract_numerics,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures & Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _mock_llm_provider(response: str) -> MagicMock:
|
||||
"""Erzeuge einen mock LLM-Provider mit einer festen Antwort."""
|
||||
provider = MagicMock()
|
||||
provider.complete = AsyncMock(return_value=response)
|
||||
provider.model = "test-model"
|
||||
return provider
|
||||
|
||||
|
||||
def _make_extractor(
|
||||
llm_response: str,
|
||||
claims: list[Claim] | None = None,
|
||||
) -> Stage7Clustering:
|
||||
"""Erzeuge einen Stage7Clustering mit mock LLM."""
|
||||
provider = _mock_llm_provider(llm_response)
|
||||
provider.model = "test-model"
|
||||
|
||||
config = MagicMock()
|
||||
config.llm.base_url = "http://localhost:8030/openai/v1"
|
||||
config.llm.model = "test-model"
|
||||
config.llm.max_concurrency = 3
|
||||
|
||||
if claims is None:
|
||||
claims = [
|
||||
Claim(
|
||||
research_run_id=uuid4(),
|
||||
source_id=uuid4(),
|
||||
claim_text="Deutschland hat 83 Millionen Einwohner.",
|
||||
evidence_span="Deutschland hat 83 Millionen Einwohner",
|
||||
claim_type=ClaimType.FACT,
|
||||
source_url="https://example.com/1",
|
||||
),
|
||||
]
|
||||
|
||||
return Stage7Clustering(
|
||||
llm_provider=provider,
|
||||
config=config,
|
||||
research_run_id=claims[0].research_run_id,
|
||||
claims=claims,
|
||||
)
|
||||
|
||||
|
||||
def asyncio_run(coro):
|
||||
"""Hilfsfunktion: Koroutine synchron ausführen."""
|
||||
loop = asyncio.new_event_loop()
|
||||
try:
|
||||
return loop.run_until_complete(coro)
|
||||
finally:
|
||||
loop.close()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test Group 1-10: Numerische Normalisierung — Grundlegende Tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestNumericNormalization:
|
||||
"""Tests für die numerische Normalisierung."""
|
||||
|
||||
def test_percent_integer(self) -> None:
|
||||
"""Integer-Prozent: 50% → 0.5."""
|
||||
results = extract_numerics("Die Emissionen sinken um 50%.")
|
||||
assert len(results) > 0
|
||||
pcts = [r for r in results if r.unit == "%"]
|
||||
assert len(pcts) > 0
|
||||
assert pcts[0].normalized_value == "0.5"
|
||||
|
||||
def test_percent_decimal(self) -> None:
|
||||
"""Dezimal-Prozent: 5.5% → 0.055."""
|
||||
results = extract_numerics("Ein Anstieg von 5.5% ist zu erwarten.")
|
||||
pcts = [r for r in results if r.unit == "%"]
|
||||
assert len(pcts) > 0
|
||||
assert pcts[0].normalized_value == "0.055"
|
||||
|
||||
def test_currency_euro(self) -> None:
|
||||
"""Euro: €50 → 50.0 EUR."""
|
||||
results = extract_numerics("Kosten von €50.")
|
||||
euros = [r for r in results if r.unit == "EUR"]
|
||||
assert len(euros) > 0
|
||||
assert euros[0].normalized_value == "50.0"
|
||||
|
||||
def test_currency_dollar(self) -> None:
|
||||
"""Dollar: $100 → 100.0 USD."""
|
||||
results = extract_numerics("Kosten von $100.")
|
||||
dollars = [r for r in results if r.unit == "USD"]
|
||||
assert len(dollars) > 0
|
||||
assert dollars[0].normalized_value == "100.0"
|
||||
|
||||
def test_currency_code(self) -> None:
|
||||
"""Währungscode: 50 EUR → 50.0 EUR."""
|
||||
results = extract_numerics("Kosten von 50 EUR und 100 USD.")
|
||||
euros = [r for r in results if r.unit == "EUR"]
|
||||
assert len(euros) > 0
|
||||
assert euros[0].normalized_value == "50.0"
|
||||
|
||||
def test_word_currency_euro(self) -> None:
|
||||
"""Wort+Währung: fünfzig Euro → 50.0 EUR."""
|
||||
results = extract_numerics("Kosten von fünfzig Euro.")
|
||||
euros = [r for r in results if r.unit in ("EUR", "Euro")]
|
||||
assert len(euros) > 0
|
||||
assert euros[0].normalized_value == "50.0"
|
||||
|
||||
def test_standalone_number(self) -> None:
|
||||
"""Standalone-Zahl: 100 → 100.0."""
|
||||
results = extract_numerics("Es gibt 100 Bewerber und 83 Stimmen.")
|
||||
numbers = [r for r in results if r.unit in (None, "")]
|
||||
assert len(numbers) > 0
|
||||
assert any(float(r.normalized_value) == 100.0 for r in numbers)
|
||||
|
||||
def test_no_numbers(self) -> None:
|
||||
"""Text ohne Zahlen → leer."""
|
||||
results = extract_numerics("Es gibt keine Zahlen in diesem Text.")
|
||||
assert len(results) == 0
|
||||
|
||||
def test_empty_text(self) -> None:
|
||||
"""Leerer Text → leer."""
|
||||
results = extract_numerics("")
|
||||
assert results == []
|
||||
|
||||
def test_none_text(self) -> None:
|
||||
"""None-Text → leer."""
|
||||
results = extract_numerics(None) # type: ignore[arg-type]
|
||||
assert results == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test Group 11-20: Deutsche/Englische Zahlenwörter
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestWordNumbers:
|
||||
"""Tests für deutsche und englische Zahlenwörter."""
|
||||
|
||||
def test_parse_fuenfzig(self) -> None:
|
||||
"""'fünfzig' → 50.0."""
|
||||
assert _parse_word_number("fünfzig") == 50
|
||||
|
||||
def test_parse_fifty(self) -> None:
|
||||
"""'fifty' → 50.0."""
|
||||
assert _parse_word_number("fifty") == 50
|
||||
|
||||
def test_parse_drei(self) -> None:
|
||||
"""'drei' → 3.0."""
|
||||
assert _parse_word_number("drei") == 3
|
||||
|
||||
def test_parse_three(self) -> None:
|
||||
"""'three' → 3.0."""
|
||||
assert _parse_word_number("three") == 3
|
||||
|
||||
def test_parse_hundert(self) -> None:
|
||||
"""'hundert' → 100.0."""
|
||||
assert _parse_word_number("hundert") == 100
|
||||
|
||||
def test_parse_thousand(self) -> None:
|
||||
"""'thousand' → 1000.0."""
|
||||
assert _parse_word_number("thousand") == 1000
|
||||
|
||||
def test_parse_unknown_word(self) -> None:
|
||||
"""Unbekanntes Wort → None."""
|
||||
assert _parse_word_number("xyzunknown") is None
|
||||
|
||||
def test_extract_fifty_percent(self) -> None:
|
||||
"""'fünfzig Prozent' → 50% → 0.5."""
|
||||
results = extract_numerics("fünfzig Prozent der Bürger")
|
||||
pcts = [r for r in results if r.unit == "%"]
|
||||
assert len(pcts) > 0
|
||||
assert pcts[0].normalized_value == "0.5"
|
||||
|
||||
def test_extract_ten_dollars(self) -> None:
|
||||
"""'ten dollars' → 10.0 USD."""
|
||||
results = extract_numerics("Kosten von ten dollar.")
|
||||
# The unit will be the converted code if recognized
|
||||
dollars = [r for r in results if r.unit in ("USD", "dollar", "Dollar")]
|
||||
assert len(dollars) > 0
|
||||
# At least one should have the correct normalized value
|
||||
assert any(float(r.normalized_value) == 10.0 for r in dollars)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test Group 21-30: _normalize_number
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestNormalizeNumber:
|
||||
"""Tests für die _normalize_number-Funktion."""
|
||||
|
||||
def test_percent_50(self) -> None:
|
||||
"""50% → ('0.5', '%')."""
|
||||
result = _normalize_number("50", "%")
|
||||
assert result is not None
|
||||
assert result[0] == "0.5"
|
||||
assert result[1] == "%"
|
||||
|
||||
def test_percent_100(self) -> None:
|
||||
"""100% → ('1.0', '%')."""
|
||||
result = _normalize_number("100", "%")
|
||||
assert result is not None
|
||||
assert result[0] == "1.0"
|
||||
|
||||
def test_percent_0(self) -> None:
|
||||
"""0% → ('0.0', '%')."""
|
||||
result = _normalize_number("0", "%")
|
||||
assert result is not None
|
||||
assert result[0] == "0.0"
|
||||
|
||||
def test_euro_symbol(self) -> None:
|
||||
"""€50 → ('50.0', 'EUR')."""
|
||||
result = _normalize_number("50", "€")
|
||||
assert result is not None
|
||||
assert result[1] == "EUR"
|
||||
|
||||
def test_dollar_symbol(self) -> None:
|
||||
"""$100 → ('100.0', 'USD')."""
|
||||
result = _normalize_number("100", "$")
|
||||
assert result is not None
|
||||
assert result[1] == "USD"
|
||||
|
||||
def test_pound_symbol(self) -> None:
|
||||
"""£30 → ('30.0', 'GBP')."""
|
||||
result = _normalize_number("30", "£")
|
||||
assert result is not None
|
||||
assert result[1] == "GBP"
|
||||
|
||||
def test_weight_kg(self) -> None:
|
||||
"""3 kg → ('3.0', 'kg')."""
|
||||
result = _normalize_number("3", "kg")
|
||||
assert result is not None
|
||||
assert result[0] == "3.0"
|
||||
|
||||
def test_distance_km(self) -> None:
|
||||
"""500 km → ('500.0', 'km')."""
|
||||
result = _normalize_number("500", "km")
|
||||
assert result is not None
|
||||
assert result[0] == "500.0"
|
||||
|
||||
def test_unit_unknown(self) -> None:
|
||||
"""Unbekannte Einheit wird durchgereicht."""
|
||||
result = _normalize_number("42", "xyz")
|
||||
assert result is not None
|
||||
assert result[1] == "xyz"
|
||||
|
||||
def test_negative_value(self) -> None:
|
||||
"""Negative Werte: -10 → ('-10.0', '')."""
|
||||
result = _normalize_number("-10", "")
|
||||
assert result is not None
|
||||
assert result[0] == "-10.0"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test Group 31-40: LLM-Response-Parsing — Cluster
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestParseClusterResponse:
|
||||
"""Tests für parse_cluster_response."""
|
||||
|
||||
def test_parse_valid_clusters(self) -> None:
|
||||
"""Gültige JSON-Antwort parsen."""
|
||||
response = json.dumps({
|
||||
"clusters": [
|
||||
{"label": "Klimaschutz", "claim_ids": ["id1", "id2"]},
|
||||
{"label": "Wirtschaft", "claim_ids": ["id3"]},
|
||||
]
|
||||
})
|
||||
result = parse_cluster_response(response)
|
||||
assert len(result) == 2
|
||||
assert result[0]["label"] == "Klimaschutz"
|
||||
assert result[0]["claim_ids"] == ["id1", "id2"]
|
||||
assert result[1]["label"] == "Wirtschaft"
|
||||
|
||||
def test_parse_code_block(self) -> None:
|
||||
"""JSON in Markdown-Code-Blocks extrahieren."""
|
||||
response = '```json\n{"clusters": [{"label": "Test", "claim_ids": ["id1"]}]}\n```'
|
||||
result = parse_cluster_response(response)
|
||||
assert len(result) == 1
|
||||
assert result[0]["label"] == "Test"
|
||||
|
||||
def test_parse_invalid_json(self) -> None:
|
||||
"""Ungültiges JSON löst ValueError."""
|
||||
with pytest.raises(ValueError):
|
||||
parse_cluster_response("Das ist kein JSON")
|
||||
|
||||
def test_parse_empty_clusters(self) -> None:
|
||||
"""Leeres Cluster-Array."""
|
||||
response = json.dumps({"clusters": []})
|
||||
result = parse_cluster_response(response)
|
||||
assert len(result) == 0
|
||||
|
||||
def test_parse_list_format(self) -> None:
|
||||
"""Liste von Clusters (Array statt Objekt)."""
|
||||
response = json.dumps({"clusters": [{"label": "Solo", "claim_ids": ["id1"]}]}).replace('"clusters"', '"clusters"')
|
||||
result = parse_cluster_response(response)
|
||||
assert len(result) == 1
|
||||
assert result[0]["label"] == "Solo"
|
||||
|
||||
def test_parse_single_cluster(self) -> None:
|
||||
"""Einzelner Cluster."""
|
||||
response = json.dumps({
|
||||
"clusters": [{"label": "Alle_Claims", "claim_ids": ["id1", "id2", "id3"]}]
|
||||
})
|
||||
result = parse_cluster_response(response)
|
||||
assert len(result) == 1
|
||||
assert result[0]["claim_ids"] == ["id1", "id2", "id3"]
|
||||
|
||||
def test_parse_skip_invalid_items(self) -> None:
|
||||
"""Ungültige Items (kein Dict) müssen übersprungen werden."""
|
||||
response = json.dumps({
|
||||
"clusters": [
|
||||
{"label": "Valid", "claim_ids": ["id1"]},
|
||||
"not_a_dict",
|
||||
42,
|
||||
{"label": "AlsoValid", "claim_ids": ["id2"]},
|
||||
]
|
||||
})
|
||||
result = parse_cluster_response(response)
|
||||
assert len(result) == 2
|
||||
assert result[0]["label"] == "Valid"
|
||||
assert result[1]["label"] == "AlsoValid"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test Group 41-50: LLM-Response-Parsing — Relation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestParseRelationResponse:
|
||||
"""Tests für parse_relation_response."""
|
||||
|
||||
def test_parse_supports(self) -> None:
|
||||
"""SUPPORTS-Relation parsen."""
|
||||
response = json.dumps({
|
||||
"relation": "SUPPORTS",
|
||||
"confidence": 0.95,
|
||||
"reason": "Beide Quellen bestätigen die Aussage."
|
||||
})
|
||||
result = parse_relation_response(response)
|
||||
assert result["relation"] == "SUPPORTS"
|
||||
assert result["confidence"] == 0.95
|
||||
assert "bestätigen" in result["reason"]
|
||||
|
||||
def test_parse_contradicts(self) -> None:
|
||||
"""CONTRADICTS-Relation parsen."""
|
||||
response = json.dumps({
|
||||
"relation": "CONTRADICTS",
|
||||
"confidence": 0.85,
|
||||
"reason": "Quelle A sagt X, Quelle B sagt Y — Gegensatz."
|
||||
})
|
||||
result = parse_relation_response(response)
|
||||
assert result["relation"] == "CONTRADICTS"
|
||||
assert result["confidence"] == 0.85
|
||||
|
||||
def test_parse_duplicate(self) -> None:
|
||||
"""DUPLICATE-Relation parsen."""
|
||||
response = json.dumps({
|
||||
"relation": "DUPLICATE",
|
||||
"confidence": 0.98,
|
||||
"reason": "Identische Aussage, leicht unterschiedliche Formulierung."
|
||||
})
|
||||
result = parse_relation_response(response)
|
||||
assert result["relation"] == "DUPLICATE"
|
||||
|
||||
def test_parse_uncertain(self) -> None:
|
||||
"""UNCERTAIN-Relation parsen."""
|
||||
response = json.dumps({
|
||||
"relation": "UNCERTAIN",
|
||||
"confidence": 0.3,
|
||||
"reason": "Keine klare Beziehung erkennbar."
|
||||
})
|
||||
result = parse_relation_response(response)
|
||||
assert result["relation"] == "UNCERTAIN"
|
||||
|
||||
def test_parse_clamped_confidence(self) -> None:
|
||||
"""Confidence > 1.0 wird geklamped."""
|
||||
response = json.dumps({
|
||||
"relation": "SUPPORTS",
|
||||
"confidence": 1.5,
|
||||
"reason": "Test"
|
||||
})
|
||||
result = parse_relation_response(response)
|
||||
assert result["confidence"] == 1.0
|
||||
|
||||
def test_parse_negative_confidence(self) -> None:
|
||||
"""Confidence < 0.0 wird geklamped."""
|
||||
response = json.dumps({
|
||||
"relation": "CONTRADICTS",
|
||||
"confidence": -0.5,
|
||||
"reason": "Test"
|
||||
})
|
||||
result = parse_relation_response(response)
|
||||
assert result["confidence"] == 0.0
|
||||
|
||||
def test_parse_code_block(self) -> None:
|
||||
"""JSON in Code-Blocks."""
|
||||
response = '```json\n{"relation": "SUPPORTS", "confidence": 0.8, "reason": "Test"}\n```'
|
||||
result = parse_relation_response(response)
|
||||
assert result["relation"] == "SUPPORTS"
|
||||
|
||||
def test_parse_invalid_json(self) -> None:
|
||||
"""Ungültiges JSON löst ValueError."""
|
||||
with pytest.raises(ValueError):
|
||||
parse_relation_response("Das ist kein JSON")
|
||||
|
||||
def test_parse_invalid_relation_type(self) -> None:
|
||||
"""Ungültiger relation_type → UNCERTAIN."""
|
||||
response = json.dumps({
|
||||
"relation": "INVALID_TYPE",
|
||||
"confidence": 0.5,
|
||||
"reason": "Test"
|
||||
})
|
||||
result = parse_relation_response(response)
|
||||
assert result["relation"] == "UNCERTAIN"
|
||||
|
||||
def test_parse_default_reason(self) -> None:
|
||||
"""Fehlende reason → Default."""
|
||||
response = json.dumps({"relation": "SUPPORTS", "confidence": 0.5})
|
||||
result = parse_relation_response(response)
|
||||
assert result["reason"] == "Keine Begründung."
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test Group 51-60: Key-Phrase-Extraktion
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestKeyPhraseExtraction:
|
||||
"""Tests für die Key-Phrase-Extraktion."""
|
||||
|
||||
def test_extract_phrases(self) -> None:
|
||||
"""Wichtige Begriffe werden extrahiert."""
|
||||
text = "Die Bundesregierung hat ein neues Steuergesetz zur Digitalisierung verabschiedet."
|
||||
phrases = _extract_key_phrases(text)
|
||||
assert len(phrases) > 0
|
||||
assert "regierung" in phrases or "steuergesetz" in phrases or "digitalisierung" in phrases
|
||||
|
||||
def test_empty_text(self) -> None:
|
||||
"""Leerer Text → leere Liste."""
|
||||
assert _extract_key_phrases("") == []
|
||||
|
||||
def test_stopword_filtering(self) -> None:
|
||||
"""Stopwords (und, oder, aber) werden gefiltert."""
|
||||
text = "und oder aber jedoch zwar auch nur kein keine nicht"
|
||||
phrases = _extract_key_phrases(text)
|
||||
assert len(phrases) == 0
|
||||
|
||||
def test_max_phrases(self) -> None:
|
||||
"""max_phrases begrenzt die Ausgabe."""
|
||||
text = "x x x y y y z z z a b c d e f g h i j k"
|
||||
phrases = _extract_key_phrases(text, max_phrases=3)
|
||||
assert len(phrases) <= 3
|
||||
|
||||
def test_duplicate_removal(self) -> None:
|
||||
"""Wörter werden nur einmal gezählt."""
|
||||
text = "x x x y y y z z z"
|
||||
phrases = _extract_key_phrases(text)
|
||||
# x, y, z — höchstens 3
|
||||
assert len(phrases) <= 3
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test Group 61-70: Claim-Text-Hash
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestClaimTextHash:
|
||||
"""Tests für den Claim-Text-Hash."""
|
||||
|
||||
def test_same_text_same_hash(self) -> None:
|
||||
"""Identischer Text → identischer Hash."""
|
||||
text = "Test claim text"
|
||||
assert _claim_text_hash(text) == _claim_text_hash(text)
|
||||
|
||||
def test_different_text_different_hash(self) -> None:
|
||||
"""Verschiedener Text → verschiedener Hash."""
|
||||
h1 = _claim_text_hash("Claim A")
|
||||
h2 = _claim_text_hash("Claim B")
|
||||
assert h1 != h2
|
||||
|
||||
def test_case_insensitive(self) -> None:
|
||||
"""Groß-/Kleinschreibung wird ignoriert."""
|
||||
assert _claim_text_hash("Test") == _claim_text_hash("test")
|
||||
|
||||
def test_whitespace_normalized(self) -> None:
|
||||
"""Mehrere Leerzeichen werden normalisiert."""
|
||||
assert _claim_text_hash("test claim") == _claim_text_hash("test claim")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test Group 71-80: Stage7Clustering Pipeline
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestStage7Pipeline:
|
||||
"""Tests für die Stage7Clustering-Pipeline."""
|
||||
|
||||
def test_empty_claims(self) -> None:
|
||||
"""Leere Claims-Liste → empty summary."""
|
||||
stage = Stage7Clustering(
|
||||
llm_provider=MagicMock(),
|
||||
config=MagicMock(),
|
||||
research_run_id=uuid4(),
|
||||
claims=[],
|
||||
)
|
||||
result = asyncio_run(stage.run())
|
||||
assert result["summary"]["total_claims"] == 0
|
||||
assert result["summary"]["clusters_created"] == 0
|
||||
|
||||
def test_single_claim(self) -> None:
|
||||
"""Ein einzelner Claim → 1 Cluster, 0 Relations."""
|
||||
claim = Claim(
|
||||
research_run_id=uuid4(),
|
||||
source_id=uuid4(),
|
||||
claim_text="Ein Claim.",
|
||||
evidence_span="Evidenz",
|
||||
claim_type=ClaimType.FACT,
|
||||
source_url="https://example.com",
|
||||
)
|
||||
stage = Stage7Clustering(
|
||||
llm_provider=_mock_llm_provider(json.dumps({"clusters": [{"label": "Test", "claim_ids": [str(claim.id)]}]})),
|
||||
config=MagicMock(),
|
||||
research_run_id=claim.research_run_id,
|
||||
claims=[claim],
|
||||
)
|
||||
result = asyncio_run(stage.run())
|
||||
assert result["summary"]["total_claims"] == 1
|
||||
|
||||
def test_numeric_extraction_integration(self) -> None:
|
||||
"""Numerische Normalisierung im Pipeline-Kontext."""
|
||||
claim = Claim(
|
||||
research_run_id=uuid4(),
|
||||
source_id=uuid4(),
|
||||
claim_text="Die Emissionen sinken um 50% bis 2030. Kosten: €100.",
|
||||
evidence_span="50%, €100",
|
||||
claim_type=ClaimType.FACT,
|
||||
source_url="https://example.com",
|
||||
)
|
||||
stage = Stage7Clustering(
|
||||
llm_provider=_mock_llm_provider(json.dumps({"clusters": []})),
|
||||
config=MagicMock(),
|
||||
research_run_id=claim.research_run_id,
|
||||
claims=[claim],
|
||||
)
|
||||
nlu = stage._normalize_numerics()
|
||||
assert len(nlu) >= 2 # 50% und €100
|
||||
|
||||
def test_preprocess_short_claims(self) -> None:
|
||||
"""Kurze Claims werden als 'short' markiert."""
|
||||
claim = Claim(
|
||||
research_run_id=uuid4(),
|
||||
source_id=uuid4(),
|
||||
claim_text="Short claim.",
|
||||
evidence_span="Evidenz",
|
||||
claim_type=ClaimType.FACT,
|
||||
source_url="https://example.com",
|
||||
)
|
||||
stage = Stage7Clustering(
|
||||
llm_provider=_mock_llm_provider(json.dumps({"clusters": []})),
|
||||
config=MagicMock(),
|
||||
research_run_id=claim.research_run_id,
|
||||
claims=[claim],
|
||||
)
|
||||
groups = stage._preprocess_claims()
|
||||
assert groups[0]["short"] is True
|
||||
assert groups[0]["key_phrases"] == []
|
||||
|
||||
def test_preprocess_long_claims(self) -> None:
|
||||
"""Lange Claims (>=500) werden als 'long' markiert."""
|
||||
long_text = "x " * 250 # ~500 chars
|
||||
claim = Claim(
|
||||
research_run_id=uuid4(),
|
||||
source_id=uuid4(),
|
||||
claim_text=long_text,
|
||||
evidence_span="Evidenz",
|
||||
claim_type=ClaimType.FACT,
|
||||
source_url="https://example.com",
|
||||
)
|
||||
stage = Stage7Clustering(
|
||||
llm_provider=_mock_llm_provider(json.dumps({"clusters": []})),
|
||||
config=MagicMock(),
|
||||
research_run_id=claim.research_run_id,
|
||||
claims=[claim],
|
||||
)
|
||||
groups = stage._preprocess_claims()
|
||||
assert groups[0]["short"] is False
|
||||
|
||||
def test_llm_error_handled(self) -> None:
|
||||
"""LLM-Fehler werden abgefangen."""
|
||||
claim = Claim(
|
||||
research_run_id=uuid4(),
|
||||
source_id=uuid4(),
|
||||
claim_text="Test.",
|
||||
evidence_span="Evidenz",
|
||||
claim_type=ClaimType.FACT,
|
||||
source_url="https://example.com",
|
||||
)
|
||||
failing_provider = MagicMock()
|
||||
failing_provider.complete = AsyncMock(side_effect=RuntimeError("LLM down"))
|
||||
failing_provider.model = "test-model"
|
||||
|
||||
stage = Stage7Clustering(
|
||||
llm_provider=failing_provider,
|
||||
config=MagicMock(),
|
||||
research_run_id=claim.research_run_id,
|
||||
claims=[claim],
|
||||
)
|
||||
result = asyncio_run(stage.run())
|
||||
# Error sollte geloggt, aber nicht geworfen werden
|
||||
assert result["summary"]["total_claims"] == 1
|
||||
|
||||
def test_relation_batching(self) -> None:
|
||||
"""Relations werden paarweise generiert."""
|
||||
claim_a = Claim(
|
||||
research_run_id=uuid4(),
|
||||
source_id=uuid4(),
|
||||
claim_text="Claim A: Steuererhöhung.",
|
||||
evidence_span="Steuererhöhung",
|
||||
claim_type=ClaimType.FACT,
|
||||
source_url="https://example.com/a",
|
||||
)
|
||||
claim_b = Claim(
|
||||
research_run_id=claim_a.research_run_id,
|
||||
source_id=uuid4(),
|
||||
claim_text="Claim B: Steuer Senkung.",
|
||||
evidence_span="Steuer Senkung",
|
||||
claim_type=ClaimType.FACT,
|
||||
source_url="https://example.com/b",
|
||||
)
|
||||
relation_response = json.dumps({
|
||||
"relation": "CONTRADICTS",
|
||||
"confidence": 0.9,
|
||||
"reason": "Steuererhöhung vs. SteuerSenkung"
|
||||
})
|
||||
stage = Stage7Clustering(
|
||||
llm_provider=_mock_llm_provider(relation_response),
|
||||
config=MagicMock(),
|
||||
research_run_id=claim_a.research_run_id,
|
||||
claims=[claim_a, claim_b],
|
||||
)
|
||||
clusters = [{"label": "Steuer", "claim_ids": [str(claim_a.id), str(claim_b.id)]}]
|
||||
relations = asyncio_run(stage._analyze_relations(clusters))
|
||||
assert len(relations) == 1
|
||||
assert relations[0]["relation_type"] == "CONTRADICTS"
|
||||
|
||||
def test_relation_error_fallback(self) -> None:
|
||||
"""LLM-Fehler bei Relation → UNCERTAIN mit low confidence."""
|
||||
claim_a = Claim(
|
||||
research_run_id=uuid4(),
|
||||
source_id=uuid4(),
|
||||
claim_text="Claim A.",
|
||||
evidence_span="Evidenz",
|
||||
claim_type=ClaimType.FACT,
|
||||
source_url="https://example.com/a",
|
||||
)
|
||||
claim_b = Claim(
|
||||
research_run_id=claim_a.research_run_id,
|
||||
source_id=uuid4(),
|
||||
claim_text="Claim B.",
|
||||
evidence_span="Evidenz",
|
||||
claim_type=ClaimType.FACT,
|
||||
source_url="https://example.com/b",
|
||||
)
|
||||
failing_provider = MagicMock()
|
||||
failing_provider.complete = AsyncMock(side_effect=RuntimeError("LLM down"))
|
||||
failing_provider.model = "test-model"
|
||||
|
||||
stage = Stage7Clustering(
|
||||
llm_provider=failing_provider,
|
||||
config=MagicMock(),
|
||||
research_run_id=claim_a.research_run_id,
|
||||
claims=[claim_a, claim_b],
|
||||
)
|
||||
clusters = [{"label": "Test", "claim_ids": [str(claim_a.id), str(claim_b.id)]}]
|
||||
relations = asyncio_run(stage._analyze_relations(clusters))
|
||||
assert len(relations) == 1
|
||||
assert relations[0]["relation_type"] == "UNCERTAIN"
|
||||
assert relations[0]["confidence"] == 0.1
|
||||
|
||||
def test_single_claim_cluster_no_relations(self) -> None:
|
||||
"""Cluster mit nur 1 Claim → keine Relations."""
|
||||
claim = Claim(
|
||||
research_run_id=uuid4(),
|
||||
source_id=uuid4(),
|
||||
claim_text="Solo-Claim.",
|
||||
evidence_span="Evidenz",
|
||||
claim_type=ClaimType.FACT,
|
||||
source_url="https://example.com",
|
||||
)
|
||||
stage = Stage7Clustering(
|
||||
llm_provider=_mock_llm_provider(json.dumps({"clusters": []})),
|
||||
config=MagicMock(),
|
||||
research_run_id=claim.research_run_id,
|
||||
claims=[claim],
|
||||
)
|
||||
clusters = [{"label": "Solo", "claim_ids": [str(claim.id)]}]
|
||||
relations = asyncio_run(stage._analyze_relations(clusters))
|
||||
assert len(relations) == 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test Group 81-90: System Prompts & Constants
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestPrompts:
|
||||
"""Tests für System-Prompts und Konstanten."""
|
||||
|
||||
def test_cluster_system_prompt_not_empty(self) -> None:
|
||||
assert CLUSTER_SYSTEM_PROMPT and len(CLUSTER_SYSTEM_PROMPT) > 0
|
||||
assert "Cluster" in CLUSTER_SYSTEM_PROMPT or "clustering" in CLUSTER_SYSTEM_PROMPT.lower()
|
||||
|
||||
def test_cluster_user_prompt_format(self) -> None:
|
||||
"""Cluster-Prompt muss claims_list-Platzhalter haben."""
|
||||
prompt = CLUSTER_USER_PROMPT.format(claims_list="Test")
|
||||
assert "Test" in prompt
|
||||
assert "JSON" in prompt
|
||||
|
||||
def test_relation_system_prompt_not_empty(self) -> None:
|
||||
assert RELATION_SYSTEM_PROMPT and len(RELATION_SYSTEM_PROMPT) > 0
|
||||
assert "SUPPORTS" in RELATION_SYSTEM_PROMPT or "CONTRADICTS" in RELATION_SYSTEM_PROMPT
|
||||
|
||||
def test_relation_user_prompt_format(self) -> None:
|
||||
"""Relation-Prompt muss alle Platzhalter haben."""
|
||||
prompt = RELATION_USER_PROMPT.format(
|
||||
url_a="https://a.com",
|
||||
text_a="Claim A",
|
||||
type_a="fact",
|
||||
url_b="https://b.com",
|
||||
text_b="Claim B",
|
||||
type_b="opinion",
|
||||
)
|
||||
assert "Claim A" in prompt
|
||||
assert "Claim B" in prompt
|
||||
assert "SUPPORTS" in prompt
|
||||
assert "CONTRADICTS" in prompt
|
||||
assert "DUPLICATE" in prompt
|
||||
assert "UNCERTAIN" in prompt
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test Group 91-100: ClaimRelationType enum (storage models)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestClaimRelationTypeEnum:
|
||||
"""Tests für die ClaimRelationType-Enum (pydantic models)."""
|
||||
|
||||
def test_all_values_present(self) -> None:
|
||||
from nsct.models.source_independence import CitationEdgeType
|
||||
values = {e.value for e in CitationEdgeType}
|
||||
assert "syndicated" in values
|
||||
|
||||
def test_values_are_lowercase(self) -> None:
|
||||
from nsct.models.source_independence import CitationEdgeType
|
||||
for e in CitationEdgeType:
|
||||
assert e.value.islower()
|
||||
|
||||
# All remaining tests are skipped when sqlalchemy is unavailable
|
||||
def test_clustermodule_exists(self) -> None:
|
||||
pytest.importorskip("sqlalchemy")
|
||||
from nsct.storage.models import ClaimClusterModel
|
||||
assert ClaimClusterModel is not None
|
||||
|
||||
def test_claimrelationmodel_exists(self) -> None:
|
||||
pytest.importorskip("sqlalchemy")
|
||||
from nsct.storage.models import ClaimRelationModel
|
||||
assert ClaimRelationModel is not None
|
||||
|
||||
def test_claimnlumodel_exists(self) -> None:
|
||||
pytest.importorskip("sqlalchemy")
|
||||
from nsct.storage.models import ClaimNLUModel
|
||||
assert ClaimNLUModel is not None
|
||||
|
||||
def test_clustermodule_table_name(self) -> None:
|
||||
pytest.importorskip("sqlalchemy")
|
||||
from nsct.storage.models import ClaimClusterModel
|
||||
assert ClaimClusterModel.__tablename__ == "claim_clusters"
|
||||
|
||||
def test_claimrelationmodule_table_name(self) -> None:
|
||||
pytest.importorskip("sqlalchemy")
|
||||
from nsct.storage.models import ClaimRelationModel
|
||||
assert ClaimRelationModel.__tablename__ == "claim_relations"
|
||||
|
||||
def test_claimnlumodule_table_name(self) -> None:
|
||||
pytest.importorskip("sqlalchemy")
|
||||
from nsct.storage.models import ClaimNLUModel
|
||||
assert ClaimNLUModel.__tablename__ == "claim_nlu_numeric"
|
||||
|
||||
def test_clustermodule_all_columns(self) -> None:
|
||||
pytest.importorskip("sqlalchemy")
|
||||
from nsct.storage.models import ClaimClusterModel
|
||||
columns = {c.name for c in ClaimClusterModel.__table__.columns}
|
||||
assert "id" in columns
|
||||
assert "research_run_id" in columns
|
||||
assert "cluster_label" in columns
|
||||
assert "representative_claim_id" in columns
|
||||
assert "claim_count" in columns
|
||||
assert "created_at" in columns
|
||||
|
||||
def test_claimrelationmodel_all_columns(self) -> None:
|
||||
pytest.importorskip("sqlalchemy")
|
||||
from nsct.storage.models import ClaimRelationModel
|
||||
columns = {c.name for c in ClaimRelationModel.__table__.columns}
|
||||
assert "id" in columns
|
||||
assert "source_claim_id" in columns
|
||||
assert "target_claim_id" in columns
|
||||
assert "relation_type" in columns
|
||||
assert "confidence" in columns
|
||||
assert "reason" in columns
|
||||
assert "cluster_id" in columns
|
||||
assert "created_at" in columns
|
||||
|
||||
def test_claimnlumodel_all_columns(self) -> None:
|
||||
pytest.importorskip("sqlalchemy")
|
||||
from nsct.storage.models import ClaimNLUModel
|
||||
columns = {c.name for c in ClaimNLUModel.__table__.columns}
|
||||
assert "id" in columns
|
||||
assert "claim_id" in columns
|
||||
assert "normalized_numeric_value" in columns
|
||||
assert "original_numeric_text" in columns
|
||||
assert "unit" in columns
|
||||
assert "created_at" in columns
|
||||
Reference in New Issue
Block a user