feat(stage8): evidence scoring — transparent multidimensional scores (independence, proximity, support, contradiction, directness, date)

This commit is contained in:
NSCT Agent
2026-08-24 07:47:00 +00:00
parent cc9d4f2ba5
commit 719e218d9a
6 changed files with 2092 additions and 3 deletions

View File

@@ -21,6 +21,7 @@ dependencies = [
"trafilatura>=2.0.0",
"uvicorn>=0.30.0",
"structlog>=24.0.0",
"openai>=3.3.1",
]
[project.optional-dependencies]

View File

@@ -95,6 +95,10 @@ def create_app() -> FastAPI:
from nsct.api.stage7 import router as stage7_router
app.include_router(stage7_router, tags=["research"])
# Mount evidence scoring router (Stage 8)
from nsct.api.stage8 import router as stage8_router
app.include_router(stage8_router, tags=["research"])
return app

275
src/nsct/api/stage8.py Normal file
View File

@@ -0,0 +1,275 @@
"""Stage 8 API endpoints — Evidence Scoring.
Endpunkte:
POST /research/{run_id}/score-evidence — Triggers Stage 8
GET /research/{run_id}/scores — All evidence scores
GET /research/{run_id}/score-summary — Score summary with top claims, contradictions, uncertainties
"""
from __future__ import annotations
import logging
from typing import Any
from uuid import UUID
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, Field
from nsct.config import AppSettings
from nsct.models.claim import Claim as ClaimModel, ClaimType
from nsct.providers.llm import get_provider
from nsct.providers.metrics import ProviderMetrics
from nsct.stages.stage8_evidence_scoring import Stage8EvidenceScoring
logger = logging.getLogger(__name__)
router = APIRouter()
# ---------------------------------------------------------------------------
# Request / Response Schemas
# ---------------------------------------------------------------------------
class ScoreEvidenceRequest(BaseModel):
"""Request zum Triggern von Stage 8 (Evidence Scoring)."""
research_run_id: str = Field(
...,
description="UUID des Research-Runs, für den Scores berechnet werden sollen.",
)
source_ids: list[str] | None = Field(
default=None,
description="Optionale Liste von Source-IDs. Wenn None → alle Sources.",
)
class ScoreResponse(BaseModel):
"""Ein einzelner Evidence-Score für einen Claim."""
claim_id: str = Field(..., description="UUID des Claims")
research_run_id: str = Field(..., description="Research-Run-UUID")
source_independence_score: float = Field(..., description="Unabhängigkeit der Quelle (0.0-1.0)")
primary_source_proximity: float = Field(..., description="Nähe an Primärquelle (0.0-1.0)")
cross_source_support: float = Field(..., description="Unterstützung durch andere Quellen (0.0-1.0)")
contradiction_level: float = Field(..., description="Widerspruchsniveau: 1.0=keine, 0.0=viele (0.0-1.0)")
evidence_directness: float = Field(..., description="Direktheit der Evidenz (0.0-1.0)")
date_relevance_score: float = Field(..., description="Aktualität der Quelle (0.0-1.0)")
evidence_type: str = Field(..., description="EvidenceType: DIRECT_OBSERVATION|SECONDARY_REPORT|ANALYSIS|OPINION|SPECULATION")
raw_scores_json: dict[str, Any] = Field(..., description="Alle Rohdaten für Nachvollziehbarkeit")
relation_links: list[dict[str, Any]] = Field(
default_factory=list,
description="Relations zu anderen Claims",
)
class ScoreListResponse(BaseModel):
"""Alle Evidence-Scores für einen Research-Run."""
research_run_id: str
total_claims: int
scored_claims: int
scores: list[ScoreResponse]
class ScoreSummaryResponse(BaseModel):
"""Zusammenfassung aller Scores."""
research_run_id: str
total_scores: int
avg_source_independence: float
avg_primary_source_proximity: float
avg_cross_source_support: float
avg_contradiction_level: float
avg_evidence_directness: float
avg_date_relevance: float
top_support_claims: list[str]
top_contradiction_claims: list[str]
top_directness_claims: list[str]
top_independence_claims: list[str]
uncertain_claims: list[str]
uncertain_count: int
evidence_type_distribution: dict[str, int]
# ---------------------------------------------------------------------------
# Helper
# ---------------------------------------------------------------------------
def _get_llm_provider():
"""Initialisiere den LLM-Provider."""
config = AppSettings.from_env()
metrics = ProviderMetrics()
return get_provider(config, metrics)
def _get_mock_claims(run_id: UUID, source_ids: list[str] | None = None) -> list[ClaimModel]:
"""Mock-Daten für Evidence Scoring.
TODO: In Produktion aus DB laden.
"""
return []
# ---------------------------------------------------------------------------
# Endpoints
# ---------------------------------------------------------------------------
@router.post(
"/research/{run_id}/score-evidence",
response_model=ScoreListResponse,
summary="Stage 8 — Trigger Evidence Scoring",
)
async def score_evidence(
run_id: str,
request: ScoreEvidenceRequest,
) -> ScoreListResponse:
"""Startet Stage 8: Evidence Scoring — multidimensionale, transparente Scores.
Parameters
----------
run_id : str
UUID des Research-Runs.
request : ScoreEvidenceRequest
Optionale Source-IDs zum Filtern.
Returns
-------
ScoreListResponse
Alle Scores mit allen 6 Dimensionen und raw_scores_json.
"""
if not run_id:
raise HTTPException(status_code=400, detail="run_id darf nicht leer sein")
try:
run_uuid = UUID(run_id)
except ValueError:
raise HTTPException(status_code=400, detail="Ungültige run_id")
# TODO: In Produktion — Claims aus DB laden
mock_claims = _get_mock_claims(run_uuid, request.source_ids)
if not mock_claims:
raise HTTPException(
status_code=404,
detail=f"Keine Claims für research_run_id={run_id} gefunden",
)
stage8 = Stage8EvidenceScoring(
research_run_id=run_uuid,
claims=mock_claims,
)
try:
result = stage8.run()
except Exception as exc:
raise HTTPException(
status_code=500,
detail=f"Evidence Scoring fehlgeschlagen: {exc}",
)
scores_data = result.get("scores", [])
response_scores = [
ScoreResponse(
claim_id=s["claim_id"],
research_run_id=s["research_run_id"],
source_independence_score=s["source_independence_score"],
primary_source_proximity=s["primary_source_proximity"],
cross_source_support=s["cross_source_support"],
contradiction_level=s["contradiction_level"],
evidence_directness=s["evidence_directness"],
date_relevance_score=s["date_relevance_score"],
evidence_type=s["evidence_type"],
raw_scores_json=s.get("raw_scores_json", {}),
relation_links=s.get("relation_links", []),
)
for s in scores_data
]
return ScoreListResponse(
research_run_id=run_id,
total_claims=result.get("total_claims", len(scores_data)),
scored_claims=result.get("scored_claims", len(scores_data)),
scores=response_scores,
)
@router.get(
"/research/{run_id}/scores",
response_model=ScoreListResponse,
summary="Stage 8 — Liefert alle Evidence-Scores",
)
async def get_scores(run_id: str) -> ScoreListResponse:
"""Liefert alle Evidence-Scores für einen Research-Run.
Parameters
----------
run_id : str
UUID des Research-Runs.
Returns
-------
ScoreListResponse
Alle Scores mit allen 6 Dimensionen.
"""
if not run_id:
raise HTTPException(status_code=400, detail="run_id darf nicht leer sein")
try:
run_uuid = UUID(run_id)
except ValueError:
raise HTTPException(status_code=400, detail="Ungültige run_id")
# TODO: In Produktion — Scores aus DB laden
return ScoreListResponse(
research_run_id=run_id,
total_claims=0,
scored_claims=0,
scores=[],
)
@router.get(
"/research/{run_id}/score-summary",
response_model=ScoreSummaryResponse,
summary="Stage 8 — Liefert Score-Zusammenfassung",
)
async def get_score_summary(run_id: str) -> ScoreSummaryResponse:
"""Liefert eine Zusammenfassung aller Scores mit Top-Claims, Contradictions, Uncertainties.
Parameters
----------
run_id : str
UUID des Research-Runs.
Returns
-------
ScoreSummaryResponse
Statistiken, Top-Claims, Uncertain-Claims, EvidenceType-Verteilung.
"""
if not run_id:
raise HTTPException(status_code=400, detail="run_id darf nicht leer sein")
try:
run_uuid = UUID(run_id)
except ValueError:
raise HTTPException(status_code=400, detail="Ungültige run_id")
# TODO: In Produktion — Scores aus DB laden und Summary berechnen
return ScoreSummaryResponse(
research_run_id=run_id,
total_scores=0,
avg_source_independence=0.0,
avg_primary_source_proximity=0.0,
avg_cross_source_support=0.0,
avg_contradiction_level=0.0,
avg_evidence_directness=0.0,
avg_date_relevance=0.0,
top_support_claims=[],
top_contradiction_claims=[],
top_directness_claims=[],
top_independence_claims=[],
uncertain_claims=[],
uncertain_count=0,
evidence_type_distribution={},
)

