"""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"], )