feat(stage9): neutral synthesis engine — LLM-generated report from evidence package
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timezone
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
from uuid import UUID, uuid4
|
||||
@@ -181,4 +181,104 @@ class ResearchReport(BaseModel):
|
||||
)
|
||||
generated_at: datetime = Field(default_factory=datetime.utcnow)
|
||||
|
||||
model_config = {"frozen": True}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Synthesis — Stage 9: Neutral Synthesis Engine
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class SynthesisClaimType(str, Enum):
|
||||
"""Art des Claims für die Synthese."""
|
||||
|
||||
WELL_SUPORTED = "well_supported"
|
||||
PARTIALLY_SUPPORTED = "partially_supported"
|
||||
UNCERTAIN = "uncertain"
|
||||
CONTRADICTED = "contradicted"
|
||||
SPECULATION = "speculation"
|
||||
|
||||
|
||||
class ContradictionCategory(str, Enum):
|
||||
"""Kategorie eines Widerspruchs im Bericht."""
|
||||
|
||||
EVIDENCE_CONFLICT = "evidence_conflict"
|
||||
METHODOLOGY_DIFFERENCE = "methodology_difference"
|
||||
TEMPORAL_DISCREPANCY = "temporal_discrepancy"
|
||||
INTERPRETATION_DIFFERENCE = "interpretation_difference"
|
||||
UNKNOWN = "unknown"
|
||||
|
||||
|
||||
class SynthesisClaimModel(BaseModel):
|
||||
"""Verknüpft einen Claim mit Evidence-Scores und Provenance.
|
||||
|
||||
Felder:
|
||||
claim_text: Der Claim-Text
|
||||
evidence_type: Direkte Beobachtung, Spekulation, etc.
|
||||
source_independence_score: Wie unabhängig ist die Quelle? (0-1)
|
||||
cross_source_support: Wie viele Quellen unterstützen den Claim? (0-1)
|
||||
contradiction_level: Widerspruchsniveau (1.0 = keine) (0-1)
|
||||
evidence_directness: Direktheit der Evidenz (0-1)
|
||||
source_id: UUID der Quelle
|
||||
source_url: URL der Quelle (zur Provenance)
|
||||
source_title: Titel der Quelle
|
||||
evidence_span: Zitat aus der Quelle
|
||||
confidence: Extraktions-Confidence (0-1)
|
||||
"""
|
||||
|
||||
claim_text: str = Field(..., min_length=1, description="Der atomare Claim-Text")
|
||||
evidence_type: str = Field(
|
||||
default="secondary_report",
|
||||
description="Klassifikation: direct_observation, secondary_report, analysis, opinion, speculation",
|
||||
)
|
||||
source_independence_score: float = Field(default=0.5, ge=0.0, le=1.0, description="Unabhängigkeits-Score")
|
||||
cross_source_support: float = Field(default=0.0, ge=0.0, le=1.0, description="Cross-Source-Support")
|
||||
contradiction_level: float = Field(default=1.0, ge=0.0, le=1.0, description="Widerspruchsniveau")
|
||||
evidence_directness: float = Field(default=0.5, ge=0.0, le=1.0, description="Direktheit")
|
||||
source_id: UUID = Field(..., description="Quelle-UUID")
|
||||
source_url: str = Field(..., description="URL der Quelle")
|
||||
source_title: str | None = Field(default=None, description="Titel der Quelle")
|
||||
evidence_span: str | None = Field(default=None, description="Zitat im Original")
|
||||
confidence: float = Field(default=1.0, ge=0.0, le=1.0, description="Extraktions-Confidence")
|
||||
|
||||
|
||||
class SynthesisReportModel(BaseModel):
|
||||
"""Finaler Bericht der Neutral Synthesis Engine (Stage 9).
|
||||
|
||||
Das Schema trennt explizit Fakten von Interpretation und kennzeichnet
|
||||
Unsicherheit. Jede Aussage ist mit einer Quelle verknüpft (Provenance).
|
||||
Keine politischen Empfehlungen.
|
||||
"""
|
||||
|
||||
research_topic: str = Field(..., description="Das Forschungs-Thema / die Query")
|
||||
summary: str = Field(default="", description="Neutrale Zusammenfassung aller Ergebnisse")
|
||||
confident_findings: list[SynthesisClaimModel] = Field(
|
||||
default_factory=list,
|
||||
description="Funde mit hoher Sicherheit (unterstützt, wenig Widerspruch)",
|
||||
)
|
||||
uncertain_areas: list[SynthesisClaimModel] = Field(
|
||||
default_factory=list,
|
||||
description="Bereiche mit Unsicherheit (wenig Unterstützung, starke Spekulation)",
|
||||
)
|
||||
contradictions: list[dict[str, Any]] = Field(
|
||||
default_factory=list,
|
||||
description="Widersprüche mit Details: welche Claims widersprechen sich",
|
||||
)
|
||||
source_list: list[dict[str, Any]] = Field(
|
||||
default_factory=list,
|
||||
description="Alle einzigartigen Quellen im Bericht mit Metadaten",
|
||||
)
|
||||
llm_model_used: str = Field(default="", description="Modell-Name/ID des LLM")
|
||||
generation_timestamp: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
methodology: str = Field(
|
||||
default=(
|
||||
"NSCT Stage 9: Neutral Synthesis Engine. "
|
||||
"Bericht generiert aus evidenzbasierten Claims (Claims 0-8). "
|
||||
"Trennung von Fakten und Interpretation. "
|
||||
"Keine politischen Empfehlungen. "
|
||||
"Jede Aussage ist mit Quellen verknüpft (Provenance)."
|
||||
),
|
||||
description="Methodenbeschreibung der Synthese",
|
||||
)
|
||||
|
||||
model_config = {"frozen": True}
|
||||
304
src/nsct/models/synthesis.py
Normal file
304
src/nsct/models/synthesis.py
Normal file
@@ -0,0 +1,304 @@
|
||||
"""Pydantic v2 schemas — Neutral Synthesis Engine (Stage 9).
|
||||
|
||||
Alle Klassen sind frozens, damit keine ungewollten Mutationen
|
||||
der berichtsfähigen Datenstruktur möglich sind.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from pydantic import BaseModel, Field, field_validator, model_validator
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Enums
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class Direction(str, Enum):
|
||||
"""Wie ein Claim zur Gesamtlage beiträgt (neutral-synthese)."""
|
||||
|
||||
SUPPORT = "support"
|
||||
CONTRADICT = "contradict"
|
||||
UNCERTAIN = "uncertain"
|
||||
|
||||
|
||||
class EvidenceLevel(str, Enum):
|
||||
"""Evidenz-Level eines Claims im Synthese-Kontext."""
|
||||
|
||||
HIGH = "high"
|
||||
MEDIUM = "medium"
|
||||
LOW = "low"
|
||||
UNKNOWN = "unknown"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SynthesisRequestSchema — API-Request
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class SynthesisRequestSchema(BaseModel):
|
||||
"""Request, um eine Synthese durchzuführen."""
|
||||
|
||||
research_run_id: UUID = Field(
|
||||
...,
|
||||
description="UUID des Research-Runs, für den eine Synthese erstellt werden soll.",
|
||||
)
|
||||
topic: str = Field(
|
||||
...,
|
||||
min_length=1,
|
||||
max_length=1024,
|
||||
description="Forschungs-Thema / Query.",
|
||||
)
|
||||
constraints: dict[str, Any] = Field(
|
||||
default_factory=dict,
|
||||
description="Optionale Constraints (z.B. max_sources, languages, date_range).",
|
||||
)
|
||||
|
||||
@field_validator("topic")
|
||||
@classmethod
|
||||
def topic_no_only_whitespace(cls, v: str) -> str:
|
||||
if not v.strip():
|
||||
raise ValueError("topic darf nicht nur Whitespaces enthalten")
|
||||
return v.strip()
|
||||
|
||||
model_config = {"frozen": True}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SynthesisClaimSchema — einzelner Claim im Kontext des Berichts
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class SynthesisClaimSchema(BaseModel):
|
||||
"""Ein einzelner Claim im Kontext des Synthese-Berichts.
|
||||
|
||||
Enthält Provenance (Quelle) und Evidenz-Level, damit der Leser
|
||||
nachvollziehen kann, worauf eine Aussage basiert.
|
||||
"""
|
||||
|
||||
claim_text: str = Field(
|
||||
...,
|
||||
min_length=1,
|
||||
description="Der atomare Claim-Text.",
|
||||
)
|
||||
evidence_level: EvidenceLevel = Field(
|
||||
default=EvidenceLevel.MEDIUM,
|
||||
description="Evidenz-Level: high / medium / low / unknown.",
|
||||
)
|
||||
direction: Direction = Field(
|
||||
default=Direction.UNCERTAIN,
|
||||
description="Beitrag des Claims: support, contradict oder uncertain.",
|
||||
)
|
||||
source_id: UUID = Field(
|
||||
...,
|
||||
description="UUID der Quelle (source_id).",
|
||||
)
|
||||
source_url: str = Field(
|
||||
...,
|
||||
min_length=1,
|
||||
description="URL der Quelle zur Provenance.",
|
||||
)
|
||||
source_title: str | None = Field(
|
||||
default=None,
|
||||
description="Titel der Quelle.",
|
||||
)
|
||||
evidence_span: str | None = Field(
|
||||
default=None,
|
||||
description="Zitat oder Textpassage aus der Quelle.",
|
||||
)
|
||||
confidence: float = Field(
|
||||
default=1.0,
|
||||
ge=0.0,
|
||||
le=1.0,
|
||||
description="Confidence-Wert der Claim-Extraktion (0-1).",
|
||||
)
|
||||
metadata: dict[str, Any] = Field(
|
||||
default_factory=dict,
|
||||
description="Zusätzliche Metadaten (z.B. evidence_type, score_dimensions).",
|
||||
)
|
||||
|
||||
@field_validator("claim_text")
|
||||
@classmethod
|
||||
def claim_text_not_empty(cls, v: str) -> str:
|
||||
if not v.strip():
|
||||
raise ValueError("claim_text darf nicht nur aus Whitespaces bestehen")
|
||||
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 nur aus Whitespaces bestehen")
|
||||
return v
|
||||
|
||||
model_config = {"frozen": True}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SynthesisSectionSchema — ein Abschnitt des Berichts
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class SectionType(str, Enum):
|
||||
"""Mögliche Abschnitte eines Synthese-Berichts."""
|
||||
|
||||
EXECUTIVE_SUMMARY = "executive_summary"
|
||||
CONTEXT = "context"
|
||||
CLAIMS_ANALYSIS = "claims_analysis"
|
||||
CONTRADICTIONS = "contradictions"
|
||||
UNCERTAINTY = "uncertainty"
|
||||
RECOMMENDATION = "recommendation"
|
||||
|
||||
|
||||
class SynthesisSectionSchema(BaseModel):
|
||||
"""Ein Abschnitt des Synthese-Berichts.
|
||||
|
||||
Trennt Facts, Interpretation und Unsicherheit explizit,
|
||||
so dass der Leser die Herleitung nachvollziehen kann.
|
||||
"""
|
||||
|
||||
section_type: SectionType = Field(
|
||||
description="Typ des Abschnitts.",
|
||||
)
|
||||
title: str = Field(
|
||||
...,
|
||||
min_length=1,
|
||||
max_length=256,
|
||||
description="Menschlicher Titel des Abschnitts.",
|
||||
)
|
||||
content: str = Field(
|
||||
...,
|
||||
min_length=1,
|
||||
description="Hauptinhalt des Abschnitts.",
|
||||
)
|
||||
facts: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="Gesicherte Fakten im Abschnitt — nur behauptete, verifizierte Tatsachen.",
|
||||
)
|
||||
interpretation: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="Interpretationen / Schlussfolgerungen, die aus den Fakten abgeleitet wurden.",
|
||||
)
|
||||
uncertainty: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="Offene Fragen und Unsicherheiten, die nicht geklärt sind.",
|
||||
)
|
||||
evidence_references: list[str] = Field(
|
||||
default_factory=list,
|
||||
description="Referenzen (URLs, IDs) zu den verwendeten Quellen.",
|
||||
)
|
||||
|
||||
@field_validator("content")
|
||||
@classmethod
|
||||
def content_not_empty(cls, v: str) -> str:
|
||||
if not v.strip():
|
||||
raise ValueError("content darf nicht leer sein")
|
||||
return v
|
||||
|
||||
@field_validator("title")
|
||||
@classmethod
|
||||
def title_not_empty(cls, v: str) -> str:
|
||||
if not v.strip():
|
||||
raise ValueError("title darf nicht nur Whitespaces enthalten")
|
||||
return v.strip()
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _validate_not_political_recommendation(self) -> "SynthesisSectionSchema":
|
||||
"""Stellt sicher, dass keine politischen Empfehlungen im Inhalt stehen."""
|
||||
forbidden = re.compile(
|
||||
r"(sollte\s+(Regierung|Bundesregierung|CDU|SPD|AfD|Grüne|FDP)\s+(handeln|machen|tun|unterstützen|verbieten)|"
|
||||
r"(Regierung|Bundesregierung|CDU|SPD|AfD|Grüne|FDP)\s+(muss|sollte|werden|soll)\s+(geändert|eingesetzt|abgewählt|gestürzt))",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
if forbidden.search(self.content):
|
||||
raise ValueError(
|
||||
"Section content darf keine politische Empfehlung enthalten"
|
||||
)
|
||||
return self
|
||||
|
||||
model_config = {"frozen": True}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SynthesisReportSchema — Hauptantwort von der LLM
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class SynthesisReportSchema(BaseModel):
|
||||
"""Finaler Bericht der Neutral Synthesis Engine (Stage 9).
|
||||
|
||||
Trennt explizit Fakten von Interpretation und kennzeichnet
|
||||
Unsicherheit. Jede Aussage ist mit einer Quelle verknüpft (Provenance).
|
||||
Keine politischen Empfehlungen.
|
||||
"""
|
||||
|
||||
research_topic: str = Field(
|
||||
...,
|
||||
min_length=1,
|
||||
max_length=1024,
|
||||
description="Das Forschungs-Thema / die Query.",
|
||||
)
|
||||
summary: str = Field(
|
||||
default="",
|
||||
description="Neutrale, sachliche Zusammenfassung aller Ergebnisse (ca. 3–5 Sätze).",
|
||||
)
|
||||
sections: list[SynthesisSectionSchema] = Field(
|
||||
default_factory=list,
|
||||
description="Strukturierte Abschnitte des Berichts.",
|
||||
)
|
||||
claims: list[SynthesisClaimSchema] = Field(
|
||||
default_factory=list,
|
||||
description="Alle analysierten Claims im Bericht.",
|
||||
)
|
||||
source_list: list[dict[str, Any]] = Field(
|
||||
default_factory=list,
|
||||
description="Alle einzigartigen Quellen im Bericht mit Metadaten.",
|
||||
)
|
||||
llm_model_used: str = Field(
|
||||
default="",
|
||||
description="Modell-Name/ID des LLM.",
|
||||
)
|
||||
generation_timestamp: datetime = Field(
|
||||
default_factory=lambda: datetime.now(timezone.utc),
|
||||
description="Zeitpunkt der Generierung.",
|
||||
)
|
||||
methodology: str = Field(
|
||||
default=(
|
||||
"NSCT Stage 9: Neutral Synthesis Engine. "
|
||||
"Bericht generiert aus evidenzbasierten Claims (Claims 0-8). "
|
||||
"Trennung von Fakten und Interpretation. "
|
||||
"Keine politischen Empfehlungen. "
|
||||
"Jede Aussage ist mit Quellen verknüpft (Provenance)."
|
||||
),
|
||||
description="Methodenbeschreibung der Synthese.",
|
||||
)
|
||||
|
||||
@field_validator("research_topic")
|
||||
@classmethod
|
||||
def research_topic_not_empty(cls, v: str) -> str:
|
||||
if not v.strip():
|
||||
raise ValueError("research_topic darf nicht nur Whitespaces enthalten")
|
||||
return v.strip()
|
||||
|
||||
@field_validator("summary")
|
||||
@classmethod
|
||||
def summary_not_political(cls, v: str) -> str:
|
||||
"""Kurze Prüfung, dass die Zusammenfassung keine politischen Empfehlungen enthält."""
|
||||
forbidden = re.compile(
|
||||
r"(sollte\s+(Regierung|Bundesregierung)\s+(handeln|unterstützen)|"
|
||||
r"muss\s+(geändert|eingesetzt|gestürzt))",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
if forbidden.search(v):
|
||||
raise ValueError(
|
||||
"Summary darf keine politische Empfehlung enthalten"
|
||||
)
|
||||
return v
|
||||
|
||||
model_config = {"frozen": True}
|
||||
Reference in New Issue
Block a user