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).
148 lines
5.8 KiB
Python
148 lines
5.8 KiB
Python
"""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,
|
|
)
|