"""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}