feat(stage5): implement claim extraction — atomic verifiable claims from sources

- Claim model with provenance, evidence_span, attribution, claim_type
- Stage5Extractor: LLM-based atomic claim extraction from source content
  - Never summarizes — always extracts atomic, verifiable claims
  - Claims require evidence span (exact quote from source)
  - Attribution per claim (who says what)
  - Claim types: fact, opinion, prediction, recommendation, claim
  - Confidence score 0.0–1.0 per claim
  - Bounded concurrency, SSRF-safe, max content truncation
- REST API: GET/POST /research/{run_id}/claims
- 36 tests: parsing, edge cases, integration, validation
This commit is contained in:
NSCT Agent
2026-08-23 17:56:01 +00:00
parent b8181deb05
commit e8b6515f67
7 changed files with 1533 additions and 0 deletions

107
src/nsct/models/claim.py Normal file
View File

@@ -0,0 +1,107 @@
"""Pydantic v2 schema — Claim für Stage 5: Claim Extraction.
Jeder Claim ist eine atomare, überprüfbare Behauptung mit Provenance.
Keine Zusammenfassungen — immer einzelne, isolierte Claims.
"""
from __future__ import annotations
import json
from datetime import datetime
from enum import Enum
from typing import Any
from uuid import UUID, uuid4
from pydantic import BaseModel, Field, field_validator
class ClaimType(str, Enum):
"""Klassifikation eines Claims nach seinem Epistemischen Status."""
FACT = "fact"
OPINION = "opinion"
PREDICTION = "prediction"
RECOMMENDATION = "recommendation"
CLAIM = "claim"
class Claim(BaseModel):
"""Eine atomare, überprüfbare Behauptung aus einer Quelle.
Felder:
id: UUID — Primärschlüssel
research_run_id: UUID — Zuordnung zum Research-Run
source_id: UUID — Quelle, aus der der Claim extrahiert wurde
claim_text: str — Der atomare Claim-Text (NOT NULL)
evidence_span: str — Das exakte Zitat aus dem Original
claim_type: ClaimType — Typisierung
source_url: str — URL der Quelle
confidence: float 0.0-1.0 — Wie sicher ist der Claim?
metadata: dict — Zusätzliche Kontextdaten
created_at: datetime — Erstellungszeitpunkt
"""
id: UUID = Field(default_factory=uuid4)
research_run_id: UUID = Field(
..., description="Research-Run-UUID für Gruppierung"
)
source_id: UUID = Field(
..., description="Source-UUID, aus der dieser Claim stammt"
)
claim_text: str = Field(
..., min_length=1, description="Der atomare Claim-Text"
)
evidence_span: str = Field(
..., description="Exakter Textabschnitt im Original als Evidenz"
)
claim_type: ClaimType = Field(default=ClaimType.CLAIM)
source_url: str = Field(
..., description="URL der Quelle"
)
confidence: float = Field(
default=1.0, ge=0.0, le=1.0, description="Confidence 0-1"
)
metadata: dict[str, Any] = Field(
default_factory=dict, description="Zusätzliche Metadaten"
)
created_at: datetime = Field(
default_factory=datetime.utcnow
)
@field_validator("claim_text")
@classmethod
def claim_text_not_empty(cls, v: str) -> str:
if not v.strip():
raise ValueError("claim_text darf nicht leer sein")
return v
@field_validator("evidence_span")
@classmethod
def evidence_span_not_empty(cls, v: str) -> str:
if not v.strip():
raise ValueError("evidence_span darf nicht leer sein")
return v
@field_validator("source_url")
@classmethod
def source_url_not_empty(cls, v: str) -> str:
if not v.strip():
raise ValueError("source_url darf nicht leer sein")
return v
model_config = {"frozen": True}
class ClaimExtractionResult(BaseModel):
"""Ergebnis einer Claim-Extraktion pro Dokument."""
source_id: UUID
source_url: str
research_run_id: UUID
claims: list[Claim] = Field(default_factory=list)
total_tokens: int = 0
extraction_tool: str = "llm"
metadata: dict[str, Any] = Field(
default_factory=dict,
description="Extraktions-Metadaten (z.B. Token-Anzahl, Dauer)"
)