View File

@@ -0,0 +1,695 @@
"""Stage 8: Evidence Scoring — transparente multidimensionale Scores.
Pipeline für ein Research-Run:
1. Lädt alle Claims des Runs aus der DB.
2. Für jeden Claim berechnet 6 dimensionale Scores:
a) source_independence_score wie unabhängig ist die Quelle?
b) primary_source_proximity Nähe an einer Primärquelle
c) cross_source_support wie viele Quellen unterstützen denselben Claim?
d) contradiction_level wie stark ist der Dissens?
e) evidence_directness wie direkt ist die Evidenz?
f) date_relevance_score wie aktuell ist die Quelle?
3. evidence_type wird klassifiziert (DIRECT_OBSERVATION … SPECULATION).
4. Alle Rohwerte werden in raw_scores_json für vollständige Nachvollziehbarkeit gespeichert.
5. EvidenceScoreRelationModel verknüpft支持与/oder widersprechende Claims.
ARCHITEKTUR-REGELN:
- Kein einziger \"truth_score\" — mehrere dimensionale Scores
- Scores sind transparent — jede Komponente nachvollziehbar
- Jede relevante Behauptung benötigt Provenance
"""
from __future__ import annotations
import json
import logging
import re
from datetime import datetime, timezone
from typing import Any
from uuid import UUID
from nsct.models.claim import Claim as ClaimModel, ClaimType
from nsct.storage.models import (
EvidenceScoreModel,
EvidenceScoreRelationModel,
EvidenceRelationTypeV2,
EvidenceType,
)
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# Pattern-Mapping für evidence_directness und evidence_type
# ---------------------------------------------------------------------------
# Wörter/Phrasen die auf direkte Beobachtung hindeuten
_DIRECT_PATTERNS: list[str] = [
r"zeigen\b", r"belegen\b", r"weisen\s*nach\b", r"ermitteln\b",
r"feststellen\b", r"messbar\b", r"quantifiziert\b", r"gemessen\b",
r"beobachtet\b", r"verzeichnet\b", r"erhoben\b", r"dokumentiert\b",
r"zitiert\b", r"offenbar\b", r"deutlich\s*auf\s*[\w-]+\s*hin",
r"ergibt\s*sich", r"führt\s*zu\s*dem\s*Ergebnis",
r"die\s*Daten\s*zeigen", r"die\s*Studie\s*zeigt",
r"laut\s*Untersuchung", r"nach\s*Angabe\s*von",
]
# Wörter/Phrasen die auf Schlussfolgerung hindeuten
_INFERRED_PATTERNS: list[str] = [
r"impliziert\b", r"lässt\s*schließen", r"deutet\s*hin\s*auf",
r"kann\s*geschlossen\s*werden", r"lässt\s*sich\s*ableiten",
r"führt\s*zu\s*dem\s*Schluss", r"folgt\s*daraus",
r"erlaubt\s*einen\s*Schluss", r"lässt\s*erwarten",
r"weist\s*auf\s*einen\s*Zusammenhang\s*hin",
]
# Wörter/Phrasen die auf Spekulation/Meinung hindeuten
_SPECULATION_PATTERNS: list[str] = [
r"vielleicht", r"könnte\s*sein", r"dürfte\s*sein",
r"vermutet", r"schätzt", r"glaubt", r"vermuten",
r"hypothese", r"spekulieren", r"könnte\s*auch",
r"wahrscheinlich", r"vielleicht\s*gar", r"evtl\b", r"eventuell",
r"potenziell", r"könnte\s*führen", r"würde\s*erlauben",
r"scheint\s*so", r"wirkt\s*auf\s*diesen\s*Hinblick",
]
# Wörter/Phrasen die auf Analyse hindeuten
_ANALYSIS_PATTERNS: list[str] = [
r"analyse\b", r"analysieren", r"betrachtet", r"betrachtung",
r"auswertung", r"auswerten", r"bewertung", r"bewerten",
r"interpretation", r"deutung", r"einordnung",
r"kritisch\s*betrachtet", r"gegenüberstellung",
r"vergleichende\s*betrachtung",
]
# Vage Formulierungen
_VAGUE_PATTERNS: list[str] = [
r"angeblich", r"reportet", r"nach\s*meldungen", r"gerücht",
r"anscheinend", r"wohl", r"offenbar", r"angeblich\s*",
r"berichten\s*von", r"zitiert\s*werden",
]
# ---------------------------------------------------------------------------
# evidence_type Klassifikation
# ---------------------------------------------------------------------------
def _classify_evidence_type(claim_text: str) -> EvidenceType:
"""Klassifiziert den Claim-Text in eine EvidenceType-Kategorie.
Priorität: DIRECT_OBSERVATION > SECONDARY_REPORT > ANALYSIS > OPINION > SPECULATION
"""
text_lower = claim_text.lower()
# Check direct observation
if any(re.search(p, text_lower) for p in _DIRECT_PATTERNS):
return EvidenceType.DIRECT_OBSERVATION
# Check analysis
if any(re.search(p, text_lower) for p in _ANALYSIS_PATTERNS):
return EvidenceType.ANALYSIS
# Check speculation
if any(re.search(p, text_lower) for p in _SPECULATION_PATTERNS):
return EvidenceType.SPECULATION
# Check secondary report (reported speech, quotes, references)
if any(re.search(p, text_lower) for p in _VAGUE_PATTERNS):
return EvidenceType.SECONDARY_REPORT
# Default: second report / general claim
return EvidenceType.SECONDARY_REPORT
# ---------------------------------------------------------------------------
# evidence_directness — Score (0.01.0)
# ---------------------------------------------------------------------------
def _compute_evidence_directness(claim_text: str) -> float:
"""Berechnet die Direktheit der Evidenz (0.01.0).
- 1.0 = direkter Befund (Zitat, Beobachtung, spezifische Zahlen)
- 0.5 = Schlussfolgerung
- 0.0 = Spekulation
"""
if not claim_text:
return 0.5
text_lower = claim_text.lower()
# Spekulation → 0.0
if any(re.search(p, text_lower) for p in _SPECULATION_PATTERNS):
return 0.0
# Vag/formell → 0.3
if any(re.search(p, text_lower) for p in _VAGUE_PATTERNS):
return 0.3
# Analyse → 0.5
if any(re.search(p, text_lower) for p in _ANALYSIS_PATTERNS):
return 0.5
# Inference → 0.5
if any(re.search(p, text_lower) for p in _INFERRED_PATTERNS):
return 0.5
# Direkte Beobachtung/Zahlen/Zitate → 1.0
if any(re.search(p, text_lower) for p in _DIRECT_PATTERNS):
return 1.0
# Enthält spezifische Zahlen → 0.8
if re.search(r"\b\d{1,3}(?:\.\d{1,2})?\s*(?:%|°[cCF]|[\s]€/€/€/£\$|[\s]M[\s]|[\s]Mr\.|[\s]\d{3}\b)", text_lower):
return 0.8
# Enthält direkte Zitate → 0.7
if re.search(r'["„«].+["„»]', claim_text):
return 0.7
# Default: Schlussfolgerung → 0.5
return 0.5
# ---------------------------------------------------------------------------
# date_relevance_score
# ---------------------------------------------------------------------------
def _compute_date_relevance_score(publication_date: datetime | None) -> float:
"""Score basierend auf der Aktualität der Quelle.
score = max(0.0, 1.0 - age_days / 365.0) (max 1 Jahr)
Wenn kein Datum → 0.5
"""
if publication_date is None:
return 0.5
now = datetime.now(timezone.utc).replace(tzinfo=None)
try:
age = now - publication_date
except TypeError:
# timezone-aware vs naive mismatch
age = now - publication_date.replace(tzinfo=None) if publication_date.tzinfo else now - publication_date
age_days = max(0, age.days)
score = max(0.0, 1.0 - age_days / 365.0)
return round(score, 4)
# ---------------------------------------------------------------------------
# source_independence_score
# ---------------------------------------------------------------------------
def _compute_source_independence_score(
source_data: dict[str, Any] | None,
) -> float:
"""Berechnet den independence_score basierend auf Stage-6-Daten.
- independence_score=1.0 → 1.0
- Quelle ist syndiziert → 0.3-0.7 je nach Syndication-Größe
- Wenn keine Daten → 0.5 als Default
"""
if not source_data:
return 0.5
independence = source_data.get("independence_score")
if independence is not None:
return round(float(independence), 4)
# Check syndication group
synd_group_id = source_data.get("syndication_group_id")
if synd_group_id:
# Syndiziert: score 0.3-0.7
shared_urls = source_data.get("shared_urls", {})
shared_count = 0
if isinstance(shared_urls, dict):
shared_count = len(shared_urls)
elif isinstance(shared_urls, list):
shared_count = len(shared_urls)
# More shared URLs → lower score
score = 0.7 - min(0.4, shared_count * 0.1)
return round(max(0.3, min(0.7, score)), 4)
return 0.5
# ---------------------------------------------------------------------------
# primary_source_proximity
# ---------------------------------------------------------------------------
def _compute_primary_source_proximity(
source_data: dict[str, Any] | None,
) -> tuple[float, str]:
"""Berechnet primary_source_proximity und gibt (score, label) zurück.
- DIRECT (1.0): Quelle hat parent_source_id und ist kein Syndicated
- SYNDICATED (0.6): Quelle ist in syndication_group
- DERIVED (0.3): Keine parent_source_id, nicht syndiziert
- UNKNOWN (0.0): Keine Daten
"""
if not source_data:
return 0.0, "UNKNOWN"
parent_source_id = source_data.get("parent_source_id")
synd_group_id = source_data.get("syndication_group_id")
independence = source_data.get("independence_score")
# Hat parent_source_id UND ist nicht selbst syndiziert → DIRECT
if parent_source_id and not synd_group_id and (independence is None or independence >= 0.7):
return 1.0, "DIRECT"
# Ist in Syndication-Gruppe → SYNDICATED
if synd_group_id:
return 0.6, "SYNDICATED"
# Keine parent_source_id und nicht syndiziert → DERIVED
if parent_source_id is None and not synd_group_id:
return 0.3, "DERIVED"
# Fallback → UNKNOWN
return 0.0, "UNKNOWN"
# ---------------------------------------------------------------------------
# cross_source_support
# ---------------------------------------------------------------------------
def _compute_cross_source_support(
claim_id: str,
cluster_claims: list[dict[str, Any]],
cluster_relations: list[dict[str, Any]],
source_independence_map: dict[str, dict[str, Any]],
) -> tuple[float, dict[str, Any]]:
"""Berechnet cross_source_support basierend auf unterstützenden Quellen.
Score = (supported_count / total_independent_sources) * weight
Gibt (score, raw_data_dict) zurück.
"""
if not cluster_claims:
return 0.0, {"supported_count": 0, "total_count": 0, "weight": 1.0}
# Count unique source_ids in cluster
unique_sources: dict[str, dict[str, Any]] = {}
for claim_info in cluster_claims:
src_id = claim_info.get("source_id", "")
if src_id:
unique_sources[src_id] = source_independence_map.get(src_id, {
"independence_score": 0.5,
})
total_sources = len(unique_sources)
if total_sources == 0:
return 0.0, {
"supported_count": 0,
"total_count": 0,
"weight": 1.0,
}
# Count SUPPORTS relations for this claim within cluster
supports_count = 0
total_relations_in_cluster = 0
for rel in cluster_relations:
total_relations_in_cluster += 1
src_id = rel.get("source_claim_id", "")
tgt_id = rel.get("target_claim_id", "")
rel_type = rel.get("relation_type", "")
if rel_type == "SUPPORTS":
if src_id == claim_id or tgt_id == claim_id:
supports_count += 1
# If no relations, use direct claim count as fallback
if total_relations_in_cluster == 0:
score = min(1.0, supports_count / max(1, total_sources))
else:
score = min(1.0, supports_count / max(1, total_relations_in_cluster))
return round(score, 4), {
"supported_count": supports_count,
"total_count": total_sources,
"total_relations": total_relations_in_cluster,
"weight": 1.0,
}
# ---------------------------------------------------------------------------
# contradiction_level
# ---------------------------------------------------------------------------
def _compute_contradiction_level(
claim_id: str,
cluster_relations: list[dict[str, Any]],
) -> tuple[float, dict[str, Any]]:
"""Berechnet contradiction_level (1.0 = keine Widersprüche, 0.0 = viele).
Score = 1.0 - (contradictions / total_relations)
"""
total_relations = 0
contradiction_count = 0
for rel in cluster_relations:
src_id = rel.get("source_claim_id", "")
tgt_id = rel.get("target_claim_id", "")
rel_type = rel.get("relation_type", "")
if rel_type in ("CONTRADICTS", "disagrees"):
if src_id == claim_id or tgt_id == claim_id:
contradiction_count += 1
total_relations += 1
if total_relations == 0:
return 1.0, {
"contradiction_count": 0,
"total_relations": 0,
"contradiction_ratio": 0.0,
}
score = max(0.0, 1.0 - (contradiction_count / total_relations))
ratio = contradiction_count / total_relations
return round(score, 4), {
"contradiction_count": contradiction_count,
"total_relations": total_relations,
"contradiction_ratio": round(ratio, 4),
}
# ---------------------------------------------------------------------------
# Raw-Score Dict
# ---------------------------------------------------------------------------
def _build_raw_scores_json(
source_independence: float,
proximity_label: str,
cross_support_data: dict[str, Any],
contradiction_data: dict[str, Any],
directness_score: float,
date_score: float,
evidence_type: str,
claim_text: str,
publication_date: datetime | None,
source_data: dict[str, Any] | None,
) -> dict[str, Any]:
"""Baut das raw_scores_json mit allen Rohdaten für vollständige Nachvollziehbarkeit."""
return {
"source_independence": source_independence,
"primary_source_proximity": proximity_label,
"cross_support_count": cross_support_data.get("supported_count", 0),
"cross_total": cross_support_data.get("total_count", 0),
"contradiction_count": contradiction_data.get("contradiction_count", 0),
"total_relations": contradiction_data.get("total_relations", 0),
"contradiction_ratio": contradiction_data.get("contradiction_ratio", 0.0),
"evidence_directness": directness_score,
"date_relevance_score": date_score,
"evidence_type": evidence_type,
"claim_text_preview": claim_text[:200] if claim_text else "",
"publication_date": publication_date.isoformat() if publication_date else None,
"source_has_parent": source_data.get("parent_source_id") if source_data else None,
"source_in_syndication_group": source_data.get("syndication_group_id") if source_data else None,
"source_independence_raw": source_data.get("independence_score") if source_data else None,
}
# ---------------------------------------------------------------------------
# Stage 8 Pipeline
# ---------------------------------------------------------------------------
class Stage8EvidenceScoring:
"""Stage 8: Evidence Scoring — multidimensionale, transparente Scores.
Usage:
scoring = Stage8EvidenceScoring(
llm_provider=...,
config=...,
research_run_id=...,
claims=claim_models,
cluster_data=..., # Stage 7 clusters + relations
source_data_map=..., # dict[source_id] -> source independence data
)
results = scoring.run()
"""
def __init__(
self,
research_run_id: UUID,
claims: list[ClaimModel],
cluster_data: dict[str, Any] | None = None,
source_data_map: dict[str, dict[str, Any]] | None = None,
):
self.research_run_id = research_run_id
self.claims = claims
self.cluster_data = cluster_data or {"clusters": [], "relations": []}
self.source_data_map = source_data_map or {}
self._claim_map: dict[str, dict[str, Any]] = {}
self._build_claim_map()
def _build_claim_map(self) -> None:
"""Baut eine schnelle Map: claim_id → claim_info."""
for claim in self.claims:
self._claim_map[str(claim.id)] = {
"id": str(claim.id),
"text": claim.claim_text or "",
"source_id": str(claim.source_id),
"research_run_id": str(claim.research_run_id),
"claim_type": str(claim.claim_type) if claim.claim_type else "",
"confidence": float(claim.confidence) if claim.confidence else 1.0,
"evidence_span": claim.evidence_span or "",
"event_date": getattr(claim, "event_date", None),
}
def _get_source_data(self, source_id: str) -> dict[str, Any] | None:
"""Liefert Source-Independence-Daten für eine Source-ID."""
return self.source_data_map.get(source_id)
def _find_claim_in_clusters(self, claim_id: str) -> tuple[
list[dict[str, Any]], # claims in same cluster
list[dict[str, Any]], # all relations in cluster
]:
"""Findet alle Claims und Relations in dem Cluster, in dem sich ein Claim befindet."""
cluster_claims: list[dict[str, Any]] = []
cluster_relations: list[dict[str, Any]] = []
# Build relations lookup: cluster_id → list of relations
relations_by_cluster: dict[str, list[dict[str, Any]]] = {}
for rel in self.cluster_data.get("relations", []):
cluster_id = rel.get("cluster_id", "")
relations_by_cluster.setdefault(cluster_id, []).append(rel)
cluster_claims_map: dict[str, list[dict[str, Any]]] = {}
for cluster in self.cluster_data.get("clusters", []):
cid = cluster.get("id", "") or cluster.get("cluster_id", "")
cids = cluster.get("claim_ids", [])
if cid:
cluster_claims_map[cid] = cids
for cid, cids in cluster_claims_map.items():
if claim_id in cids:
# This is the cluster we want
for cid_str in cids:
if cid_str in self._claim_map:
cluster_claims.append(self._claim_map[cid_str])
if cid in relations_by_cluster:
cluster_relations = list(relations_by_cluster[cid])
break
return cluster_claims, cluster_relations
def _compute_relation_links(
self,
claim_id: str,
claim_text: str,
cluster_relations: list[dict[str, Any]],
) -> list[dict[str, Any]]:
"""Berechnet die EvidenceScoreRelationModel-Einträge für einen Claim."""
relations = []
for rel in cluster_relations:
src = rel.get("source_claim_id", "")
tgt = rel.get("target_claim_id", "")
rel_type_str = rel.get("relation_type", "NEUTRAL")
# Determine our role in the relation
if src == claim_id:
our_role = "source"
target_id = tgt
elif tgt == claim_id:
our_role = "target"
target_id = src
else:
continue # not relevant to this claim
# Map to EvidenceRelationTypeV2
if rel_type_str in ("CONTRADICTS", "contradicts", "disagrees"):
evidence_rel = EvidenceRelationTypeV2.CONTRADICTS
elif rel_type_str in ("SUPPORTS", "supports", "agrees"):
evidence_rel = EvidenceRelationTypeV2.SUPPORTS
else:
evidence_rel = EvidenceRelationTypeV2.NEUTRAL
# If the relation is about our claim, invert the meaning
if our_role == "target":
# If source CONTRADICTS target (us), then target is contradicted
if evidence_rel == EvidenceRelationTypeV2.SUPPORTS:
evidence_rel = EvidenceRelationTypeV2.SUPPORTS
elif evidence_rel == EvidenceRelationTypeV2.CONTRADICTS:
evidence_rel = EvidenceRelationTypeV2.CONTRADICTS
# Neutral stays neutral
weight = float(rel.get("confidence", 1.0))
# Only add if there's a meaningful relationship
if rel_type_str.upper() not in ("NEUTRAL", "UNCERTAIN"):
relations.append({
"related_claim_id": target_id,
"relation_type": evidence_rel,
"weight": weight,
"reason": rel.get("reason", ""),
})
return relations
def run(self) -> dict[str, Any]:
"""Führt die vollständige Stage-8-Pipeline aus.
Returns
-------
dict mit 'scores', 'summary'.
"""
scores: list[dict[str, Any]] = []
errors: list[str] = []
for claim in self.claims:
claim_id = str(claim.id)
claim_text = claim.claim_text or ""
source_id = str(claim.source_id)
try:
source_data = self._get_source_data(source_id)
# --- Dimension 1: source_independence_score ---
source_independence = _compute_source_independence_score(source_data)
# --- Dimension 2: primary_source_proximity ---
proximity_score, proximity_label = _compute_primary_source_proximity(source_data)
# --- Cluster relations for this claim ---
cluster_claims, cluster_relations = self._find_claim_in_clusters(claim_id)
# --- Dimension 3: cross_source_support ---
cross_support, cross_support_data = _compute_cross_source_support(
claim_id, cluster_claims, cluster_relations, self.source_data_map
)
# --- Dimension 4: contradiction_level ---
contradiction, contradiction_data = _compute_contradiction_level(
claim_id, cluster_relations
)
# --- Dimension 5: evidence_directness ---
directness = _compute_evidence_directness(claim_text)
# --- Dimension 6: date_relevance_score ---
pub_date = getattr(claim, "event_date", None)
if pub_date is None and source_data and source_data.get("publication_date"):
pub_date = source_data.get("publication_date")
date_relevance = _compute_date_relevance_score(pub_date)
# --- evidence_type ---
evidence_type = _classify_evidence_type(claim_text)
# --- Raw scores ---
raw_scores = _build_raw_scores_json(
source_independence=source_independence,
proximity_label=proximity_label,
cross_support_data=cross_support_data,
contradiction_data=contradiction_data,
directness_score=directness,
date_score=date_relevance,
evidence_type=evidence_type.value,
claim_text=claim_text,
publication_date=pub_date,
source_data=source_data,
)
# --- Relation links ---
relation_links = self._compute_relation_links(
claim_id, claim_text, cluster_relations
)
scores.append({
"claim_id": claim_id,
"research_run_id": str(self.research_run_id),
"source_independence_score": source_independence,
"primary_source_proximity": proximity_score,
"cross_source_support": cross_support,
"contradiction_level": contradiction,
"evidence_directness": directness,
"date_relevance_score": date_relevance,
"evidence_type": evidence_type.value,
"raw_scores_json": raw_scores,
"relation_links": relation_links,
})
except Exception as exc:
logger.error("Score calculation failed for claim %s: %s", claim_id, exc)
errors.append(f"claim {claim_id}: {exc}")
# Compute summary stats
summary = self._build_summary(scores)
return {
"scores": scores,
"summary": summary,
"errors": errors,
"research_run_id": str(self.research_run_id),
"total_claims": len(self.claims),
"scored_claims": len(scores),
}
@staticmethod
def _build_summary(scores: list[dict[str, Any]]) -> dict[str, Any]:
"""Baut eine Zusammenfassung aller Scores."""
if not scores:
return {"total_scores": 0}
top_support = sorted(scores, key=lambda s: s["cross_source_support"], reverse=True)[:5]
top_contradiction = sorted(
scores, key=lambda s: s["contradiction_level"]
)[:5] # low = more contradiction
top_directness = sorted(scores, key=lambda s: s["evidence_directness"], reverse=True)[:5]
top_independence = sorted(scores, key=lambda s: s["source_independence_score"], reverse=True)[:5]
# High-uncertainty claims
uncertain = [
s for s in scores
if s["cross_source_support"] < 0.3
and s["contradiction_level"] < 0.5
]
# Evidence type distribution
type_dist: dict[str, int] = {}
for s in scores:
et = s["evidence_type"]
type_dist[et] = type_dist.get(et, 0) + 1
# Avg scores per dimension
avg_independence = sum(s["source_independence_score"] for s in scores) / len(scores)
avg_proximity = sum(s["primary_source_proximity"] for s in scores) / len(scores)
avg_support = sum(s["cross_source_support"] for s in scores) / len(scores)
avg_contradiction = sum(s["contradiction_level"] for s in scores) / len(scores)
avg_directness = sum(s["evidence_directness"] for s in scores) / len(scores)
avg_date = sum(s["date_relevance_score"] for s in scores) / len(scores)
return {
"total_scores": len(scores),
"avg_source_independence": round(avg_independence, 4),
"avg_primary_source_proximity": round(avg_proximity, 4),
"avg_cross_source_support": round(avg_support, 4),
"avg_contradiction_level": round(avg_contradiction, 4),
"avg_evidence_directness": round(avg_directness, 4),
"avg_date_relevance": round(avg_date, 4),
"top_support_claims": [s["claim_id"] for s in top_support],
"top_contradiction_claims": [s["claim_id"] for s in top_contradiction],
"top_directness_claims": [s["claim_id"] for s in top_directness],
"top_independence_claims": [s["claim_id"] for s in top_independence],
"uncertain_claims": [s["claim_id"] for s in uncertain],
"uncertain_count": len(uncertain),
"evidence_type_distribution": type_dist,
}

