"""Tests for `api.reasoning` — the HTTP layer over a `ReasoningRunner` port. The API layer is decoupled from docling-agent / mellea / docling-core. Tests inject a fake `ReasoningRunner` on `app.state.reasoning_runner` and assert on HTTP status / payload + on what the runner was called with. Adapter behaviour (the actual docling-agent integration) is tested separately in `tests/test_docling_agent_reasoning.py`. """ from __future__ import annotations from unittest.mock import AsyncMock import pytest from fastapi import FastAPI from fastapi.testclient import TestClient from api.reasoning import router from domain.models import AnalysisJob from domain.ports import ReasoningParseError from domain.value_objects import ReasoningIteration, ReasoningResult def _job_with_doc_json() -> AnalysisJob: job = AnalysisJob(document_id="doc-1") job.document_filename = "hello.pdf" job.mark_running() job.mark_completed( markdown="# Hello", html="

Hello

", pages_json="[]", document_json='{"stub": true}', chunks_json="[]", ) return job def _sample_result() -> ReasoningResult: return ReasoningResult( answer="stub answer", converged=True, iterations=[ ReasoningIteration( iteration=1, section_ref="#/texts/0", reason="looks relevant", section_text_length=42, can_answer=True, response="stub answer", ) ], ) class _FakeRunner: """In-memory ReasoningRunner. Records the last call so tests can assert on the args without touching docling-agent.""" def __init__( self, *, result: ReasoningResult | None = None, is_available: bool = True, raises: Exception | None = None, ) -> None: self._result = result or _sample_result() self._is_available = is_available self._raises = raises self.last_call: dict | None = None @property def is_available(self) -> bool: return self._is_available async def run( self, *, document_json: str, query: str, model_id: str | None = None, ) -> ReasoningResult: self.last_call = { "document_json": document_json, "query": query, "model_id": model_id, } if self._raises is not None: raise self._raises return self._result @pytest.fixture def mock_analysis_repo() -> AsyncMock: repo = AsyncMock() repo.find_latest_completed_by_document.return_value = _job_with_doc_json() return repo def _make_client(*, runner: _FakeRunner | None, repo: AsyncMock) -> TestClient: app = FastAPI() app.include_router(router) app.state.analysis_repo = repo app.state.reasoning_runner = runner return TestClient(app) class TestReasoningDisabled: def test_503_when_runner_not_wired(self, mock_analysis_repo: AsyncMock) -> None: client = _make_client(runner=None, repo=mock_analysis_repo) resp = client.post("/api/documents/doc-1/reasoning", json={"query": "Q"}) assert resp.status_code == 503 assert "REASONING_ENABLED" in resp.json()["detail"] def test_503_when_runner_unavailable(self, mock_analysis_repo: AsyncMock) -> None: runner = _FakeRunner(is_available=False) client = _make_client(runner=runner, repo=mock_analysis_repo) resp = client.post("/api/documents/doc-1/reasoning", json={"query": "Q"}) assert resp.status_code == 503 class TestReasoningValidation: def test_400_when_query_empty(self, mock_analysis_repo: AsyncMock) -> None: client = _make_client(runner=_FakeRunner(), repo=mock_analysis_repo) resp = client.post("/api/documents/doc-1/reasoning", json={"query": " "}) assert resp.status_code == 400 def test_404_when_no_completed_analysis(self, mock_analysis_repo: AsyncMock) -> None: mock_analysis_repo.find_latest_completed_by_document.return_value = None client = _make_client(runner=_FakeRunner(), repo=mock_analysis_repo) resp = client.post("/api/documents/doc-1/reasoning", json={"query": "Q"}) assert resp.status_code == 404 class TestReasoningSuccess: def test_returns_reasoning_result_shape(self, mock_analysis_repo: AsyncMock) -> None: runner = _FakeRunner() client = _make_client(runner=runner, repo=mock_analysis_repo) resp = client.post("/api/documents/doc-1/reasoning", json={"query": "What is this?"}) assert resp.status_code == 200 data = resp.json() assert data["answer"] == "stub answer" assert data["converged"] is True assert len(data["iterations"]) == 1 it = data["iterations"][0] assert it["iteration"] == 1 assert it["section_ref"] == "#/texts/0" assert it["can_answer"] is True assert it["section_text_length"] == 42 def test_passes_query_and_doc_json_to_runner(self, mock_analysis_repo: AsyncMock) -> None: runner = _FakeRunner() client = _make_client(runner=runner, repo=mock_analysis_repo) client.post("/api/documents/doc-1/reasoning", json={"query": "Hello?"}) assert runner.last_call is not None assert runner.last_call["query"] == "Hello?" assert runner.last_call["document_json"] == '{"stub": true}' def test_no_model_override_passes_none(self, mock_analysis_repo: AsyncMock) -> None: runner = _FakeRunner() client = _make_client(runner=runner, repo=mock_analysis_repo) client.post("/api/documents/doc-1/reasoning", json={"query": "Q"}) assert runner.last_call is not None assert runner.last_call["model_id"] is None def test_per_request_model_id_override_wins(self, mock_analysis_repo: AsyncMock) -> None: runner = _FakeRunner() client = _make_client(runner=runner, repo=mock_analysis_repo) client.post( "/api/documents/doc-1/reasoning", json={"query": "Q", "model_id": "override:13b"}, ) assert runner.last_call is not None assert runner.last_call["model_id"] == "override:13b" def test_iterations_with_multiple_steps_serialize_correctly( self, mock_analysis_repo: AsyncMock ) -> None: """R6 — trace serializable on >=2 iterations, all fields preserved.""" result = ReasoningResult( answer="final", converged=False, iterations=[ ReasoningIteration( iteration=1, section_ref="#/texts/0", reason="r1", section_text_length=10, can_answer=False, response="not yet", ), ReasoningIteration( iteration=2, section_ref="#/texts/5", reason="r2", section_text_length=20, can_answer=True, response="final", ), ], ) runner = _FakeRunner(result=result) client = _make_client(runner=runner, repo=mock_analysis_repo) resp = client.post("/api/documents/doc-1/reasoning", json={"query": "Q"}) assert resp.status_code == 200 data = resp.json() assert data["converged"] is False assert [it["iteration"] for it in data["iterations"]] == [1, 2] assert [it["section_ref"] for it in data["iterations"]] == [ "#/texts/0", "#/texts/5", ] assert data["iterations"][0]["can_answer"] is False assert data["iterations"][1]["can_answer"] is True class TestReasoningUpstreamFailure: def test_502_when_runner_raises_parse_error(self, mock_analysis_repo: AsyncMock) -> None: """The model couldn't produce a parseable JSON answer after retries.""" runner = _FakeRunner( raises=ReasoningParseError(model_id="granite4:micro-h", reason="no parseable answer") ) client = _make_client(runner=runner, repo=mock_analysis_repo) resp = client.post( "/api/documents/doc-1/reasoning", json={"query": "Quelle tarification ?"}, ) assert resp.status_code == 502 detail = resp.json()["detail"] assert "granite4:micro-h" in detail assert "parseable" in detail or "rephrase" in detail def test_500_for_other_unexpected_errors(self, mock_analysis_repo: AsyncMock) -> None: runner = _FakeRunner(raises=RuntimeError("Ollama unreachable")) client = _make_client(runner=runner, repo=mock_analysis_repo) resp = client.post("/api/documents/doc-1/reasoning", json={"query": "Q"}) assert resp.status_code == 500 assert "Ollama unreachable" in resp.json()["detail"]