feat(stage9): neutral synthesis engine — LLM-generated report from evidence package

This commit is contained in:
NSCT Agent
2026-08-24 11:52:10 +00:00
parent 1b54b172ca
commit d87e2b4d14
7 changed files with 3586 additions and 1 deletions

View File

@@ -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}

View 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. 35 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}