330 lines
10 KiB
Python
330 lines
10 KiB
Python
"""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"],
|
|
) |