View File

@@ -316,8 +316,6 @@ class ClaimClusterModel(Base):
# Association table: claims ↔ clusters (many-to-many)
_claim_cluster_mapping = Base() # noqa: F811 -- dummy for type resolution
claim_cluster_mapping = Base()
claim_cluster_mapping.__tablename__ = "claim_cluster_mapping"
claim_cluster_mapping.id = Column(String(36), primary_key=True, default=lambda: str(uuid4()))
@@ -380,3 +378,97 @@ class ClaimNLUModel(Base):
__table_args__ = (
Index("ix_claim_nlu_numeric_claim_id", "claim_id"),
)
# ---------------------------------------------------------------------------
# Stage 8 — Evidence Scoring (multidimensional, transparent scores)
# ---------------------------------------------------------------------------
class EvidenceType(str, enum.Enum):
"""Klassifizierung der Evidenz-Qualität pro Claim."""
DIRECT_OBSERVATION = "direct_observation"
SECONDARY_REPORT = "secondary_report"
ANALYSIS = "analysis"
OPINION = "opinion"
SPECULATION = "speculation"
class EvidenceRelationTypeV2(str, enum.Enum):
"""Relation zwischen einem Scored-Claim und einem anderen Claim (Stage 8)."""
SUPPORTS = "supports"
CONTRADICTS = "contradicts"
NEUTRAL = "neutral"
class EvidenceScoreModel(Base):
"""Transparente multidimensionale Scores für jeden Claim (Stage 8)."""
__tablename__ = "evidence_scores"
id = Column(String(36), primary_key=True, default=lambda: str(uuid4()))
claim_id = Column(String(36), ForeignKey("claims.id"), nullable=False, unique=True)
research_run_id = Column(String(36), nullable=False)
# Dimension 1: source independence
source_independence_score = Column(Float, nullable=False, default=0.5)
# Dimension 2: proximity to primary source
primary_source_proximity = Column(Float, nullable=False, default=0.0)
# Dimension 3: cross-source support
cross_source_support = Column(Float, nullable=False, default=0.0)
# Dimension 4: contradiction level (1.0 = no contradictions)
contradiction_level = Column(Float, nullable=False, default=1.0)
# Dimension 5: directness of evidence
evidence_directness = Column(Float, nullable=False, default=0.5)
# Dimension 6: date relevance
date_relevance_score = Column(Float, nullable=False, default=0.5)
# Evidence classification
evidence_type = Column(Enum(EvidenceType), nullable=False, default=EvidenceType.SECONDARY_REPORT)
# Raw scores for full auditability
raw_scores_json = Column(JSON, nullable=False, default=dict)
created_at = Column(DateTime, nullable=False, default=datetime.utcnow)
updated_at = Column(DateTime, nullable=False, default=datetime.utcnow)
# Relationships
relations = relationship(
"EvidenceScoreRelationModel",
back_populates="score",
cascade="all, delete-orphan",
foreign_keys="EvidenceScoreRelationModel.score_id",
)
__table_args__ = (
Index("ix_evidence_scores_claim_id", "claim_id"),
Index("ix_evidence_scores_research_run_id", "research_run_id"),
)
class EvidenceScoreRelationModel(Base):
"""Relation zwischen einem Evidence-Scored Claim und anderen Claims (Stage 8)."""
__tablename__ = "evidence_score_relations"
id = Column(String(36), primary_key=True, default=lambda: str(uuid4()))
score_id = Column(
String(36),
ForeignKey("evidence_scores.id"),
nullable=False,
)
related_claim_id = Column(String(36), ForeignKey("claims.id"), nullable=False)
relation_type = Column(Enum(EvidenceRelationTypeV2), nullable=False)
weight = Column(Float, nullable=False, default=1.0)
created_at = Column(DateTime, nullable=False, default=datetime.utcnow)
# Relationships
score = relationship("EvidenceScoreModel", back_populates="relations", foreign_keys=[score_id])
related_claim = relationship("ClaimModel", foreign_keys=[related_claim_id])
__table_args__ = (
Index("ix_evidence_score_relations_score_id", "score_id"),
Index("ix_evidence_score_relations_related_claim_id", "related_claim_id"),
)

File diff suppressed because it is too large Load Diff