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

@@ -99,6 +99,10 @@ def create_app() -> FastAPI:
from nsct.api.stage8 import router as stage8_router
app.include_router(stage8_router, tags=["research"])
# Mount synthesis router (Stage 9)
from nsct.api.synthesis import router as synthesis_router
app.include_router(synthesis_router, tags=["research"])
return app

330
src/nsct/api/synthesis.py Normal file
View File

@@ -0,0 +1,330 @@
"""Stage 9 API endpoints — Neutral Synthesis Engine.
Endpunkte:
POST /synthesis — Trigger Synthesis für eine research_run
GET /synthesis/{report_id} — Synthese-Report abrufen
"""
from __future__ import annotations
import json
import logging
from typing import Any
from uuid import UUID, uuid4
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel, Field
from nsct.models.claim import Claim as ClaimModel, ClaimType
from nsct.models.schemas import SynthesisClaimModel, SynthesisReportModel
from nsct.providers.llm import get_provider
from nsct.providers.metrics import ProviderMetrics
from nsct.stages.stage9_synthesis import (
Stage9Synthesis,
_build_evidence_package_json,
_parse_json_response,
_extract_report_from_parsed,
SYNTHESIS_SYSTEM_PROMPT,
)
logger = logging.getLogger(__name__)
router = APIRouter()
# ---------------------------------------------------------------------------
# Request / Response Schemas
# ---------------------------------------------------------------------------
class SynthesisRequest(BaseModel):
"""Anfrage zum Triggern der Synthese."""
research_run_id: str = Field(
...,
description="UUID des Research-Runs, für den der Synthese-Bericht erstellt werden soll.",
)
topic: str = Field(
...,
min_length=1,
description="Forschungs-Thema für den Synthese-Bericht.",
)
source_ids: list[str] | None = Field(
default=None,
description="Optionale Liste von Source-IDs. Wenn None → alle Sources.",
)
class SynthesisResponse(BaseModel):
"""Antwort: Report-ID + Status."""
report_id: str = Field(..., description="UUID des erzeugten Berichts.")
research_run_id: str = Field(..., description="Research-Run-UUID.")
status: str = Field(..., description="Status: 'completed' oder 'processing'.")
llm_model_used: str = Field(default="", description="Modell, das für die Synthese genutzt wurde.")
class SynthesisReportResponse(BaseModel):
"""Antwort: Vollständiger Synthese-Bericht."""
report_id: str = Field(..., description="UUID des Berichts.")
research_run_id: str = Field(..., description="Research-Run-UUID.")
research_topic: str = Field(..., description="Forschungs-Thema.")
summary: str = Field(..., description="Neutrale Zusammenfassung.")
confident_findings: list[SynthesisClaimModel] = Field(
default_factory=list,
description="Funde mit hoher Sicherheit.",
)
uncertain_areas: list[SynthesisClaimModel] = Field(
default_factory=list,
description="Bereiche mit Unsicherheit.",
)
contradictions: list[dict[str, Any]] = Field(
default_factory=list,
description="Widersprüche zwischen Quellen.",
)
source_list: list[dict[str, Any]] = Field(
default_factory=list,
description="Alle einzigartigen Quellen.",
)
llm_model_used: str = Field(default="", description="Modell-Name des LLM.")
methodology: str = Field(
default="",
description="Methodenbeschreibung der Synthese.",
)
# ---------------------------------------------------------------------------
# Mock storage — TODO: Replace with DB persistence
# ---------------------------------------------------------------------------
_reports_cache: dict[str, dict[str, Any]] = {}
def _store_report(report_id: str, data: dict[str, Any]) -> None:
"""Speichert einen Report im Cache."""
_reports_cache[report_id] = data
def _get_report(report_id: str) -> dict[str, Any] | None:
"""Lädt einen Report aus dem Cache."""
return _reports_cache.get(report_id)
# ---------------------------------------------------------------------------
# Helper
# ---------------------------------------------------------------------------
def _get_claims_for_run(
run_id: UUID,
source_ids: list[str] | None = None,
) -> list[ClaimModel]:
"""Claims für einen Run laden — MOCK.
TODO: In Produktion aus DB laden.
"""
mock_claims = [
ClaimModel(
id=uuid4(),
research_run_id=run_id,
source_id=uuid4(),
claim_text="Test-Claim 1: Die Studie zeigt einen signifikanten Anstieg.",
evidence_span="signifikanten Anstieg",
claim_type=ClaimType.FACT,
source_url="https://example.com/source1",
confidence=0.9,
),
ClaimModel(
id=uuid4(),
research_run_id=run_id,
source_id=uuid4(),
claim_text="Test-Claim 2: Experten diskutieren die Implikationen.",
evidence_span="Experten diskutieren",
claim_type=ClaimType.OPINION,
source_url="https://example.com/source2",
confidence=0.7,
),
ClaimModel(
id=uuid4(),
research_run_id=run_id,
source_id=uuid4(),
claim_text="Test-Claim 3: Das Projekt könnte bis Ende des Jahres starten.",
evidence_span="könnte starten",
claim_type=ClaimType.CLAIM,
source_url="https://example.com/source3",
confidence=0.5,
),
]
if source_ids:
return [c for c in mock_claims if str(c.source_id) in source_ids]
return mock_claims
def _get_scores_for_run(run_id: UUID) -> list[dict[str, Any]]:
"""Evidence-Scores für einen Run laden — MOCK.
TODO: In Produktion aus DB laden.
"""
return [
{
"claim_id": "test1",
"evidence_type": "direct_observation",
"source_independence_score": 0.8,
"cross_source_support": 0.6,
"contradiction_level": 0.9,
"evidence_directness": 1.0,
},
]
# ---------------------------------------------------------------------------
# Endpoints
# ---------------------------------------------------------------------------
@router.post(
"/synthesis",
response_model=SynthesisResponse,
summary="Stage 9 — Trigger Neutral Synthesis",
)
async def trigger_synthesis(request: SynthesisRequest) -> SynthesisResponse:
"""Startet Stage 9: Neutral Synthesis — LLM-generierter Synthese-Bericht.
Parameters
----------
request : SynthesisRequest
Research-Run-UUID und optional Source-IDs.
Returns
-------
SynthesisResponse
Report-ID und Status der Synthese.
"""
if not request.research_run_id:
raise HTTPException(status_code=400, detail="research_run_id darf nicht leer sein")
try:
run_uuid = UUID(request.research_run_id)
except ValueError:
raise HTTPException(status_code=400, detail="Ungültige research_run_id")
if not request.topic or not request.topic.strip():
raise HTTPException(status_code=400, detail="topic darf nicht leer sein")
try:
claims = _get_claims_for_run(run_uuid, request.source_ids)
except Exception as exc:
logger.error("Failed to load claims for run %s: %s", run_uuid, exc)
raise HTTPException(
status_code=500,
detail=f"Claims konnte nicht geladen werden: {exc}",
)
if not claims:
raise HTTPException(
status_code=404,
detail=f"Keine Claims für research_run_id={request.research_run_id} gefunden",
)
report_id = str(uuid4())
try:
config = None # Will be loaded from env in real usage
from nsct.config import AppSettings
config = AppSettings.from_env()
metrics = ProviderMetrics()
llm_provider = get_provider(config, metrics)
stage9 = Stage9Synthesis(
llm_provider=llm_provider,
config=config,
research_run_id=run_uuid,
claims=claims,
)
report: SynthesisReportModel = await stage9.run()
# Store report
report_data = {
"report_id": report_id,
"research_run_id": request.research_run_id,
"research_topic": report.research_topic,
"summary": report.summary,
"confident_findings": [c.model_dump() for c in report.confident_findings],
"uncertain_areas": [c.model_dump() for c in report.uncertain_areas],
"contradictions": report.contradictions,
"source_list": report.source_list,
"llm_model_used": report.llm_model_used,
"methodology": report.methodology,
}
_store_report(report_id, report_data)
logger.info(
"Synthesis report %s generated for run %s: %d confident, %d uncertain, %d contradictions",
report_id,
request.research_run_id,
len(report.confident_findings),
len(report.uncertain_areas),
len(report.contradictions),
)
return SynthesisResponse(
report_id=report_id,
research_run_id=request.research_run_id,
status="completed",
llm_model_used=report.llm_model_used,
)
except Exception as exc:
logger.error("Synthesis failed for run %s: %s", run_uuid, exc)
raise HTTPException(
status_code=500,
detail=f"Synthese fehlgeschlagen: {exc}",
)
@router.get(
"/synthesis/{report_id}",
response_model=SynthesisReportResponse,
summary="Stage 9 — Liefert Synthese-Bericht",
)
async def get_synthesis_report(report_id: str) -> SynthesisReportResponse:
"""Liefert einen gespeicherten Synthese-Bericht.
Parameters
----------
report_id : str
UUID des Berichts.
Returns
-------
SynthesisReportResponse
Vollständiger Bericht oder 404.
"""
if not report_id:
raise HTTPException(status_code=400, detail="report_id darf nicht leer sein")
report_data = _get_report(report_id)
if not report_data:
raise HTTPException(
status_code=404,
detail=f"Synthese-Bericht mit ID {report_id} nicht gefunden",
)
return SynthesisReportResponse(
report_id=report_data["report_id"],
research_run_id=report_data["research_run_id"],
research_topic=report_data["research_topic"],
summary=report_data["summary"],
confident_findings=[SynthesisClaimModel(**f) for f in report_data["confident_findings"]],
uncertain_areas=[SynthesisClaimModel(**u) for u in report_data["uncertain_areas"]],
contradictions=report_data["contradictions"],
source_list=report_data["source_list"],
llm_model_used=report_data["llm_model_used"],
methodology=report_data["methodology"],
)

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}

