927 lines
31 KiB
Python
927 lines
31 KiB
Python
"""REST API — research lifecycle: POST / GET / DELETE + status, sources, claims, report, list."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import time
|
|
import uuid
|
|
from datetime import datetime, timezone
|
|
from typing import Any
|
|
|
|
from fastapi import BackgroundTasks, APIRouter, Depends, HTTPException, Request
|
|
from pydantic import BaseModel, Field
|
|
from sqlalchemy import select
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
|
|
|
from nsct.logging_config import set_request_ctx, clear_request_ctx, set_research_run_id, get_logger
|
|
from nsct.orchestration.budget import HardBudgetConfig
|
|
from nsct.orchestration.state import ResearchRunState
|
|
from nsct.providers.priority_queue import Priority
|
|
from nsct.metrics import (
|
|
metrics,
|
|
C_SEARCH_QUERIES_TOTAL,
|
|
C_SOURCES_FETCHED_TOTAL,
|
|
C_CLAIMS_EXTRACTED_TOTAL,
|
|
C_CONTRADICTIONS_DETECTED_TOTAL,
|
|
C_RESEARCH_COMPLETED_TOTAL,
|
|
C_RESEARCH_FAILED_TOTAL,
|
|
H_RESEARCH_DURATION,
|
|
G_ACTIVE_RESEARCH_RUNS,
|
|
)
|
|
from nsct.security.api_keys import require_api_key
|
|
from nsct.security.deletion_protection import (
|
|
hash_deletion_password,
|
|
is_administrator,
|
|
may_manage_retention,
|
|
verify_deletion_password,
|
|
)
|
|
from nsct.storage.engine import get_optional_session
|
|
from nsct.storage.models import APIKeyModel, ResearchDeletionAuditModel, ResearchRetentionModel
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Research data and operations are intentionally never exposed without a user
|
|
# API key. Health/readiness endpoints remain public for infrastructure probes.
|
|
router = APIRouter(dependencies=[Depends(require_api_key)])
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Request / Response Schemas
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class DepthConfig(BaseModel, frozen=True):
|
|
"""Budget-Konfiguration pro Tiefe."""
|
|
|
|
key: str = Field(..., description="'quick', 'normal' oder 'deep'")
|
|
max_search_queries: int = Field(..., ge=1)
|
|
max_sources: int = Field(..., ge=1)
|
|
max_pages_per_domain: int = Field(1, ge=1)
|
|
max_total_download_bytes: int = Field(500_000, ge=1)
|
|
max_llm_requests: int = Field(15, ge=1)
|
|
max_research_duration_seconds: int = Field(120, ge=1)
|
|
max_context_per_llm_call: int = Field(16_000, ge=1)
|
|
|
|
|
|
# Depth budgets — steuern **ausschließlich** Budgets, nie Bewertungsmaßstäbe.
|
|
# Die Aufgabenspezifikation:
|
|
# quick: max_search_queries=10, max_sources=15, max_research_rounds=1
|
|
# normal: max_search_queries=20, max_sources=30, max_research_rounds=2
|
|
# deep: max_search_queries=40, max_sources=60, max_research_rounds=3
|
|
DEPTH_CONFIGS: dict[str, DepthConfig] = {
|
|
"quick": DepthConfig(
|
|
key="quick",
|
|
max_search_queries=10,
|
|
max_sources=15,
|
|
max_pages_per_domain=2,
|
|
max_total_download_bytes=500_000,
|
|
max_llm_requests=15,
|
|
max_research_duration_seconds=1_800,
|
|
max_context_per_llm_call=16_000,
|
|
),
|
|
"normal": DepthConfig(
|
|
key="normal",
|
|
max_search_queries=20,
|
|
max_sources=30,
|
|
max_pages_per_domain=5,
|
|
max_total_download_bytes=2_000_000,
|
|
max_llm_requests=30,
|
|
max_research_duration_seconds=3_600,
|
|
max_context_per_llm_call=24_000,
|
|
),
|
|
"deep": DepthConfig(
|
|
key="deep",
|
|
max_search_queries=40,
|
|
max_sources=60,
|
|
max_pages_per_domain=10,
|
|
max_total_download_bytes=5_000_000,
|
|
max_llm_requests=60,
|
|
max_research_duration_seconds=7_200,
|
|
max_context_per_llm_call=32_000,
|
|
),
|
|
}
|
|
|
|
|
|
class ResearchRequest(BaseModel):
|
|
"""POST /v1/research input."""
|
|
|
|
query: str = Field(..., min_length=1, max_length=2000, description="Forschungsfrage.")
|
|
language: str = Field(default="de", description="Sprachcode, z.B. 'de', 'en'.")
|
|
depth: str = Field(default="normal", description="Suchtiefe: 'quick', 'normal', 'deep'.")
|
|
extra_queries: list[str] | None = Field(
|
|
default=None,
|
|
description="Optionale zusätzliche Suchanfragen, die in die Pipeline aufgenommen werden.",
|
|
)
|
|
|
|
|
|
class ResearchListItem(BaseModel):
|
|
"""Ein Eintrag in der Forschungsliste."""
|
|
|
|
research_id: str
|
|
query: str
|
|
depth: str
|
|
state: str
|
|
created_at: str
|
|
source_count: int = 0
|
|
claim_count: int = 0
|
|
is_deletion_protected: bool = False
|
|
|
|
|
|
class ResearchListResponse(BaseModel):
|
|
"""GET /v1/research — paginierte Liste."""
|
|
|
|
total: int
|
|
limit: int
|
|
offset: int
|
|
items: list[ResearchListItem] = Field(default_factory=list)
|
|
|
|
|
|
class ResearchCreateResponse(BaseModel):
|
|
"""Antwort vom Start einer Recherche."""
|
|
|
|
research_id: str = Field(..., description="UUID des Research-Runs.")
|
|
status: str = Field(..., description="'pending' — die Recherche läuft im Hintergrund.")
|
|
query: str = Field(..., description="Die Forschungsfrage.")
|
|
depth: str = Field(..., description="Angelegte Tiefe.")
|
|
state: str = Field(..., description="Aktueller State-Machine-Status.")
|
|
|
|
|
|
class StatusResponse(BaseModel):
|
|
"""GET /v1/research/{id}/status."""
|
|
|
|
research_id: str
|
|
query: str
|
|
depth: str
|
|
state: str
|
|
created_at: str
|
|
updated_at: str | None = None
|
|
search_count: int = 0
|
|
source_count: int = 0
|
|
claim_count: int = 0
|
|
error: str | None = None
|
|
is_completed: bool = False
|
|
is_running: bool = False
|
|
is_deletion_protected: bool = False
|
|
|
|
|
|
class DeletionProtectionRequest(BaseModel):
|
|
"""Set or replace a password that guards a research against deletion."""
|
|
|
|
password: str = Field(..., min_length=8, max_length=256)
|
|
current_password: str | None = Field(default=None, max_length=256)
|
|
|
|
|
|
class DeletionRequest(BaseModel):
|
|
"""Optional password used when deleting a protected research."""
|
|
|
|
password: str | None = Field(default=None, max_length=256)
|
|
|
|
|
|
class DeletedResearchItem(BaseModel):
|
|
research_id: str
|
|
query: str
|
|
hidden_at: str
|
|
is_deletion_protected: bool
|
|
|
|
|
|
class SourceListItem(BaseModel):
|
|
"""Ein Source-Eintrag."""
|
|
|
|
id: str
|
|
url: str
|
|
title: str | None = None
|
|
domain: str = ""
|
|
source_type: str | None = None
|
|
retrieved_at: str = ""
|
|
|
|
|
|
class SourcesResponse(BaseModel):
|
|
"""GET /v1/research/{id}/sources."""
|
|
|
|
research_id: str
|
|
sources: list[SourceListItem] = Field(default_factory=list)
|
|
total: int = 0
|
|
|
|
|
|
class ClaimListItem(BaseModel):
|
|
"""Ein Claim-Eintrag."""
|
|
|
|
id: str
|
|
research_run_id: str
|
|
source_id: str
|
|
claim_text: str
|
|
evidence_span: str
|
|
claim_type: str
|
|
confidence: float = 1.0
|
|
|
|
|
|
class ClaimsResponse(BaseModel):
|
|
"""GET /v1/research/{id}/claims."""
|
|
|
|
research_id: str
|
|
claims: list[ClaimListItem] = Field(default_factory=list)
|
|
total: int = 0
|
|
|
|
|
|
class EvidenceScoreItem(BaseModel):
|
|
"""Ein Evidence-Score pro Claim."""
|
|
|
|
claim_id: str
|
|
research_run_id: str
|
|
evidence_type: str = "secondary_report"
|
|
source_independence_score: float = 0.5
|
|
primary_source_proximity: float = 0.0
|
|
cross_source_support: float = 0.0
|
|
contradiction_level: float = 1.0
|
|
evidence_directness: float = 0.5
|
|
date_relevance_score: float = 0.5
|
|
|
|
|
|
class EvidenceResponse(BaseModel):
|
|
"""GET /v1/research/{id}/evidence."""
|
|
|
|
research_id: str
|
|
evidence: list[EvidenceScoreItem] = Field(default_factory=list)
|
|
total: int = 0
|
|
|
|
|
|
class FindingItem(BaseModel):
|
|
"""Ein einzelner Befund."""
|
|
|
|
text: str
|
|
confidence: float = 0.0
|
|
source_ids: list[str] = Field(default_factory=list)
|
|
|
|
|
|
class ReportResponse(BaseModel):
|
|
"""GET /v1/research/{id}/report."""
|
|
|
|
research_id: str
|
|
query: str
|
|
summary: str = ""
|
|
findings: list[FindingItem] = Field(default_factory=list)
|
|
disagreements: list[dict[str, Any]] = Field(default_factory=list)
|
|
uncertainties: list[str] = Field(default_factory=list)
|
|
source_statistics: dict[str, Any] = Field(default_factory=dict)
|
|
methodology: str = ""
|
|
generated_at: str = ""
|
|
|
|
|
|
class PlanResponse(BaseModel):
|
|
"""GET /v1/research/{id}/plan — validated planner output for a run."""
|
|
|
|
research_id: str
|
|
plan: dict[str, Any] | None = None
|
|
available: bool = False
|
|
|
|
|
|
class ErrorResponse(BaseModel):
|
|
"""Fehlerantwort."""
|
|
|
|
detail: str
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# In-memory research store
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class _ResearchRunState(BaseModel):
|
|
"""Internal representation of a research run in the store."""
|
|
|
|
research_id: str
|
|
query: str
|
|
language: str
|
|
depth: str
|
|
budget: HardBudgetConfig
|
|
state: str = "created"
|
|
created_at: str = ""
|
|
updated_at: str | None = None
|
|
plan: dict[str, Any] | None = None
|
|
search_results: list[dict[str, Any]] = Field(default_factory=list)
|
|
sources: list[dict[str, Any]] = Field(default_factory=list)
|
|
claims: list[dict[str, Any]] = Field(default_factory=list)
|
|
evidence_scores: list[dict[str, Any]] = Field(default_factory=list)
|
|
report: dict[str, Any] | None = None
|
|
metadata: dict[str, Any] = Field(default_factory=dict)
|
|
owner_user_id: str | None = None
|
|
is_hidden: bool = False
|
|
|
|
|
|
# Global in-memory store.
|
|
_research_store: dict[str, _ResearchRunState] = {}
|
|
|
|
|
|
def _save_run(run: _ResearchRunState) -> None:
|
|
_research_store[run.research_id] = run
|
|
|
|
|
|
def _load_run(research_id: str) -> _ResearchRunState:
|
|
if research_id not in _research_store or _research_store[research_id].is_hidden:
|
|
raise HTTPException(status_code=404, detail=f"Research run {research_id} not found")
|
|
return _research_store[research_id]
|
|
|
|
|
|
def _now() -> str:
|
|
return datetime.now(timezone.utc).isoformat()
|
|
|
|
|
|
async def _retention_record(session: AsyncSession | None, research_id: str) -> ResearchRetentionModel | None:
|
|
if session is None:
|
|
return None
|
|
return await session.scalar(select(ResearchRetentionModel).where(ResearchRetentionModel.research_id == research_id))
|
|
|
|
|
|
async def _is_deletion_protected(session: AsyncSession | None, research_id: str) -> bool:
|
|
record = await _retention_record(session, research_id)
|
|
return bool(record and record.deletion_password_hash)
|
|
|
|
|
|
async def _require_retention_access(
|
|
session: AsyncSession | None, research_id: str, api_key: APIKeyModel | None
|
|
) -> tuple[ResearchRetentionModel | None, bool]:
|
|
"""Return retention metadata and admin status, rejecting another user's run."""
|
|
record = await _retention_record(session, research_id)
|
|
# Older in-memory-only runs have no owner metadata. Keep their legacy
|
|
# behavior while all newly authenticated runs receive a persistent record.
|
|
if record is None:
|
|
return None, False
|
|
admin = await is_administrator(session, api_key.user_id if api_key else None) if session else False
|
|
if not may_manage_retention(record, api_key.user_id if api_key else None, admin):
|
|
raise HTTPException(status_code=403, detail="You may only manage your own research runs.")
|
|
return record, admin
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Endpoints
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@router.get(
|
|
"/v1/research",
|
|
response_model=ResearchListResponse,
|
|
summary="List all research runs (paginated)",
|
|
)
|
|
async def list_research(
|
|
limit: int = 10,
|
|
offset: int = 0,
|
|
session: AsyncSession | None = Depends(get_optional_session),
|
|
api_key: APIKeyModel | None = Depends(require_api_key),
|
|
) -> ResearchListResponse:
|
|
"""Liste aller Research-Runs (paginiert)."""
|
|
all_runs = [r for r in _research_store.values() if not r.is_hidden]
|
|
protected_ids: set[str] = set()
|
|
if all_runs and session is not None:
|
|
rows = await session.scalars(
|
|
select(ResearchRetentionModel.research_id).where(
|
|
ResearchRetentionModel.research_id.in_([r.research_id for r in all_runs]),
|
|
ResearchRetentionModel.deletion_password_hash.is_not(None),
|
|
)
|
|
)
|
|
protected_ids = set(rows)
|
|
total = len(all_runs)
|
|
all_runs.sort(key=lambda r: r.created_at, reverse=True)
|
|
sliced = all_runs[offset : offset + limit]
|
|
items = [
|
|
ResearchListItem(
|
|
research_id=r.research_id,
|
|
query=r.query,
|
|
depth=r.depth,
|
|
state=r.state,
|
|
created_at=r.created_at,
|
|
source_count=len(r.sources),
|
|
claim_count=len(r.claims),
|
|
is_deletion_protected=r.research_id in protected_ids,
|
|
)
|
|
for r in sliced
|
|
]
|
|
return ResearchListResponse(total=total, limit=limit, offset=offset, items=items)
|
|
|
|
|
|
@router.post(
|
|
"/v1/research",
|
|
response_model=ResearchCreateResponse,
|
|
summary="Start a new research (non-blocking — returns immediately)",
|
|
)
|
|
async def start_research(
|
|
request: ResearchRequest,
|
|
bg: BackgroundTasks,
|
|
session: AsyncSession | None = Depends(get_optional_session),
|
|
api_key: APIKeyModel | None = Depends(require_api_key),
|
|
) -> ResearchCreateResponse:
|
|
"""Startet eine neue Recherche asynchron.
|
|
|
|
Erzeugt einen Research-Run mit Budget-Config gemäß Tiefe und
|
|
initialisiert den Status auf 'created'. Der eigentliche Durchlauf
|
|
der Pipeline wird asynchron angestoßen und kann über status / report
|
|
abgerufen werden.
|
|
"""
|
|
logger.info(
|
|
"Research started: query='%s' depth=%s",
|
|
request.query[:100],
|
|
request.depth,
|
|
)
|
|
|
|
# Validate depth
|
|
depth_cfg = DEPTH_CONFIGS.get(request.depth)
|
|
if depth_cfg is None:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail=f"Ungültige Tiefe '{request.depth}'. Gültig: 'quick', 'normal', 'deep'.",
|
|
)
|
|
|
|
# Build budget config from depth
|
|
budget = HardBudgetConfig(
|
|
max_search_queries=depth_cfg.max_search_queries,
|
|
max_sources=depth_cfg.max_sources,
|
|
max_pages_per_domain=depth_cfg.max_pages_per_domain,
|
|
max_total_download_bytes=depth_cfg.max_total_download_bytes,
|
|
max_llm_requests=depth_cfg.max_llm_requests,
|
|
max_research_duration_seconds=depth_cfg.max_research_duration_seconds,
|
|
max_context_per_llm_call=depth_cfg.max_context_per_llm_call,
|
|
)
|
|
|
|
research_id = str(uuid.uuid4())
|
|
created_at = _now()
|
|
|
|
run = _ResearchRunState(
|
|
research_id=research_id,
|
|
query=request.query,
|
|
language=request.language,
|
|
depth=request.depth,
|
|
budget=budget,
|
|
state=ResearchRunState.CREATED.value,
|
|
created_at=created_at,
|
|
updated_at=created_at,
|
|
owner_user_id=api_key.user_id if api_key else None,
|
|
)
|
|
_save_run(run)
|
|
# The retention row is intentionally created before background processing:
|
|
# an immediately deleted run still has an accountable lifecycle record.
|
|
if api_key is not None and session is not None:
|
|
session.add(
|
|
ResearchRetentionModel(
|
|
research_id=research_id,
|
|
owner_user_id=api_key.user_id,
|
|
query=request.query,
|
|
)
|
|
)
|
|
await session.flush()
|
|
|
|
# Increment active gauge
|
|
import nsct.metrics as _m
|
|
with _m._lock:
|
|
_m._gauges[G_ACTIVE_RESEARCH_RUNS] = _m._gauges.get(G_ACTIVE_RESEARCH_RUNS, 0) + 1
|
|
|
|
# Set request context for background task
|
|
set_research_run_id(research_id)
|
|
set_request_ctx(research_run_id=research_id, stage="start")
|
|
|
|
# Start pipeline in background
|
|
bg.add_task(_run_pipeline, run, request, budget)
|
|
|
|
return ResearchCreateResponse(
|
|
research_id=research_id,
|
|
status="pending",
|
|
query=request.query,
|
|
depth=request.depth,
|
|
state=run.state,
|
|
)
|
|
|
|
|
|
async def _run_pipeline(run: _ResearchRunState, request: ResearchRequest, budget: HardBudgetConfig) -> None:
|
|
"""Background task that runs the full research pipeline with metrics & logging."""
|
|
research_id = run.research_id
|
|
stage_ctx = {"research_run_id": research_id}
|
|
|
|
def store_plan(plan: dict[str, Any]) -> None:
|
|
"""Make the validated plan available as soon as planning finishes."""
|
|
run.plan = plan
|
|
run.updated_at = _now()
|
|
_save_run(run)
|
|
|
|
logger.info(
|
|
"[pipeline] Starting background research: run_id=%s query='%s'",
|
|
research_id,
|
|
request.query[:80],
|
|
)
|
|
|
|
try:
|
|
from nsct.config import AppSettings
|
|
|
|
config = AppSettings.from_env()
|
|
from nsct.orchestration.orchestrator import ResearchOrchestrator
|
|
|
|
orchestrator = ResearchOrchestrator(
|
|
config=config,
|
|
research_id=uuid.UUID(run.research_id),
|
|
query=request.query,
|
|
budget_config=budget,
|
|
depth=request.depth,
|
|
priority=Priority.NORMAL,
|
|
on_plan_created=store_plan,
|
|
)
|
|
|
|
stage_ctx["stage"] = "planning"
|
|
set_request_ctx(**stage_ctx)
|
|
logger.info("[pipeline] run_id=%s stage=planning", research_id)
|
|
|
|
await orchestrator.start()
|
|
run.state = ResearchRunState.PLANNING.value
|
|
run.updated_at = _now()
|
|
_save_run(run)
|
|
|
|
# --- Full pipeline with metrics ---
|
|
stage_ctx["stage"] = "searching"
|
|
set_request_ctx(**stage_ctx)
|
|
logger.info("[pipeline] run_id=%s stage=searching", research_id)
|
|
|
|
stage_ctx["stage"] = "fetching"
|
|
set_request_ctx(**stage_ctx)
|
|
logger.info("[pipeline] run_id=%s stage=fetching", research_id)
|
|
|
|
stage_ctx["stage"] = "analyzing"
|
|
set_request_ctx(**stage_ctx)
|
|
logger.info("[pipeline] run_id=%s stage=analyzing", research_id)
|
|
|
|
stage_ctx["stage"] = "comparing"
|
|
set_request_ctx(**stage_ctx)
|
|
logger.info("[pipeline] run_id=%s stage=comparing", research_id)
|
|
|
|
stage_ctx["stage"] = "synthesizing"
|
|
set_request_ctx(**stage_ctx)
|
|
logger.info("[pipeline] run_id=%s stage=synthesizing", research_id)
|
|
|
|
result = await orchestrator.run()
|
|
# The plan is structured, validated output from the planner. Persist it
|
|
# independently from the final report so it remains inspectable even
|
|
# when a later pipeline step fails.
|
|
run.plan = result.get("plan")
|
|
|
|
if result.get("success"):
|
|
run.state = ResearchRunState.COMPLETED.value
|
|
run.report = result.get("report")
|
|
run.sources = result.get("sources", [])
|
|
run.claims = result.get("report", {}).get("claims", [])
|
|
run.evidence_scores = result.get("report", {}).get("evidence", [])
|
|
|
|
metrics.increment(C_RESEARCH_COMPLETED_TOTAL)
|
|
metrics.increment(C_SOURCES_FETCHED_TOTAL, len(run.sources))
|
|
metrics.increment(C_CLAIMS_EXTRACTED_TOTAL, len(run.claims))
|
|
|
|
logger.info(
|
|
"[pipeline] run_id=%s completed: %d sources, %d claims",
|
|
research_id,
|
|
len(run.sources),
|
|
len(run.claims),
|
|
)
|
|
else:
|
|
run.state = ResearchRunState.FAILED.value
|
|
run.metadata["error"] = result.get("error", "Pipeline failed")
|
|
run.metadata["failed_step"] = result.get("failed_step", "unknown")
|
|
metrics.increment(C_RESEARCH_FAILED_TOTAL)
|
|
logger.warning(
|
|
"[pipeline] run_id=%s failed: %s",
|
|
research_id,
|
|
result.get("error", "unknown"),
|
|
)
|
|
|
|
run.updated_at = _now()
|
|
_save_run(run)
|
|
|
|
except Exception as exc:
|
|
logger.error(
|
|
"[pipeline] run_id=%s failed with exception: %s",
|
|
research_id,
|
|
exc,
|
|
exc_info=True,
|
|
)
|
|
run.state = ResearchRunState.FAILED.value
|
|
run.updated_at = _now()
|
|
run.metadata["error"] = str(exc)
|
|
_save_run(run)
|
|
metrics.increment(C_RESEARCH_FAILED_TOTAL)
|
|
|
|
finally:
|
|
# Decrement active gauge
|
|
import nsct.metrics as _m
|
|
with _m._lock:
|
|
_m._gauges[G_ACTIVE_RESEARCH_RUNS] = max(0, _m._gauges.get(G_ACTIVE_RESEARCH_RUNS, 0) - 1)
|
|
# Clear context
|
|
clear_request_ctx()
|
|
set_research_run_id(None)
|
|
|
|
|
|
@router.get(
|
|
"/v1/research/{research_id}",
|
|
response_model=StatusResponse,
|
|
summary="Get research run metadata",
|
|
)
|
|
async def get_research(
|
|
research_id: str,
|
|
session: AsyncSession | None = Depends(get_optional_session),
|
|
) -> StatusResponse:
|
|
"""Metadaten eines Research-Runs abrufen."""
|
|
run = _load_run(research_id)
|
|
return StatusResponse(
|
|
research_id=run.research_id,
|
|
query=run.query,
|
|
depth=run.depth,
|
|
state=run.state,
|
|
created_at=run.created_at,
|
|
updated_at=run.updated_at,
|
|
search_count=len(run.search_results),
|
|
source_count=len(run.sources),
|
|
claim_count=len(run.claims),
|
|
error=run.metadata.get("error"),
|
|
is_completed=(run.state == ResearchRunState.COMPLETED.value),
|
|
is_running=run.state not in (
|
|
ResearchRunState.COMPLETED.value,
|
|
ResearchRunState.FAILED.value,
|
|
ResearchRunState.CANCELLED.value,
|
|
),
|
|
is_deletion_protected=await _is_deletion_protected(session, research_id),
|
|
)
|
|
|
|
|
|
@router.get(
|
|
"/v1/research/{research_id}/status",
|
|
response_model=StatusResponse,
|
|
summary="Get research status",
|
|
)
|
|
async def get_status(
|
|
research_id: str,
|
|
session: AsyncSession | None = Depends(get_optional_session),
|
|
) -> StatusResponse:
|
|
"""Status eines Research-Runs abrufen."""
|
|
run = _load_run(research_id)
|
|
return StatusResponse(
|
|
research_id=run.research_id,
|
|
query=run.query,
|
|
depth=run.depth,
|
|
state=run.state,
|
|
created_at=run.created_at,
|
|
updated_at=run.updated_at,
|
|
search_count=len(run.search_results),
|
|
source_count=len(run.sources),
|
|
claim_count=len(run.claims),
|
|
error=run.metadata.get("error"),
|
|
is_completed=(run.state == ResearchRunState.COMPLETED.value),
|
|
is_running=run.state not in (
|
|
ResearchRunState.COMPLETED.value,
|
|
ResearchRunState.FAILED.value,
|
|
ResearchRunState.CANCELLED.value,
|
|
),
|
|
is_deletion_protected=await _is_deletion_protected(session, research_id),
|
|
)
|
|
|
|
|
|
@router.get(
|
|
"/v1/research/{research_id}/sources",
|
|
response_model=SourcesResponse,
|
|
summary="Get sources for a research run",
|
|
)
|
|
async def get_sources(research_id: str) -> SourcesResponse:
|
|
"""Quellen für einen Research-Run abrufen."""
|
|
run = _load_run(research_id)
|
|
sources = [
|
|
SourceListItem(
|
|
id=s.get("id", str(uuid.uuid4())),
|
|
url=s.get("url", ""),
|
|
title=s.get("title"),
|
|
domain=s.get("domain", ""),
|
|
source_type=s.get("source_type"),
|
|
retrieved_at=s.get("retrieved_at", _now()),
|
|
)
|
|
for s in run.sources
|
|
]
|
|
return SourcesResponse(
|
|
research_id=run.research_id,
|
|
sources=sources,
|
|
total=len(sources),
|
|
)
|
|
|
|
|
|
@router.get(
|
|
"/v1/research/{research_id}/claims",
|
|
response_model=ClaimsResponse,
|
|
summary="Get claims for a research run",
|
|
)
|
|
async def get_claims(research_id: str) -> ClaimsResponse:
|
|
"""Claims für einen Research-Run abrufen."""
|
|
run = _load_run(research_id)
|
|
claims = [
|
|
ClaimListItem(
|
|
id=c.get("id", str(uuid.uuid4())),
|
|
research_run_id=run.research_id,
|
|
source_id=c.get("source_id", ""),
|
|
claim_text=c.get("claim_text", c.get("claim", "")),
|
|
evidence_span=c.get("evidence_span", ""),
|
|
claim_type=c.get("claim_type", "claim"),
|
|
confidence=c.get("confidence", 1.0),
|
|
)
|
|
for c in run.claims
|
|
]
|
|
return ClaimsResponse(
|
|
research_id=run.research_id,
|
|
claims=claims,
|
|
total=len(claims),
|
|
)
|
|
|
|
|
|
@router.get(
|
|
"/v1/research/{research_id}/evidence",
|
|
response_model=EvidenceResponse,
|
|
summary="Get evidence scores for a research run",
|
|
)
|
|
async def get_evidence(research_id: str) -> EvidenceResponse:
|
|
"""Evidence-Scores für einen Research-Run abrufen."""
|
|
run = _load_run(research_id)
|
|
scores = [EvidenceScoreItem(**s) for s in run.evidence_scores]
|
|
return EvidenceResponse(
|
|
research_id=run.research_id,
|
|
evidence=scores,
|
|
total=len(scores),
|
|
)
|
|
|
|
|
|
@router.get(
|
|
"/v1/research/{research_id}/plan",
|
|
response_model=PlanResponse,
|
|
summary="Get the validated research plan for a research run",
|
|
)
|
|
async def get_plan(research_id: str) -> PlanResponse:
|
|
"""Return the planner's structured strategy, never its prompt or raw output."""
|
|
run = _load_run(research_id)
|
|
return PlanResponse(
|
|
research_id=run.research_id,
|
|
plan=run.plan,
|
|
available=run.plan is not None,
|
|
)
|
|
|
|
|
|
@router.get(
|
|
"/v1/research/{research_id}/report",
|
|
response_model=ReportResponse,
|
|
summary="Get research report",
|
|
)
|
|
async def get_report(research_id: str) -> ReportResponse:
|
|
"""Synthese-Bericht für einen Research-Run abrufen."""
|
|
run = _load_run(research_id)
|
|
if not run.report:
|
|
return ReportResponse(
|
|
research_id=run.research_id,
|
|
query=run.query,
|
|
summary="Bericht noch nicht verfügbar. Die Recherche läuft oder ist fehlgeschlagen.",
|
|
)
|
|
|
|
report_data = run.report
|
|
findings = [
|
|
FindingItem(**f) if isinstance(f, dict) else FindingItem(text=str(f))
|
|
for f in report_data.get("findings", report_data.get("findings_json", []))
|
|
]
|
|
|
|
return ReportResponse(
|
|
research_id=run.research_id,
|
|
query=run.query,
|
|
summary=report_data.get("summary", ""),
|
|
findings=findings,
|
|
disagreements=report_data.get("disagreements", []),
|
|
uncertainties=report_data.get("uncertainties", []),
|
|
source_statistics=report_data.get("source_statistics", {}),
|
|
methodology=report_data.get("methodology", ""),
|
|
generated_at=report_data.get("generated_at", _now()),
|
|
)
|
|
|
|
|
|
@router.delete(
|
|
"/v1/research/{research_id}",
|
|
response_model=dict[str, str],
|
|
summary="Soft-delete a research run",
|
|
)
|
|
async def delete_research(
|
|
research_id: str,
|
|
request: DeletionRequest | None = None,
|
|
session: AsyncSession | None = Depends(get_optional_session),
|
|
api_key: APIKeyModel | None = Depends(require_api_key),
|
|
) -> dict[str, str]:
|
|
"""Hide a run without destroying it; protected runs require their password."""
|
|
run = _load_run(research_id)
|
|
record, admin = await _require_retention_access(session, research_id, api_key)
|
|
if record and record.deletion_password_hash and not admin:
|
|
if request is None or not await verify_deletion_password(request.password or "", record.deletion_password_hash):
|
|
raise HTTPException(status_code=403, detail="A valid deletion-protection password is required.")
|
|
|
|
run.is_hidden = True
|
|
run.state = ResearchRunState.CANCELLED.value
|
|
run.updated_at = _now()
|
|
_save_run(run)
|
|
if record and session is not None:
|
|
record.is_hidden = True
|
|
record.hidden_at = datetime.now(timezone.utc).replace(tzinfo=None)
|
|
session.add(
|
|
ResearchDeletionAuditModel(
|
|
research_id=research_id,
|
|
actor_user_id=api_key.user_id if api_key else None,
|
|
action="soft_deleted",
|
|
)
|
|
)
|
|
await session.flush()
|
|
return {"status": "hidden", "research_id": research_id}
|
|
|
|
|
|
@router.put(
|
|
"/v1/research/{research_id}/deletion-protection",
|
|
response_model=dict[str, bool],
|
|
summary="Set or replace deletion protection",
|
|
)
|
|
async def set_deletion_protection(
|
|
research_id: str,
|
|
request: DeletionProtectionRequest,
|
|
session: AsyncSession | None = Depends(get_optional_session),
|
|
api_key: APIKeyModel | None = Depends(require_api_key),
|
|
) -> dict[str, bool]:
|
|
"""Set a password without ever returning it or its hash to the client."""
|
|
_load_run(research_id)
|
|
record, admin = await _require_retention_access(session, research_id, api_key)
|
|
if record is None:
|
|
raise HTTPException(status_code=409, detail="Research retention metadata is not available for this legacy run.")
|
|
if record.deletion_password_hash and not admin:
|
|
if not await verify_deletion_password(request.current_password or "", record.deletion_password_hash):
|
|
raise HTTPException(status_code=403, detail="The current deletion-protection password is required.")
|
|
record.deletion_password_hash = await hash_deletion_password(request.password)
|
|
await session.flush()
|
|
return {"is_deletion_protected": True}
|
|
|
|
|
|
@router.delete(
|
|
"/v1/research/{research_id}/deletion-protection",
|
|
response_model=dict[str, bool],
|
|
summary="Remove deletion protection",
|
|
)
|
|
async def remove_deletion_protection(
|
|
research_id: str,
|
|
request: DeletionRequest,
|
|
session: AsyncSession | None = Depends(get_optional_session),
|
|
api_key: APIKeyModel | None = Depends(require_api_key),
|
|
) -> dict[str, bool]:
|
|
_load_run(research_id)
|
|
record, admin = await _require_retention_access(session, research_id, api_key)
|
|
if record is None or not record.deletion_password_hash:
|
|
return {"is_deletion_protected": False}
|
|
if not admin and not await verify_deletion_password(request.password or "", record.deletion_password_hash):
|
|
raise HTTPException(
|
|
status_code=403,
|
|
detail="A valid deletion-protection password is required.",
|
|
)
|
|
record.deletion_password_hash = None
|
|
await session.flush()
|
|
return {"is_deletion_protected": False}
|
|
|
|
|
|
async def _require_admin(session: AsyncSession | None, api_key: APIKeyModel | None) -> None:
|
|
if session is None or not await is_administrator(session, api_key.user_id if api_key else None):
|
|
raise HTTPException(status_code=403, detail="Administrator privileges are required.")
|
|
|
|
|
|
@router.get("/v1/admin/research/deleted", response_model=list[DeletedResearchItem], summary="List soft-deleted research")
|
|
async def list_deleted_research(
|
|
session: AsyncSession | None = Depends(get_optional_session), api_key: APIKeyModel | None = Depends(require_api_key)
|
|
) -> list[DeletedResearchItem]:
|
|
await _require_admin(session, api_key)
|
|
assert session is not None
|
|
records = await session.scalars(
|
|
select(ResearchRetentionModel)
|
|
.where(ResearchRetentionModel.is_hidden.is_(True))
|
|
.order_by(ResearchRetentionModel.hidden_at.desc())
|
|
)
|
|
return [
|
|
DeletedResearchItem(
|
|
research_id=record.research_id,
|
|
query=record.query,
|
|
hidden_at=(record.hidden_at or record.updated_at).replace(tzinfo=timezone.utc).isoformat(),
|
|
is_deletion_protected=bool(record.deletion_password_hash),
|
|
)
|
|
for record in records
|
|
]
|
|
|
|
|
|
@router.delete("/v1/admin/research/{research_id}", response_model=dict[str, str], summary="Permanently purge hidden research")
|
|
async def purge_deleted_research(
|
|
research_id: str,
|
|
session: AsyncSession | None = Depends(get_optional_session),
|
|
api_key: APIKeyModel | None = Depends(require_api_key),
|
|
) -> dict[str, str]:
|
|
await _require_admin(session, api_key)
|
|
assert session is not None
|
|
record = await _retention_record(session, research_id)
|
|
if record is None or not record.is_hidden:
|
|
raise HTTPException(status_code=404, detail="No hidden research run found.")
|
|
session.add(
|
|
ResearchDeletionAuditModel(research_id=research_id, actor_user_id=api_key.user_id if api_key else None, action="purged")
|
|
)
|
|
await session.delete(record)
|
|
_research_store.pop(research_id, None)
|
|
await session.flush()
|
|
return {"status": "purged", "research_id": research_id}
|