docling-studio/document-parser/tests/test_reasoning_api.py
Pier-Jean Malandrino 8ae9dcdc04 refactor(audit): remediate 0.5.0 audit findings — clean architecture, security, DRY, SOLID, perf
Closes the 12 MAJ raised by the release/0.5.0 audit pipeline (cf.
docs/audit/reports/release-0.5.0/summary.md → summary-reaudit.md).

Volet 1 — Reasoning architecture (audits 01/02/06/07 strengthening)
  * Domain ports: LLMProvider, ReasoningRunner, ReasoningParseError
  * Domain DTOs: LLMProviderType, ReasoningResult, ReasoningIteration
  * infra/llm/ollama_provider.py — OllamaProvider with health_check
  * infra/docling_agent_reasoning.py — runner adapter, encapsulates the
    private _rag_loop call (tracked at docling-project/docling-agent#26),
    commits OLLAMA_HOST once at boot (eliminates the per-request env race),
    translates upstream IndexError into ReasoningParseError
  * api/reasoning.py — zero coupling to docling-agent / mellea / docling-core,
    consumes app.state.reasoning_runner via the port
  * main.py — DI wires OllamaProvider + DoclingAgentReasoningRunner at boot
    when REASONING_ENABLED=true and deps are importable
  * Rename RAG_* env vars → REASONING_*, endpoint /rag → /reasoning,
    type RAGResult → ReasoningResult, frontend feature flag wiring,
    i18n strings, tests, docs (BREAKING — pre-1.0 surface, no external
    consumers in production)
  * 17 new tests: adapter unit tests with sys.modules stubs, OllamaProvider
    httpx tests, R3 concurrent-host isolation, R6 multi-iteration trace
    serialization, R13 Protocol conformance via isinstance
  * E2E Karate scenario: nav-reasoning hidden when REASONING_ENABLED=false
  * README — Live Reasoning section (env vars, archi, link to issue #26)

Bloc B — Security (audit 08, dev-only context)
  * docker-compose.yml — DEV DEFAULTS header, OpenSearch DISABLE_SECURITY_PLUGIN
    flagged as dev-only with link to OpenSearch security docs
  * main.py — boot warning if NEO4J_URI is set with the default 'changeme'
    password, so prod operators can't silently inherit it

Bloc C — DRY frontend (audit 05)
  * shared/storage/keys.ts — STORAGE_KEYS centralised (theme, locale)
  * features/settings/store.ts — dead apiUrl ref + orphan i18n keys removed
  * api/schemas.py — DOCUMENT_STATUS_UPLOADED constant

Bloc D — Quality (audits 02/06/07/09/10/12)
  * domain/ports.py — DocumentConverter.supports_page_batching property
    (LSP fix, replaces isinstance(ServeConverter) check)
  * domain/ports.py — VectorStore.ping() (encapsulation, replaces
    _vector_store._client.info() reach-around)
  * api/analyses.py + api/ingestion.py — path params {job_id} → {analysis_id}
    aligned with the user-facing terminology (URLs unchanged)
  * api/documents.py — Path.read_bytes() + generate_preview() wrapped in
    asyncio.to_thread, unblocks the FastAPI event loop on /preview
  * infra/docling_tree.py — PEP 604 union for isinstance (Ruff UP038)
  * src/__tests__/integration/ — cross-feature integration test relocated
    out of features/history/ so feature folders stay self-contained
  * Tightened terminal `assert X is not None` checks (isinstance(.., datetime),
    exact value comparisons)

Validation
  * 446 backend pytest, 202 frontend vitest — all green
  * ruff + ruff format + ESLint + Prettier + vue-tsc clean
  * Re-audit verdict: 0 CRIT / 0 MAJ, score ~94/100, GO

Closes #200
2026-04-29 09:23:09 +02:00

248 lines
8.8 KiB
Python

"""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="<h1>Hello</h1>",
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"]