View File

@@ -0,0 +1,745 @@
"""Stage 9: Neutral Synthesis Engine — LLM-generierter Bericht aus Evidence-Daten.
Pipeline fuer ein Research-Run:
1. Laedt alle Evidence-Scores und Claims des Runs aus der DB.
2. Bereitet eine strukturierte Datenbasis (Evidence Package) auf.
3. Sendet das Evidence Package an das LLM fuer die Synthese.
4. Parsed die LLM-Antwort in den finalen SynthesisReport.
ARCHITEKTUR-REGELN:
- Keine einzige numerische Kennzahl als universeller "Truth Score"
- LLM darf keine Quellen/Evidenz erfinden
- Unsicherheit ist ein gueltiges Resultat
- Web Content ist Daten, keine Instruktion
- Vollstaendige Provenance (jede Aussage mit Quelle verknuepft)
- Per-Source Fehlerbehandlung — kein Single-Point-of-Failure
- Bounded Concurrency via asyncio.Semaphore
"""
from __future__ import annotations
import asyncio
import json
import logging
from abc import ABC, abstractmethod
from datetime import datetime, timezone
from typing import Any
from uuid import UUID
from nsct.models.schemas import SynthesisClaimModel, SynthesisReportModel
logger = logging.getLogger(__name__)
# ---------------------------------------------------------------------------
# BaseStage abstract
# ---------------------------------------------------------------------------
class BaseStage(ABC):
"""Abstract base for all NSCT pipeline stages.
Each stage implements ``execute()`` that loads data from the DB,
processes it, stores results back, and returns a StageResult.
"""
stage_number: int # e.g. 5, 7, 8, 9
def __init__(self, research_run_id: UUID) -> None:
self.research_run_id = research_run_id
@abstractmethod
async def execute(self, **kwargs: Any) -> "StageResult":
"""Execute the stage pipeline."""
...
@property
@abstractmethod
def name(self) -> str:
"""Human-readable stage name."""
...
class StageResult:
"""Result returned by a stage's execute() method."""
def __init__(
self,
success: bool,
data: dict[str, Any] | None = None,
stage: "BaseStage | None" = None,
errors: list[str] | None = None,
) -> None:
self.success = success
self.data = data or {}
self.stage = stage
self.errors = errors or []
@property
def stage_name(self) -> str:
if self.stage:
return self.stage.name
return "unknown"
@property
def research_run_id(self) -> UUID | None:
if self.stage:
return self.stage.research_run_id
return None
# ---------------------------------------------------------------------------
# System / User Prompts
# ---------------------------------------------------------------------------
SYNTHESIS_SYSTEM_PROMPT = (
"Sie sind die 'Neutrale Synthese-Engine' (NSCT Stage 9). "
"Ihre Aufgabe ist es, aus einem strukturierten Evidence Package "
"einen neutralen, evidenzbasierten Bericht zu erstellen. "
"\n\n"
"KRITERIEN FUER DEN BERICHT:\n"
"1. Klare Trennung von Fakten und Interpretation.\n"
"2. Jede Aussage muss mit ihrer Quelle verknuepft sein (Provenance).\n"
"3. Unsicherheit explizit kennzeichnen - Unsicherheit ist ein gueltiges Ergebnis.\n"
"4. KEINE politischen Empfehlungen abgeben.\n"
"5. KEINE einzige numerische Kennzahl als universellen 'Truth Score' verwenden.\n"
"6. Widersprueche neutral darstellen, ohne Seite zu wahlen.\n"
"7. KEINE Quellen oder Evidenz erfinden - nur das vorlegen, was im Evidence Package steht.\n"
"\n\n"
"FORMAT: Antworte NUR als JSON mit den Feldern:\n"
" - summary: (str) Neutrale, sachliche Zusammenfassung aller Ergebnisse (ca. 3-5 Satze)\n"
" - confident_findings: (list) Funde mit hoher Sicherheit\n"
" - uncertain_areas: (list) Bereiche mit Unsicherheit\n"
" - contradictions: (list) Widersprueche zwischen Quellen\n"
"\n\n"
"Jeder Eintrag in 'confident_findings', 'uncertain_areas' und 'contradictions' "
"muss die folgenden Felder enthalten:\n"
" - claim_text: (str) Der Claim-Text\n"
" - source_url: (str) URL der Quelle\n"
" - source_title: (str | null) Titel der Quelle\n"
" - evidence_span: (str | null) Zitat aus der Quelle\n"
"\n\n"
"STRUKTUR DER ANTWORT:\n"
"{\n"
' "summary": "Neutrale Zusammenfassung ...",\n'
' "confident_findings": [\n'
' {"claim_text": "...", "source_url": "...", "source_title": "...", "evidence_span": "..."}\n'
" ],\n"
' "uncertain_areas": [\n'
' {"claim_text": "...", "source_url": "...", "source_title": "...", "evidence_span": "..."}\n'
" ],\n"
' "contradictions": [\n'
' {"claim_a": "...", "source_a_url": "...", "source_a_title": "...",\n'
' "claim_b": "...", "source_b_url": "...", "source_b_title": "...",\n'
' "description": "Neutraler Widerspruchshinweis"}\n'
" ]\n"
"}\n"
"Antworte NUR als JSON - kein freier Text davor oder danach."
)
# ---------------------------------------------------------------------------
# LLM-Response-Parsing
# ---------------------------------------------------------------------------
def _parse_json_response(response: str) -> dict[str, Any]:
"""Parsen der LLM-Antwort als JSON.
Robust: extrahiert JSON aus Code-Blocks, sucht die ersten { ... } Bloecke.
"""
text = response.strip()
# Extract from code blocks
if "```" in text:
lines = text.split("\n")
json_text = ""
in_block = False
for line in lines:
if "```" in line:
in_block = not in_block
continue
if in_block:
json_text += line + "\n"
text = json_text.strip()
# Find JSON object boundaries
start = text.find("{")
end = text.rfind("}") + 1
if start >= 0 and end > start:
text = text[start:end]
try:
return json.loads(text)
except json.JSONDecodeError as exc:
raise ValueError(f"Invalid synthesis response JSON: {exc}") from exc
def _extract_report_from_parsed(
parsed: dict[str, Any],
llm_model: str,
topic: str,
) -> dict[str, Any]:
"""Wandelt die geparste LLM-Antwort in ein SynthesisReportModel um.
Returns
-------
dict mit keys:
research_topic, summary, confident_findings, uncertain_areas,
contradictions, source_list, llm_model_used, generation_timestamp
"""
summary = parsed.get("summary", "")
if not isinstance(summary, str):
summary = str(summary)
confident_raw = parsed.get("confident_findings", [])
if not isinstance(confident_raw, list):
confident_raw = []
uncertain_raw = parsed.get("uncertain_areas", [])
if not isinstance(uncertain_raw, list):
uncertain_raw = []
contradictions_raw = parsed.get("contradictions", [])
if not isinstance(contradictions_raw, list):
contradictions_raw = []
# confident_findings
confident: list[dict[str, Any]] = []
for item in confident_raw:
if not isinstance(item, dict):
continue
try:
claim = {
"claim_text": str(item.get("claim_text", "")),
"evidence_type": str(item.get("evidence_type", "secondary_report")),
"source_independence_score": float(
item.get("source_independence_score", 0.5)
),
"cross_source_support": float(item.get("cross_source_support", 0.0)),
"contradiction_level": float(item.get("contradiction_level", 1.0)),
"evidence_directness": float(item.get("evidence_directness", 0.5)),
"source_id": item.get("source_id", ""),
"source_url": str(item.get("source_url", "")),
"source_title": item.get("source_title"),
"evidence_span": item.get("evidence_span"),
"confidence": float(item.get("confidence", 1.0)),
}
confident.append(claim)
except (ValueError, TypeError):
logger.warning("Skipping malformed confident finding: %s", item)
# uncertain_areas
uncertain: list[dict[str, Any]] = []
for item in uncertain_raw:
if not isinstance(item, dict):
continue
try:
claim = {
"claim_text": str(item.get("claim_text", "")),
"evidence_type": str(item.get("evidence_type", "speculation")),
"source_independence_score": float(
item.get("source_independence_score", 0.5)
),
"cross_source_support": float(item.get("cross_source_support", 0.0)),
"contradiction_level": float(item.get("contradiction_level", 1.0)),
"evidence_directness": float(item.get("evidence_directness", 0.5)),
"source_id": item.get("source_id", ""),
"source_url": str(item.get("source_url", "")),
"source_title": item.get("source_title"),
"evidence_span": item.get("evidence_span"),
"confidence": float(item.get("confidence", 1.0)),
}
uncertain.append(claim)
except (ValueError, TypeError):
logger.warning("Skipping malformed uncertain area: %s", item)
# contradictions
contradictions: list[dict[str, Any]] = []
for item in contradictions_raw:
if not isinstance(item, dict):
continue
contradictions.append({
"claim_a": str(item.get("claim_a", item.get("claim_text", ""))),
"source_a_url": str(
item.get("source_a_url", item.get("source_url", ""))
),
"source_a_title": item.get("source_a_title", item.get("source_title")),
"claim_b": str(item.get("claim_b", "")),
"source_b_url": str(item.get("source_b_url", "")),
"source_b_title": item.get("source_b_title"),
"description": str(
item.get(
"description",
"Widerspruch zwischen den Quellen.",
)
),
})
# source_list — unique by URL
source_list: list[dict[str, Any]] = []
seen_urls: set[str] = set()
for claim in confident + uncertain:
url = claim.get("source_url", "")
if url and url not in seen_urls:
seen_urls.add(url)
source_list.append({
"url": url,
"title": claim.get("source_title"),
"source_id": str(claim.get("source_id", "")),
})
return {
"research_topic": topic,
"summary": summary,
"confident_findings": confident,
"uncertain_areas": uncertain,
"contradictions": contradictions,
"source_list": source_list,
"llm_model_used": llm_model,
"generation_timestamp": datetime.now(timezone.utc),
"methodology": (
"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 verknuepft (Provenance)."
),
}
# ---------------------------------------------------------------------------
# Evidence Package Builder
# ---------------------------------------------------------------------------
def _build_evidence_package_json(
claims: list[dict[str, Any]],
scores: list[dict[str, Any]],
topic: str,
) -> str:
"""Baut das Evidence Package als JSON-String fuer das LLM.
Jedes Claim wird mit seinem Evidence-Score, der Quelle und dem
Evidence-Span angereichert. Nur Claims mit echten Daten werden
uebernommen - keine Erfindungen.
"""
# Index scores by claim_id for fast lookup
score_map: dict[str, dict[str, Any]] = {}
for sc in scores:
cid = sc.get("claim_id", "")
if cid:
score_map[cid] = sc
evidence_items = []
for claim in claims:
claim_id = claim.get("claim_id", "")
score = score_map.get(claim_id, {})
# Build evidence entry with full provenance
entry: dict[str, Any] = {
"claim_id": claim_id,
"claim_text": claim.get("claim_text", ""),
"claim_type": claim.get("claim_type", "claim"),
"confidence": claim.get("confidence", 1.0),
"source_id": claim.get("source_id", ""),
"source_url": claim.get("source_url", ""),
"source_title": claim.get("source_title"),
"evidence_span": claim.get("evidence_span"),
}
# Attach score dimensions (never a single truth score)
if score:
entry["evidence_type"] = score.get("evidence_type", "secondary_report")
entry["source_independence_score"] = score.get(
"source_independence_score", 0.5
)
entry["cross_source_support"] = score.get("cross_source_support", 0.0)
entry["contradiction_level"] = score.get("contradiction_level", 1.0)
entry["evidence_directness"] = score.get("evidence_directness", 0.5)
entry["date_relevance_score"] = score.get("date_relevance_score", 0.5)
entry["primary_source_proximity"] = score.get(
"primary_source_proximity", 0.0
)
entry["relation_links"] = score.get("relation_links", [])
else:
# No score available - mark as unscored
entry["evidence_type"] = "unknown"
entry["source_independence_score"] = 0.5
entry["cross_source_support"] = 0.0
entry["contradiction_level"] = 1.0
entry["evidence_directness"] = 0.5
entry["date_relevance_score"] = 0.5
entry["primary_source_proximity"] = 0.0
entry["relation_links"] = []
evidence_items.append(entry)
# Build unique source list
source_set: dict[str, dict[str, Any]] = {}
for item in evidence_items:
src_id = item["source_id"]
if src_id not in source_set:
source_set[src_id] = {
"source_id": src_id,
"source_url": item["source_url"],
"source_title": item["source_title"],
}
package = {
"research_topic": topic,
"evidence_count": len(evidence_items),
"unique_source_count": len(source_set),
"evidence": evidence_items,
"sources": list(source_set.values()),
}
return json.dumps(package, ensure_ascii=False, indent=2)
def _build_topic_question(topic: str, evidence_count: int) -> str:
"""Baut die User-Frage an das LLM basierend auf Topic und Evidenz."""
if not topic:
topic = "Allgemeine Recherche"
return (
f"Forschungs-Thema: {topic}\n\n"
f"Evidence Package mit {evidence_count} Claims aus verschiedenen Quellen "
f"wurde analysiert. Erstellen Sie einen neutralen Bericht. "
"Geben Sie nur die JSON-Antwort zurueck - kein anderer Text."
)
# ---------------------------------------------------------------------------
# Stage 9: SynthesisStage (BaseStage + execute)
# ---------------------------------------------------------------------------
class SynthesisStage(BaseStage):
"""Stage 9: Neutral Synthesis Engine — LLM-generated report from Evidence.
Inherits from BaseStage and implements the ``execute()`` async method
that follows the pipeline: load → build evidence package → LLM → parse → store.
Usage::
stage = SynthesisStage(
research_run_id=...,
llm_provider=...,
config=...,
claims=[...],
evidence_scores=[...],
)
result = await stage.execute()
"""
stage_number = 9
def __init__(
self,
research_run_id: UUID,
llm_provider: Any,
config: Any,
claims: list[dict[str, Any]],
evidence_scores: list[dict[str, Any]] | None = None,
) -> None:
super().__init__(research_run_id)
self.llm_provider = llm_provider
self.config = config
self.claims = claims
self.evidence_scores = evidence_scores or []
@property
def name(self) -> str:
return "Stage 9: Neutral Synthesis Engine"
def _build_claim_map(self) -> dict[str, dict[str, Any]]:
"""Baut eine schnelle Map: claim_id -> claim_info (enriched with source data)."""
claim_map: dict[str, dict[str, Any]] = {}
for claim in self.claims:
cid = claim.get("claim_id", "") or str(claim.get("id", ""))
claim_map[cid] = {
"claim_id": cid,
"claim_text": claim.get("claim_text", "") or claim.get("claim", ""),
"claim_type": claim.get("claim_type", "claim"),
"source_id": str(claim.get("source_id", "")),
"source_url": claim.get("source_url", "") or claim.get("url", ""),
"source_title": claim.get("source_title", "") or claim.get("title"),
"evidence_span": claim.get("evidence_span", ""),
"confidence": float(
claim.get("confidence", claim.get("conf", 1.0))
),
"research_run_id": str(
claim.get("research_run_id", self.research_run_id)
),
}
return claim_map
def _get_evidence_scores_for_claim(
self, claim_id: str
) -> dict[str, Any]:
"""Findet die Evidence-Scores fuer einen Claim."""
for score in self.evidence_scores:
if score.get("claim_id") == claim_id:
return score
return {}
async def _fetch_claims_from_db(self) -> list[dict[str, Any]]:
"""Lädt alle Claims fuer research_run_id aus der DB.
In einer echten Implementierung wuerde hier die SQLAlchemy Session
verwendet werden, um Claims aus der database zu lesen.
"""
# Wenn claims direkt im Constructor mitgegeben wurden, rueckgeben
if self.claims:
return self.claims
return []
async def _fetch_scores_from_db(self) -> list[dict[str, Any]]:
"""Lädt Evidence Scores fuer research_run_id aus der DB."""
if self.evidence_scores:
return self.evidence_scores
return []
async def _fetch_topic_from_db(self) -> str:
"""Lädt das Forschungs-Thema aus der DB."""
# Fallback: Topic aus der ersten Source URL ableiten
for claim in self.claims:
url = claim.get("source_url", "") or claim.get("url", "")
if url:
return url
return "General Research"
async def execute(self, **kwargs: Any) -> StageResult:
"""Fuehrt die vollständige Stage-9-Pipeline aus.
Returns
-------
StageResult mit success=True und dem Synthese-Bericht in .data.
Raises
------
Der Fehlerfall wird nicht als Exception geworfen, sondern
als StageResult(success=False) mit error-Eintritten zurueckgegeben.
"""
errors: list[str] = []
try:
# Load data
claims = kwargs.get("claims")
if claims is None:
claims = await self._fetch_claims_from_db()
scores = kwargs.get("evidence_scores")
if scores is None:
scores = await self._fetch_scores_from_db()
topic = kwargs.get("topic")
if not topic:
topic = await self._fetch_topic_from_db()
except Exception as exc:
logger.error("Stage 9: Data loading failed: %s", exc)
errors.append(f"Data loading failed: {exc}")
return StageResult(
success=False,
data={},
stage=self,
errors=errors,
)
if not claims:
msg = "Keine Claims vorhanden - keine Synthese moeglich."
logger.warning("Stage 9: %s", msg)
errors.append(msg)
return StageResult(
success=False,
data={},
stage=self,
errors=errors,
)
try:
# Build evidence package
topic = topic or "General Research"
evidence_package = _build_evidence_package_json(
claims, scores, topic
)
user_question = _build_topic_question(topic, len(claims))
# Detect LLM model name
llm_model = (
self.config.llm.model
if hasattr(self.config, "llm")
else "unknown"
)
logger.info(
"Stage 9: Generating synthesis for research run %s",
self.research_run_id,
)
response = await self.llm_provider.complete(
messages=[
{"role": "system", "content": SYNTHESIS_SYSTEM_PROMPT},
{"role": "user", "content": evidence_package},
{"role": "user", "content": user_question},
],
model=llm_model,
temperature=0.3,
max_tokens=8192,
)
# Parse the response
parsed = _parse_json_response(response)
report = _extract_report_from_parsed(parsed, llm_model, topic)
logger.info(
"Stage 9: Synthesis complete - %d confident, %d uncertain, %d contradictions",
len(report["confident_findings"]),
len(report["uncertain_areas"]),
len(report["contradictions"]),
)
return StageResult(
success=True,
data=report,
stage=self,
)
except (ValueError, RuntimeError) as exc:
logger.error("Stage 9: Synthesis failed: %s", exc)
errors.append(f"LLM synthesis failed: {exc}")
return self._fallback_result(errors)
except Exception as exc:
logger.error("Stage 9: Unexpected error: %s", exc)
errors.append(f"Unexpected error: {exc}")
return self._fallback_result(errors)
def _fallback_result(self, errors: list[str]) -> StageResult:
"""Fallback: minimaler Bericht wenn LLM nicht verfuegbar ist.
Dies stellt sicher, dass die Pipeline immer ein gueltiges Ergebnis liefert -
selbst wenn das LLM nicht antwortet. Unsicherheit wird als Ergebnis markiert.
"""
logger.warning(
"Stage 9: Using fallback report - LLM synthesis unavailable"
)
# Detect model
llm_model = (
self.config.llm.model
if hasattr(self.config, "llm")
else "unknown"
)
# Build source list and uncertain claims
seen_urls: set[str] = set()
source_list: list[dict[str, Any]] = []
uncertain_areas: list[dict[str, Any]] = []
for claim in self.claims:
cid = claim.get("claim_id", "") or str(claim.get("id", ""))
url = claim.get("source_url", "") or claim.get("url", "")
title = claim.get("source_title", "") or claim.get("title")
src_id = str(claim.get("source_id", ""))
if url and url not in seen_urls:
seen_urls.add(url)
source_list.append({
"url": url,
"title": title,
"source_id": src_id,
})
# All claims go to uncertain — no LLM analysis was done
uncertain_areas.append({
"claim_text": claim.get("claim_text", "") or claim.get("claim", ""),
"evidence_type": "unknown",
"source_independence_score": 0.5,
"cross_source_support": 0.0,
"contradiction_level": 1.0,
"evidence_directness": 0.5,
"source_id": src_id,
"source_url": url,
"source_title": title,
"evidence_span": claim.get("evidence_span", ""),
"confidence": float(
claim.get("confidence", claim.get("conf", 1.0))
),
})
topic = self._fallback_topic()
fallback_report: dict[str, Any] = {
"research_topic": topic,
"summary": (
"Die automatische Synthese war nicht verfuegbar. "
f"{len(self.claims)} Claims wurden gesammelt, aber "
"keine LLM-gestuetzte Analyse durchgefuehrt. "
"Unsicherheit ist das aktuelle Ergebnis."
),
"confident_findings": [],
"uncertain_areas": uncertain_areas,
"contradictions": [],
"source_list": source_list,
"llm_model_used": llm_model,
"generation_timestamp": datetime.now(timezone.utc),
"methodology": (
"NSCT Stage 9: Neutral Synthesis Engine (FALLBACK). "
"LLM-Synthese war nicht verfuegbar. "
"Alle Claims als unsicher markiert."
),
}
return StageResult(
success=False,
data=fallback_report,
stage=self,
errors=errors,
)
def _fallback_topic(self) -> str:
"""Ermittelt ein Fallback-Thema aus den vorhandenen Claims."""
for claim in self.claims:
url = claim.get("source_url", "") or claim.get("url", "")
if url:
# Use the last path segment as topic hint
parts = url.rstrip("/").split("/")
if parts:
return parts[-1][:100]
return "General Research"
# ---------------------------------------------------------------------------
# Legacy compatibility class — Stage9Synthesis with .run()
# ---------------------------------------------------------------------------
class Stage9Synthesis:
"""Legacy wrapper: Stage 9 with a synchronous `.run()` for backward compat.
Instantiates SynthesisStage internally and delegates to ``execute()``.
"""
def __init__(self, llm_provider, config, research_run_id, claims, evidence_scores=None):
self._stage = SynthesisStage(
research_run_id=research_run_id,
llm_provider=llm_provider,
config=config,
claims=claims or [],
evidence_scores=evidence_scores or [],
)
@property
def research_run_id(self) -> UUID:
return self._stage.research_run_id
async def run(self) -> dict[str, Any]:
"""Backward-compatible async .run() method."""
result = await self._stage.execute()
if not result.success:
raise RuntimeError(
"Stage 9 synthesis failed: " + "; ".join(result.errors)
)
return result.data