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:
NSCT Agent
2026-08-27 16:07:47 +00:00
parent a55e26f3b1
commit 4d5f6747f7
3 changed files with 1036 additions and 0 deletions

View File

@@ -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

View 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
View 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