diff --git a/src/nsct/api/main.py b/src/nsct/api/main.py index 9aecce0..9e8bc09 100644 --- a/src/nsct/api/main.py +++ b/src/nsct/api/main.py @@ -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 diff --git a/src/nsct/api/rest_research.py b/src/nsct/api/rest_research.py new file mode 100644 index 0000000..89e1f13 --- /dev/null +++ b/src/nsct/api/rest_research.py @@ -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} \ No newline at end of file diff --git a/tests/test_rest_research.py b/tests/test_rest_research.py new file mode 100644 index 0000000..a01ac17 --- /dev/null +++ b/tests/test_rest_research.py @@ -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 \ No newline at end of file