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_page_count: int | None = None
|
||||||
max_file_size_mb: int | None = None
|
max_file_size_mb: int | None = None
|
||||||
ingestion_available: bool = False
|
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):
|
class DocumentResponse(_CamelModel):
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,12 @@ class Settings:
|
||||||
neo4j_uri: str = "" # empty = disabled (e.g. bolt://neo4j:7687)
|
neo4j_uri: str = "" # empty = disabled (e.g. bolt://neo4j:7687)
|
||||||
neo4j_user: str = "neo4j"
|
neo4j_user: str = "neo4j"
|
||||||
neo4j_password: str = "changeme"
|
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
|
opensearch_default_limit: int = 1000 # max chunks returned by get_chunks
|
||||||
embedding_dimension: int = 384 # Granite Embedding 30M / all-MiniLM-L6-v2
|
embedding_dimension: int = 384 # Granite Embedding 30M / all-MiniLM-L6-v2
|
||||||
upload_dir: str = "./uploads"
|
upload_dir: str = "./uploads"
|
||||||
|
|
@ -102,12 +108,20 @@ class Settings:
|
||||||
max_file_size=int(os.environ.get("MAX_FILE_SIZE", "0")),
|
max_file_size=int(os.environ.get("MAX_FILE_SIZE", "0")),
|
||||||
max_file_size_mb=int(os.environ.get("MAX_FILE_SIZE_MB", "50")),
|
max_file_size_mb=int(os.environ.get("MAX_FILE_SIZE_MB", "50")),
|
||||||
rate_limit_rpm=int(os.environ.get("RATE_LIMIT_RPM", "100")),
|
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", ""),
|
opensearch_url=os.environ.get("OPENSEARCH_URL", ""),
|
||||||
embedding_url=os.environ.get("EMBEDDING_URL", ""),
|
embedding_url=os.environ.get("EMBEDDING_URL", ""),
|
||||||
neo4j_uri=os.environ.get("NEO4J_URI", ""),
|
neo4j_uri=os.environ.get("NEO4J_URI", ""),
|
||||||
neo4j_user=os.environ.get("NEO4J_USER", "neo4j"),
|
neo4j_user=os.environ.get("NEO4J_USER", "neo4j"),
|
||||||
neo4j_password=os.environ.get("NEO4J_PASSWORD", "changeme"),
|
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")),
|
opensearch_default_limit=int(os.environ.get("OPENSEARCH_DEFAULT_LIMIT", "1000")),
|
||||||
embedding_dimension=int(os.environ.get("EMBEDDING_DIMENSION", "384")),
|
embedding_dimension=int(os.environ.get("EMBEDDING_DIMENSION", "384")),
|
||||||
upload_dir=os.environ.get("UPLOAD_DIR", "./uploads"),
|
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)
|
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)
|
@app.get("/api/health", response_model=HealthResponse)
|
||||||
async def health() -> 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_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,
|
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,
|
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
|
pypdfium2>=4.0.0,<5.0.0
|
||||||
opensearch-py[async]>=2.6.0,<3.0.0
|
opensearch-py[async]>=2.6.0,<3.0.0
|
||||||
neo4j>=5.15.0,<6.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": "^3.30.0",
|
||||||
"cytoscape-dagre": "^2.5.0",
|
"cytoscape-dagre": "^2.5.0",
|
||||||
"cytoscape-expand-collapse": "^4.1.1",
|
"cytoscape-expand-collapse": "^4.1.1",
|
||||||
"cytoscape-navigator": "^2.0.2",
|
|
||||||
"dompurify": "^3.3.3",
|
"dompurify": "^3.3.3",
|
||||||
"marked": "^17.0.4",
|
"marked": "^17.0.4",
|
||||||
"pinia": "^2.3.0",
|
"pinia": "^2.3.0",
|
||||||
|
|
@ -1876,14 +1875,6 @@
|
||||||
"cytoscape": "^3.3.0"
|
"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": {
|
"node_modules/dagre": {
|
||||||
"version": "0.8.5",
|
"version": "0.8.5",
|
||||||
"resolved": "https://registry.npmjs.org/dagre/-/dagre-0.8.5.tgz",
|
"resolved": "https://registry.npmjs.org/dagre/-/dagre-0.8.5.tgz",
|
||||||
|
|
|
||||||
|
|
@ -49,7 +49,12 @@
|
||||||
{{ tooltip.text }}
|
{{ tooltip.text }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<NodeDetailsPanel :node="selectedNode" @close="closeDetails" />
|
<NodeDetailsPanel
|
||||||
|
:node="selectedNode"
|
||||||
|
:contents="selectedNodeContents"
|
||||||
|
@close="closeDetails"
|
||||||
|
@navigate="navigateToNode"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -57,7 +62,7 @@
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Core } from 'cytoscape'
|
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 { useI18n } from '../../../shared/i18n'
|
||||||
import { reasoningOverlayStyles } from '../../reasoning/graphReasoningOverlay'
|
import { reasoningOverlayStyles } from '../../reasoning/graphReasoningOverlay'
|
||||||
import { fetchDocumentGraph, type GraphNode, type GraphPayload } from '../graphApi'
|
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.
|
// Click → details panel. Null = panel hidden.
|
||||||
const selectedNode = ref<GraphNode | null>(null)
|
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.
|
// Hover tooltip: position (px within .graph-canvas) + text. Null hides it.
|
||||||
const tooltip = ref<{ x: number; y: number; text: string } | null>(null)
|
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
|
// Compute compound parenting: merge docling-native PARENT_OF with the
|
||||||
// synthetic section-scope parents so every non-root element sits inside
|
// synthetic section-scope parents so every non-root element sits inside
|
||||||
// its section visually (enables per-section collapse via the legend chips
|
// its section visually (enables per-section collapse via the legend chips
|
||||||
// and the expand-collapse plugin).
|
// and the expand-collapse plugin). Also persisted on `parentMap` so the
|
||||||
const parentMap = mergeParentMaps(
|
// NodeDetailsPanel can list what a given section contains.
|
||||||
|
const computedParentMap = mergeParentMaps(
|
||||||
explicitParentMap(payload.value.edges),
|
explicitParentMap(payload.value.edges),
|
||||||
computeSectionParents(payload.value.nodes, payload.value.edges),
|
computeSectionParents(payload.value.nodes, payload.value.edges),
|
||||||
)
|
)
|
||||||
|
parentMap.value = computedParentMap
|
||||||
|
|
||||||
const elements = [
|
const elements = [
|
||||||
...payload.value.nodes.map((n) => ({
|
...payload.value.nodes.map((n) => ({
|
||||||
|
|
@ -202,7 +235,7 @@ async function renderGraph(): Promise<void> {
|
||||||
// Compound-node parent: used by the expand-collapse plugin to
|
// Compound-node parent: used by the expand-collapse plugin to
|
||||||
// fold/unfold a section's scope. `undefined` = this node is a root
|
// fold/unfold a section's scope. `undefined` = this node is a root
|
||||||
// of the compound hierarchy (Documents, unparented sections, etc.).
|
// of the compound hierarchy (Documents, unparented sections, etc.).
|
||||||
parent: parentMap.get(n.id),
|
parent: computedParentMap.get(n.id),
|
||||||
raw: n,
|
raw: n,
|
||||||
},
|
},
|
||||||
})),
|
})),
|
||||||
|
|
@ -399,6 +432,22 @@ function closeDetails(): void {
|
||||||
cy.value?.nodes('.nd-selected').removeClass('nd-selected')
|
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 {
|
function disposeGraph(): void {
|
||||||
if (cy.value) {
|
if (cy.value) {
|
||||||
cy.value.destroy()
|
cy.value.destroy()
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,31 @@
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</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>
|
</aside>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
@ -66,8 +91,20 @@ import { computed } from 'vue'
|
||||||
import { useI18n } from '../../../shared/i18n'
|
import { useI18n } from '../../../shared/i18n'
|
||||||
import type { GraphNode, GraphProvenance } from '../graphApi'
|
import type { GraphNode, GraphProvenance } from '../graphApi'
|
||||||
|
|
||||||
const props = defineProps<{ node: GraphNode | null }>()
|
const props = defineProps<{
|
||||||
defineEmits<{ close: [] }>()
|
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()
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
|
@ -133,6 +170,35 @@ function fmt(n: number | null | undefined): string {
|
||||||
if (n == null) return '—'
|
if (n == null) return '—'
|
||||||
return n.toFixed(1)
|
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>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
@ -275,4 +341,69 @@ function fmt(n: number | null | undefined): string {
|
||||||
font-size: 9px;
|
font-size: 9px;
|
||||||
text-transform: lowercase;
|
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>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { apiFetch } from '../../shared/api/http'
|
import { apiFetch } from '../../shared/api/http'
|
||||||
import type { GraphPayload } from '../analysis/graphApi'
|
import type { GraphPayload } from '../analysis/graphApi'
|
||||||
|
import type { RAGResult } from './types'
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fetch the reasoning-trace graph for a document — built on the backend from
|
* 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> {
|
export function fetchReasoningGraph(docId: string): Promise<GraphPayload> {
|
||||||
return apiFetch<GraphPayload>(`/api/documents/${encodeURIComponent(docId)}/reasoning-graph`)
|
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', () => {
|
export const useReasoningStore = defineStore('reasoning', () => {
|
||||||
const importDialogOpen = ref(false)
|
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 rawResult = ref<RAGResult | null>(null)
|
||||||
const envelope = ref<SidecarEnvelope | null>(null)
|
const envelope = ref<SidecarEnvelope | null>(null)
|
||||||
const overlay = ref<OverlayResult | null>(null)
|
const overlay = ref<OverlayResult | null>(null)
|
||||||
|
|
@ -62,6 +67,18 @@ export const useReasoningStore = defineStore('reasoning', () => {
|
||||||
importDialogOpen.value = false
|
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.
|
* Called by `ImportTraceDialog` once the user has supplied a JSON file.
|
||||||
* Does NOT touch Cytoscape — the `ReasoningPanel` watches `rawResult` and
|
* Does NOT touch Cytoscape — the `ReasoningPanel` watches `rawResult` and
|
||||||
|
|
@ -98,12 +115,16 @@ export const useReasoningStore = defineStore('reasoning', () => {
|
||||||
activeIteration.value = null
|
activeIteration.value = null
|
||||||
error.value = null
|
error.value = null
|
||||||
importDialogOpen.value = false
|
importDialogOpen.value = false
|
||||||
|
runDialogOpen.value = false
|
||||||
|
running.value = false
|
||||||
focusMode.value = true
|
focusMode.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
// state
|
// state
|
||||||
importDialogOpen,
|
importDialogOpen,
|
||||||
|
runDialogOpen,
|
||||||
|
running,
|
||||||
rawResult,
|
rawResult,
|
||||||
envelope,
|
envelope,
|
||||||
overlay,
|
overlay,
|
||||||
|
|
@ -118,6 +139,9 @@ export const useReasoningStore = defineStore('reasoning', () => {
|
||||||
// actions
|
// actions
|
||||||
openImportDialog,
|
openImportDialog,
|
||||||
closeImportDialog,
|
closeImportDialog,
|
||||||
|
openRunDialog,
|
||||||
|
closeRunDialog,
|
||||||
|
setRunning,
|
||||||
setResult,
|
setResult,
|
||||||
setOverlay,
|
setOverlay,
|
||||||
setActiveIteration,
|
setActiveIteration,
|
||||||
|
|
|
||||||
|
|
@ -20,9 +20,8 @@
|
||||||
{{ statusLabel }}
|
{{ statusLabel }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p v-if="iteration.reason" class="it-reason">{{ iteration.reason }}</p>
|
<p v-if="iteration.reason" class="it-reason" :class="{ placeholder: isPlaceholderReason }">
|
||||||
<p v-if="iteration.response && iteration.canAnswer" class="it-response">
|
{{ isPlaceholderReason ? t('reasoning.reasonPlaceholder') : iteration.reason }}
|
||||||
{{ iteration.response }}
|
|
||||||
</p>
|
</p>
|
||||||
<div class="it-meta">
|
<div class="it-meta">
|
||||||
<span v-if="iteration.sectionTextLength">
|
<span v-if="iteration.sectionTextLength">
|
||||||
|
|
@ -52,6 +51,15 @@ const statusLabel = computed(() => {
|
||||||
if (props.iteration.canAnswer) return t('reasoning.statusAnswered')
|
if (props.iteration.canAnswer) return t('reasoning.statusAnswered')
|
||||||
return t('reasoning.statusMore')
|
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>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
@ -151,16 +159,9 @@ const statusLabel = computed(() => {
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.it-response {
|
.it-reason.placeholder {
|
||||||
margin: 6px 0 0;
|
color: var(--text-muted);
|
||||||
font-size: 12px;
|
|
||||||
line-height: 1.45;
|
|
||||||
color: var(--text-secondary);
|
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
padding: 6px 8px;
|
|
||||||
border-left: 2px solid #ea580c;
|
|
||||||
background: rgba(234, 88, 12, 0.04);
|
|
||||||
border-radius: 2px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.it-meta {
|
.it-meta {
|
||||||
|
|
|
||||||
|
|
@ -40,14 +40,28 @@
|
||||||
|
|
||||||
<section v-if="result" class="rp-answer">
|
<section v-if="result" class="rp-answer">
|
||||||
<div class="rp-answer-header">
|
<div class="rp-answer-header">
|
||||||
<span class="rp-converged" :class="{ yes: result.converged, no: !result.converged }">
|
<span class="rp-answer-label">{{ t('reasoning.answerLabel') }}</span>
|
||||||
{{ result.converged ? t('reasoning.converged') : t('reasoning.notConverged') }}
|
<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>
|
</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">
|
<span class="rp-stats">
|
||||||
{{ store.presentCount }} / {{ store.iterations.length }} {{ t('reasoning.resolved') }}
|
{{ store.presentCount }} / {{ store.iterations.length }} {{ t('reasoning.resolved') }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="rp-answer-text">{{ result.answer }}</p>
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section v-if="store.missingCount > 0" class="rp-warn" data-e2e="reasoning-missing-warn">
|
<section v-if="store.missingCount > 0" class="rp-warn" data-e2e="reasoning-missing-warn">
|
||||||
|
|
@ -76,7 +90,9 @@
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Core } from 'cytoscape'
|
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 { useI18n } from '../../../shared/i18n'
|
||||||
import {
|
import {
|
||||||
|
|
@ -104,6 +120,34 @@ const { t } = useI18n()
|
||||||
|
|
||||||
const result = computed(() => store.rawResult)
|
const result = computed(() => store.rawResult)
|
||||||
const envelope = computed(() => store.envelope)
|
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(() => {
|
const missingWarning = computed(() => {
|
||||||
// Full miss + no cy → the graph simply isn't loaded. Different message
|
// Full miss + no cy → the graph simply isn't loaded. Different message
|
||||||
// than "N sections are actually missing from the graph".
|
// than "N sections are actually missing from the graph".
|
||||||
|
|
@ -273,11 +317,12 @@ function onClear(): void {
|
||||||
.rp-answer {
|
.rp-answer {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 6px;
|
gap: 10px;
|
||||||
padding: 10px 12px;
|
padding: 14px 16px;
|
||||||
background: var(--accent-muted, rgba(234, 88, 12, 0.04));
|
background: var(--bg);
|
||||||
border: 1px solid var(--border-light);
|
border: 1px solid #ea580c;
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius);
|
||||||
|
box-shadow: 0 1px 3px rgba(234, 88, 12, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
.rp-answer-header {
|
.rp-answer-header {
|
||||||
|
|
@ -287,6 +332,20 @@ function onClear(): void {
|
||||||
gap: 8px;
|
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 {
|
.rp-converged {
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
|
@ -306,18 +365,97 @@ function onClear(): void {
|
||||||
color: #a16207;
|
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 {
|
.rp-stats {
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-family: 'IBM Plex Mono', monospace;
|
font-family: 'IBM Plex Mono', monospace;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rp-answer-text {
|
.rp-answer-footer {
|
||||||
margin: 0;
|
display: flex;
|
||||||
font-size: 13px;
|
justify-content: flex-end;
|
||||||
line-height: 1.5;
|
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);
|
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 {
|
.rp-warn {
|
||||||
|
|
|
||||||
|
|
@ -8,18 +8,27 @@
|
||||||
{{ docFilename ?? docId }}
|
{{ docFilename ?? docId }}
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
class="rw-action-btn"
|
class="rw-action-btn rw-action-ghost"
|
||||||
data-e2e="reasoning-workspace-import"
|
data-e2e="reasoning-workspace-import"
|
||||||
@click="reasoningStore.openImportDialog()"
|
@click="reasoningStore.openImportDialog()"
|
||||||
>
|
>
|
||||||
{{ t('reasoning.importBtn') }}
|
{{ t('reasoning.importBtn') }}
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
class="rw-action-btn"
|
||||||
|
data-e2e="reasoning-workspace-run"
|
||||||
|
@click="reasoningStore.openRunDialog()"
|
||||||
|
>
|
||||||
|
{{ t('reasoning.runBtn') }}
|
||||||
|
</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div class="rw-body">
|
<div class="rw-body">
|
||||||
<GraphView ref="graphViewRef" :doc-id="docId" :fetcher="fetchReasoningGraph" />
|
<GraphView ref="graphViewRef" :doc-id="docId" :fetcher="fetchReasoningGraph" />
|
||||||
<ReasoningPanel :cy="graphCy" />
|
<ReasoningPanel :cy="graphCy" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<RunReasoningDialog :doc-id="docId" :doc-filename="docFilename" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
@ -31,6 +40,7 @@ import { useI18n } from '../../../shared/i18n'
|
||||||
import { fetchReasoningGraph } from '../api'
|
import { fetchReasoningGraph } from '../api'
|
||||||
import { useReasoningStore } from '../store'
|
import { useReasoningStore } from '../store'
|
||||||
import ReasoningPanel from './ReasoningPanel.vue'
|
import ReasoningPanel from './ReasoningPanel.vue'
|
||||||
|
import RunReasoningDialog from './RunReasoningDialog.vue'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
docId: string
|
docId: string
|
||||||
|
|
@ -115,6 +125,19 @@ onBeforeUnmount(() => reasoningStore.reset())
|
||||||
filter: brightness(0.95);
|
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 {
|
.rw-body {
|
||||||
flex: 1 1 auto;
|
flex: 1 1 auto;
|
||||||
min-height: 0;
|
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.page': 'Page',
|
||||||
'graph.text': 'Texte',
|
'graph.text': 'Texte',
|
||||||
'graph.provenances': 'Provenances ({n})',
|
'graph.provenances': 'Provenances ({n})',
|
||||||
|
'graph.contains': 'Contenu ({n})',
|
||||||
'results.retry': 'Réessayer',
|
'results.retry': 'Réessayer',
|
||||||
'results.pageOf': 'Page {current} sur {total}',
|
'results.pageOf': 'Page {current} sur {total}',
|
||||||
'results.noElements': 'Aucun élément détecté sur cette page',
|
'results.noElements': 'Aucun élément détecté sur cette page',
|
||||||
|
|
@ -147,6 +148,11 @@ const messages: Messages = {
|
||||||
'reasoning.converged': 'Convergé',
|
'reasoning.converged': 'Convergé',
|
||||||
'reasoning.notConverged': 'Itérations max atteintes',
|
'reasoning.notConverged': 'Itérations max atteintes',
|
||||||
'reasoning.resolved': 'sections résolues',
|
'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':
|
'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.',
|
'{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':
|
'reasoning.graphNotLoadedWarn':
|
||||||
|
|
@ -172,6 +178,19 @@ const messages: Messages = {
|
||||||
'reasoning.analyzing': 'Analyse du document...',
|
'reasoning.analyzing': 'Analyse du document...',
|
||||||
'reasoning.analyzingHint':
|
'reasoning.analyzingHint':
|
||||||
'Docling analyse le PDF avec la configuration par défaut. Cela peut prendre 1 à 3 minutes selon la taille.',
|
'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.cancel': 'Annuler',
|
||||||
'reasoning.retry': 'Réessayer',
|
'reasoning.retry': 'Réessayer',
|
||||||
'reasoning.pickAnother': 'Choisir un autre document',
|
'reasoning.pickAnother': 'Choisir un autre document',
|
||||||
|
|
@ -332,6 +351,7 @@ const messages: Messages = {
|
||||||
'graph.page': 'Page',
|
'graph.page': 'Page',
|
||||||
'graph.text': 'Text',
|
'graph.text': 'Text',
|
||||||
'graph.provenances': 'Provenances ({n})',
|
'graph.provenances': 'Provenances ({n})',
|
||||||
|
'graph.contains': 'Contents ({n})',
|
||||||
'results.retry': 'Retry',
|
'results.retry': 'Retry',
|
||||||
'results.pageOf': 'Page {current} of {total}',
|
'results.pageOf': 'Page {current} of {total}',
|
||||||
'results.noElements': 'No elements detected on this page',
|
'results.noElements': 'No elements detected on this page',
|
||||||
|
|
@ -383,6 +403,11 @@ const messages: Messages = {
|
||||||
'reasoning.converged': 'Converged',
|
'reasoning.converged': 'Converged',
|
||||||
'reasoning.notConverged': 'Max iterations',
|
'reasoning.notConverged': 'Max iterations',
|
||||||
'reasoning.resolved': 'sections resolved',
|
'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':
|
'reasoning.missingWarn':
|
||||||
'{n} section(s) missing from the graph. The document may have been re-analyzed — re-run Maintain or regenerate the trace.',
|
'{n} section(s) missing from the graph. The document may have been re-analyzed — re-run Maintain or regenerate the trace.',
|
||||||
'reasoning.graphNotLoadedWarn':
|
'reasoning.graphNotLoadedWarn':
|
||||||
|
|
@ -408,6 +433,19 @@ const messages: Messages = {
|
||||||
'reasoning.analyzing': 'Analyzing document...',
|
'reasoning.analyzing': 'Analyzing document...',
|
||||||
'reasoning.analyzingHint':
|
'reasoning.analyzingHint':
|
||||||
'Docling is parsing the PDF with default settings. May take 1–3 minutes depending on size.',
|
'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.cancel': 'Cancel',
|
||||||
'reasoning.retry': 'Retry',
|
'reasoning.retry': 'Retry',
|
||||||
'reasoning.pickAnother': 'Pick another document',
|
'reasoning.pickAnother': 'Pick another document',
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue