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',