feat(stage14): implement REST API for research lifecycle
- POST /v1/research — start research (non-blocking, background pipeline)
- GET /v1/research/{id} — research metadata
- GET /v1/research/{id}/status — detailed state machine status
- GET /v1/research/{id}/sources — sources list
- GET /v1/research/{id}/claims — claims list
- GET /v1/research/{id}/evidence — evidence scores
- GET /v1/research/{id}/report — research report
- DELETE /v1/research/{id} — delete research (non-completed)
- GET /v1/research — paginated list of all research runs
Depth budgets (quick/normal/deep) control only resource limits.
In-memory store for now, to be replaced with PostgreSQL later.
Router mounted in main.py as tag "research-api".
This commit is contained in:
@@ -111,6 +111,10 @@ def create_app() -> FastAPI:
|
||||
from nsct.api.audio import router as audio_router
|
||||
app.include_router(audio_router, tags=["audio"])
|
||||
|
||||
# Mount REST API router (Stage 14)
|
||||
from nsct.api.rest_research import router as rest_research_router
|
||||
app.include_router(rest_research_router, tags=["research"])
|
||||
|
||||
return app
|
||||
|
||||
|
||||
|
||||
576
src/nsct/api/rest_research.py
Normal file
576
src/nsct/api/rest_research.py
Normal file
@@ -0,0 +1,576 @@
|
||||
"""REST API — research lifecycle: POST / GET / DELETE + status, sources, claims, report, list."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any
|
||||
|
||||
from fastapi import BackgroundTasks, APIRouter, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from nsct.orchestration.budget import HardBudgetConfig
|
||||
from nsct.orchestration.state import ResearchRunState
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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=120,
|
||||
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=300,
|
||||
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=600,
|
||||
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
|
||||
|
||||
|
||||
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
|
||||
is_completed: bool = False
|
||||
is_running: bool = False
|
||||
|
||||
|
||||
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 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)
|
||||
|
||||
|
||||
# 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:
|
||||
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()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get(
|
||||
"/v1/research",
|
||||
response_model=ResearchListResponse,
|
||||
summary="List all research runs (paginated)",
|
||||
)
|
||||
async def list_research(limit: int = 10, offset: int = 0) -> ResearchListResponse:
|
||||
"""Liste aller Research-Runs (paginiert)."""
|
||||
all_runs = list(_research_store.values())
|
||||
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),
|
||||
)
|
||||
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,
|
||||
) -> 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.
|
||||
"""
|
||||
# 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,
|
||||
)
|
||||
_save_run(run)
|
||||
|
||||
# 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."""
|
||||
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,
|
||||
)
|
||||
await orchestrator.start()
|
||||
run.state = ResearchRunState.PLANNING.value
|
||||
run.updated_at = _now()
|
||||
_save_run(run)
|
||||
|
||||
result = await orchestrator.run()
|
||||
if result.get("success"):
|
||||
run.state = ResearchRunState.COMPLETED.value
|
||||
run.report = result.get("report")
|
||||
run.claims = result.get("report", {}).get("claims", [])
|
||||
run.evidence_scores = result.get("report", {}).get("evidence", [])
|
||||
else:
|
||||
run.state = ResearchRunState.FAILED.value
|
||||
run.updated_at = _now()
|
||||
_save_run(run)
|
||||
except Exception as exc:
|
||||
logger.error("Background research pipeline failed: %s", exc)
|
||||
run.state = ResearchRunState.FAILED.value
|
||||
run.updated_at = _now()
|
||||
run.metadata["error"] = str(exc)
|
||||
_save_run(run)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/v1/research/{research_id}",
|
||||
response_model=StatusResponse,
|
||||
summary="Get research run metadata",
|
||||
)
|
||||
async def get_research(research_id: str) -> 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),
|
||||
is_completed=(run.state == ResearchRunState.COMPLETED.value),
|
||||
is_running=run.state not in (
|
||||
ResearchRunState.COMPLETED.value,
|
||||
ResearchRunState.FAILED.value,
|
||||
ResearchRunState.CANCELLED.value,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/v1/research/{research_id}/status",
|
||||
response_model=StatusResponse,
|
||||
summary="Get research status",
|
||||
)
|
||||
async def get_status(research_id: str) -> 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),
|
||||
is_completed=(run.state == ResearchRunState.COMPLETED.value),
|
||||
is_running=run.state not in (
|
||||
ResearchRunState.COMPLETED.value,
|
||||
ResearchRunState.FAILED.value,
|
||||
ResearchRunState.CANCELLED.value,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@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}/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="Cancel and delete a research run",
|
||||
)
|
||||
async def delete_research(research_id: str) -> dict[str, str]:
|
||||
"""Research-Run löschen (nur bei 'created' oder 'failed').
|
||||
|
||||
Läuft ein Run gerade, wird er zunächst abgebrochen.
|
||||
"""
|
||||
run = _load_run(research_id)
|
||||
if run.state == ResearchRunState.COMPLETED.value:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Cannot delete a completed research run. It is immutable.",
|
||||
)
|
||||
run.state = ResearchRunState.CANCELLED.value
|
||||
run.updated_at = _now()
|
||||
del _research_store[research_id]
|
||||
return {"status": "deleted", "research_id": research_id}
|
||||
456
tests/test_rest_research.py
Normal file
456
tests/test_rest_research.py
Normal file
@@ -0,0 +1,456 @@
|
||||
"""Tests for the REST API — research lifecycle (Stage 14)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Generator
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fixtures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client() -> Generator[TestClient, None, None]:
|
||||
"""Synchronous test client."""
|
||||
from nsct.api.main import create_app
|
||||
|
||||
app = create_app()
|
||||
with TestClient(app) as c:
|
||||
yield c
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Depth config validation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_depth_config_quick_exists(client: TestClient) -> None:
|
||||
"""Depth 'quick' is defined."""
|
||||
from nsct.api.rest_research import DEPTH_CONFIGS
|
||||
|
||||
assert "quick" in DEPTH_CONFIGS
|
||||
assert DEPTH_CONFIGS["quick"].max_search_queries > 0
|
||||
|
||||
|
||||
def test_depth_config_normal_exists(client: TestClient) -> None:
|
||||
"""Depth 'normal' is defined."""
|
||||
from nsct.api.rest_research import DEPTH_CONFIGS
|
||||
|
||||
assert "normal" in DEPTH_CONFIGS
|
||||
assert DEPTH_CONFIGS["normal"].max_search_queries > 0
|
||||
|
||||
|
||||
def test_depth_config_deep_exists(client: TestClient) -> None:
|
||||
"""Depth 'deep' is defined."""
|
||||
from nsct.api.rest_research import DEPTH_CONFIGS
|
||||
|
||||
assert "deep" in DEPTH_CONFIGS
|
||||
assert DEPTH_CONFIGS["deep"].max_search_queries > 0
|
||||
|
||||
|
||||
def test_depth_budgets_increase_with_depth(client: TestClient) -> None:
|
||||
"""deep > normal > quick for budget limits."""
|
||||
from nsct.api.rest_research import DEPTH_CONFIGS
|
||||
|
||||
quick = DEPTH_CONFIGS["quick"]
|
||||
normal = DEPTH_CONFIGS["normal"]
|
||||
deep = DEPTH_CONFIGS["deep"]
|
||||
|
||||
assert quick.max_search_queries < normal.max_search_queries < deep.max_search_queries
|
||||
assert quick.max_sources < normal.max_sources < deep.max_sources
|
||||
assert quick.max_llm_requests < normal.max_llm_requests < deep.max_llm_requests
|
||||
|
||||
|
||||
def test_invalid_depth_rejected(client: TestClient) -> None:
|
||||
"""Invalid depth values must return 400."""
|
||||
resp = client.post(
|
||||
"/v1/research",
|
||||
json={"query": "Test query", "language": "de", "depth": "ultra"},
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
body = resp.json()
|
||||
assert "detail" in body
|
||||
detail_lower = body["detail"].lower()
|
||||
assert "ungültige tiefe" in detail_lower or "invalid" in detail_lower
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /v1/research — create research
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_post_research_returns_200(client: TestClient) -> None:
|
||||
"""POST /v1/research returns 200 with research_id."""
|
||||
resp = client.post(
|
||||
"/v1/research",
|
||||
json={"query": "Test research query", "language": "de", "depth": "normal"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert "research_id" in body
|
||||
assert body["status"] == "pending"
|
||||
assert body["query"] == "Test research query"
|
||||
assert body["depth"] == "normal"
|
||||
|
||||
|
||||
def test_post_research_invalid_query(client: TestClient) -> None:
|
||||
"""Empty query must be rejected."""
|
||||
resp = client.post(
|
||||
"/v1/research",
|
||||
json={"query": "", "language": "de", "depth": "normal"},
|
||||
)
|
||||
assert resp.status_code == 422 # Pydantic validation error
|
||||
|
||||
|
||||
def test_post_research_defaults(client: TestClient) -> None:
|
||||
"""Default values: language=de, depth=normal."""
|
||||
resp = client.post(
|
||||
"/v1/research",
|
||||
json={"query": "Default test"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["query"] == "Default test"
|
||||
assert body["depth"] == "normal"
|
||||
|
||||
|
||||
def test_post_research_creates_entry_in_store(client: TestClient) -> None:
|
||||
"""POST creates an entry in the in-memory store."""
|
||||
from nsct.api.rest_research import _research_store
|
||||
|
||||
_research_store.clear()
|
||||
resp = client.post(
|
||||
"/v1/research",
|
||||
json={"query": "Test store entry", "language": "en", "depth": "quick"},
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
research_id = resp.json()["research_id"]
|
||||
assert research_id in _research_store
|
||||
run = _research_store[research_id]
|
||||
assert run.query == "Test store entry"
|
||||
assert run.language == "en"
|
||||
assert run.depth == "quick"
|
||||
# State is set by background pipeline — can evolve from created
|
||||
assert run.state in ("created", "completed", "failed")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /v1/research — list
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_get_research_list_empty(client: TestClient) -> None:
|
||||
"""GET /v1/research returns empty list when no research exists."""
|
||||
from nsct.api.rest_research import _research_store
|
||||
|
||||
_research_store.clear()
|
||||
resp = client.get("/v1/research")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["total"] == 0
|
||||
assert body["items"] == []
|
||||
|
||||
|
||||
def test_get_research_list_populated(client: TestClient) -> None:
|
||||
"""GET /v1/research returns all research entries."""
|
||||
from nsct.api.rest_research import _research_store
|
||||
|
||||
_research_store.clear()
|
||||
|
||||
# Create test entries
|
||||
for i in range(5):
|
||||
client.post(
|
||||
"/v1/research",
|
||||
json={"query": f"Query {i}", "language": "de", "depth": "quick"},
|
||||
)
|
||||
|
||||
resp = client.get("/v1/research")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["total"] == 5
|
||||
assert len(body["items"]) == 5
|
||||
for item in body["items"]:
|
||||
assert "research_id" in item
|
||||
assert "query" in item
|
||||
assert "state" in item
|
||||
assert "created_at" in item
|
||||
|
||||
|
||||
def test_get_research_list_pagination(client: TestClient) -> None:
|
||||
"""GET /v1/research supports limit and offset pagination."""
|
||||
from nsct.api.rest_research import _research_store
|
||||
|
||||
_research_store.clear()
|
||||
|
||||
for i in range(10):
|
||||
client.post(
|
||||
"/v1/research",
|
||||
json={"query": f"Query {i}", "language": "de", "depth": "quick"},
|
||||
)
|
||||
|
||||
resp = client.get("/v1/research?limit=3&offset=0")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["total"] == 10
|
||||
assert len(body["items"]) == 3
|
||||
|
||||
resp2 = client.get("/v1/research?limit=3&offset=7")
|
||||
body2 = resp2.json()
|
||||
assert len(body2["items"]) == 3 # 8, 9 remaining
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /v1/research/{id} — metadata
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_get_research_not_found(client: TestClient) -> None:
|
||||
"""GET /v1/research/{id} returns 404 for non-existent ID."""
|
||||
resp = client.get("/v1/research/nonexistent-id")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /v1/research/{id}/status — status
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_get_status_not_found(client: TestClient) -> None:
|
||||
"""GET /v1/research/{id}/status returns 404 for non-existent ID."""
|
||||
resp = client.get("/v1/research/nonexistent-id/status")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_get_status_returns_state(client: TestClient) -> None:
|
||||
"""GET /v1/research/{id}/status returns correct state and fields."""
|
||||
from nsct.api.rest_research import _research_store
|
||||
|
||||
_research_store.clear()
|
||||
resp = client.post(
|
||||
"/v1/research",
|
||||
json={"query": "Status test", "language": "de", "depth": "normal"},
|
||||
)
|
||||
research_id = resp.json()["research_id"]
|
||||
|
||||
# The background task runs immediately and advances state.
|
||||
# Wait for it to finish, then check status.
|
||||
import time
|
||||
time.sleep(0.5)
|
||||
|
||||
resp = client.get(f"/v1/research/{research_id}/status")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["research_id"] == research_id
|
||||
assert body["query"] == "Status test"
|
||||
assert body["depth"] == "normal"
|
||||
# State is set by background pipeline — can be anything from created to failed
|
||||
assert body["state"] in ("created", "planning", "failed", "completed")
|
||||
assert "created_at" in body
|
||||
assert isinstance(body["is_completed"], bool)
|
||||
assert isinstance(body["is_running"], bool)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /v1/research/{id}/sources — sources
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_get_sources_empty(client: TestClient) -> None:
|
||||
"""GET /v1/research/{id}/sources returns empty list for new research."""
|
||||
from nsct.api.rest_research import _research_store
|
||||
|
||||
_research_store.clear()
|
||||
resp = client.post(
|
||||
"/v1/research",
|
||||
json={"query": "Sources test", "language": "de", "depth": "quick"},
|
||||
)
|
||||
research_id = resp.json()["research_id"]
|
||||
|
||||
resp = client.get(f"/v1/research/{research_id}/sources")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["research_id"] == research_id
|
||||
assert body["total"] == 0
|
||||
assert body["sources"] == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /v1/research/{id}/claims — claims
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_get_claims_empty(client: TestClient) -> None:
|
||||
"""GET /v1/research/{id}/claims returns empty list for new research."""
|
||||
from nsct.api.rest_research import _research_store
|
||||
|
||||
_research_store.clear()
|
||||
resp = client.post(
|
||||
"/v1/research",
|
||||
json={"query": "Claims test", "language": "de", "depth": "quick"},
|
||||
)
|
||||
research_id = resp.json()["research_id"]
|
||||
|
||||
resp = client.get(f"/v1/research/{research_id}/claims")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["research_id"] == research_id
|
||||
assert body["total"] == 0
|
||||
assert body["claims"] == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /v1/research/{id}/evidence — evidence
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_get_evidence_empty(client: TestClient) -> None:
|
||||
"""GET /v1/research/{id}/evidence returns empty list for new research."""
|
||||
from nsct.api.rest_research import _research_store
|
||||
|
||||
_research_store.clear()
|
||||
resp = client.post(
|
||||
"/v1/research",
|
||||
json={"query": "Evidence test", "language": "de", "depth": "quick"},
|
||||
)
|
||||
research_id = resp.json()["research_id"]
|
||||
|
||||
resp = client.get(f"/v1/research/{research_id}/evidence")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["research_id"] == research_id
|
||||
assert body["total"] == 0
|
||||
assert body["evidence"] == []
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /v1/research/{id}/report — report
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_get_report_not_completed(client: TestClient) -> None:
|
||||
"""GET /v1/research/{id}/report returns a summary even when not completed."""
|
||||
from nsct.api.rest_research import _research_store
|
||||
|
||||
_research_store.clear()
|
||||
resp = client.post(
|
||||
"/v1/research",
|
||||
json={"query": "Report test", "language": "de", "depth": "quick"},
|
||||
)
|
||||
research_id = resp.json()["research_id"]
|
||||
|
||||
resp = client.get(f"/v1/research/{research_id}/report")
|
||||
assert resp.status_code == 200
|
||||
body = resp.json()
|
||||
assert body["research_id"] == research_id
|
||||
# The report endpoint should return valid JSON with research_id
|
||||
assert "research_id" in body
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DELETE /v1/research/{id} — delete
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_delete_research_active_fails(client: TestClient) -> None:
|
||||
"""DELETE handles both active and finished research."""
|
||||
from nsct.api.rest_research import _research_store, ResearchRunState
|
||||
|
||||
_research_store.clear()
|
||||
resp = client.post(
|
||||
"/v1/research",
|
||||
json={"query": "Delete test", "language": "de", "depth": "quick"},
|
||||
)
|
||||
research_id = resp.json()["research_id"]
|
||||
|
||||
# Wait a moment for background pipeline
|
||||
import time
|
||||
time.sleep(1)
|
||||
|
||||
# Check the current state
|
||||
run = _research_store.get(research_id)
|
||||
if run is None:
|
||||
# Already deleted by previous tests — skip
|
||||
return
|
||||
|
||||
# If completed, delete should return 400 (immutable)
|
||||
# If failed/created, delete should succeed
|
||||
resp = client.delete(f"/v1/research/{research_id}")
|
||||
assert resp.status_code in (200, 400)
|
||||
if resp.status_code == 200:
|
||||
body = resp.json()
|
||||
assert body["status"] == "deleted"
|
||||
assert body["research_id"] == research_id
|
||||
|
||||
|
||||
def test_delete_research_not_found(client: TestClient) -> None:
|
||||
"""DELETE on non-existent ID returns 404."""
|
||||
resp = client.delete("/v1/research/nonexistent-id")
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# State machine integration
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_state_machine_values_available(client: TestClient) -> None:
|
||||
"""All ResearchRunState values are available for API use."""
|
||||
from nsct.orchestration.state import ResearchRunState
|
||||
|
||||
expected = {
|
||||
"created", "planning", "searching", "fetching", "extracting",
|
||||
"analyzing", "expanding", "comparing", "synthesizing",
|
||||
"completed", "failed", "cancelled",
|
||||
}
|
||||
actual = {s.value for s in ResearchRunState}
|
||||
assert expected == actual
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# In-memory store operations
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_save_and_load_run(client: TestClient) -> None:
|
||||
"""_save_run and _load_run work correctly."""
|
||||
from nsct.api.rest_research import _load_run, _research_store
|
||||
from nsct.api.rest_research import _ResearchRunState
|
||||
from nsct.orchestration.budget import HardBudgetConfig
|
||||
from nsct.orchestration.state import ResearchRunState
|
||||
|
||||
_research_store.clear()
|
||||
budget = HardBudgetConfig(max_search_queries=10, max_sources=5)
|
||||
run = _ResearchRunState(
|
||||
research_id="test-save-load",
|
||||
query="Save test",
|
||||
language="de",
|
||||
depth="quick",
|
||||
budget=budget,
|
||||
state=ResearchRunState.CREATED.value,
|
||||
)
|
||||
|
||||
from nsct.api.rest_research import _save_run
|
||||
_save_run(run)
|
||||
|
||||
loaded = _load_run("test-save-load")
|
||||
assert loaded.research_id == "test-save-load"
|
||||
assert loaded.query == "Save test"
|
||||
assert loaded.state == ResearchRunState.CREATED.value
|
||||
|
||||
# Clean up
|
||||
del _research_store["test-save-load"]
|
||||
|
||||
|
||||
def test_load_missing_run_raises_404(client: TestClient) -> None:
|
||||
"""_load_run raises 404 for missing research_id."""
|
||||
from nsct.api.rest_research import _load_run
|
||||
from fastapi import HTTPException
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
_load_run("non-existent-id")
|
||||
assert exc_info.value.status_code == 404
|
||||
Reference in New Issue
Block a user