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)
114 lines
3.9 KiB
Python
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)
|