feat(stage9): neutral synthesis engine — LLM-generated report from evidence package
This commit is contained in:
@@ -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
330
src/nsct/api/synthesis.py
Normal 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"],
|
||||
)
|
||||
@@ -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}
|
||||
745
src/nsct/stages/stage9_synthesis.py
Normal file
745
src/nsct/stages/stage9_synthesis.py
Normal 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
|
||||
Reference in New Issue
Block a user