docling-studio/document-parser/tests/test_graph_api.py
Pier-Jean Malandrino 8103460e9c feat(reasoning): reasoning-trace viewer v1 with SQLite-backed graph
Adds the `docling-agent` reasoning-trace viewer as a Studio tunnel, per
`docs/design/reasoning-trace.md`. Users pick an analyzed document, import
a RAGResult JSON, and the iterations are overlaid on the document graph.

Graph source is decoupled from Neo4j: a new pure builder
(`infra/docling_graph.build_graph_payload`) reads `document_json` from
SQLite and emits the same Cytoscape-shaped payload that `fetch_graph`
returns from Neo4j. Neo4j stays exclusive to the Maintain ingestion
pipeline. Shared DoclingDocument helpers live in `infra/docling_tree.py`
so TreeWriter and the builder can't drift on label taxonomy or tree walks.

Also removes the Cytoscape minimap (cytoscape-navigator) from GraphView:
second render instance hurt perf on large documents for no UX win.

Backend
- new `GET /api/documents/:id/reasoning-graph` (SQLite-only)
- new `infra/docling_tree.py`, `infra/docling_graph.py`
- `analysis_repo.find_latest_completed_by_document`
- tests: `test_docling_graph.py` (builder), `test_graph_api.py` (endpoint)

Frontend
- `features/reasoning/` — store, overlay, types, panel, import dialog,
  workspace, doc picker
- new `ReasoningPage` + `/reasoning` and `/reasoning/:docId` routes
- `GraphView` gains a `fetcher` prop so reasoning can inject the
  SQLite-backed fetcher while Maintain keeps using the Neo4j one
- drops minimap (nav container, dep, CSS)
- legend filters + section parenting extracted for reuse
- i18n base strings (FR + EN)
2026-04-29 14:00:00 +02:00

114 lines
3.9 KiB
Python

"""Tests for `api.graph` — the `/graph` (Neo4j) and `/reasoning-graph`
(SQLite) endpoints. Neo4j itself is not exercised here; `/graph` is covered
by the integration tests under `tests/neo4j/`. This file focuses on the
SQLite-backed reasoning endpoint and the error paths.
"""
from __future__ import annotations
import json
from unittest.mock import AsyncMock
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from api.graph import router
from domain.models import AnalysisJob
FIXTURE = {
"pages": {"1": {"page_no": 1, "size": {"width": 595, "height": 842}}},
"body": {"self_ref": "#/body", "children": [{"$ref": "#/texts/0"}]},
"texts": [
{
"self_ref": "#/texts/0",
"parent": {"$ref": "#/body"},
"label": "section_header",
"text": "Hello",
"level": 1,
"prov": [{"page_no": 1, "bbox": {"l": 0, "t": 0, "r": 10, "b": 10}}],
}
],
"tables": [],
"pictures": [],
"groups": [],
}
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=json.dumps(FIXTURE),
chunks_json="[]",
)
return job
@pytest.fixture
def mock_analysis_repo() -> AsyncMock:
repo = AsyncMock()
repo.find_latest_completed_by_document.return_value = _job_with_doc_json()
return repo
@pytest.fixture
def client(mock_analysis_repo: AsyncMock) -> TestClient:
app = FastAPI()
app.include_router(router)
app.state.analysis_repo = mock_analysis_repo
app.state.neo4j = None # /reasoning-graph must not need Neo4j
return TestClient(app)
class TestReasoningGraph:
def test_returns_payload_built_from_sqlite_json(self, client: TestClient) -> None:
resp = client.get("/api/documents/doc-1/reasoning-graph")
assert resp.status_code == 200
data = resp.json()
assert data["doc_id"] == "doc-1"
assert data["page_count"] == 1
assert data["truncated"] is False
groups = {n["group"] for n in data["nodes"]}
assert groups == {"document", "page", "element"}
edge_types = {e["type"] for e in data["edges"]}
# HAS_ROOT + ON_PAGE expected; NEXT absent (single element so no chain).
assert edge_types == {"HAS_ROOT", "ON_PAGE"}
def test_404_when_no_completed_analysis(
self, client: TestClient, mock_analysis_repo: AsyncMock
) -> None:
mock_analysis_repo.find_latest_completed_by_document.return_value = None
resp = client.get("/api/documents/doc-1/reasoning-graph")
assert resp.status_code == 404
def test_404_when_analysis_has_no_document_json(
self, client: TestClient, mock_analysis_repo: AsyncMock
) -> None:
job = AnalysisJob(document_id="doc-1")
job.mark_running()
job.mark_completed(
markdown="", html="", pages_json="[]", document_json=None, chunks_json="[]"
)
mock_analysis_repo.find_latest_completed_by_document.return_value = job
resp = client.get("/api/documents/doc-1/reasoning-graph")
assert resp.status_code == 404
def test_does_not_need_neo4j(self, client: TestClient) -> None:
# `app.state.neo4j = None` and the endpoint still serves — proves the
# reasoning graph is fully decoupled from the Neo4j provider.
resp = client.get("/api/documents/doc-1/reasoning-graph")
assert resp.status_code == 200
class TestPrimeEndpointRemoved:
def test_graph_prime_endpoint_is_gone(self, client: TestClient) -> None:
# Guardrail — if someone reintroduces /graph/prime we want a failing test.
resp = client.post("/api/documents/doc-1/graph/prime")
assert resp.status_code in (404, 405)