Files
NSCT---Neutral-Search-Crawl…/src/nsct/api/rest_research.py
2026-09-07 12:34:41 +02:00

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}