diff --git a/document-parser/api/reasoning.py b/document-parser/api/reasoning.py
new file mode 100644
index 0000000..a8df225
--- /dev/null
+++ b/document-parser/api/reasoning.py
@@ -0,0 +1,148 @@
+"""Reasoning API — live `docling-agent` runner (R&D).
+
+`POST /api/documents/:id/rag` invokes `docling-agent`'s Chunkless RAG loop
+against the stored `DoclingDocument` and returns a `RAGResult` in the same
+shape the v1 import dialog already consumes — so the frontend overlay code
+is fully reused.
+
+Constraints (docling-agent v0.1.0):
+- Backend is hard-wired to Ollama (`setup_local_session` in
+ `docling_agent/agent_models.py`). Set `OLLAMA_HOST` + `RAG_MODEL_ID` in the
+ environment. No OpenAI/WatsonX path without forking upstream.
+- We call the private `_rag_loop` because `DoclingRAGAgent.run()` wraps the
+ answer in a synthetic `DoclingDocument` and never returns the iteration
+ trace. This is brittle — track upstream for a public hook.
+- Sync blocking call offloaded to a thread so we don't stall the event loop.
+ No streaming at this step (see design doc §7 for v2 SSE plan).
+"""
+
+from __future__ import annotations
+
+import asyncio
+import logging
+import os
+
+from fastapi import APIRouter, HTTPException, Request
+from pydantic import BaseModel
+
+from infra.settings import settings
+
+logger = logging.getLogger(__name__)
+router = APIRouter(prefix="/api/documents", tags=["reasoning"])
+
+
+class RagRunRequest(BaseModel):
+ query: str
+ # Optional per-run override; falls back to settings.rag_model_id.
+ model_id: str | None = None
+
+
+class RagIterationResponse(BaseModel):
+ iteration: int
+ section_ref: str
+ reason: str
+ section_text_length: int
+ can_answer: bool
+ response: str
+
+
+class RagResultResponse(BaseModel):
+ answer: str
+ iterations: list[RagIterationResponse]
+ converged: bool
+
+
+@router.post("/{doc_id}/rag", response_model=RagResultResponse)
+async def run_rag(doc_id: str, body: RagRunRequest, request: Request) -> RagResultResponse:
+ if not settings.rag_enabled:
+ raise HTTPException(status_code=503, detail="Live reasoning disabled (RAG_ENABLED=false)")
+
+ if not body.query.strip():
+ raise HTTPException(status_code=400, detail="Query must not be empty")
+
+ analysis_repo = getattr(request.app.state, "analysis_repo", None)
+ if analysis_repo is None:
+ raise HTTPException(status_code=500, detail="AnalysisRepository not wired")
+
+ latest = await analysis_repo.find_latest_completed_by_document(doc_id)
+ if latest is None or not latest.document_json:
+ raise HTTPException(
+ status_code=404,
+ detail=f"No completed analysis with document_json for {doc_id}",
+ )
+
+ # Lazy-import docling-agent so the backend boots even if the dep isn't
+ # installed (R&D group). If missing, return 503 with a clear install hint.
+ try:
+ from docling_agent.agents import DoclingRAGAgent
+ from docling_core.types.doc.document import DoclingDocument
+ from mellea.backends.model_ids import ModelIdentifier
+ except ImportError as e:
+ raise HTTPException(
+ status_code=503,
+ detail=f"docling-agent not installed: {e}. `pip install docling-agent mellea`.",
+ ) from e
+
+ # Ollama client reads OLLAMA_HOST at request time; set it per-call so the
+ # configured host takes effect without needing to restart the server.
+ os.environ["OLLAMA_HOST"] = settings.ollama_host
+ raw_model_id = body.model_id or settings.rag_model_id
+ # `DoclingRAGAgent` (pydantic) validates `model_id` strictly against the
+ # `ModelIdentifier` dataclass from Mellea. A raw string like "gpt-oss:20b"
+ # is rejected even though the Ollama backend itself would accept one.
+ # Wrap on the Ollama axis; add other axes here if we ever fork upstream to
+ # support non-Ollama backends.
+ model_id = ModelIdentifier(ollama_name=raw_model_id)
+
+ try:
+ doc = DoclingDocument.model_validate_json(latest.document_json)
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=f"Failed to parse document_json: {e}") from e
+
+ agent = DoclingRAGAgent(model_id=model_id, tools=[])
+ logger.info(
+ "RAG run: doc_id=%s model_id=%s ollama_host=%s query=%r",
+ doc_id,
+ model_id,
+ settings.ollama_host,
+ body.query[:120],
+ )
+
+ try:
+ # `_rag_loop` is a synchronous LLM-heavy call (N * model latency). Run
+ # it in a worker thread so concurrent requests don't block the loop.
+ result = await asyncio.to_thread(agent._rag_loop, query=body.query, doc=doc)
+ except IndexError as e:
+ # Known docling-agent bug: `_attempt_answer` / `_select_section` call
+ # `find_json_dicts(answer.value)[0]` without checking for an empty
+ # list. When the model can't produce a parseable JSON after 3
+ # rejection-sampling retries + 3 `select_from_failure` retries, the
+ # list is empty and the `[0]` crashes. It's model-dependent (some
+ # questions + some models trip it, others don't).
+ #
+ # Report as 502 Bad Gateway — the upstream LLM couldn't produce a
+ # usable response, not our fault — with a message the UI can show
+ # to the user so they pick another model or rephrase.
+ logger.warning(
+ "docling-agent produced no parseable JSON for doc=%s model=%s query=%r",
+ doc_id,
+ raw_model_id,
+ body.query[:120],
+ )
+ raise HTTPException(
+ status_code=502,
+ detail=(
+ f"The model '{raw_model_id}' couldn't produce a parseable "
+ "answer after retries. Try a different model (e.g. mistral-small3.2) "
+ "or rephrase the question."
+ ),
+ ) from e
+ except Exception as e:
+ logger.exception("RAG loop failed for doc %s", doc_id)
+ raise HTTPException(status_code=500, detail=f"RAG loop failed: {e}") from e
+
+ return RagResultResponse(
+ answer=result.answer,
+ iterations=[RagIterationResponse(**it.model_dump()) for it in result.iterations],
+ converged=result.converged,
+ )
diff --git a/document-parser/api/schemas.py b/document-parser/api/schemas.py
index 2b4a1b1..1ed8b4e 100644
--- a/document-parser/api/schemas.py
+++ b/document-parser/api/schemas.py
@@ -35,6 +35,10 @@ class HealthResponse(_CamelModel):
max_page_count: int | None = None
max_file_size_mb: int | None = None
ingestion_available: bool = False
+ # True when the live-reasoning runner (docling-agent + Ollama) is
+ # available: RAG_ENABLED=true AND deps importable. Doesn't imply Ollama
+ # itself is reachable — that's checked per-call.
+ rag_available: bool = False
class DocumentResponse(_CamelModel):
diff --git a/document-parser/infra/settings.py b/document-parser/infra/settings.py
index b114045..f1aeeb4 100644
--- a/document-parser/infra/settings.py
+++ b/document-parser/infra/settings.py
@@ -28,6 +28,12 @@ class Settings:
neo4j_uri: str = "" # empty = disabled (e.g. bolt://neo4j:7687)
neo4j_user: str = "neo4j"
neo4j_password: str = "changeme"
+ # Live reasoning via docling-agent — off by default (heavy deps, needs an
+ # Ollama host reachable from the backend). Toggle RAG_ENABLED=true + point
+ # OLLAMA_HOST at a running instance (default http://localhost:11434).
+ rag_enabled: bool = False
+ ollama_host: str = "http://localhost:11434"
+ rag_model_id: str = "gpt-oss:20b" # matches docling-agent's example_05
opensearch_default_limit: int = 1000 # max chunks returned by get_chunks
embedding_dimension: int = 384 # Granite Embedding 30M / all-MiniLM-L6-v2
upload_dir: str = "./uploads"
@@ -102,12 +108,20 @@ class Settings:
max_file_size=int(os.environ.get("MAX_FILE_SIZE", "0")),
max_file_size_mb=int(os.environ.get("MAX_FILE_SIZE_MB", "50")),
rate_limit_rpm=int(os.environ.get("RATE_LIMIT_RPM", "100")),
- batch_page_size=int(os.environ.get("BATCH_PAGE_SIZE", "10")),
+ # 0 = batching disabled (matches dataclass default). Batching
+ # preserves memory on very large docs but `merge_results` drops
+ # `document_json`, which breaks the reasoning tunnel. Enable
+ # explicitly (e.g. 50+) for memory-bound deploys.
+ batch_page_size=int(os.environ.get("BATCH_PAGE_SIZE", "0")),
opensearch_url=os.environ.get("OPENSEARCH_URL", ""),
embedding_url=os.environ.get("EMBEDDING_URL", ""),
neo4j_uri=os.environ.get("NEO4J_URI", ""),
neo4j_user=os.environ.get("NEO4J_USER", "neo4j"),
neo4j_password=os.environ.get("NEO4J_PASSWORD", "changeme"),
+ rag_enabled=os.environ.get("RAG_ENABLED", "false").lower()
+ in ("1", "true", "yes", "on"),
+ ollama_host=os.environ.get("OLLAMA_HOST", "http://localhost:11434"),
+ rag_model_id=os.environ.get("RAG_MODEL_ID", "gpt-oss:20b"),
opensearch_default_limit=int(os.environ.get("OPENSEARCH_DEFAULT_LIMIT", "1000")),
embedding_dimension=int(os.environ.get("EMBEDDING_DIMENSION", "384")),
upload_dir=os.environ.get("UPLOAD_DIR", "./uploads"),
diff --git a/document-parser/main.py b/document-parser/main.py
index 6eae99c..288fa5e 100644
--- a/document-parser/main.py
+++ b/document-parser/main.py
@@ -221,6 +221,13 @@ from api.graph import router as graph_router # noqa: E402
app.include_router(graph_router)
+# Live reasoning (docling-agent runner). Router is mounted unconditionally so
+# the route is introspectable in OpenAPI; the handler itself 503s when
+# `RAG_ENABLED` is off or the deps aren't installed.
+from api.reasoning import router as reasoning_router # noqa: E402
+
+app.include_router(reasoning_router)
+
@app.get("/api/health", response_model=HealthResponse)
async def health() -> HealthResponse:
@@ -243,4 +250,18 @@ async def health() -> HealthResponse:
max_page_count=settings.max_page_count if settings.max_page_count > 0 else None,
max_file_size_mb=settings.max_file_size_mb if settings.max_file_size_mb > 0 else None,
ingestion_available=getattr(app.state, "ingestion_service", None) is not None,
+ # True when the live-reasoning runner is wired (flag on + deps present).
+ # The actual Ollama reachability is checked lazily at call-time to avoid
+ # blocking health checks on the LLM host.
+ rag_available=settings.rag_enabled and _rag_deps_present(),
)
+
+
+def _rag_deps_present() -> bool:
+ """Import-check only — does not hit Ollama."""
+ try:
+ import docling_agent.agents # noqa: F401
+ import mellea # noqa: F401
+ except ImportError:
+ return False
+ return True
diff --git a/document-parser/requirements.txt b/document-parser/requirements.txt
index 1137357..dde2b32 100644
--- a/document-parser/requirements.txt
+++ b/document-parser/requirements.txt
@@ -9,3 +9,7 @@ httpx>=0.27.0,<1.0.0
pypdfium2>=4.0.0,<5.0.0
opensearch-py[async]>=2.6.0,<3.0.0
neo4j>=5.15.0,<6.0.0
+# R&D reasoning-trace live runner — calls docling-agent's `_rag_loop` over
+# an Ollama backend. Gated server-side by `RAG_ENABLED`; pulls ~60MB of deps.
+docling-agent==0.1.0
+mellea==0.4.2
diff --git a/document-parser/tests/test_reasoning_api.py b/document-parser/tests/test_reasoning_api.py
new file mode 100644
index 0000000..8feefa0
--- /dev/null
+++ b/document-parser/tests/test_reasoning_api.py
@@ -0,0 +1,261 @@
+"""Tests for `api.reasoning` — the live `docling-agent` RAG runner endpoint.
+
+docling-agent + mellea are NOT installed in the CI test env (heavy deps).
+The endpoint does a lazy import inside the handler; we stub the modules via
+`sys.modules` injection so the tests cover the real code path without
+bringing in Ollama, mellea, or LLM clients.
+"""
+
+from __future__ import annotations
+
+import sys
+import types
+from dataclasses import replace
+from unittest.mock import AsyncMock, MagicMock
+
+import pytest
+from fastapi import FastAPI
+from fastapi.testclient import TestClient
+
+from api import reasoning as reasoning_module
+from api.reasoning import router
+from domain.models import AnalysisJob
+
+
+def _patched_settings(monkeypatch, **overrides):
+ """Replace `api.reasoning.settings` with a frozen dataclass copy carrying
+ the given overrides. `Settings` is frozen, so attribute-level monkeypatch
+ doesn't work — we swap the whole instance on the module.
+ """
+ new_settings = replace(reasoning_module.settings, **overrides)
+ monkeypatch.setattr(reasoning_module, "settings", new_settings)
+ return new_settings
+
+
+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="[]",
+ # Minimal placeholder — the test stubs `DoclingDocument.model_validate_json`
+ # so the content doesn't need to be a real DoclingDocument.
+ document_json='{"stub": true}',
+ 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 stub_docling_agent(monkeypatch):
+ """Inject fake `docling_agent.agents` + `docling_core.types.doc.document`
+ modules so the endpoint's lazy imports resolve to our stubs.
+
+ Returns the `DoclingRAGAgent` stub class so tests can assert on its calls
+ / configure its `_rag_loop` return value.
+ """
+ fake_result = MagicMock()
+ fake_result.answer = "stub answer"
+ fake_result.converged = True
+ fake_result.iterations = [
+ MagicMock(
+ model_dump=lambda: {
+ "iteration": 1,
+ "section_ref": "#/texts/0",
+ "reason": "looks relevant",
+ "section_text_length": 42,
+ "can_answer": True,
+ "response": "stub answer",
+ }
+ )
+ ]
+
+ agent_instance = MagicMock()
+ agent_instance._rag_loop.return_value = fake_result
+ agent_class = MagicMock(return_value=agent_instance)
+
+ fake_agents_mod = types.ModuleType("docling_agent.agents")
+ fake_agents_mod.DoclingRAGAgent = agent_class
+ fake_root_mod = types.ModuleType("docling_agent")
+ fake_root_mod.agents = fake_agents_mod
+
+ fake_doc_class = MagicMock()
+ fake_doc_class.model_validate_json = MagicMock(return_value="fake-doc-instance")
+ fake_doc_mod = types.ModuleType("docling_core.types.doc.document")
+ fake_doc_mod.DoclingDocument = fake_doc_class
+
+ # Stub `mellea.backends.model_ids.ModelIdentifier` — the endpoint wraps
+ # the string model_id in this dataclass before handing to DoclingRAGAgent.
+ # Identity-like: stores the kwargs so tests can assert on `ollama_name`.
+ def fake_model_identifier(**kwargs):
+ m = MagicMock()
+ m.ollama_name = kwargs.get("ollama_name")
+ m.openai_name = kwargs.get("openai_name")
+ return m
+
+ fake_model_ids_mod = types.ModuleType("mellea.backends.model_ids")
+ fake_model_ids_mod.ModelIdentifier = fake_model_identifier
+ fake_backends_mod = types.ModuleType("mellea.backends")
+ fake_backends_mod.model_ids = fake_model_ids_mod
+ fake_mellea_mod = types.ModuleType("mellea")
+ fake_mellea_mod.backends = fake_backends_mod
+
+ monkeypatch.setitem(sys.modules, "docling_agent", fake_root_mod)
+ monkeypatch.setitem(sys.modules, "docling_agent.agents", fake_agents_mod)
+ monkeypatch.setitem(sys.modules, "docling_core.types.doc.document", fake_doc_mod)
+ monkeypatch.setitem(sys.modules, "mellea", fake_mellea_mod)
+ monkeypatch.setitem(sys.modules, "mellea.backends", fake_backends_mod)
+ monkeypatch.setitem(sys.modules, "mellea.backends.model_ids", fake_model_ids_mod)
+
+ return agent_class, agent_instance, fake_result
+
+
+@pytest.fixture
+def client(mock_analysis_repo: AsyncMock) -> TestClient:
+ app = FastAPI()
+ app.include_router(router)
+ app.state.analysis_repo = mock_analysis_repo
+ return TestClient(app)
+
+
+class TestRagDisabled:
+ def test_503_when_flag_off(self, client: TestClient, monkeypatch) -> None:
+ _patched_settings(monkeypatch, rag_enabled=False)
+ resp = client.post("/api/documents/doc-1/rag", json={"query": "Q"})
+ assert resp.status_code == 503
+ assert "RAG_ENABLED" in resp.json()["detail"]
+
+
+class TestRagValidation:
+ def test_400_when_query_empty(self, client: TestClient, monkeypatch) -> None:
+ _patched_settings(monkeypatch, rag_enabled=True)
+ resp = client.post("/api/documents/doc-1/rag", json={"query": " "})
+ assert resp.status_code == 400
+
+ def test_404_when_no_completed_analysis(
+ self, client: TestClient, mock_analysis_repo: AsyncMock, monkeypatch
+ ) -> None:
+ _patched_settings(monkeypatch, rag_enabled=True)
+ mock_analysis_repo.find_latest_completed_by_document.return_value = None
+ resp = client.post("/api/documents/doc-1/rag", json={"query": "Q"})
+ assert resp.status_code == 404
+
+
+class TestRagSuccess:
+ def test_returns_rag_result_shape(
+ self, client: TestClient, stub_docling_agent, monkeypatch
+ ) -> None:
+ _patched_settings(monkeypatch, rag_enabled=True)
+ _agent_class, _agent_instance, _fake_result = stub_docling_agent
+
+ resp = client.post("/api/documents/doc-1/rag", 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
+
+ def test_calls_rag_loop_with_query_and_doc(
+ self, client: TestClient, stub_docling_agent, monkeypatch
+ ) -> None:
+ _patched_settings(monkeypatch, rag_enabled=True)
+ _agent_class, agent_instance, _ = stub_docling_agent
+
+ client.post("/api/documents/doc-1/rag", json={"query": "Hello?"})
+
+ agent_instance._rag_loop.assert_called_once()
+ kwargs = agent_instance._rag_loop.call_args.kwargs
+ assert kwargs["query"] == "Hello?"
+ # The stub returns the string "fake-doc-instance" from model_validate_json
+ # and we pass it straight through to `doc=`.
+ assert kwargs["doc"] == "fake-doc-instance"
+
+ def test_uses_default_model_id_when_not_overridden(
+ self, client: TestClient, stub_docling_agent, monkeypatch
+ ) -> None:
+ _patched_settings(monkeypatch, rag_enabled=True, rag_model_id="custom-model:7b")
+ agent_class, _, _ = stub_docling_agent
+
+ client.post("/api/documents/doc-1/rag", json={"query": "Q"})
+
+ agent_class.assert_called_once()
+ # model_id is wrapped in a ModelIdentifier(ollama_name=...) dataclass
+ # before reaching the agent — the stub exposes the field for assertion.
+ passed = agent_class.call_args.kwargs["model_id"]
+ assert passed.ollama_name == "custom-model:7b"
+
+ def test_per_request_model_id_override_wins(
+ self, client: TestClient, stub_docling_agent, monkeypatch
+ ) -> None:
+ _patched_settings(monkeypatch, rag_enabled=True, rag_model_id="default:7b")
+ agent_class, _, _ = stub_docling_agent
+
+ client.post("/api/documents/doc-1/rag", json={"query": "Q", "model_id": "override:13b"})
+
+ passed = agent_class.call_args.kwargs["model_id"]
+ assert passed.ollama_name == "override:13b"
+
+ def test_sets_ollama_host_env_from_settings(
+ self, client: TestClient, stub_docling_agent, monkeypatch
+ ) -> None:
+ import os
+
+ _patched_settings(monkeypatch, rag_enabled=True, ollama_host="http://ollama:11434")
+
+ client.post("/api/documents/doc-1/rag", json={"query": "Q"})
+ assert os.environ["OLLAMA_HOST"] == "http://ollama:11434"
+
+
+class TestRagDepsMissing:
+ def test_503_when_docling_agent_not_installed(self, client: TestClient, monkeypatch) -> None:
+ _patched_settings(monkeypatch, rag_enabled=True)
+ # Simulate the import failing: remove any stub and ensure the name
+ # resolves to a module that raises on attribute access.
+ monkeypatch.setitem(sys.modules, "docling_agent.agents", None)
+
+ resp = client.post("/api/documents/doc-1/rag", json={"query": "Q"})
+ assert resp.status_code == 503
+ assert "docling-agent" in resp.json()["detail"]
+
+
+class TestRagUpstreamFailure:
+ def test_502_when_docling_agent_raises_indexerror(
+ self, client: TestClient, stub_docling_agent, monkeypatch
+ ) -> None:
+ """Known docling-agent bug: `find_json_dicts(answer.value)[0]` raises
+ `IndexError` when the model fails to produce parseable JSON after
+ retries. Our endpoint must surface a 502 with a human-readable
+ message, not a 500 stack trace."""
+ _patched_settings(monkeypatch, rag_enabled=True, rag_model_id="granite4:micro-h")
+ _agent_class, agent_instance, _ = stub_docling_agent
+ agent_instance._rag_loop.side_effect = IndexError("list index out of range")
+
+ resp = client.post("/api/documents/doc-1/rag", 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, client: TestClient, stub_docling_agent, monkeypatch
+ ) -> None:
+ _patched_settings(monkeypatch, rag_enabled=True)
+ _agent_class, agent_instance, _ = stub_docling_agent
+ agent_instance._rag_loop.side_effect = RuntimeError("Ollama unreachable")
+
+ resp = client.post("/api/documents/doc-1/rag", json={"query": "Q"})
+ assert resp.status_code == 500
+ assert "Ollama unreachable" in resp.json()["detail"]
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 6be331f..f7c4e8c 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -11,7 +11,6 @@
"cytoscape": "^3.30.0",
"cytoscape-dagre": "^2.5.0",
"cytoscape-expand-collapse": "^4.1.1",
- "cytoscape-navigator": "^2.0.2",
"dompurify": "^3.3.3",
"marked": "^17.0.4",
"pinia": "^2.3.0",
@@ -1876,14 +1875,6 @@
"cytoscape": "^3.3.0"
}
},
- "node_modules/cytoscape-navigator": {
- "version": "2.0.2",
- "resolved": "https://registry.npmjs.org/cytoscape-navigator/-/cytoscape-navigator-2.0.2.tgz",
- "integrity": "sha512-TZFBLFWEMW858UOt4rzusOjtDj7YT5vNx2uCwpUuicUYbaWCHHcUROBZWO+hiuSPWpVhvGLFlOq3NBcAVYOAgw==",
- "peerDependencies": {
- "cytoscape": "^2.6.0 || ^3.0.0"
- }
- },
"node_modules/dagre": {
"version": "0.8.5",
"resolved": "https://registry.npmjs.org/dagre/-/dagre-0.8.5.tgz",
diff --git a/frontend/src/features/analysis/ui/GraphView.vue b/frontend/src/features/analysis/ui/GraphView.vue
index 7a31f9e..4552170 100644
--- a/frontend/src/features/analysis/ui/GraphView.vue
+++ b/frontend/src/features/analysis/ui/GraphView.vue
@@ -49,7 +49,12 @@
{{ tooltip.text }}
-
+
@@ -57,7 +62,7 @@
diff --git a/frontend/src/features/reasoning/api.ts b/frontend/src/features/reasoning/api.ts
index 59c5c8a..8769197 100644
--- a/frontend/src/features/reasoning/api.ts
+++ b/frontend/src/features/reasoning/api.ts
@@ -1,5 +1,6 @@
import { apiFetch } from '../../shared/api/http'
import type { GraphPayload } from '../analysis/graphApi'
+import type { RAGResult } from './types'
/**
* Fetch the reasoning-trace graph for a document — built on the backend from
@@ -13,3 +14,28 @@ import type { GraphPayload } from '../analysis/graphApi'
export function fetchReasoningGraph(docId: string): Promise {
return apiFetch(`/api/documents/${encodeURIComponent(docId)}/reasoning-graph`)
}
+
+/**
+ * Kick off a `docling-agent` RAG run against a document and wait for the
+ * `RAGResult` (no streaming yet — the backend blocks on `_rag_loop` and
+ * returns once the loop converges or hits `max_iterations`).
+ *
+ * Runs typically take 20–40s depending on the model + Ollama latency. The
+ * caller should show a loading state.
+ *
+ * Errors:
+ * - 503 if `RAG_ENABLED=false` server-side or docling-agent isn't installed
+ * - 404 if no completed analysis exists for the doc
+ * - 500 if the loop itself raises (Ollama unreachable, model missing, …)
+ */
+export function runReasoning(docId: string, query: string, modelId?: string): Promise {
+ return apiFetch(`/api/documents/${encodeURIComponent(docId)}/rag`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ query,
+ // Backend accepts snake_case; don't camelCase here.
+ model_id: modelId || undefined,
+ }),
+ })
+}
diff --git a/frontend/src/features/reasoning/store.ts b/frontend/src/features/reasoning/store.ts
index b82eaaf..62b1779 100644
--- a/frontend/src/features/reasoning/store.ts
+++ b/frontend/src/features/reasoning/store.ts
@@ -39,6 +39,11 @@ function isRAGResult(x: RAGResult | undefined): boolean {
export const useReasoningStore = defineStore('reasoning', () => {
const importDialogOpen = ref(false)
+ // Separate modal for the live runner (POST /api/documents/:id/rag), so it
+ // can coexist with the import dialog conceptually even if only one is ever
+ // open at a time.
+ const runDialogOpen = ref(false)
+ const running = ref(false)
const rawResult = ref(null)
const envelope = ref(null)
const overlay = ref(null)
@@ -62,6 +67,18 @@ export const useReasoningStore = defineStore('reasoning', () => {
importDialogOpen.value = false
}
+ function openRunDialog(): void {
+ runDialogOpen.value = true
+ }
+
+ function closeRunDialog(): void {
+ runDialogOpen.value = false
+ }
+
+ function setRunning(v: boolean): void {
+ running.value = v
+ }
+
/**
* Called by `ImportTraceDialog` once the user has supplied a JSON file.
* Does NOT touch Cytoscape — the `ReasoningPanel` watches `rawResult` and
@@ -98,12 +115,16 @@ export const useReasoningStore = defineStore('reasoning', () => {
activeIteration.value = null
error.value = null
importDialogOpen.value = false
+ runDialogOpen.value = false
+ running.value = false
focusMode.value = true
}
return {
// state
importDialogOpen,
+ runDialogOpen,
+ running,
rawResult,
envelope,
overlay,
@@ -118,6 +139,9 @@ export const useReasoningStore = defineStore('reasoning', () => {
// actions
openImportDialog,
closeImportDialog,
+ openRunDialog,
+ closeRunDialog,
+ setRunning,
setResult,
setOverlay,
setActiveIteration,
diff --git a/frontend/src/features/reasoning/ui/IterationCard.vue b/frontend/src/features/reasoning/ui/IterationCard.vue
index 6798845..edbf6cc 100644
--- a/frontend/src/features/reasoning/ui/IterationCard.vue
+++ b/frontend/src/features/reasoning/ui/IterationCard.vue
@@ -20,9 +20,8 @@
{{ statusLabel }}
- {{ iteration.reason }}
-
- {{ iteration.response }}
+
+ {{ isPlaceholderReason ? t('reasoning.reasonPlaceholder') : iteration.reason }}
@@ -52,6 +51,15 @@ const statusLabel = computed(() => {
if (props.iteration.canAnswer) return t('reasoning.statusAnswered')
return t('reasoning.statusMore')
})
+
+// docling-agent emits the literal string "fallback" for `reason` when its
+// `select_from_failure` branch runs (the model's structured output didn't
+// parse N times in a row). Don't show that noise — render a dash-style
+// placeholder the user can visually skip.
+const isPlaceholderReason = computed(() => {
+ const r = (props.iteration.reason || '').trim().toLowerCase()
+ return r === '' || r === 'fallback'
+})
diff --git a/frontend/src/shared/i18n.ts b/frontend/src/shared/i18n.ts
index 68fe3ed..2c16d62 100644
--- a/frontend/src/shared/i18n.ts
+++ b/frontend/src/shared/i18n.ts
@@ -91,6 +91,7 @@ const messages: Messages = {
'graph.page': 'Page',
'graph.text': 'Texte',
'graph.provenances': 'Provenances ({n})',
+ 'graph.contains': 'Contenu ({n})',
'results.retry': 'Réessayer',
'results.pageOf': 'Page {current} sur {total}',
'results.noElements': 'Aucun élément détecté sur cette page',
@@ -147,6 +148,11 @@ const messages: Messages = {
'reasoning.converged': 'Convergé',
'reasoning.notConverged': 'Itérations max atteintes',
'reasoning.resolved': 'sections résolues',
+ 'reasoning.answerLabel': 'Réponse',
+ 'reasoning.copy': 'Copier',
+ 'reasoning.copied': 'Copié ✓',
+ 'reasoning.copyAnswer': 'Copier la réponse dans le presse-papier',
+ 'reasoning.reasonPlaceholder': '— pas de justification structurée',
'reasoning.missingWarn':
'{n} section(s) introuvable(s) dans le graphe. Le document a peut-être été re-analysé — relance « Maintenir » ou régénère la trace.',
'reasoning.graphNotLoadedWarn':
@@ -172,6 +178,19 @@ const messages: Messages = {
'reasoning.analyzing': 'Analyse du document...',
'reasoning.analyzingHint':
'Docling analyse le PDF avec la configuration par défaut. Cela peut prendre 1 à 3 minutes selon la taille.',
+ 'reasoning.runBtn': 'Lancer le reasoning',
+ 'reasoning.runTitle': 'Lancer docling-agent',
+ 'reasoning.runHint':
+ 'Pose une question au document. Le backend appelle docling-agent via Ollama et renvoie la trace dès que la boucle converge (20-40s).',
+ 'reasoning.runQueryLabel': 'Question',
+ 'reasoning.runQueryPlaceholder': 'Ex : Quelles sont les obligations du fournisseur ?',
+ 'reasoning.runModelLabel': 'Modèle (optionnel)',
+ 'reasoning.runModelPlaceholder': 'gpt-oss:20b',
+ 'reasoning.runModelSub':
+ 'Nom du modèle Ollama. Laisser vide pour utiliser le défaut serveur (RAG_MODEL_ID).',
+ 'reasoning.runSubmit': 'Lancer',
+ 'reasoning.running': 'docling-agent tourne... (20-40s)',
+ 'reasoning.runErrUnknown': 'Erreur inconnue lors de l\u2019appel à docling-agent.',
'reasoning.cancel': 'Annuler',
'reasoning.retry': 'Réessayer',
'reasoning.pickAnother': 'Choisir un autre document',
@@ -332,6 +351,7 @@ const messages: Messages = {
'graph.page': 'Page',
'graph.text': 'Text',
'graph.provenances': 'Provenances ({n})',
+ 'graph.contains': 'Contents ({n})',
'results.retry': 'Retry',
'results.pageOf': 'Page {current} of {total}',
'results.noElements': 'No elements detected on this page',
@@ -383,6 +403,11 @@ const messages: Messages = {
'reasoning.converged': 'Converged',
'reasoning.notConverged': 'Max iterations',
'reasoning.resolved': 'sections resolved',
+ 'reasoning.answerLabel': 'Answer',
+ 'reasoning.copy': 'Copy',
+ 'reasoning.copied': 'Copied ✓',
+ 'reasoning.copyAnswer': 'Copy answer to clipboard',
+ 'reasoning.reasonPlaceholder': '— no structured rationale',
'reasoning.missingWarn':
'{n} section(s) missing from the graph. The document may have been re-analyzed — re-run Maintain or regenerate the trace.',
'reasoning.graphNotLoadedWarn':
@@ -408,6 +433,19 @@ const messages: Messages = {
'reasoning.analyzing': 'Analyzing document...',
'reasoning.analyzingHint':
'Docling is parsing the PDF with default settings. May take 1–3 minutes depending on size.',
+ 'reasoning.runBtn': 'Run reasoning',
+ 'reasoning.runTitle': 'Run docling-agent',
+ 'reasoning.runHint':
+ 'Ask a question against this document. The backend calls docling-agent over Ollama and returns the trace once the loop converges (20–40s).',
+ 'reasoning.runQueryLabel': 'Question',
+ 'reasoning.runQueryPlaceholder': 'e.g. What are the supplier obligations?',
+ 'reasoning.runModelLabel': 'Model (optional)',
+ 'reasoning.runModelPlaceholder': 'gpt-oss:20b',
+ 'reasoning.runModelSub':
+ 'Ollama model name. Leave empty to use the server default (RAG_MODEL_ID).',
+ 'reasoning.runSubmit': 'Run',
+ 'reasoning.running': 'docling-agent is thinking… (20–40s)',
+ 'reasoning.runErrUnknown': 'Unknown error while calling docling-agent.',
'reasoning.cancel': 'Cancel',
'reasoning.retry': 'Retry',
'reasoning.pickAnother': 'Pick another document',