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:
NSCT Agent
2026-08-23 20:57:17 +00:00
parent d2294c58d0
commit d1e6bb6cf4
6 changed files with 2104 additions and 0 deletions

View 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