feat(reasoning): live docling-agent runner + UX polish
Backend — live runner - New `POST /api/documents/:id/rag` endpoint. Loads `document_json` from SQLite, reconstructs the DoclingDocument, wraps the model id in `ModelIdentifier(ollama_name=...)`, and calls `agent._rag_loop` off-thread (blocking sync call). Returns a `RAGResult` in the shape the existing v1 import path already consumes, so the frontend overlay is fully reused. - `_rag_loop` is private upstream; we call it because `run()` wraps the answer in a synthetic DoclingDocument and drops the iteration trace. - Settings: `RAG_ENABLED`, `OLLAMA_HOST`, `RAG_MODEL_ID`. Router mounts unconditionally; handler 503s when the flag is off or deps aren't installed. `rag_available` surfaced in `/api/health`. - Maps known docling-agent bugs to readable HTTP errors: 502 with "the model couldn't produce a parseable answer" when `_rag_loop` raises `IndexError` from `find_json_dicts([])[0]` after 3 + 3 rejection-sampling retries (model-dependent). - Tests: 11 cases (flag off, query empty, no analysis, happy path, model_id wrap, Ollama env, IndexError → 502, other errors → 500, deps missing → 503). Backend — bug fix - Default `BATCH_PAGE_SIZE` flipped from `10` to `0` to match the dataclass default. The old default silently dropped `document_json` (see `domain/services.merge_results`) for any doc > 10 pages, which broke the reasoning tunnel. Set `BATCH_PAGE_SIZE>0` explicitly on memory-constrained deploys if batching is wanted. Frontend — runner UX - `features/reasoning/api.ts:runReasoning()` — POST wrapper. - `RunReasoningDialog.vue` — query textarea + optional model_id override. Blocks close while running, 20-40s loading state, synthesises a sidecar-shaped envelope so the panel surfaces query + model the same way an imported trace would. - `ReasoningWorkspace.vue` — primary "Run reasoning" button; "Import trace" relegated to ghost secondary. - Store: `runDialogOpen`, `running`, `setRunning`. Frontend — answer polish - Answer rendered through `marked` + DOMPurify (models emit markdown lists; `pre-wrap` rendered them as plain "1. …" strings). - Dedicated answer block with orange border, "ANSWER" label, "Copy" button (clipboard + "Copied ✓" feedback). - IterationCard: drop the duplicate `response` block (the main answer is authoritative); style reasons equal to `"fallback"` (docling-agent `select_from_failure` placeholder) as italic muted "— no structured rationale". Frontend — node details contents - Clicking a SectionHeader (or any node with compound children) lists its contained elements in `NodeDetailsPanel` under a new "Contents" block. Children come from the same `parentMap` used for Cytoscape compound parenting (explicit PARENT_OF + synthetic section scope), inverted once and cached as a computed. - Click a child row → pan the viewport to it + swap the selection. Housekeeping - `cytoscape-navigator` removed from `package-lock.json` (follow-up from the minimap removal in the previous commit).
This commit is contained in:
parent
8103460e9c
commit
5b7700df83
16 changed files with 1213 additions and 44 deletions
148
document-parser/api/reasoning.py
Normal file
148
document-parser/api/reasoning.py
Normal file
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
261
document-parser/tests/test_reasoning_api.py
Normal file
261
document-parser/tests/test_reasoning_api.py
Normal file
|
|
@ -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="<h1>Hello</h1>",
|
||||
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"]
|
||||
9
frontend/package-lock.json
generated
9
frontend/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -49,7 +49,12 @@
|
|||
{{ tooltip.text }}
|
||||
</div>
|
||||
</div>
|
||||
<NodeDetailsPanel :node="selectedNode" @close="closeDetails" />
|
||||
<NodeDetailsPanel
|
||||
:node="selectedNode"
|
||||
:contents="selectedNodeContents"
|
||||
@close="closeDetails"
|
||||
@navigate="navigateToNode"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
|
@ -57,7 +62,7 @@
|
|||
|
||||
<script setup lang="ts">
|
||||
import type { Core } from 'cytoscape'
|
||||
import { onMounted, onBeforeUnmount, ref, watch, nextTick } from 'vue'
|
||||
import { computed, onMounted, onBeforeUnmount, ref, watch, nextTick } from 'vue'
|
||||
import { useI18n } from '../../../shared/i18n'
|
||||
import { reasoningOverlayStyles } from '../../reasoning/graphReasoningOverlay'
|
||||
import { fetchDocumentGraph, type GraphNode, type GraphPayload } from '../graphApi'
|
||||
|
|
@ -98,6 +103,32 @@ const hiddenChips = ref<Set<string>>(new Set())
|
|||
// Click → details panel. Null = panel hidden.
|
||||
const selectedNode = ref<GraphNode | null>(null)
|
||||
|
||||
// Compound parenting map (childId → parentId), kept in sync with the
|
||||
// Cytoscape render so the details panel can show "this section contains …".
|
||||
// Updated at the end of `renderGraph` — before that it's empty.
|
||||
const parentMap = ref<Map<string, string>>(new Map())
|
||||
|
||||
// Inverse index of parentMap: parentId → childId[]. Enables the
|
||||
// NodeDetailsPanel "contents" section (click a section → see what's in it).
|
||||
const childrenByParent = computed<Map<string, GraphNode[]>>(() => {
|
||||
const out = new Map<string, GraphNode[]>()
|
||||
const byId = new Map<string, GraphNode>()
|
||||
for (const n of payload.value?.nodes ?? []) byId.set(n.id, n)
|
||||
for (const [childId, parentId] of parentMap.value) {
|
||||
const child = byId.get(childId)
|
||||
if (!child) continue
|
||||
if (!out.has(parentId)) out.set(parentId, [])
|
||||
out.get(parentId)!.push(child)
|
||||
}
|
||||
return out
|
||||
})
|
||||
|
||||
const selectedNodeContents = computed<GraphNode[]>(() => {
|
||||
const id = selectedNode.value?.id
|
||||
if (!id) return []
|
||||
return childrenByParent.value.get(id) ?? []
|
||||
})
|
||||
|
||||
// Hover tooltip: position (px within .graph-canvas) + text. Null hides it.
|
||||
const tooltip = ref<{ x: number; y: number; text: string } | null>(null)
|
||||
|
||||
|
|
@ -182,11 +213,13 @@ async function renderGraph(): Promise<void> {
|
|||
// Compute compound parenting: merge docling-native PARENT_OF with the
|
||||
// synthetic section-scope parents so every non-root element sits inside
|
||||
// its section visually (enables per-section collapse via the legend chips
|
||||
// and the expand-collapse plugin).
|
||||
const parentMap = mergeParentMaps(
|
||||
// and the expand-collapse plugin). Also persisted on `parentMap` so the
|
||||
// NodeDetailsPanel can list what a given section contains.
|
||||
const computedParentMap = mergeParentMaps(
|
||||
explicitParentMap(payload.value.edges),
|
||||
computeSectionParents(payload.value.nodes, payload.value.edges),
|
||||
)
|
||||
parentMap.value = computedParentMap
|
||||
|
||||
const elements = [
|
||||
...payload.value.nodes.map((n) => ({
|
||||
|
|
@ -202,7 +235,7 @@ async function renderGraph(): Promise<void> {
|
|||
// Compound-node parent: used by the expand-collapse plugin to
|
||||
// fold/unfold a section's scope. `undefined` = this node is a root
|
||||
// of the compound hierarchy (Documents, unparented sections, etc.).
|
||||
parent: parentMap.get(n.id),
|
||||
parent: computedParentMap.get(n.id),
|
||||
raw: n,
|
||||
},
|
||||
})),
|
||||
|
|
@ -399,6 +432,22 @@ function closeDetails(): void {
|
|||
cy.value?.nodes('.nd-selected').removeClass('nd-selected')
|
||||
}
|
||||
|
||||
/**
|
||||
* Triggered when the user clicks a child row inside the NodeDetailsPanel
|
||||
* (e.g. the "Contents" list of a section). Switch the selection, center the
|
||||
* viewport on the target, and flash the node briefly so the eye can catch it.
|
||||
*/
|
||||
function navigateToNode(target: GraphNode): void {
|
||||
selectedNode.value = target
|
||||
if (!cy.value) return
|
||||
cy.value.nodes('.nd-selected').removeClass('nd-selected')
|
||||
const el = cy.value.getElementById(target.id)
|
||||
if (el && el.length > 0) {
|
||||
el.addClass('nd-selected')
|
||||
cy.value.animate({ center: { eles: el }, duration: 250 })
|
||||
}
|
||||
}
|
||||
|
||||
function disposeGraph(): void {
|
||||
if (cy.value) {
|
||||
cy.value.destroy()
|
||||
|
|
|
|||
|
|
@ -57,6 +57,31 @@
|
|||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<section v-if="contents && contents.length > 0" class="nd-contents-block">
|
||||
<h4 class="nd-section-title">
|
||||
{{ t('graph.contains').replace('{n}', String(contents.length)) }}
|
||||
</h4>
|
||||
<ul class="nd-contents">
|
||||
<li v-for="child in contents" :key="child.id">
|
||||
<button
|
||||
type="button"
|
||||
class="nd-child"
|
||||
:data-e2e="`node-details-child-${child.id}`"
|
||||
@click="$emit('navigate', child)"
|
||||
>
|
||||
<span
|
||||
class="nd-child-chip"
|
||||
:style="{ background: kindColorFor(child) }"
|
||||
:title="child.label ?? child.group"
|
||||
>
|
||||
{{ kindLabelFor(child) }}
|
||||
</span>
|
||||
<span class="nd-child-text">{{ previewText(child) }}</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
|
|
@ -66,8 +91,20 @@ import { computed } from 'vue'
|
|||
import { useI18n } from '../../../shared/i18n'
|
||||
import type { GraphNode, GraphProvenance } from '../graphApi'
|
||||
|
||||
const props = defineProps<{ node: GraphNode | null }>()
|
||||
defineEmits<{ close: [] }>()
|
||||
const props = defineProps<{
|
||||
node: GraphNode | null
|
||||
/**
|
||||
* Nodes whose compound parent (PARENT_OF or synthetic section scope) is the
|
||||
* currently-selected node. Computed upstream in GraphView so we don't have
|
||||
* to re-walk the whole edge list here. Empty or null for leaf nodes.
|
||||
*/
|
||||
contents?: readonly GraphNode[] | null
|
||||
}>()
|
||||
defineEmits<{
|
||||
close: []
|
||||
/** User clicked a child row — GraphView pans + swaps selection. */
|
||||
navigate: [node: GraphNode]
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
|
|
@ -133,6 +170,35 @@ function fmt(n: number | null | undefined): string {
|
|||
if (n == null) return '—'
|
||||
return n.toFixed(1)
|
||||
}
|
||||
|
||||
// Label + color helpers factored so they work for children too, not just the
|
||||
// currently-selected node. Keep them consistent with the chips above.
|
||||
function kindLabelFor(n: GraphNode): string {
|
||||
if (n.group === 'document') return 'Document'
|
||||
if (n.group === 'page') return 'Page'
|
||||
if (n.group === 'chunk') return 'Chunk'
|
||||
return n.label ?? 'Element'
|
||||
}
|
||||
|
||||
function kindColorFor(n: GraphNode): string {
|
||||
if (n.group === 'document') return KIND_COLORS.document
|
||||
if (n.group === 'page') return KIND_COLORS.Page
|
||||
if (n.group === 'chunk') return KIND_COLORS.Chunk
|
||||
return KIND_COLORS[n.label ?? ''] || KIND_COLORS.TextElement
|
||||
}
|
||||
|
||||
/**
|
||||
* Short label for a child row. Prefer the node's own text (truncated), fall
|
||||
* back to its self_ref so users can still recognise / debug missing text.
|
||||
*/
|
||||
function previewText(n: GraphNode): string {
|
||||
const raw = (n.text as string | undefined) ?? ''
|
||||
const clean = raw.replace(/\s+/g, ' ').trim()
|
||||
if (clean) return clean.length > 80 ? clean.slice(0, 80) + '…' : clean
|
||||
if (n.group === 'page') return `p.${n.page_no ?? '?'}`
|
||||
if (n.group === 'chunk') return `chunk #${n.chunk_index ?? '?'}`
|
||||
return n.self_ref ?? n.id
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
|
@ -275,4 +341,69 @@ function fmt(n: number | null | undefined): string {
|
|||
font-size: 9px;
|
||||
text-transform: lowercase;
|
||||
}
|
||||
|
||||
.nd-contents-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding-top: 10px;
|
||||
border-top: 1px solid var(--border-light);
|
||||
}
|
||||
|
||||
.nd-contents {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
/* Cap the list height so a section with hundreds of paragraphs doesn't
|
||||
* blow the panel out. Scroll internally above that. */
|
||||
max-height: 340px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.nd-child {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--radius-sm);
|
||||
padding: 5px 8px;
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
transition: all var(--transition);
|
||||
}
|
||||
|
||||
.nd-child:hover {
|
||||
background: var(--border-light);
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
.nd-child-chip {
|
||||
flex: 0 0 auto;
|
||||
display: inline-block;
|
||||
padding: 1px 7px;
|
||||
border-radius: 8px;
|
||||
color: #f8fafc;
|
||||
font-size: 9px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.3px;
|
||||
}
|
||||
|
||||
.nd-child-text {
|
||||
flex: 1 1 auto;
|
||||
font-size: 12px;
|
||||
line-height: 1.4;
|
||||
color: var(--text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -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<GraphPayload> {
|
||||
return apiFetch<GraphPayload>(`/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<RAGResult> {
|
||||
return apiFetch<RAGResult>(`/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,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<RAGResult | null>(null)
|
||||
const envelope = ref<SidecarEnvelope | null>(null)
|
||||
const overlay = ref<OverlayResult | null>(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,
|
||||
|
|
|
|||
|
|
@ -20,9 +20,8 @@
|
|||
{{ statusLabel }}
|
||||
</span>
|
||||
</div>
|
||||
<p v-if="iteration.reason" class="it-reason">{{ iteration.reason }}</p>
|
||||
<p v-if="iteration.response && iteration.canAnswer" class="it-response">
|
||||
{{ iteration.response }}
|
||||
<p v-if="iteration.reason" class="it-reason" :class="{ placeholder: isPlaceholderReason }">
|
||||
{{ isPlaceholderReason ? t('reasoning.reasonPlaceholder') : iteration.reason }}
|
||||
</p>
|
||||
<div class="it-meta">
|
||||
<span v-if="iteration.sectionTextLength">
|
||||
|
|
@ -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'
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
|
@ -151,16 +159,9 @@ const statusLabel = computed(() => {
|
|||
color: var(--text);
|
||||
}
|
||||
|
||||
.it-response {
|
||||
margin: 6px 0 0;
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
color: var(--text-secondary);
|
||||
.it-reason.placeholder {
|
||||
color: var(--text-muted);
|
||||
font-style: italic;
|
||||
padding: 6px 8px;
|
||||
border-left: 2px solid #ea580c;
|
||||
background: rgba(234, 88, 12, 0.04);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
.it-meta {
|
||||
|
|
|
|||
|
|
@ -40,14 +40,28 @@
|
|||
|
||||
<section v-if="result" class="rp-answer">
|
||||
<div class="rp-answer-header">
|
||||
<span class="rp-answer-label">{{ t('reasoning.answerLabel') }}</span>
|
||||
<span class="rp-answer-actions">
|
||||
<span class="rp-converged" :class="{ yes: result.converged, no: !result.converged }">
|
||||
{{ result.converged ? t('reasoning.converged') : t('reasoning.notConverged') }}
|
||||
</span>
|
||||
<button
|
||||
class="rp-copy-btn"
|
||||
:title="t('reasoning.copyAnswer')"
|
||||
data-e2e="reasoning-copy-answer"
|
||||
@click="copyAnswer"
|
||||
>
|
||||
{{ copied ? t('reasoning.copied') : t('reasoning.copy') }}
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
<!-- eslint-disable-next-line vue/no-v-html -- sanitized by DOMPurify -->
|
||||
<div class="rp-answer-body markdown-body" v-html="renderedAnswer" />
|
||||
<div class="rp-answer-footer">
|
||||
<span class="rp-stats">
|
||||
{{ store.presentCount }} / {{ store.iterations.length }} {{ t('reasoning.resolved') }}
|
||||
</span>
|
||||
</div>
|
||||
<p class="rp-answer-text">{{ result.answer }}</p>
|
||||
</section>
|
||||
|
||||
<section v-if="store.missingCount > 0" class="rp-warn" data-e2e="reasoning-missing-warn">
|
||||
|
|
@ -76,7 +90,9 @@
|
|||
|
||||
<script setup lang="ts">
|
||||
import type { Core } from 'cytoscape'
|
||||
import { computed, watch } from 'vue'
|
||||
import DOMPurify from 'dompurify'
|
||||
import { marked } from 'marked'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import { useI18n } from '../../../shared/i18n'
|
||||
import {
|
||||
|
|
@ -104,6 +120,34 @@ const { t } = useI18n()
|
|||
|
||||
const result = computed(() => store.rawResult)
|
||||
const envelope = computed(() => store.envelope)
|
||||
|
||||
// Render the answer as markdown so numbered lists, bold, etc. render properly.
|
||||
// Models tend to produce markdown-formatted answers (numbered lists especially),
|
||||
// and plain-text `pre-wrap` made them near-unreadable.
|
||||
const renderedAnswer = computed(() => {
|
||||
const raw = result.value?.answer ?? ''
|
||||
if (!raw.trim()) return ''
|
||||
return DOMPurify.sanitize(marked.parse(raw, { async: false }) as string)
|
||||
})
|
||||
|
||||
const copied = ref(false)
|
||||
let copyResetTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
async function copyAnswer(): Promise<void> {
|
||||
const text = result.value?.answer
|
||||
if (!text) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(text)
|
||||
copied.value = true
|
||||
if (copyResetTimer) clearTimeout(copyResetTimer)
|
||||
copyResetTimer = setTimeout(() => {
|
||||
copied.value = false
|
||||
}, 1800)
|
||||
} catch (e) {
|
||||
console.warn('Copy failed', e)
|
||||
}
|
||||
}
|
||||
|
||||
const missingWarning = computed(() => {
|
||||
// Full miss + no cy → the graph simply isn't loaded. Different message
|
||||
// than "N sections are actually missing from the graph".
|
||||
|
|
@ -273,11 +317,12 @@ function onClear(): void {
|
|||
.rp-answer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 10px 12px;
|
||||
background: var(--accent-muted, rgba(234, 88, 12, 0.04));
|
||||
border: 1px solid var(--border-light);
|
||||
border-radius: var(--radius-sm);
|
||||
gap: 10px;
|
||||
padding: 14px 16px;
|
||||
background: var(--bg);
|
||||
border: 1px solid #ea580c;
|
||||
border-radius: var(--radius);
|
||||
box-shadow: 0 1px 3px rgba(234, 88, 12, 0.08);
|
||||
}
|
||||
|
||||
.rp-answer-header {
|
||||
|
|
@ -287,6 +332,20 @@ function onClear(): void {
|
|||
gap: 8px;
|
||||
}
|
||||
|
||||
.rp-answer-label {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.8px;
|
||||
text-transform: uppercase;
|
||||
color: #ea580c;
|
||||
}
|
||||
|
||||
.rp-answer-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.rp-converged {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
|
|
@ -306,18 +365,97 @@ function onClear(): void {
|
|||
color: #a16207;
|
||||
}
|
||||
|
||||
.rp-copy-btn {
|
||||
background: transparent;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-secondary);
|
||||
padding: 2px 8px;
|
||||
font-size: 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition);
|
||||
}
|
||||
|
||||
.rp-copy-btn:hover {
|
||||
background: var(--border-light);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.rp-stats {
|
||||
font-size: 10px;
|
||||
color: var(--text-muted);
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
}
|
||||
|
||||
.rp-answer-text {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
.rp-answer-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
border-top: 1px solid var(--border-light);
|
||||
padding-top: 6px;
|
||||
}
|
||||
|
||||
/* Markdown-rendered answer body. Mirrors a subset of MarkdownViewer styles,
|
||||
* tuned for a narrow right-rail context (tighter sizes than the full viewer). */
|
||||
.rp-answer-body {
|
||||
font-size: 13.5px;
|
||||
line-height: 1.6;
|
||||
color: var(--text);
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.rp-answer-body :deep(p) {
|
||||
margin: 0 0 8px;
|
||||
}
|
||||
|
||||
.rp-answer-body :deep(p:last-child) {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.rp-answer-body :deep(ol),
|
||||
.rp-answer-body :deep(ul) {
|
||||
margin: 4px 0 8px;
|
||||
padding-left: 22px;
|
||||
}
|
||||
|
||||
.rp-answer-body :deep(li) {
|
||||
margin: 2px 0;
|
||||
}
|
||||
|
||||
.rp-answer-body :deep(strong) {
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.rp-answer-body :deep(code) {
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
font-size: 12px;
|
||||
background: var(--border-light);
|
||||
padding: 1px 5px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.rp-answer-body :deep(pre) {
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
font-size: 12px;
|
||||
background: var(--border-light);
|
||||
padding: 8px 10px;
|
||||
border-radius: var(--radius-sm);
|
||||
overflow-x: auto;
|
||||
margin: 6px 0;
|
||||
}
|
||||
|
||||
.rp-answer-body :deep(h1),
|
||||
.rp-answer-body :deep(h2),
|
||||
.rp-answer-body :deep(h3),
|
||||
.rp-answer-body :deep(h4) {
|
||||
margin: 10px 0 4px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.rp-answer-body :deep(a) {
|
||||
color: #ea580c;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.rp-warn {
|
||||
|
|
|
|||
|
|
@ -8,18 +8,27 @@
|
|||
{{ docFilename ?? docId }}
|
||||
</div>
|
||||
<button
|
||||
class="rw-action-btn"
|
||||
class="rw-action-btn rw-action-ghost"
|
||||
data-e2e="reasoning-workspace-import"
|
||||
@click="reasoningStore.openImportDialog()"
|
||||
>
|
||||
{{ t('reasoning.importBtn') }}
|
||||
</button>
|
||||
<button
|
||||
class="rw-action-btn"
|
||||
data-e2e="reasoning-workspace-run"
|
||||
@click="reasoningStore.openRunDialog()"
|
||||
>
|
||||
{{ t('reasoning.runBtn') }}
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<div class="rw-body">
|
||||
<GraphView ref="graphViewRef" :doc-id="docId" :fetcher="fetchReasoningGraph" />
|
||||
<ReasoningPanel :cy="graphCy" />
|
||||
</div>
|
||||
|
||||
<RunReasoningDialog :doc-id="docId" :doc-filename="docFilename" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
|
@ -31,6 +40,7 @@ import { useI18n } from '../../../shared/i18n'
|
|||
import { fetchReasoningGraph } from '../api'
|
||||
import { useReasoningStore } from '../store'
|
||||
import ReasoningPanel from './ReasoningPanel.vue'
|
||||
import RunReasoningDialog from './RunReasoningDialog.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
docId: string
|
||||
|
|
@ -115,6 +125,19 @@ onBeforeUnmount(() => reasoningStore.reset())
|
|||
filter: brightness(0.95);
|
||||
}
|
||||
|
||||
/* Secondary action next to the primary Run button — import is a rarer path. */
|
||||
.rw-action-ghost {
|
||||
background: transparent;
|
||||
color: var(--text-secondary);
|
||||
border: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.rw-action-ghost:hover {
|
||||
background: var(--border-light);
|
||||
color: var(--text);
|
||||
filter: none;
|
||||
}
|
||||
|
||||
.rw-body {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
|
|
|
|||
296
frontend/src/features/reasoning/ui/RunReasoningDialog.vue
Normal file
296
frontend/src/features/reasoning/ui/RunReasoningDialog.vue
Normal file
|
|
@ -0,0 +1,296 @@
|
|||
<template>
|
||||
<div
|
||||
v-if="store.runDialogOpen"
|
||||
class="run-modal-backdrop"
|
||||
data-e2e="reasoning-run-modal"
|
||||
@click.self="close"
|
||||
>
|
||||
<div class="run-modal" role="dialog" aria-modal="true" :aria-label="t('reasoning.runTitle')">
|
||||
<div class="run-modal-header">
|
||||
<h3>{{ t('reasoning.runTitle') }}</h3>
|
||||
<button
|
||||
class="run-modal-close"
|
||||
:aria-label="t('reasoning.close')"
|
||||
:disabled="store.running"
|
||||
@click="close"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p class="run-modal-hint">{{ t('reasoning.runHint') }}</p>
|
||||
|
||||
<label class="run-field">
|
||||
<span class="run-field-label">{{ t('reasoning.runQueryLabel') }}</span>
|
||||
<textarea
|
||||
v-model="query"
|
||||
class="run-field-input"
|
||||
rows="3"
|
||||
:placeholder="t('reasoning.runQueryPlaceholder')"
|
||||
:disabled="store.running"
|
||||
data-e2e="reasoning-run-query"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label class="run-field">
|
||||
<span class="run-field-label">{{ t('reasoning.runModelLabel') }}</span>
|
||||
<input
|
||||
v-model="modelId"
|
||||
type="text"
|
||||
class="run-field-input"
|
||||
:placeholder="t('reasoning.runModelPlaceholder')"
|
||||
:disabled="store.running"
|
||||
data-e2e="reasoning-run-model"
|
||||
/>
|
||||
<span class="run-field-sub">{{ t('reasoning.runModelSub') }}</span>
|
||||
</label>
|
||||
|
||||
<div v-if="store.running" class="run-loading" data-e2e="reasoning-run-loading">
|
||||
<div class="spinner" />
|
||||
<span>{{ t('reasoning.running') }}</span>
|
||||
</div>
|
||||
|
||||
<div v-if="errorMsg" class="run-modal-error" data-e2e="reasoning-run-error">
|
||||
{{ errorMsg }}
|
||||
</div>
|
||||
|
||||
<div class="run-modal-actions">
|
||||
<button class="run-ghost" :disabled="store.running" @click="close">
|
||||
{{ t('reasoning.cancel') }}
|
||||
</button>
|
||||
<button
|
||||
class="run-primary"
|
||||
:disabled="!query.trim() || store.running"
|
||||
data-e2e="reasoning-run-submit"
|
||||
@click="submit"
|
||||
>
|
||||
{{ t('reasoning.runSubmit') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { useI18n } from '../../../shared/i18n'
|
||||
import { runReasoning } from '../api'
|
||||
import { useReasoningStore } from '../store'
|
||||
import type { SidecarEnvelope } from '../types'
|
||||
|
||||
const props = defineProps<{ docId: string; docFilename?: string | null }>()
|
||||
|
||||
const store = useReasoningStore()
|
||||
const { t } = useI18n()
|
||||
|
||||
const query = ref('')
|
||||
const modelId = ref('')
|
||||
const errorMsg = ref<string | null>(null)
|
||||
|
||||
function close(): void {
|
||||
if (store.running) return // don't let the user close mid-run
|
||||
store.closeRunDialog()
|
||||
errorMsg.value = null
|
||||
}
|
||||
|
||||
async function submit(): Promise<void> {
|
||||
const q = query.value.trim()
|
||||
if (!q) return
|
||||
errorMsg.value = null
|
||||
store.setRunning(true)
|
||||
try {
|
||||
const result = await runReasoning(props.docId, q, modelId.value.trim() || undefined)
|
||||
// Synthesize a sidecar-like envelope so the panel can show what was asked
|
||||
// and which model answered, same as an imported trace.
|
||||
const envelope: SidecarEnvelope = {
|
||||
filename: props.docFilename ?? undefined,
|
||||
query: q,
|
||||
model: modelId.value.trim()
|
||||
? { ollama_name: modelId.value.trim(), hf_model_name: null }
|
||||
: undefined,
|
||||
result,
|
||||
}
|
||||
store.setResult(result, envelope)
|
||||
// Keep the query for the user's reference but close the dialog.
|
||||
store.closeRunDialog()
|
||||
} catch (e) {
|
||||
errorMsg.value = (e as Error).message || t('reasoning.runErrUnknown')
|
||||
} finally {
|
||||
store.setRunning(false)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.run-modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(15, 23, 42, 0.55);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.run-modal {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius);
|
||||
padding: 20px;
|
||||
width: min(560px, 100%);
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 12px 48px rgba(15, 23, 42, 0.25);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.run-modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.run-modal-header h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.run-modal-close {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
color: var(--text-muted);
|
||||
font-size: 16px;
|
||||
cursor: pointer;
|
||||
padding: 4px 8px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.run-modal-close:hover:not(:disabled) {
|
||||
background: var(--border-light);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.run-modal-close:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.run-modal-hint {
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.run-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.run-field-label {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.run-field-input {
|
||||
width: 100%;
|
||||
padding: 8px;
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.run-field-input:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.run-field-sub {
|
||||
font-size: 11px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.run-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 13px;
|
||||
color: var(--text-secondary);
|
||||
padding: 8px 12px;
|
||||
background: var(--accent-muted, rgba(234, 88, 12, 0.08));
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.run-modal-error {
|
||||
padding: 8px 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
background: rgba(220, 38, 38, 0.08);
|
||||
color: var(--error, #dc2626);
|
||||
font-size: 12px;
|
||||
font-family: 'IBM Plex Mono', monospace;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.run-modal-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.run-primary {
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
border: 0;
|
||||
padding: 7px 16px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.run-primary:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.run-ghost {
|
||||
background: transparent;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--text-secondary);
|
||||
padding: 7px 14px;
|
||||
font-size: 13px;
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.run-ghost:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
border: 2px solid var(--border-light);
|
||||
border-top-color: var(--accent);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.6s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Reference in a new issue