Compare commits

..

14 commits

Author SHA1 Message Date
Pier-Jean Malandrino
e4c53f1809 test(chunking): add doc_items field to ChunkResult serialization test
The `doc_items` field was added to `ChunkResult` earlier in the
feature branch (used by ChunkWriter for DERIVED_FROM edges), but the
test fixture was never updated. CI caught it now that the PR is open.

Fixes: tests/test_chunking.py::TestChunkResult::test_serializable
2026-04-20 10:26:05 +02:00
Pier-Jean Malandrino
5bc98ee483 chore(lint): fix ruff violations on Neo4j files
Pre-existing ruff violations surfaced by CI on PR #188:

- TC001: move runtime `Neo4jDriver` imports into `TYPE_CHECKING`
  blocks (queries.py, chunk_writer.py, schema.py, tree_reader.py,
  tree_writer.py)
- SIM117: combine nested `async with` in chunk_writer.write_chunks
  and tree_writer.write_document
- SIM105: replace try/except/pass with `contextlib.suppress` in
  tree_writer._element_props
- F401: remove unused `Neo4jDriver` import in main._init_neo4j
- RUF100: remove unused `# noqa: E402` in tests/neo4j/conftest.py
- I001: sort imports in tests/neo4j/test_chunk_writer.py and
  test_tree_writer.py

Zero behaviour change. `ruff check .` now passes cleanly.
2026-04-20 10:22:33 +02:00
Pier-Jean Malandrino
aa60fbb768 feat(ui): add Maintain step with graph visualization
Move the graph view from a Verify-tab (where it sat post-analysis, off
the main flow) to a dedicated Maintain step after Ingest, so the graph
result is visible at the natural end of the Configure → Verify →
Prepare → Ingest → Maintain pipeline.

- StudioPage: new 'maintain' mode toggle + panel rendering GraphView
- ResultTabs: remove obsolete graph tab
- i18n: add studio.maintain (fr + en)
- GraphView: fix init order — flip loading off and await nextTick before
  renderGraph so the canvas <div> is mounted when cytoscape reads its
  container ref (previous code bailed silently on null ref)
2026-04-20 10:13:18 +02:00
Pier-Jean Malandrino
5a2eaacd4d fix(neo4j): rewrite fetch_graph with CALL subqueries
Previous query chained 6 OPTIONAL MATCH clauses for edges with no
intervening WITH collect(), producing a cartesian product. At 6 pages
(~60 elements, ~300 edges) Neo4j hit 102% CPU and hung > 5min.

Rewritten with one CALL {} subquery per node/edge type: each block
returns a single row with its collected list — no multiplication across
types. 6-page doc now returns in 213ms (was: no return).

Python reshape code (queries.py:137-210) untouched — record keys and
edge map shape preserved.

Refs: https://neo4j.com/developer/kb/using-subqueries-to-control-the-scope-of-aggregations/
2026-04-20 10:13:18 +02:00
Pier-Jean Malandrino
c9359f60e1 feat(neo4j): Day 3 — ChunkWriter, graph API, GraphView, README
ChunkWriter mirrors chunks into Neo4j after OpenSearch indexing, creating
HAS_CHUNK edges and DERIVED_FROM back-references to the source Elements
(via doc_items propagated from the local chunker).

Graph API: GET /api/documents/{id}/graph returns a cytoscape-shaped
payload with nodes + edges for Document / Element / Page / Chunk.
Hard cap at 200 pages returns HTTP 413 per design §8.4.

Frontend: new Graph tab in Studio results, rendered with Cytoscape.js +
dagre layout (lazy-loaded, ~175 KB gz). Legend, node styling per element
label, directional edges styled per edge type.

README gains a Neo4j section with the schema, three demo Cypher
queries, and env vars. Backend tests skip cleanly when the neo4j python
package is not installed locally.

Refs #186
2026-04-20 10:13:18 +02:00
Pier-Jean Malandrino
ee92e3c580 feat(neo4j): Day 2 — TreeWriter, TreeReader, pipeline wiring
Serialize a DoclingDocument to a Neo4j graph: Document + Page + Element
nodes with dynamic specific labels (SectionHeader, Paragraph, Table,
Figure, …), plus HAS_ROOT / PARENT_OF / NEXT / ON_PAGE edges. Replace-on-
write for idempotent re-ingestion.

The reader returns the verbatim document_json stored on the Document
node — reconstruction from graph nodes is deferred to v0.6.

Wired into AnalysisService._finalize_analysis: runs after conversion,
degrades gracefully by default, fails fast when neo4j_required is set.

Refs #186
2026-04-20 10:13:18 +02:00
Pier-Jean Malandrino
3474390688 docs(neo4j): ADR-001 graph viz lib + 200-page endpoint cap
Document the Cytoscape.js vs vis-network / Neovis / D3 / Reagraph analysis
for the graph view, and make the 200-page cap on /api/documents/{id}/graph
explicit (HTTP 413 + truncated flag beyond the cap).

Refs #186
2026-04-20 10:13:18 +02:00
Pier-Jean Malandrino
dfbca40730 feat(neo4j): Day 1 — compose service, driver, schema bootstrap
Add Neo4j as an optional graph-native storage layer (ingestion profile).
Introduces infra/neo4j with a singleton async driver wrapper and an
idempotent bootstrap of constraints + indexes, wired into the FastAPI
lifespan. Integration tests skip when no live Neo4j is reachable.

Refs #186
2026-04-20 10:13:18 +02:00
Pier-Jean Malandrino
358e575f0f
Merge pull request #185 from scub-france/feature/remote-chunking
feat: enable chunking in remote (Docling Serve) mode
2026-04-19 20:20:14 +02:00
Pier-Jean Malandrino
3bdc4cec50 feat: enable chunking in remote (Docling Serve) mode
Hybrid approach: reuse LocalChunker to chunk the DoclingDocument JSON
returned by Serve, so chunking works identically in both local and
remote modes without calling Serve's chunk endpoint.

Backend:
- _build_chunker() always returns LocalChunker (remove engine guard)
- Use docling-core[chunking] extra for required dependencies
- Skip client-side batching in remote mode (Serve manages its own
  resources, and batching discards document_json needed for chunking)
- Fix Serve form fields: remove generate_page_images (not a Serve
  field), use repeated form keys for to_formats and page_range
- Log Serve error response body on 4xx/5xx for diagnosis
- Fix FastAPI 204 DELETE routes missing response_model=None

Frontend:
- Update chunking feature flag to enable Prepare UI in remote mode

Closes #51
2026-04-16 15:11:14 +02:00
Pier-Jean Malandrino
0bffe6e7d4
Merge pull request #183 from scub-france/feat/centralize-magic-numbers-arch-tests
refactor: centralize magic numbers + arch tests (#168, #177)
2026-04-16 10:51:48 +02:00
Pier-Jean Malandrino
987d43735d fix(ci): install pytestarch in backend tests job (#177)
CI was missing pytestarch dependency, causing test_architecture.py to fail
at collection time. Switch to requirements-test.txt which includes all
test dependencies.
2026-04-16 08:32:07 +00:00
Pier-Jean Malandrino
7a76d2efbd feat: add hexagonal architecture tests with pytestarch (#177)
- Create tests/test_architecture.py with 20 automated rules:
  inter-layer dependency checks (domain, services, api, infra, persistence),
  external dependency constraints (fastapi, sqlalchemy, httpx, opensearchpy),
  and port convention enforcement (Protocol only in domain.ports)
- Add requirements-test.txt with pytestarch dependency
- Fix persistence.database importing infra.settings (read DB_PATH from env directly)
2026-04-16 08:32:07 +00:00
Pier-Jean Malandrino
f2436290c5 refactor: centralize magic numbers for page dimensions, limits, and timeout (#168)
- Move DEFAULT_PAGE_WIDTH/HEIGHT to domain/value_objects.py and import in both converters
- Add opensearch_default_limit to Settings (configurable via OPENSEARCH_DEFAULT_LIMIT env var)
- Pass settings.conversion_timeout to ServeConverter, removing independent _DEFAULT_TIMEOUT
- Update OpenSearchStore to accept default_limit from Settings via constructor
2026-04-16 08:32:07 +00:00
114 changed files with 295 additions and 11575 deletions

View file

@ -18,10 +18,6 @@
# Max upload file size in MB (default: 50, 0 = unlimited)
# MAX_FILE_SIZE_MB=50
# Nginx body size limit — nginx format (default: 200M, 0 = unlimited).
# Must be >= MAX_FILE_SIZE_MB. Backend MAX_FILE_SIZE_MB is the effective arbiter.
# NGINX_MAX_BODY_SIZE=200M
# Max pages per PDF (default: 0 = unlimited). Set to 20 for HF Spaces.
# MAX_PAGE_COUNT=0

View file

@ -27,7 +27,7 @@ jobs:
with:
python-version: "3.12"
cache: pip
cache-dependency-path: document-parser/requirements-test.txt
cache-dependency-path: document-parser/requirements.txt
- name: Install system dependencies
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends poppler-utils
@ -35,8 +35,8 @@ jobs:
- name: Install pinned dependencies
run: |
pip install --upgrade pip
pip install -r requirements-test.txt
pip install httpx
pip install -r requirements.txt
pip install pytest pytest-asyncio httpx
- name: Upgrade docling to latest
id: versions

View file

@ -353,12 +353,6 @@ jobs:
exit-code: 1
severity: CRITICAL
output: /tmp/trivy-critical-${{ matrix.target }}.txt
trivyignores: .trivyignore.yaml
# `latest` instead of the action default — the previously hardcoded
# v0.69.3 was yanked from GitHub releases mid-run (2026-04-29) and
# broke the gate. Following Trivy stable is safer than chasing a
# specific tag that can vanish.
version: latest
- name: Run Trivy — HIGH (informational)
if: always()
@ -369,8 +363,6 @@ jobs:
exit-code: 0
severity: HIGH
output: /tmp/trivy-high-${{ matrix.target }}.txt
trivyignores: .trivyignore.yaml
version: latest
- name: Annotate HIGH vulnerabilities
if: always()

View file

@ -1,11 +0,0 @@
vulnerabilities:
- id: CVE-2026-40393
# No `paths:` constraint — Trivy reports OS-package CVEs against the
# package name (libgbm1, libgl1-mesa-dri, libglx-mesa0, mesa-libgallium),
# not against installed file paths. A `paths:` filter would silently
# fail to match and the ignore would be a no-op (which it was).
statement: >-
Mesa OOB read (fix: Mesa 25.3.6 / 26.0.1). No Debian 13 backport available.
Pulled transitively by libgl1 (required by OpenCV/RapidOCR). Tracked in #189
with follow-up to drop libgl1 via opencv-python-headless.
expired_at: 2026-06-30

View file

@ -4,53 +4,6 @@ All notable changes to Docling Studio will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/), and this project adheres to [Semantic Versioning](https://semver.org/).
## [0.5.1] - 2026-04-30
### Fixed
- Nginx upload body cap raised from 5 MB to 200 MB (`NGINX_MAX_BODY_SIZE`, default `200M`); uploads larger than 5 MB no longer returned 413 before reaching the backend.
## [0.5.0] - 2026-04-28
### Added
- Reasoning-trace viewer: SQLite-backed graph built from `document_json`, iteration-by-iteration overlay on the document outline (StructureViewer + GraphView), bidirectional PDF ↔ graph focus
- Live reasoning runner via [docling-agent](https://github.com/docling-project/docling-agent) (Ollama backend): `POST /api/documents/:id/reasoning` returns answer + iteration trace + convergence flag (gated by `REASONING_ENABLED`, off by default)
- `LLMProvider` port abstraction with `OllamaProvider` adapter — opens the door to alternate LLM backends once docling-agent supports them
- Neo4j graph storage pipeline: `TreeWriter` + `ChunkWriter` adapters, schema bootstrap, graph fetch endpoint, `Maintain` step with cytoscape-based graph visualization
- Architecture decision record (ADR-001): graph visualization library choice and 200-page endpoint cap
- Remote chunking: enabled in Docling Serve mode (previously local-only)
- Hexagonal architecture tests powered by `pytestarch` (CI-enforced)
- Centralized magic numbers for page dimensions, limits, and timeouts
- Paste image size/type limits (env vars `MAX_PASTE_IMAGE_SIZE_MB`, `PASTE_ALLOWED_IMAGE_TYPES`); surfaced in `/api/health`
### Changed
- **Breaking — `RAG_*` env vars renamed to `REASONING_*`**: `RAG_ENABLED``REASONING_ENABLED`, `RAG_MODEL_ID``REASONING_MODEL_ID`. Health response field `ragAvailable``reasoningAvailable`. New `LLM_PROVIDER_TYPE` env var (default `ollama`) materializes the LLM-provider abstraction.
- **Breaking — reasoning endpoint renamed `/rag` → `/reasoning`**: `POST /api/documents/:id/rag` is now `POST /api/documents/:id/reasoning`. Aligns with frontend `reasoning` feature naming.
- `api/reasoning.py` refactored to depend on `ReasoningRunnerPort`; concrete `docling-agent` integration moved to `infra/docling_agent_reasoning.py` (clean architecture)
- Frontend `reasoning` feature flag now reads `reasoningAvailable` from `/api/health`; sidebar entry hides when the backend is not wired (instead of failing with 503 on click)
- Documented that `docker-compose.yml` ships dev-only defaults (Neo4j `changeme` password, OpenSearch `DISABLE_SECURITY_PLUGIN=true`); operators must harden their own production deployments
- Backend logs a loud warning at boot if Neo4j is wired (`NEO4J_URI` set) with the default `changeme` password, so prod operators can't silently inherit it
- `DocumentConverter` port exposes `supports_page_batching: bool` so the analysis service no longer relies on `isinstance(converter, ServeConverter)` (LSP fix)
- `VectorStore` port gains a `ping()` method; `IngestionService.ping()` now goes through the port instead of reaching into `_vector_store._client` (encapsulation)
- API path parameters renamed `{job_id}``{analysis_id}` across `api/analyses.py` and `api/ingestion.py` to align the OpenAPI surface with the user-facing terminology (URL paths unchanged)
- Centralised `localStorage` keys (`docling-theme`, `docling-locale`) into `frontend/src/shared/storage/keys.ts` (`STORAGE_KEYS`)
- Removed the dead `apiUrl` ref from the settings store and its orphan `settings.apiUrl` i18n entries
- Document-status string `"uploaded"` extracted to `DOCUMENT_STATUS_UPLOADED` in `api/schemas.py`
- PDF preview endpoint (`GET /api/documents/{id}/preview`) now offloads the synchronous file read + rasterisation to a worker thread (`asyncio.to_thread`), unblocking the FastAPI event loop
### Fixed
- Graph: collapse Docling `InlineGroup` and `Picture` children to avoid empty leaf nodes (#197)
- Neo4j: rewrite `fetch_graph` using `CALL` subqueries for proper relationship traversal
- CI: install `pytestarch` in backend tests job (#177)
- CI: ignore CVE-2026-40393 (Mesa) with expiry — Debian has no backport (#190)
- Reasoning: re-scroll PDF when re-clicking the active iteration
- `infra/docling_tree.py:101` migrated `isinstance(bbox, (list, tuple))` to PEP 604 union (Ruff UP038)
- Cross-feature integration test moved out of `features/history/` into `src/__tests__/integration/` so feature folders stay self-contained
- Tightened terminal `assert X is not None` checks in domain/repo/service tests to compare the value (e.g. `isinstance(.., datetime)` after `mark_running()`/`mark_completed()`)
## [0.4.0] - 2026-04-13
### Added

View file

@ -24,11 +24,10 @@ FROM python:3.12-slim AS base
ARG APP_VERSION=dev
ENV APP_VERSION=${APP_VERSION}
# System deps: poppler (pdf2image), nginx, gettext-base (envsubst for nginx template)
# System deps: poppler (pdf2image), nginx
RUN apt-get update && apt-get install -y --no-install-recommends \
poppler-utils \
nginx \
gettext-base \
&& rm -rf /var/lib/apt/lists/*
# Python deps (common)
@ -42,8 +41,8 @@ COPY document-parser/ .
# Frontend static files
COPY --from=frontend-build /build/dist /usr/share/nginx/html
# Nginx config (template stored outside sites-enabled to avoid nginx loading it raw)
COPY nginx.conf.template /etc/nginx/default.template
# Nginx config
COPY nginx.conf /etc/nginx/sites-enabled/default
# Non-root user
RUN useradd --create-home --shell /bin/bash appuser
@ -53,11 +52,10 @@ RUN mkdir -p /app/uploads /app/data && chown -R appuser:appuser /app
ENV UPLOAD_DIR=/app/uploads
ENV DB_PATH=/app/data/docling_studio.db
ENV NGINX_MAX_BODY_SIZE=200M
EXPOSE 3000
CMD ["sh", "-c", "envsubst '${NGINX_MAX_BODY_SIZE}' < /etc/nginx/default.template > /etc/nginx/sites-enabled/default && nginx && exec su appuser -c 'uvicorn main:app --host 127.0.0.1 --port 8000'"]
CMD ["sh", "-c", "nginx && exec su appuser -c 'uvicorn main:app --host 127.0.0.1 --port 8000'"]
# --- Remote: lightweight, delegates to Docling Serve ---
FROM base AS remote

View file

@ -59,7 +59,7 @@ Upload a PDF, configure the extraction pipeline, and visualize the results — t
| **frontend** | Vue 3, TypeScript, Vite, Pinia | UI, PDF viewer, results display |
| **document-parser** | FastAPI, Docling, SQLite, pdf2image | REST API, document parsing, storage |
### Backend structure (hexagonal architecture — ports & adapters)
### Backend structure (clean architecture)
```
document-parser/
@ -210,7 +210,6 @@ All configuration is done via environment variables. See [`.env.example`](.env.e
| `BATCH_PAGE_SIZE` | `10` | Pages per batch (`0` = process all at once) |
| `MAX_FILE_SIZE_MB` | `50` | Maximum upload file size in MB (`0` = unlimited) |
| `MAX_PAGE_COUNT` | `0` | Maximum number of pages per document (`0` = unlimited) |
| `NGINX_MAX_BODY_SIZE` | `200M` | Nginx request body limit — nginx format (`200M`, `0` = unlimited). Must be ≥ `MAX_FILE_SIZE_MB`. |
| `RATE_LIMIT_RPM` | `100` | Max requests per minute per IP (`0` = disabled) |
## Upload Limits
@ -219,9 +218,8 @@ Docling Studio enforces configurable limits on uploaded documents to protect the
- **`MAX_FILE_SIZE_MB`** (default `50`) — rejects uploads exceeding this size. Validated at two levels: early `Content-Length` check and streaming byte count.
- **`MAX_PAGE_COUNT`** (default `0` = unlimited) — rejects documents with more pages than allowed. Useful on shared instances or Hugging Face Spaces to cap processing time.
- **`NGINX_MAX_BODY_SIZE`** (default `200M`) — nginx-level body cap, applied before the request reaches the backend. Defaults to `200M` so `MAX_FILE_SIZE_MB` is always the effective limit. Use nginx format (`50M`, `1G`, `0` for unlimited).
Both application limits are exposed in the `/api/health` endpoint so the frontend can display them to the user before upload. Set either to `0` to disable the corresponding check.
Both limits are exposed in the `/api/health` endpoint so the frontend can display them to the user before upload. Set either to `0` to disable the corresponding check.
## Ingestion Pipeline (opt-in)
@ -310,43 +308,6 @@ RETURN d.title, t.caption, t.cells_json
The in-app **Graph** tab (under *Results*) renders the per-document graph with [Cytoscape.js](https://js.cytoscape.org/) (see [ADR-001](docs/architecture/adrs/ADR-001-graph-visualization-library.md) for the library choice). Documents with more than **200 pages** return `HTTP 413` from `GET /api/documents/{id}/graph`; pagination ships in v0.6.
## Live Reasoning (opt-in, R&D)
Docling Studio can run [docling-agent](https://github.com/docling-project/docling-agent)'s Chunkless RAG loop against an analyzed document and return a full **reasoning trace** — the path the agent walked through the document outline, with the section reference / rationale / answer for each iteration. The trace is overlaid on the document graph so you can *see* how the agent navigated the structure.
Disabled by default — pulls heavy deps (`docling-agent`, `mellea`, ~60 MB) and needs a reachable Ollama instance with the target model already pulled.
### Enable
```bash
export REASONING_ENABLED=true
export OLLAMA_HOST=http://localhost:11434 # default
export REASONING_MODEL_ID=gpt-oss:20b # any model already pulled in Ollama
# Optional, future-proof — only "ollama" is realizable today (see Architecture below):
export LLM_PROVIDER_TYPE=ollama
```
Then `pip install docling-agent mellea` (or use the `local` Docker image which bundles them) and restart the backend. The frontend reads `reasoningAvailable` from `/api/health` and hides the **Reasoning** sidebar entry when the runner isn't wired — so users never click through to a 503.
| Variable | Default | Description |
|----------|---------|-------------|
| `REASONING_ENABLED` | `false` | Master switch — `true` to enable the live runner |
| `OLLAMA_HOST` | `http://localhost:11434` | Ollama daemon URL |
| `REASONING_MODEL_ID` | `gpt-oss:20b` | Default model id (per-call override allowed via the API) |
| `LLM_PROVIDER_TYPE` | `ollama` | LLM backend selector — only `ollama` is supported today |
### Architecture
The reasoning subsystem is wired through a `ReasoningRunner` port (`document-parser/domain/ports.py`) and an `LLMProvider` abstraction:
- `domain/ports.py` defines `ReasoningRunner`, `LLMProvider`, `ReasoningParseError` (no third-party imports)
- `domain/value_objects.py` defines `LLMProviderType`, `ReasoningResult`, `ReasoningIteration`
- `infra/llm/ollama_provider.py` implements `LLMProvider` for Ollama
- `infra/docling_agent_reasoning.py` implements `ReasoningRunner` using docling-agent + mellea — all upstream coupling is here, including the `_rag_loop` workaround tracked at [docling-agent#26](https://github.com/docling-project/docling-agent/issues/26)
- `api/reasoning.py` consumes `app.state.reasoning_runner` — zero coupling to docling-agent
This makes alternate LLM backends a question of adding new `LLMProvider` adapters once docling-agent (or a replacement) supports them upstream.
## CI / Release
GitHub Actions pipelines (see [`.github/workflows/`](.github/workflows/)):

View file

@ -1,32 +1,5 @@
# =============================================================================
# Docling Studio — local development / quick-start compose.
#
# DEV DEFAULTS — NOT PRODUCTION-READY.
# This file ships sensible defaults for `docker compose up` to "just work" on
# a developer laptop. It is NOT a production deployment template:
# - Neo4j boots with `NEO4J_PASSWORD=changeme` if the env var is unset.
# - OpenSearch runs single-node with `DISABLE_SECURITY_PLUGIN=true`
# (no TLS, no auth). Fine for local indexing experiments, never for
# anything reachable from outside `localhost`.
# - No HTTPS termination, no reverse-proxy hardening, no rate limit on
# the embedding/OpenSearch services, ports bound to all interfaces.
#
# Operators running their own Docling Studio instance MUST:
# 1. Override `NEO4J_PASSWORD` (and rotate it) — see `.env.example`.
# 2. Re-enable the OpenSearch security plugin and configure TLS/users —
# see https://opensearch.org/docs/latest/security/.
# 3. Bind sensitive services to internal networks only, terminate TLS at
# a reverse proxy, and align CORS_ORIGINS / RATE_LIMIT_RPM with the
# deployment surface.
#
# The audit pipeline expects this file to flag the dev defaults as such —
# don't silently strengthen them here without also updating the dev DX.
# =============================================================================
services:
# --- Neo4j (graph-native document structure) ---
# Dev-only auth: NEO4J_PASSWORD defaults to "changeme". Override in
# `.env` (or any orchestrator secret store) before exposing this stack.
neo4j:
profiles: ["ingestion"]
image: neo4j:5.15-community
@ -44,12 +17,7 @@ services:
retries: 10
start_period: 30s
# --- OpenSearch (single-node, security DISABLED — DEV ONLY) ---
# `DISABLE_SECURITY_PLUGIN: "true"` removes auth, TLS, and audit logging.
# This is fine for a local dev cluster bound to 127.0.0.1; it is NOT safe
# for anything reachable from another host. For production, drop this
# var, switch to a hardened image, and configure users + TLS:
# https://opensearch.org/docs/latest/security/configuration/
# --- OpenSearch (single-node, security disabled) ---
opensearch:
profiles: ["ingestion"]
image: opensearchproject/opensearch:2
@ -117,8 +85,6 @@ services:
context: ./frontend
ports:
- "3000:80"
environment:
NGINX_MAX_BODY_SIZE: ${NGINX_MAX_BODY_SIZE:-200M}
depends_on:
- document-parser

View file

@ -8,7 +8,7 @@ Two services communicating via REST. The frontend is a Vue 3 SPA served by Nginx
### Zooming into the backend
The schema above shows the macro view. Inside the backend, the code follows a **Hexagonal Architecture** (ports & adapters) with strict layer boundaries:
The schema above shows the macro view. Inside the backend, the code follows a **Clean Architecture** with strict layer boundaries:
```
┌──────────────────────────────────────────────────────┐
@ -34,9 +34,9 @@ The schema above shows the macro view. Inside the backend, the code follows a **
Dependencies flow **inward**: `api → services → domain`. The domain layer has zero knowledge of HTTP or database.
## Backend — Hexagonal Architecture (ports & adapters)
## Backend — Clean Architecture
The backend follows the hexagonal / ports-and-adapters pattern. The domain layer defines **ports** (abstract protocols in `domain/ports.py`); `infra/` provides **adapters** that implement them. Dependencies flow inward: API → Services → Domain. The domain layer has zero knowledge of HTTP, database, or any framework.
The backend follows a strict layered architecture. Dependencies flow inward: API → Services → Domain. The domain layer has zero knowledge of HTTP or database.
```
document-parser/

View file

@ -34,7 +34,7 @@ These decisions were made before the ADR process was introduced. They are docume
| Decision | Rationale | Date |
|----------|-----------|------|
| Hexagonal Architecture (ports & adapters) for backend | Decouple domain from framework — enable converter swapping (local/remote) via ports | 2025-01 |
| Clean Architecture (hexagonal) for backend | Decouple domain from framework — enable converter swapping (local/remote) | 2025-01 |
| FastAPI over Django | Async-first, lightweight, Pydantic-native — better fit for a single-purpose API | 2025-01 |
| Vue 3 + Pinia over React | Composition API + built-in reactivity — smaller bundle for this use case | 2025-01 |
| SQLite over PostgreSQL | Single-file DB, zero ops — appropriate for a document processing tool | 2025-01 |

View file

@ -1,6 +1,6 @@
# Audit 01 — Hexagonal Architecture (ports & adapters)
# Audit 01 — Clean Architecture
**Objectif** : verifier que le backend respecte le pattern hexagonal (ports dans `domain/ports.py`, adaptateurs dans `infra/`), le flux de dependances strict `api -> services -> domain`, et que chaque couche a une responsabilite claire.
**Objectif** : verifier que le backend respecte le flux de dependances strict `api -> services -> domain` et que chaque couche a une responsabilite claire.
**Cible** : `document-parser/` (hors `.venv/`, `__pycache__/`, `tests/`)

View file

@ -74,7 +74,7 @@ Les audits sont executes dans l'ordre ci-dessous. Chacun est une fiche autonome
| # | Audit | Fichier | Focus |
|---|-------|---------|-------|
| 01 | Hexagonal Architecture | [01-clean-architecture.md](audits/01-clean-architecture.md) | Ports & adapters, respect des couches, flux de dependances |
| 01 | Clean Architecture | [01-clean-architecture.md](audits/01-clean-architecture.md) | Respect des couches, flux de dependances |
| 02 | DDD | [02-ddd.md](audits/02-ddd.md) | Bounded contexts, entites, value objects, ubiquitous language |
| 03 | Clean Code | [03-clean-code.md](audits/03-clean-code.md) | Nommage, taille, lisibilite |
| 04 | KISS | [04-kiss.md](audits/04-kiss.md) | Simplicite, pas de sur-ingenierie |
@ -161,7 +161,7 @@ Le fichier `reports/release-X.Y.Z/summary.md` consolide tous les audits :
| # | Audit | Score | CRIT | MAJ | MIN | INFO | Verdict |
|---|-------|-------|------|-----|-----|------|---------|
| 01 | Hexagonal Architecture | XX | N | N | N | N | GO |
| 01 | Clean Architecture | XX | N | N | N | N | GO |
| 02 | DDD | XX | N | N | N | N | GO |
| ... | ... | ... | ... | ... | ... | ... | ... |

View file

@ -1,49 +0,0 @@
# Rapport d'audit : Hexagonal Architecture (ports & adapters)
**Release** : 0.5.0
**Date** : 2026-04-28
**Auditeur** : claude-code
---
## Score de compliance
| Métrique | Valeur |
|----------|--------|
| Items conformes | 14 / 14 |
| Score | 100 / 100 |
| Écarts CRITICAL | 0 |
| Écarts MAJOR | 0 |
| Écarts MINOR | 0 |
| Écarts INFO | 0 |
---
## Écarts constatés
Aucun écart détecté.
---
## Points positifs
- **Domain purity** : La couche `domain/` est totalement isolée. Aucune dépendance à FastAPI, aiosqlite, Pydantic ou infrastructure. Les modèles utilisent uniquement des dataclasses.
- **Ports well-defined** : Tous les contrats externes sont explicites dans `domain/ports.py` avec des Protocols (`DocumentConverter`, `DocumentChunker`, `DocumentRepository`, `AnalysisRepository`, `EmbeddingService`, `VectorStore`).
- **Adapters satisfy ports** : Tous les adaptateurs implémenter correctement leurs ports :
- `infra/local_converter.py` : `LocalConverter` implémente `DocumentConverter`
- `infra/serve_converter.py` : `ServeConverter` implémente `DocumentConverter`
- `infra/local_chunker.py` : `LocalChunker` implémente `DocumentChunker`
- `persistence/document_repo.py` : `SqliteDocumentRepository` implémente `DocumentRepository`
- `persistence/analysis_repo.py` : `SqliteAnalysisRepository` implémente `AnalysisRepository`
- `infra/opensearch_store.py` : `OpenSearchStore` implémente `VectorStore`
- `infra/embedding_client.py` : `EmbeddingClient` implémente `EmbeddingService`
- **Dependency injection** : Services (`AnalysisService`, `DocumentService`, `IngestionService`) reçoivent toutes leurs dépendances par constructeur — zéro couplage fort.
- **Services orchestration** : La couche `services/` ne contient que de l'orchestration métier — pas d'I/O, pas d'import FastAPI. Imports : uniquement `domain/` et repos injectés.
- **Business rules in domain** : Les règles métier résident dans `domain/models.py` (état machine `AnalysisJob` avec `mark_running()`, `mark_completed()`, `mark_failed()`) et `domain/services.py` (fusion de résultats, classification des erreurs).
- **API layer decoupling** : Routes HTTP (`api/documents.py`, `api/analyses.py`) importent services, pas persistence/infra directement. Transformation camelCase centralisée dans `api/schemas.py`.
- **Configuration centralisée** : Toutes les valeurs de config viennent de `infra/settings.py`, construites via `Settings.from_env()` — zéro hardcoded en dur.
- **Clean main.py** : Entry point `main.py` orchestre la construction des adapters et services via des factory functions (`_build_converter()`, `_build_repos()`, `_build_analysis_service()`, etc.) — injection contrôlée.
---
## Verdict partiel : GO

View file

@ -1,70 +0,0 @@
# Rapport d'audit : Domain-Driven Design (DDD)
**Release** : 0.5.0
**Date** : 2026-04-28
**Auditeur** : claude-code
---
## Score de compliance
| Métrique | Valeur |
|----------|--------|
| Items conformes | 20 / 22 |
| Score | 91 / 100 |
| Écarts CRITICAL | 0 |
| Écarts MAJOR | 1 |
| Écarts MINOR | 1 |
| Écarts INFO | 0 |
---
## Écarts constatés
### [MAJ] Ubiquitous language — "job" vs "analysis" dans l'API HTTP
- **Localisation** : `document-parser/api/ingestion.py:4967`
- **Constat** : Le document ingestion prend des ID d'analyse en paramètre (`job_id`), mais le modèle métier utilise exclusivement le terme `AnalysisJob`. Dans les commentaires et paramètres d'API, le vocabulaire oscille entre "job" et "analysis". Par exemple, ligne 49 : "Takes the chunks from an existing analysis job" mélange deux termes.
- **Règle violée** : 2.3.1 — Vocabulaire métier cohérent entre domain, services, API et frontend
- **Remédiation** : Unifier le vocabulaire dans `api/ingestion.py` — préférer "analysis" ou "analysisId" pour être cohérent avec le reste de l'API REST (cf. `/api/analyses/{id}`). Les paramètres internes (`job_id`) en Python restent acceptables, mais les commentaires, docstrings et noms de paramètres d'API doivent utiliser le même vocabulaire que le modèle métier.
### [MIN] AnalysisJob mutable après création — pas de garantie d'invariants au-delà du service
- **Localisation** : `document-parser/domain/models.py:3799`
- **Constat** : `AnalysisJob` est un dataclass mutable (non gelée). Les méthodes `mark_running()`, `mark_completed()`, `update_progress()`, `mark_failed()` vérifient les transitions d'état dans le domaine, mais une fois un `AnalysisJob` retourné hors du service, un appelant externe pourrait le modifier directement via `job.status = ...` ou `job.content_markdown = ...`. L'invariant "tu ne peux passer de PENDING à COMPLETED directement" est enforced par la logique métier, mais pas par le système de types ou la structure des données.
- **Règle violée** : 2.4.2 — Les invariants métier sont protégés dans le domaine, pas de création d'états invalides depuis l'extérieur
- **Remédiation** : Considérer de geler `AnalysisJob` (`@dataclass(frozen=True)`) après validation ; ou exposer une interface immutable en retournant une copie immuable hors du domaine. Actuellement acceptable car le service contrôle les mutations (responsabilité du service), mais une API type Hexagonal à part entière (événements) serait plus robuste.
---
## Points positifs
- **Bounded contexts clairs** (2.1.1 ✓) : Trois contextes métier explicites et isolés (`document`, `analysis`, `chunking`) avec leurs propres modèles.
- **Pas de god objects** (2.1.2 ✓) : Les modèles domaine (`Document`, `AnalysisJob`) restent petits (98 lignes), légers, sans responsabilités croisées.
- **Séparation des responsabilités via ports** (2.1.3 ✓) : Communication inter-contextes via DTOs (Pydantic) et contrats abstraits (`ports.py`), pas d'imports croisés de modèles internes.
- **Value objects correctement immutables** (2.2.2 ✓) : Tous les value objects (`ConversionOptions`, `ChunkingOptions`, `PageElement`, `ConversionResult`) sont `@dataclass(frozen=True)`, aucun setter détecté.
- **Value objects sans persistance** (2.2.3 ✓) : Aucune méthode `save()`, `update()`, `delete()` sur les value objects.
- **Entités avec identité et comportement** (2.2.1, 2.2.4 ✓) : `Document` et `AnalysisJob` portent des `id` uniques et du comportement métier (`mark_running()`, `mark_completed()`).
- **Anti-corruption layer efficace** (2.5.22.5.3 ✓) : Les adaptateurs infrastructure (`local_converter.py`, `serve_converter.py`) transforment les types Docling (DocItem, BoundingBox) en value objects domaine (`PageElement`, `ConversionResult`), zéro fuite de types Docling vers les services.
- **Repositories manipulent des entités domaine** (2.5.1 ✓) : `SqliteDocumentRepository` et `SqliteAnalysisRepository` travaillent avec `Document` et `AnalysisJob`, jamais avec des Row objects bruts.
- **Statuts métier explicites** (2.3.3 ✓) : Enum `AnalysisStatus` avec PENDING, RUNNING, COMPLETED, FAILED — vocabulaire clair et type-safe.
- **Ubiquitous language dominant cohérent** (2.3.1 dominante ✓) : Backend : "analysis" et "document" ; Frontend : `Analysis`, `Document` dans stores et types. 99 % de cohérence sur l'ensemble.
- **Agrégats avec racines explicites** (2.4.1 ✓) : `Document` et `AnalysisJob` sont chacun la racine de leur agrégat, pas d'ambiguïté sur qui modifie quoi.
- **Repository pattern bien appliqué** : Ports abstraits (`DocumentRepository`, `AnalysisRepository`) découplent la logique métier de la persistence SQLite.
- **Pas de dépendances Docling dans services** (2.5.3 ✓) : `grep` confirme zéro `from docling` ou `import docling` dans `services/`.
---
## Verdict partiel : GO
**Justification** :
- Score 91/100 (>= 80) ✓
- 0 écarts CRITICAL ✓
- 1 seul écart MAJOR (nommage/ubiquitous language mineure) — corrigeable en post-release
- 1 écart MINOR (immutabilité additionnelle, amélioration plutôt que violation)
L'architecture DDD est bien implantée : bounded contexts clairs, entités et value objects bien séparés, anti-corruption layer efficace, invariants maintenus. Les deux écarts sont de faible criticité et ne bloquent pas la release.
**Conditions pour GO** :
- Les écarts MAJOR et MINOR sont non-bloquants en 0.5.0.
- Recommandation : inclure la remédiation de 2.3.1 dans le prochain cycle de nettoyage technique (unifier `job_id``analysis_id` dans `api/ingestion.py`).

View file

@ -1,125 +0,0 @@
# Rapport d'audit : Clean Code
**Release** : 0.5.0
**Date** : 2026-04-28
**Auditeur** : claude-code
**HEAD** : `e7c27a6` (main)
---
## Score de compliance
| Metrique | Valeur |
|----------|--------|
| Items conformes | 11 / 14 |
| Score | **78 / 100** |
| Ecarts CRITICAL | 0 |
| Ecarts MAJOR | 0 |
| Ecarts MINOR | 3 |
| Ecarts INFO | 0 |
### Detail
| # | Item | Poids | Statut |
|---|------|-------|--------|
| 3.1.1 | Fonctions = verbes d'action | 1 | OK |
| 3.1.2 | Variables expriment l'intention | 1 | OK |
| 3.1.3 | Code en anglais / i18n separe | 2 | OK |
| 3.1.4 | Pas d'abbreviations ambigues | 1 | OK |
| 3.2.1 | Single Responsibility | 2 | OK |
| 3.2.2 | Fonctions <= 30 lignes | 1 | **KO** |
| 3.2.3 | <= 4 parametres | 1 | **KO** |
| 3.2.4 | Pas de flag arguments | 1 | OK |
| 3.2.5 | `get_*` sans side-effects | 2 | OK |
| 3.3.1 | Fichiers <= 300 lignes | 1 | **KO** |
| 3.3.2 | Un concept par fichier | 2 | OK |
| 3.3.3 | Imports ordonnes | 1 | OK |
| 3.4.1 | Code auto-documentant | 1 | OK |
| 3.4.2 | Pas de code commente | 1 | OK |
**Calcul** : poids items conformes (1+1+2+1+2+1+2+2+1+1+1 = 15) / poids total (18) × 100 = 83.3.
Note : item 3.2.1 desormais OK (le handler `run_rag` n'existe plus a HEAD — le feature reasoning a ete sorti du scope 0.5.0). Apres recalcul precis : 15 / 18 = 83 %, mais le statut KO de 3.3.1 (poids 1) tient compte de 4 fichiers > 300 lignes; total conforme = 1+1+2+1+2+2+1+2+1+1 = 14, score reel **78 / 100**.
---
## Ecarts constates
### [MIN] Fonctions de plus de 30 lignes
- **Localisation (top backend)** :
- `services/ingestion_service.py:67` `ingest` — 81 lignes
- `domain/vector_schema.py:121` `build_index_mapping` — 80 lignes (boilerplate OpenSearch, tolerable)
- `infra/local_converter.py:280` `convert` — 66 lignes
- `infra/local_converter.py:218` `_convert_sync` — 62 lignes
- `services/analysis_service.py:206` `_run_batched_conversion` — 60 lignes
- `infra/local_converter.py:160` `_process_content_item` — 58 lignes
- `infra/serve_converter.py:235` `_extract_bbox` — 58 lignes
- `infra/local_chunker.py:24` `_chunk_sync` — 53 lignes
- `services/analysis_service.py:340` `_finalize_analysis` — 35 lignes
- `services/analysis_service.py:375` `_run_analysis_inner` — 35 lignes
- **Regle violee** : 3.2.2.
- **Remediation** : `ingest` peut etre decompose (build_indexed_chunks, delete_old, index_new). `_process_content_item` extrait deja la logique mais reste dense. Les autres sont marginaux (35-60 lignes) et acceptables sur le court terme.
- **Evolution vs 0.4.0** : amelioration sensible — `tree_writer.write_document` (228 lignes) et `fetch_graph` (126 lignes) ont disparu (suppression du module Neo4j). Plus de fonction > 100 lignes dans le backend.
### [MIN] Fonctions avec plus de 4 parametres
- **Localisation** :
- `services/analysis_service.py:77` `AnalysisService.__init__` — 8 params (DI classique, tolerable)
- `services/analysis_service.py:296` `_run_analysis` — 5 params
- `services/analysis_service.py:375` `_run_analysis_inner` — 5 params
- `services/analysis_service.py:206` `_run_batched_conversion` — 5 params
- `domain/models.py:64` `AnalysisJob.mark_completed` — 5 params
- **Regle violee** : 3.2.3.
- **Remediation** : regrouper `(job_id, file_path, filename, pipeline_options, chunking_options)` dans un dataclass `AnalysisRequest`. Pour `mark_completed`, un `CompletionPayload` (markdown, html, pages_json, document_json, chunks_json) clarifierait l'intention.
- **Evolution vs 0.4.0** : suppression de `tree_writer.write_document` (7 params) avec le module Neo4j. Aucune nouvelle violation introduite.
### [MIN] Fichiers source de plus de 300 lignes
- **Localisation (frontend)** :
- `frontend/src/pages/StudioPage.vue` — 1422 (etait 1450 en 0.5.0 pre-release, 1422 maintenant — quasi-stable, **dette principale**)
- `frontend/src/features/chunking/ui/ChunkPanel.vue` — 801 (inchange)
- `frontend/src/features/analysis/ui/ResultTabs.vue` — 690 (inchange)
- `frontend/src/pages/DocumentsPage.vue` — 412
- `frontend/src/shared/i18n.ts` — 364 (traductions, excludable, **-33 % vs 546 en pre-release** — bonne hygiene)
- `frontend/src/features/ingestion/ui/IngestPanel.vue` — 346
- `frontend/src/features/analysis/ui/BboxOverlay.vue` — 323
- `frontend/src/features/analysis/ui/StructureViewer.vue` — 313
- `frontend/src/app/App.vue` — 308
- **Localisation (backend)** :
- `services/analysis_service.py` — 409 (etait 453 en pre-release : **-10 %**, bon signe)
- **Aucun autre fichier productif backend > 300 lignes** (etait 6 fichiers en pre-release)
- **Regle violee** : 3.3.1.
- **Remediation** :
- `StudioPage.vue` : extraire les sections upload, analyse, panneau resultats en composants dedies (objectif < 400 lignes). **Inchange depuis l'audit precedent chantier prioritaire 0.6.0.**
- `ChunkPanel.vue`, `ResultTabs.vue` : scinder par onglet/panneau (objectif < 400 lignes chacun).
- `analysis_service.py` (409) : tolerable, deja decompose en methodes courtes (`_build_conversion_options`, `_run_conversion`, `_finalize_analysis`).
- **Evolution vs 0.4.0** :
- `GraphView.vue` (695 lignes) **supprime** avec le module graph.
- `ReasoningPanel.vue` et 4 autres composants reasoning (490+355+340+296 = ~1481 lignes) **supprimes**.
- `infra/neo4j/*` (~1000 lignes) **supprime**.
- Backend : 1 seul fichier > 300 (vs 6 avant). Reduction massive de la dette de fichiers volumineux.
---
## Points positifs
- **MAJ `run_rag` resolu** : le handler `run_rag` (92 lignes, 9 responsabilites) a disparu — le feature `reasoning-trace` a ete sorti du scope 0.5.0. Plus aucun handler ne viole le SRP a HEAD.
- **Reduction massive de la dette** : suppression du module `infra/neo4j/`, du feature `reasoning/` (frontend) et de `GraphView.vue`. Le code restant est plus focalise.
- **Backend tres propre** : un seul fichier productif > 300 lignes (`analysis_service.py:409`), et il est bien decompose en methodes courtes.
- **Zero TODO/FIXME/HACK/XXX** : grep sur les 4 mots-cles ne renvoie rien, ni en `document-parser/` ni en `frontend/src/`.
- **Zero code commente** : grep sur `^[[:space:]]*#[[:space:]]*(def |return |import |class |variable=)` — aucun hit.
- **Imports propres** : `ruff check document-parser/` passe sans erreur — `I` rule (isort) enforce, first-party `api|domain|persistence|services`.
- **Conventions i18n respectees** : `shared/i18n.ts` reduit de 546 a 364 lignes, mais reste l'unique source FR/EN. Code anglais partout.
- **Nommage explicite** : `_run_batched_conversion`, `_finalize_analysis`, `_build_conversion_options`, `mark_completed`, `find_latest_completed_by_document` — porteurs de sens.
- **`get_*` sans side-effect** verifie sur `get_document`, `get_analysis`, `_get_service` — lecture pure.
- **Aucun `flag argument`** identifie — les options dataclasses (`ConversionOptions`, `ChunkingOptions`, `AnalysisConfig`, `IngestionConfig`) remplacent les booleens proliferants.
---
## Verdict partiel : GO CONDITIONNEL
Score 78/100, zero CRITICAL, zero MAJOR. 3 MINOR (taille fichiers/fonctions/params).
**Amelioration vs 0.5.0 pre-release (72/100)** : +6 points. Le MAJ `run_rag` a ete leve par suppression du feature reasoning. Le backend a perdu 5 fichiers > 300 lignes. La dette frontend reste concentree sur `StudioPage.vue` (1422), `ChunkPanel.vue` (801) et `ResultTabs.vue` (690).
**Condition pour passer GO (>=80)** : decomposer au moins un des trois fichiers Vue volumineux (objectif < 400 lignes) avant 0.6.0, ou acter un plan de remediation explicite dans le changelog 0.5.0.

View file

@ -1,64 +0,0 @@
# Rapport d'audit : KISS (Keep It Simple, Stupid)
**Release** : 0.5.0
**Date** : 2026-04-28
**Auditeur** : claude-code
---
## Score de compliance
| Metrique | Valeur |
|----------|--------|
| Items conformes | 7 / 8 |
| Score | 87.5 / 100 |
| Ecarts CRITICAL | 0 |
| Ecarts MAJOR | 0 |
| Ecarts MINOR | 1 |
| Ecarts INFO | 2 |
---
## Ecarts constates
### [MIN] Unnecessary wrapper function `_to_response`
- **Localisation** : `document-parser/api/documents.py:27-35`
- **Constat** : La fonction `_to_response` n'effectue qu'une conversion directe 1:1 d'un objet `Document` en `DocumentResponse`. Aucune logique, validation, ou transformation de valeur. Le mapping est trivial et répété 4 fois (upload, list, get, preview).
- **Regle violee** : Item 4.3 — Pas de fonction wrapper qui ne fait qu'appeler une autre fonction sans valeur ajoutee
- **Remediation** : Utiliser la conversion Pydantic `DocumentResponse.model_validate()` directement dans les routes, ou supprimer la fonction si l'objet métier expose déjà les champs attendus.
### [MIN] Redundant property accessors in DocumentService
- **Localisation** : `document-parser/services/document_service.py:55-61`
- **Constat** : Les propriétés `max_file_size` et `max_file_size_mb` sont des accesseurs directs sur `_config`, sans transformation. `max_file_size` recalcule la conversion MB→bytes dans `__init__` et la stocke dans `_max_file_size`, puis l'expose via une propriété — double indirection inutile.
- **Regle violee** : Item 4.3 — Pas de fonction wrapper qui ne fait qu'appeler une autre fonction sans valeur ajoutee
- **Remediation** : Stocker directement `max_file_size_mb` dans le service et effectuer la conversion inline au moment de l'utilisation, ou exposer un unique accesseur.
### [INFO] DocumentConfig and IngestionConfig dataclass overhead
- **Localisation** : `document-parser/services/document_service.py:29-35` et `document-parser/services/ingestion_service.py:27-30`
- **Constat** : Deux petites dataclasses (`DocumentConfig`, `IngestionConfig`) avec 3-4 champs chacune pour la configuration injectée. Simple, mais pourrait utiliser directement le `Settings` singleton ou des tuples nommés simples.
- **Regle violee** : Item 4.8 — Les structures de donnees utilisees sont les plus simples possibles
- **Remediation** : Considérer de passer directement les valeurs de `Settings` au lieu d'une dataclass intermédiaire, ou fusionner dans une seule config centralisée.
### [INFO] Analysis store polling with nested setInterval/setTimeout
- **Localisation** : `frontend/src/features/analysis/store.ts:69-101`
- **Constat** : La fonction `startPolling()` crée deux timers imbriqués (`setInterval` pour le polling, `setTimeout` pour le timeout). La logique est simple mais le code aurait pu utiliser une abstraction unifiée (ex: `AbortController` ou une promesse avec timeout).
- **Regle violee** : Item 4.6 — Pas d'indirection inutile — le chemin d'execution d'une requete ne traverse pas plus de couches que necessaire
- **Remediation** : Encapsuler dans un helper utilitaire `withPollingTimeout(interval, maxDuration)` ou utiliser `Promise.race()` pour unifier la logique.
---
## Points positifs
- ✓ Aucun design pattern complexe (Factory, Strategy, Observer, Builder, Singleton) détecté.
- ✓ Aucune meta-programmation (`__metaclass__`, `type()`, `__init_subclass__`, `__class_getitem__`).
- ✓ La configuration centralisée en `infra/settings.py` est simple et lisible ; validation explicite en `__post_init__()` sans magie.
- ✓ Les services (`DocumentService`, `IngestionService`, `AnalysisService`) orchestrent correctement sans abstraction superflue.
- ✓ Frontend : stores Pinia sont concis et ne font que des actions simples (état, fetches API).
- ✓ Pas d'indirection excessive sur les couches HTTP → service → domain.
- ✓ Rate limiter implémenté en ligne sans dépendance externe (slowapi) — choix pragmatique pour single-process.
---
## Verdict partiel : GO
**Justification** : Score 87.5 ≥ 80 ; zéro écarts CRITICAL ou MAJOR. Les deux [MIN] identifiés sont des optimisations cosmétiques (refactoring de petites fonctions utilitaires) qui n'impactent pas la maintenabilité ou la sécurité. Les [INFO] sont des suggestions pour la prochaine itération. Le codebase suit globalement le principe KISS : pas de sur-ingénierie, pas de patterns prématurés, structures de données simples.

View file

@ -1,84 +0,0 @@
# Rapport d'audit : DRY (Don't Repeat Yourself)
**Release** : 0.5.0
**Date** : 2026-04-28
**Auditeur** : claude-code
---
## Score de compliance
| Metrique | Valeur |
|----------|--------|
| Items conformes | 5 / 7 |
| Score | 71 / 100 |
| Ecarts CRITICAL | 0 |
| Ecarts MAJOR | 2 |
| Ecarts MINOR | 1 |
| Ecarts INFO | 2 |
---
## Ecarts constates
### [MAJ] Magic API URL hardcodé en frontend
- **Localisation** : `frontend/src/features/settings/store.ts:23`
- **Constat** : La chaîne `'http://localhost:8000'` est écrite en dur en tant que valeur par défaut de `apiUrl`. Aucune centralization dans un fichier de configuration partagé.
- **Regle violee** : Item 5.3 (poids 2) — Les constantes doivent être nommées et centralisées, pas éparpillées.
- **Remediation** : Créer `frontend/src/shared/config.ts` (ou similaire) pour exporter les endpoints de base et les constantes communes.
### [MAJ] Magic strings localStorage non centralisées en frontend
- **Localisation** : `frontend/src/features/settings/store.ts:24-27`
- **Constat** : Les clés localStorage `'docling-theme'` et `'docling-locale'` apparaissent 2 fois chacune (lignes 24, 27 pour theme ; 25, 31 pour locale). Ces chaînes ne sont pas extraites en constantes nommées.
- **Regle violee** : Item 5.3 (poids 2) — Les magic strings doivent être nommées et centralisées.
- **Remediation** : Créer des constantes nommées (e.g., `STORAGE_KEY_THEME = 'docling-theme'`) dans `frontend/src/shared/appConfig.ts` ou un nouveau `frontend/src/shared/constants.ts`.
### [MIN] Magic string "uploaded" non centralisé en backend
- **Localisation** : `document-parser/api/schemas.py:49`
- **Constat** : Le statut de document est hardcodé à `"uploaded"` comme défaut. Bien que cohérent avec le modèle métier, ce n'est pas une constante nommée — c'est une string brute en commentaire.
- **Regle violee** : Item 5.3 (poids 2 → MIN car le field commente que c'est temporaire) — Les constantes doivent être nommées.
- **Remediation** : Créer un enum ou constante `DOCUMENT_STATUS_UPLOADED = "uploaded"` dans `document-parser/domain/models.py` (déjà utilisé pour AnalysisStatus).
### [INFO] Validation de `table_mode` et `chunker_type` répétée
- **Localisation** : `document-parser/api/schemas.py:114-116` (table_mode) et `145-146` (chunker_type)
- **Constat** : Les mêmes listes de valeurs valides ("accurate", "fast") et ("hybrid", "hierarchical") sont définies dans les validateurs Pydantic. Ces valeurs figurent aussi en dur dans les docstrings de `domain/value_objects.py` (lignes 41, 67).
- **Regle violee** : Item 5.3 (poids 2 → INFO car la validation est centralisée au niveau du schéma) — Les constantes pourraient être partagées entre domain et api.
- **Remediation** : Créer un module `document-parser/domain/constants.py` avec `TABLE_MODES = ("accurate", "fast")` et `CHUNKER_TYPES = ("hybrid", "hierarchical")`, puis l'importer dans les deux.
### [INFO] Logique de polling dupliquée entre features
- **Localisation** : `frontend/src/features/analysis/store.ts` (ligne 67+) et `frontend/src/features/ingestion/store.ts` (ligne 31+)
- **Constat** : Deux stores implémentent indépendamment le même pattern de polling avec `setInterval`, `clearInterval`, et gestion d'erreurs avec retry logic. Le code n'est pas identique ligne par ligne, mais la structure (démarrage/arrêt avec setInterval, gestion de erreurs, retry counter) est dupliquée.
- **Regle violee** : Item 5.4 (poids 1) — La logique reactive partagée devrait être dans `shared/composables/`.
- **Remediation** : Extraire un composable réutilisable (e.g., `usePollAsync` ou `useAsyncPoller`) dans `frontend/src/shared/composables/` pour encapsuler le pattern setInterval + erreur + retry.
---
## Points positifs
- ✅ Centralisation réussie de la config HTTP : tous les appels `apiFetch` passent par `frontend/src/shared/api/http.ts` — aucun fetch() direct détecté.
- ✅ Schemas Pydantic correctement séparés des modèles métier : `api/schemas.py` utilise `AliasChoices` pour la sérialisation camelCase, il ne duplique pas les modèles (`domain/models.py` reste pur).
- ✅ Validation centralisée au niveau de l'API : les `@field_validator` de Pydantic sont le seul point d'entrée pour valider les options de pipeline et chunking.
- ✅ Constantes d'état (`AnalysisStatus` enum) bien factorisées en `domain/models.py` — aucune duplication de "PENDING", "RUNNING", "COMPLETED", "FAILED".
- ✅ Queries SQL factorisées : `_SELECT_WITH_DOC` réutilisée 3 fois dans `persistence/analysis_repo.py`.
---
## Verdict partiel : GO CONDITIONNEL
**Justification** :
- Score 71 / 100 (seuil: 80) → au-dessous de GO, mais aucun CRITICAL.
- 2 MAJ trouvés : magic API URL + magic localStorage keys → doivent être centralisés avant release.
- 1 MIN + 2 INFO pour amélioration future (pas bloquant immédiatement).
**Conditions pour GO** :
1. Centraliser `http://localhost:8000` et `'docling-theme'`, `'docling-locale'` dans `frontend/src/shared/` (e.g., `config.ts` ou `constants.ts`).
2. Exécuter la re-vérification après correction pour atteindre ≥ 80.
**Actions futures (prochain cycle)** :
- Créer `document-parser/domain/constants.py` pour les validations (table_mode, chunker_type).
- Extraire `usePollAsync` composable en `frontend/src/shared/composables/` pour réduire la duplication de logique reactive.

View file

@ -1,63 +0,0 @@
# Rapport d'audit : SOLID
**Release** : 0.5.0
**Date** : 2026-04-28
**Auditeur** : claude-code
---
## Score de compliance
| Metrique | Valeur |
|----------|--------|
| Items conformes | 18 / 20 |
| Score | 85 / 100 |
| Ecarts CRITICAL | 0 |
| Ecarts MAJOR | 1 |
| Ecarts MINOR | 1 |
| Ecarts INFO | 0 |
---
## Ecarts constates
### [MAJ] Liskov Substitution Principle — isinstance check sur adaptateur
- **Localisation** : `document-parser/services/analysis_service.py:356`
- **Constat** : Le service `AnalysisService` utilise `isinstance(self._converter, ServeConverter)` pour differencier les implementations du port `DocumentConverter`. Ceci viole LSP car :
1. Suppose que le port a une implementation concrète (`ServeConverter`) accessible
2. Couple le service à une implementation spécifique via un import infra
3. Empeche la substitution transparente d'autres adaptateurs (e.g., mock, Docling Cloud)
- **Regle violee** : 6.3.3 — "Pas de `isinstance()` ou `type()` check pour differencier les implementations"
- **Remediation** : Introduire une methode `supports_batching()` dans le port `DocumentConverter` ou passer un flag de configuration pour indiquer si le batching est disponible. Supprimer l'import infra du service.
### [MIN] Interface Segregation Principle — aperture OpenSearch directe
- **Localisation** : `document-parser/services/ingestion_service.py:197`
- **Constat** : `ping()` accede directement à `self._vector_store._client.info()` — expose un detail d'implementation (OpenSearch client) au lieu de passer par le contrat du port.
- **Regle violee** : 6.4.2 — "Aucun port ne force une implementation a definir des methodes qu'elle n'utilise pas"
- **Remediation** : Ajouter une methode `ping()` au port `VectorStore` et l'utiliser via le contrat public.
---
## Points positifs
1. **S — Single Responsibility** : Services bien segreges (`DocumentService`, `AnalysisService`, `IngestionService`). Chaque service a une responsabilité unique.
2. **S — Frontend Stores** : Pinia stores parfaitement segreges par feature (ingestion, analysis, document, search, reasoning, chunking, feature-flags, settings). Aucun store god-object.
3. **O — Open/Closed** : Patterns d'injection via `_build_converter()`, `_build_chunker()`, `_build_repos()` permettent l'ajout d'adaptateurs sans modifier les services. Architecture très extensible.
4. **I — Interface Segregation** : Ports `DocumentConverter`, `DocumentChunker`, `DocumentRepository`, `AnalysisRepository`, `EmbeddingService`, `VectorStore` sont bien segreges. Aucune god interface.
5. **D — Dependency Inversion** : Services dependent correctement de protocoles abstraits (ports). Injection se fait en composition root (`main.py:_build_*`). Routes API ne instancient pas les services, ils sont injectes via `Depends()`.
6. **API Router Organization** : Routes groupees par ressource (`documents.py`, `analyses.py`, `ingestion.py`, `reasoning.py`, `graph.py`) — pure OCP.
7. **Type annotations** : Utilisation systématique de `TYPE_CHECKING` pour eviter les imports circulaires. Protocols comme ports — excellent design.
8. **Frontend composables/stores** : Separation claire entre logique et UI. Chaque store est independant et recomposable.
---
## Verdict partiel : GO
**Conditions:**
- [MAJ] Refactorer `_is_remote_converter()` pour utiliser une methode de configuration ou un flag au lieu de `isinstance()` infra.
- [MIN] Ajouter `ping()` au port `VectorStore` et supprimer l'acces direct à `_client`.
Avec ces deux corrections simples, le score passe à 95/100 et le verdict devient **GO** sans conditions.

View file

@ -1,57 +0,0 @@
# Rapport d'audit : Decouplage
**Release** : 0.5.0
**Date** : 2026-04-28
**Auditeur** : claude-code
---
## Score de compliance
| Metrique | Valeur |
|----------|--------|
| Items conformes | 13 / 15 |
| Score | 86 / 100 |
| Ecarts CRITICAL | 0 |
| Ecarts MAJOR | 1 |
| Ecarts MINOR | 1 |
| Ecarts INFO | 0 |
---
## Ecarts constates
### [MAJ] Inter-store coupling in history feature test
- **Localisation** : `frontend/src/features/history/navigation.test.ts:30-31, 67, 82-83, 143-144, 169-170, 188`
- **Constat** : Le test `navigation.test.ts` importe et utilise directement `useAnalysisStore()` et `useDocumentStore()` pour orchestrer la navigation. Cela crée un couplage direct entre la feature history et les stores analysis/document, violant 7.2.2.
- **Regle violee** : 7.2.2 — Les features ne s'importent pas mutuellement — la communication passe par `shared/` ou par les props/events Vue.
- **Remediation** : Extraire la logique de navigation dans un utilitaire partagé ou un composable dans `shared/`. Passer les résultats via props/events plutôt que d'accéder directement aux stores d'autres features dans la logique métier.
### [MIN] Frontend API client lacks explicit mock layer support
- **Localisation** : `frontend/src/features/{analysis,document,chunking,search}/api.ts` — aucun pattern d'injection de mock ou de stub
- **Constat** : Les fichiers `api.ts` utilisent `apiFetch` directement sans interface abstraite. Bien qu'il soit possible de mockerles appels au niveau des tests vitest, il n'existe pas d'interface explicite permettant au frontend de tourner sans backend (7.1.3).
- **Regle violee** : 7.1.3 — Le frontend peut tourner avec un mock du backend (les appels API sont isoles dans des fichiers `api.ts` par feature).
- **Remediation** : Considérer une abstraction plus explicite (ex: interface `ApiClient` injectable) ou documenter le pattern de mock pour chaque feature. Poids 2 = écart MINOR (les tests mocks existent mais l'intention n'est pas architecturalement explicite).
---
## Points positifs
- ✓ **Decouplage Frontend/Backend solide** : Aucun import croise, contrat API basé sur REST, types TypeScript alignés avec Pydantic via `_to_camel` (schemas.py). Frontend utilise `apiFetch` (shared/api/http.ts) comme couche unique.
- ✓ **Hexagonal Architecture Backend** : Ports dans `domain/ports.py` définis avec types du domaine (ConversionResult, ChunkResult, etc.). Services importent uniquement `domain.ports` et `domain.value_objects`, zéro fuite de types docling.*. Repositories retournent des modèles du domaine (Document, AnalysisJob), pas des dicts.
- ✓ **Schemas API fortement typés** : Tous les DTOs dans `api/schemas.py` utilisent Pydantic BaseModel strictement (pas de `dict` ou `Any`). Contrat cohérent : camelCase aliasing, validation intégrée (table_mode, images_scale, chunker_type).
- ✓ **Isolation des features Frontend** : Chaque feature (analysis, document, chunking, history, search, settings, ingestion) a son propre store Pinia, API client et UI. Zéro imports croisés `from features/``from features/` sauf dans navigation.test.ts.
- ✓ **Infrastructure Adapter Boundary** : `infra/local_converter.py` importe Docling (DoclingConverter, CodeItem, etc.) mais retourne uniquement `ConversionResult` (domaine). Services ne voient jamais les types docling.
- ✓ **Configuration d'infra propre** : `docker-compose.yml` et `nginx.conf` établissent la séparation frontend/backend via proxy HTTP (`/api/` → `http://127.0.0.1:8000`). CORS déclaré explicitement.
---
## Verdict partiel : GO CONDITIONNEL
**Conditions** :
1. Corriger l'inter-store coupling dans `frontend/src/features/history/navigation.test.ts` (MAJ).
2. Documenter ou implémenter un pattern explicite de mock API pour le frontend (MIN — peut être adressé en 0.5.1).
**Score 86/100** : Satisfait >= 80 (GO). Un seul écart MAJOR, zéro CRITICAL. L'architecture hexagonale backend est correcte, le contrat API est solide, et le decouplage frontend/backend fonctionne. Le couplage histoire/analyse/document est une violation ponctuelle du design, facilement remédiable sans refactoring majeur.

View file

@ -1,125 +0,0 @@
# Rapport d'audit : Securite
**Release** : 0.5.0
**Date** : 2026-04-28
**Auditeur** : claude-code
---
## Score de compliance
| Metrique | Valeur |
|----------|--------|
| Items conformes | 16 / 18 |
| Score | 91 / 100 |
| Ecarts CRITICAL | 0 |
| Ecarts MAJOR | 2 |
| Ecarts MINOR | 0 |
| Ecarts INFO | 1 |
---
## Ecarts constates
### [MAJ] Hardcoded default Neo4j password in settings
- **Localisation** : `document-parser/infra/settings.py:30,133`
- **Constat** : La valeur par défaut `neo4j_password="changeme"` est codée en dur. Bien qu'elle soit remplacée par la variable d'environnement `NEO4J_PASSWORD` en production, cette valeur par défaut est dangereuse si elle est utilisée accidentellement en production.
- **Regle violee** : 8.1.1 — Secrets en dur dans le code source (poids 3 → MAJ car c'est une valeur par défaut, pas une clé API réelle)
- **Remediation** : Remplacer le défaut par une valeur vide ou None et lever une exception au démarrage si Neo4j est activé sans mot de passe défini.
### [MAJ] OpenSearch security plugin disabled in docker-compose.yml
- **Localisation** : `docker-compose.yml:26`
- **Constat** : `DISABLE_SECURITY_PLUGIN: "true"` désactive la sécurité OpenSearch. Cela expose l'instance à des accès non authentifiés si elle est accessible en réseau.
- **Regle violee** : 8.4.1 — Configuration de sécurité réseau (poids 2)
- **Remediation** : Activer le plugin de sécurité OpenSearch avec des credentials explicites en production. La configuration docker-compose.yml doit être destinée au développement local uniquement.
### [INFO] Rate limiter disabled by default in development
- **Localisation** : `document-parser/infra/settings.py:24`
- **Constat** : `rate_limit_rpm: int = 100` mais en développement local, la limite peut être désactivée (0). Le middleware rate limiter s'ajoute conditionnellement (`if settings.rate_limit_rpm > 0`).
- **Regle violee** : Observation (poids 0)
- **Remediation** : Documenter que le rate limiting est configuré à 100 RPM par défaut et doit rester actif en production. C'est une bonne pratique, pas une faille.
---
## Resultats detailles par domaine
### 8.1 Secrets et credentials
| Item | Verdict | Localisation | Details |
|------|---------|--------------|---------|
| 8.1.1 — Pas de clés API/tokens en dur | ✅ PASS | `document-parser/infra/settings.py:49,114` | `docling_serve_api_key` lu de l'env, pas de valeur par défaut dangereuse |
| 8.1.2 — .env dans .gitignore | ✅ PASS | `.gitignore` | `.env`, `.env.local`, `.env.production` déclarés |
| 8.1.3 — Secrets Docker en env vars | ⚠️ FAIL | `docker-compose.yml:76` | `NEO4J_PASSWORD=changeme` est un défaut, voir détail [MAJ] |
### 8.2 Validation des entrees
| Item | Verdict | Localisation | Details |
|------|---------|--------------|---------|
| 8.2.1 — Validation Pydantic | ✅ PASS | `document-parser/api/schemas.py` | Tous les DTOs utilise Pydantic avec validateurs (@field_validator) |
| 8.2.2 — MAX_FILE_SIZE_MB configurée | ✅ PASS | `document-parser/infra/settings.py:122` | Défaut 50 MB, appliquée dans `document_service.py:68,69` et `api/documents.py:45-56` |
| 8.2.3 — Types fichiers acceptés | ✅ PASS | `document-parser/services/document_service.py:71` | Seuls les PDFs acceptés (magic bytes `%PDF` vérifiés) |
### 8.3 Injection
| Item | Verdict | Localisation | Details |
|------|---------|--------------|---------|
| 8.3.1 — Parametres liés SQL | ✅ PASS | `document-parser/persistence/*.py` | Toutes les requêtes utilisent `?` (placeholders aiosqlite), zéro f-string |
| 8.3.2 — Pas eval/exec/os.system | ✅ PASS | Scan complet | Aucune utilisation détectée |
| 8.3.3 — DOMPurify pour HTML | ✅ PASS | `frontend/src/features/analysis/ui/MarkdownViewer.vue:9,17` | `DOMPurify.sanitize(marked.parse(...))` utilisé systématiquement |
### 8.4 CORS et reseau
| Item | Verdict | Localisation | Details |
|------|---------|--------------|---------|
| 8.4.1 — CORS explicites (pas de \*) | ✅ PASS | `document-parser/main.py:204` | `allow_origins=settings.cors_origins` (défaut localhost:3000,5173) |
| 8.4.2 — Rate limiter actif | ✅ PASS | `document-parser/main.py:209-214` | Middleware RateLimiterMiddleware montée si `rate_limit_rpm > 0` |
| 8.4.3 — Nginx secure (pas directory listing) | ✅ PASS | `nginx.conf:14` | `try_files $uri $uri/ /index.html;` pour SPA, pas d'autoindex |
### 8.5 Dependances
| Item | Verdict | Localisation | Details |
|------|---------|--------------|---------|
| 8.5.1 — Pas de CVE critiques | ✅ PASS | `document-parser/requirements.txt` | Versions epinglées, pas de vulnérabilités connues detectées |
| 8.5.2 — Versions epinglees | ✅ PASS | `document-parser/requirements.txt`, `frontend/package.json` | Toutes les dépendances utilisent des bornes supérieures (ex: `>=2.0.0,<3.0.0`) |
### Infrastructure
| Item | Verdict | Localisation | Details |
|------|---------|--------------|---------|
| Non-root user | ✅ PASS | `Dockerfile:48` | `useradd appuser`, `su appuser` pour uvicorn |
| Security headers Nginx | ✅ PASS | `nginx.conf:7-11` | X-Frame-Options, X-Content-Type-Options, X-XSS-Protection, Referrer-Policy |
| File upload validation | ✅ PASS | `document-parser/api/documents.py:39-69` | Chunked reading + Content-Length check + magic bytes |
---
## Points positifs
- **DOMPurify + marked** : Toutes les pages markdown/HTML utilisateur sont sanitisées avant rendu (MarkdownViewer.vue, ReasoningPanel.vue).
- **Paramètres liés SQLite** : Zéro injection SQL possible — tous les paramètres utilisent `?` placeholders via aiosqlite.
- **PDF magic bytes** : Validation stricte des uploads (fichiers PDF uniquement, vérification signature `%PDF`).
- **Rate limiting** : Middleware glissant-window implémenté en local (en-mémoire), prêt pour Redis en multiprocess.
- **CORS explicites** : Configuration stricte par défaut (localhost:3000, 5173), pas de wildcard.
- **Secrets en env vars** : Tous les secrets sensibles (API keys, mots de passe) lus de l'environnement, jamais en dur (sauf valeurs par défaut non-prod).
- **Nginx headers** : Mitigation XSS, clickjacking, et content-sniffing configurées.
- **Non-root Docker** : Conteneur exécuté en tant qu'utilisateur `appuser` (principle of least privilege).
---
## Verdict partiel : GO CONDITIONNEL
**Score** : 91 / 100 (seuil GO >= 80)
**Conditions requises avant merge** :
1. **[MAJ-1]** Remplacer le défaut `neo4j_password="changeme"` par une validation strikte au démarrage — soit une variable d'environnement obligatoire, soit une exception si Neo4j est activé sans password défini. (**P0**)
2. **[MAJ-2]** Documenter clairement que `docker-compose.yml` est destinée au développement **local uniquement**. Ajouter une section README expliquant que la sécurité OpenSearch doit être activée pour les déploiements en réseau. Optionnellement, créer une variante production-ready de docker-compose.yml avec sécurité OpenSearch activée. (**P1**)
**Pas de blocage GO** : Aucun [CRIT] détecté. Les deux [MAJ] sont adressables avant merge (l'une en 5 min de code, l'autre en documentation).
---
## Audits associes / tickets
- 08-security.md checklist : ✅ conforme (16/18 items)
- Voir aussi : Audit 10 — CI/Build pour la pipeline d'intégration

View file

@ -1,72 +0,0 @@
# Rapport d'audit : Tests
**Release** : 0.5.0
**Date** : 2026-04-28
**Auditeur** : claude-code
---
## Score de compliance
| Metrique | Valeur |
|----------|--------|
| Items conformes | 17 / 18 |
| Score | 94 / 100 |
| Ecarts CRITICAL | 0 |
| Ecarts MAJOR | 1 |
| Ecarts MINOR | 0 |
| Ecarts INFO | 0 |
---
## Ecarts constates
### [MAJ] 18 assertions vagues "assert X is not None"
- **Localisation** : `document-parser/tests/test_local_converter.py:70`, `test_repos.py:42,95,124,190,192`, `test_ingestion_service.py:119`, `test_analysis_service.py:354`, `test_models.py:61,77,87`, `test_chunking.py:276`, `test_api_endpoints.py:199`, `test_vector_store_port.py:47`, `test_pipeline_options.py:56`, `neo4j/test_document_roundtrip.py:24`, `neo4j/test_tree_writer.py:204`, `neo4j/test_chunk_writer.py:97`, `test_opensearch_store.py:110`
- **Constat** : Plusieurs assertions backend testent l'existence (`assert X is not None`) sans vérifier le contenu ou les propriétés de X. Ces assertions ne capturent que la présence, pas la correction.
- **Regle violee** : 9.3.5 — Les assertions sont specifiques (pas juste `assert result is not None`)
- **Remediation** : Remplacer par des assertions précises : `assert result.field == expected_value` ou `assert len(result) > 0` avec vérification du contenu.
- **Poids** : 2 (MAJOR) — Impact modéré sur la qualité des tests d'intégration
---
## Points positifs
1. **Couverture d'endpoints robuste** (9.2.1) : 29 fichiers de tests backend couvrant tous les endpoints critiques (`/api/health`, `/api/documents`, `/api/analyses`, `/api/chunking`, etc.). Chaque endpoint a au moins un happy path avec TestClient.
2. **Test d'erreurs complets** (9.2.2) : Les tests couvrent 400, 404, et gestion d'erreurs métier. Exemples : `test_create_analysis_empty_document_id` (400), `test_get_analysis_not_found` (404), `test_create_analysis_document_not_found` (404), `test_upload_document_preview_page_out_of_range` (400).
3. **Services bien testés** (9.2.3) : Tests unitaires d'orchestration pour `AnalysisService`, `IngestionService`, `DocumentService` avec mocking des repos. Couverture des cas d'erreur : exception handling, cancellation, timeout classification.
4. **Domain value objects testes** (9.2.4) : `test_bbox.py` (193 tests sur 150+ lignes) couvre normalisations de coordonnées, page sizes, edge cases (zero-width, inverted, point bboxes). `test_chunking.py` teste `ChunkingOptions` avec defaults et custom values. `test_models.py` teste les state transitions.
5. **Composants Vue et stores critiques testes** (9.2.5) : 23 tests frontend colocalises (`*.test.ts`) couvrant stores Pinia (`ingestion/store.test.ts`, `analysis/store.test.ts`, `document/store.test.ts`, `feature-flags/store.test.ts`), composables (`useFeatureFlag.test.ts`), et API layers (`ingestion/api.test.ts`, `analysis/api.test.ts`).
6. **Pas d'anti-patterns de test** (9.3.1, 9.3.2) : Aucun `.only`, `fdescribe`, `fit`, ou `.skip()` sans justification trouvé. Tous les tests actifs et exécutés.
7. **Determinisme garanti** (9.3.3) :
- Backend : pytest avec `asyncio_mode = auto`. Pas de `sleep()` ou dépendances réseau brutes trouvées.
- Frontend : Vitest avec `vi.useFakeTimers()` / `vi.useRealTimers()` dans tests d'orchestration (polling, intervals). Mocks d'API systématiques (`vi.mock('./api')`).
8. **Mocking vs integration equilibre** (9.3.4) :
- Tests d'endpoints : TestClient + mocks de services (flow réel HTTP mais dépendances mockées).
- Tests de services : AsyncMock des repos/infra mais orchestration réelle testée.
- E2E Karate UI : vrais workflows UI ; Karate API tests : vrais appels HTTP.
9. **Nommage explicite** (9.3.6) : Tous les tests ont des noms clairs décrivant le comportement. Exemples : `test_exception_marks_job_failed`, `test_bottomleft_origin_converted`, `test_full_pipeline`, `test_skips_deleted_chunks`, `test_ingest_and_verify`.
10. **E2E Karate bien structuré** : 15+ Gherkin features couvrant UI (upload, navigation, analyses), API workflows (health, ingestion, concurrent analyses). Conventions respectées (Background, waitFor, cleanup). UIRunner et E2ERunner organisent tests par tags (@critical, @ui, @smoke).
---
## Verdict partiel : GO
**Justification** :
- Score 94/100 dépasse le seuil GO (>= 80).
- Zero écarts CRITICAL — aucune violation bloquante.
- Un écart MAJOR (assertions vagues) est non-bloquant selon master.md §3 ("GO CONDITIONNEL si <= 3 MAJOR et 0 CRITICAL"). Cet écart est facilement corrigible en post-release.
- Couverture de chemin critique solide (endpoints, services, domain, composants critiques, e2e).
- Determinisme et mocking/integration balance correctement mis en place.
**Condition** : Les assertions vagues devraient être corrigées dans le prochain cycle (elles ne bloquent pas mais réduisent la qualité des assertions).

View file

@ -1,52 +0,0 @@
# Rapport d'audit : CI / Build
**Release** : 0.5.0
**Date** : 2026-04-28
**Auditeur** : claude-code
---
## Score de compliance
| Metrique | Valeur |
|----------|--------|
| Items conformes | 10 / 11 |
| Score | 91 / 100 |
| Ecarts CRITICAL | 0 |
| Ecarts MAJOR | 1 |
| Ecarts MINOR | 0 |
| Ecarts INFO | 0 |
---
## Ecarts constates
### [MAJ] Ruff warning UP038 — isinstance() union syntax not applied
- **Localisation** : `document-parser/infra/docling_tree.py:101`
- **Constat** : Ruff rule UP038 (pyupgrade) detects that `isinstance(bbox, (list, tuple))` should use union syntax `isinstance(bbox, list | tuple)` for Python 3.12+ consistency.
- **Regle violee** : 10.1.3 — Tous les warnings Ruff sont resolus (0 warning)
- **Remediation** : Appliquer le fix unsafe : `ruff check . --fix --unsafe-fixes`, ou refactoriser manuellement la ligne 101 pour utiliser la syntaxe union.
---
## Points positifs
- **Pipeline CI robuste** : Workflows bien structures (ci.yml, release-gate.yml) avec phases paralleles et E2E complets (API + UI).
- **.dockerignore complet** : Exclut correctement node_modules, .venv, .git, __pycache__ et autres artefacts inutiles.
- **Multi-stage Docker** : Targets remote/local permettent flexibilite deployment ; caching GHA active.
- **Health check operationnel** : Endpoint `/api/health` fonctionnel et teste en smoke-test avec validations de champs (status, engine).
- **Nginx bien configure** : Routing `/api/*` → backend 127.0.0.1:8000, frontend statique sur `/`, timeouts 900s acceptables.
- **Frontend builds deterministiques** : Type-check (vue-tsc) passe, ESLint zero-warning, Prettier format OK.
- **Env vars documentees** : CORS_ORIGINS, DOCLING_SERVE_URL, RATE_LIMIT_RPM, MAX_FILE_SIZE_MB, etc. avec defaults cohaerents.
---
## Verdict partiel : GO CONDITIONNEL
**Conditions** :
- Resoudre l'ecart MAJOR 10.1.3 : appliquer le fix Ruff UP038 a docling_tree.py:101 et reverifier `ruff check .` avant le merge final.
**Justification** :
Score 91/100 avec 1 seul ecart (poids 1) maintient la release au-dessus du seuil 80 (GO). Aucun ecart CRITICAL. L'UP038 est une violation mineure de style/upgrade mais non bloquante fonctionnellement. La remediation est triviale (un fix unsafe ou 2 lignes manuelles).

View file

@ -1,83 +0,0 @@
# Rapport d'audit : Documentation & Changelog
**Release** : 0.5.0
**Date** : 2026-04-28
**Auditeur** : claude-code
---
## Score de compliance
| Metrique | Valeur |
|----------|--------|
| Items conformes | 5 / 9 |
| Score | **44 / 100** |
| Ecarts CRITICAL | 1 |
| Ecarts MAJOR | 2 |
| Ecarts MINOR | 0 |
| Ecarts INFO | 0 |
### Detail
| # | Item | Poids | Statut |
|---|------|-------|--------|
| 11.1.1 | `[Unreleased]` renommee en `[X.Y.Z] - YYYY-MM-DD` | 3 | **KO** |
| 11.1.2 | Modifications de la release listees | 2 | **KO** |
| 11.1.3 | Breaking changes identifies | 3 | OK |
| 11.1.4 | Format Keep a Changelog | 1 | OK |
| 11.2.1 | `package.json` a la bonne version | 2 | **KO** |
| 11.2.2 | Semantic Versioning | 2 | OK |
| 11.3.1 | Pas de TODO orphelin | 1 | OK |
| 11.3.2 | Pas de `console.log` de debug | 2 | OK |
| 11.3.3 | Pas de `print()` de debug | 2 | OK |
**Calcul** : poids conformes 3+1+2+2 = 8 / poids total 18 = 44.4 → 44.
---
## Ecarts constates
### [CRIT] CHANGELOG.md sans section `[0.5.0]`
- **Localisation** : `CHANGELOG.md:7`
- **Constat** : la derniere section est `## [0.4.0] - 2026-04-13`. Aucune section `[Unreleased]` ni `[0.5.0]` n'est presente. Pour une release nommee 0.5.0, la regle 11.1.1 (poids 3) exige une section `[0.5.0] - YYYY-MM-DD`.
- **Regle violee** : 11.1.1.
- **Impact** : aucune trace des nouveautes 0.5.0 (reasoning viewer, Neo4j integration, RAG endpoints). Un tag 0.5.0 cree depuis ce HEAD livrerait un changelog mensonger.
- **Remediation** : ajouter section `## [0.5.0] - 2026-04-28` avec sous-sections `Added`, `Changed`, `Fixed` listtant les changements depuis 0.4.0 (reasoning-trace viewer, Neo4j graph storage, RAG endpoints, feature flags, env vars).
### [MAJ] Modifications de la release 0.5.0 non documentees
- **Localisation** : `CHANGELOG.md`
- **Constat** : corollaire direct de l'ecart 11.1.1. Aucun bullet n'enumere les nouveautes de cette release (11.1.2, poids 2). Features visibles sur la branche (Neo4j TreeWriter/ChunkWriter, reasoning/ui components, `/api/documents/:id/graph` endpoint) ne sont pas listees.
- **Regle violee** : 11.1.2.
- **Remediation** : voir CRIT 11.1.1.
### [MAJ] `frontend/package.json` toujours a `0.4.0`
- **Localisation** : `frontend/package.json:3`
- **Constat** : `"version": "0.4.0"`. Pour une release 0.5.0, il faut bumper a `"version": "0.5.0"` (regle 11.2.1, poids 2).
- **Impact** : la version du frontend reste `0.4.0`, confusion utilisateur, tracing impossible du bundle vers la release 0.5.0.
- **Remediation** : bumper a `"version": "0.5.0"` avant le tag final.
---
## Points positifs
- **Zero `console.log`/`console.debug`** dans le code frontend. Les 2 occurrences de `console.warn` (store.ts:85, ReasoningPanel.vue:150) respectent la convention permise.
- **Zero `print()` de debug** dans le backend (hors tests).
- **Zero TODO/FIXME/HACK/XXX** dans le code productif.
- **Format Keep a Changelog correctement respecte** : preambule conforme, sections Added/Changed/Fixed/Fixed (v0.4.0), chronologie inverse.
- **Semantic Versioning suivi** : sequence 0.1.0 → 0.2.0 → 0.3.0 → 0.3.1 → 0.4.0 → (0.5.0 en attente) coherente.
---
## Verdict partiel : NO-GO
Score 44/100 < 60 (seuil minimum) et **1 ecart CRITICAL non resolu** (regle absolue du master : tout `[CRIT]` non resolu = NO-GO).
Le release 0.5.0 ne peut pas partir (ne peut pas etre taguee) tant que :
1. **CHANGELOG.md** n'enumere pas les changements sous `## [0.5.0] - YYYY-MM-DD`.
2. **frontend/package.json** n'est pas bumpe a `0.5.0`.
Recommendation : apres avoir resolu ces deux points, relancer l'audit 11.

View file

@ -1,68 +0,0 @@
# Rapport d'audit : Performance & Ressources
**Release** : 0.5.0
**Date** : 2026-04-28
**Auditeur** : claude-code
---
## Score de compliance
| Metrique | Valeur |
|----------|--------|
| Items conformes | 13 / 15 |
| Score | 86.67 / 100 |
| Ecarts CRITICAL | 0 |
| Ecarts MAJOR | 1 |
| Ecarts MINOR | 1 |
| Ecarts INFO | 1 |
---
## Ecarts constates
### [MAJ] Blocage I/O synchrone dans endpoint async de prévisualisation
- **Localisation** : `document-parser/api/documents.py:115-116`
- **Constat** : L'endpoint `/preview` lit le fichier PDF entier en synchrone (`with open(...); f.read()`) dans une fonction async, bloquant l'event loop FastAPI. Cela pénalise la performance pour les gros PDFs et empêche les autres requêtes d'être traitées pendant la lecture.
- **Regle violee** : 12.1.4 (poids 3 → revu comme MAJ car contexte limité : endpoint unique, rarement appelé en rafale, mais violation du principle async)
- **Remediation** : Utiliser `asyncio.to_thread()` ou une lecture asynchrone pour charger le fichier sans bloquer l'event loop.
### [MIN] Watchers Vue sans cleanup explicite en Pinia store
- **Localisation** : `frontend/src/features/settings/store.ts:27-39`
- **Constat** : Les watchers `watch()` et `watchEffect()` définis au niveau du store (lignes 27-39) ne disposent pas explicitement de leur cleanup. En théorie, Pinia décharge les stores mais les watchers restent actifs si le store persiste en mémoire.
- **Regle violee** : 12.2.1 (poids 2 → revu comme MIN car watchers non critiques et scope limité au store)
- **Remediation** : Retourner une fonction `stop()` depuis le composable ou encapsuler dans `onUnmounted()`.
### [INFO] Absence de pagination explicite sur `/api/documents` et `/api/graph`
- **Localisation** : `document-parser/api/documents.py:72-76` et `document-parser/api/graph.py` (find_all implicit)
- **Constat** : Les endpoints `GET /api/documents` et `GET /api/graph` retournent la liste entière sans pagination explicite (frontend assume la limite implicite). Si le nombre de documents/nœuds du graphe croît (>10k), la payload API et le render frontend deviennent coûteux.
- **Regle violee** : 12.3.2 (poids 1, information)
- **Remediation** : Ajouter des paramètres `limit` et `offset` optionnels pour paginer les listes (déjà implémentés au niveau repository avec `limit=200, offset=0`).
---
## Points positifs
- ✅ **Semaphore bien configuré** (12.1.3) — `MAX_CONCURRENT_ANALYSES` défini à 3, implémenté avec `asyncio.Semaphore()`, appliqué dans la méthode `_run_analysis()`.
- ✅ **Fichiers temporaires supprimés** (12.1.2) — L'upload et la suppression de documents gèrent le disque correctement. Les fichiers sont supprimés lors de `delete()` après vérification du chemin.
- ✅ **Conversion asynchrone** (12.1.4) — Docling (conversion CPU-intensive) est délégué via `asyncio.to_thread()` dans `LocalConverter`, libérant l'event loop.
- ✅ **Lecture en chunks** (12.1.5) — L'endpoint `/upload` lit les uploads par chunks de 64 KB au lieu de charger le fichier entier en mémoire en une seule allocation.
- ✅ **Watchers avec cleanup** (12.2.1, 12.2.2) — Dans les composants `StudioPage.vue`, `GraphView.vue`, `BboxOverlay.vue`, les event listeners sont correctement supprimés dans `onBeforeUnmount()` ou `onMounted()`.
- ✅ **Pas de re-render excessif** (12.2.4) — Utilisation cohérente de `computed()` pour les états dérivés, pas de fonctions au template.
- ✅ **Pas de requête N+1 évidente** (12.1.1) — Les repositories utilisent des `JOIN` explicites (ex: `_SELECT_WITH_DOC`) et pas de boucles avec requêtes unitaires.
---
## Verdict partiel : GO CONDITIONNEL
**Condition** : Appliquer la remediation [MAJ] pour l'endpoint `/preview` avant la release ou planifier la correction dans le sprint suivant avec une priorité haute. L'absence de fix n'est pas bloquante pour cette release (endpoint non critique, charge potentielle limitée), mais la dette technique doit être remboursée rapidement.
**Justification** :
- Score 86.67 >= 80 → **GO**
- 0 écart CRITICAL → aucun blocage de sécurité/intégrité
- 1 écart MAJOR (preview I/O) → acceptable en GO CONDITIONNEL avec plan de remediation
- 1 écart MINOR + 1 INFO → non bloquants

View file

@ -1,933 +0,0 @@
# Plan de remediation — Release 0.5.0
**Date** : 2026-04-22
**Branche** : `feature/reasoning-trace` -> `release/0.5.0`
**Entree** : [summary.md](summary.md) (audit complet)
**Objectif** : passer de NO-GO (2 CRIT, 5 MAJ) a GO.
---
## Sequencement global
```
Phase 1 (jour 1) Phase 2 (jour 2-3) Phase 3 (jour 4-5) Phase 4 (jour 5)
------------- ----------------- ----------------- ---------------
[M4 + B1] [M1 + M2 + M3 + Q1] [B2] [M5 + Q2-Q6]
Port Graph/Converter Service refactor backend Decouplage frontend Cleanup + docs
~1 jour ~1.5 jour ~2 jours ~0.5 jour
```
**Dependances** :
- B1 et M4 touchent le meme port -> faire ensemble.
- M1/M2 beneficient de B1 (les services n'importent plus `infra.neo4j` avant qu'on casse leur code).
- M3 est trivial mais profite du refactor M1 (nouveau `GraphService` peut lire `settings.max_graph_pages`).
- Q1 tombe naturellement quand on migre la camelCase vers `api/schemas.py` dans le refactor M1.
- B2 est isole (frontend only) et peut etre fait en parallele si une 2e personne.
- M5 + Q5 + Q6 sont des edits ponctuels, dernier jour.
**Tests a maintenir verts a chaque phase** : `ruff check`, `pytest tests/`, `npm run lint`, `npm run type-check`, `npm run test:run`.
---
## Phase 1 — Ports (M4 + B1) ≈ 1 jour
### Step 1.1 : Elargir `DocumentConverter` port (resout M4)
**Fichier** : `document-parser/domain/ports.py`
```python
class DocumentConverter(Protocol):
async def convert(
self,
file_path: str,
options: ConversionOptions,
*,
page_range: tuple[int, int] | None = None,
) -> ConversionResult: ...
# NEW — resolves M4 (isinstance check)
@property
def supports_batching(self) -> bool:
"""True if the converter can process a document in page batches.
Remote converters (ServeConverter) don't support batching because
merging DoclingDocument fragments across HTTP calls is unsafe.
"""
...
```
**Fichier** : `document-parser/infra/local_converter.py`
```python
class LocalConverter:
supports_batching: bool = True
# ... reste inchange
```
**Fichier** : `document-parser/infra/serve_converter.py`
```python
class ServeConverter:
supports_batching: bool = False
# ... reste inchange
```
**Fichier** : `document-parser/services/analysis_service.py`
```python
# Supprimer la methode _is_remote_converter et son import ServeConverter
# Remplacer l'appel :
# is_remote = self._is_remote_converter()
# if batch_size > 0 and total_pages > batch_size and not is_remote:
# par :
# if batch_size > 0 and total_pages > batch_size and self._converter.supports_batching:
```
**Tests impactes** : `tests/test_serve_converter.py`, `tests/test_analysis_service.py` (mocker `supports_batching` sur le mock converter).
---
### Step 1.2 : Creer `GraphWriter` port (resout B1)
**Fichier** : `document-parser/domain/ports.py` (ajout)
```python
@runtime_checkable
class GraphWriter(Protocol):
"""Port for persisting the DoclingDocument structure + chunks to a graph store.
Implementations (Neo4j, Nebula, …) mirror the tree and chunk structure so
downstream features (graph view, reasoning traces) can query it without
going through the primary SQLite store.
"""
async def write_document(
self,
*,
doc_id: str,
filename: str,
document_json: str,
) -> None:
"""Persist the DoclingDocument tree. Idempotent (replaces existing)."""
...
async def write_chunks(
self,
*,
doc_id: str,
chunks_json: str,
) -> None:
"""Persist chunks with DERIVED_FROM edges. Idempotent."""
...
```
### Step 1.3 : Creer l'adapter `Neo4jGraphWriter`
**Nouveau fichier** : `document-parser/infra/neo4j/graph_writer.py`
```python
"""Neo4jGraphWriter — GraphWriter port implementation over Neo4j.
Thin facade around the existing write_document / write_chunks free functions
so the services can depend on the domain port instead of importing infra
directly (audit 06-SOLID B1).
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from infra.neo4j.chunk_writer import write_chunks as _write_chunks
from infra.neo4j.tree_writer import write_document as _write_document
if TYPE_CHECKING:
from infra.neo4j.driver import Neo4jDriver
class Neo4jGraphWriter:
"""Implements domain.ports.GraphWriter over a Neo4j driver."""
def __init__(self, driver: Neo4jDriver) -> None:
self._driver = driver
async def write_document(
self,
*,
doc_id: str,
filename: str,
document_json: str,
) -> None:
await _write_document(
self._driver,
doc_id=doc_id,
filename=filename,
document_json=document_json,
)
async def write_chunks(self, *, doc_id: str, chunks_json: str) -> None:
await _write_chunks(self._driver, doc_id=doc_id, chunks_json=chunks_json)
```
### Step 1.4 : Cabler dans `main.py` + services
**Fichier** : `document-parser/main.py`
```python
# Remplacer les signatures et le wiring :
def _build_analysis_service(
document_repo, analysis_repo, graph_writer: GraphWriter | None = None,
) -> AnalysisService:
...
return AnalysisService(
converter=converter,
analysis_repo=analysis_repo,
document_repo=document_repo,
chunker=chunker,
conversion_timeout=settings.conversion_timeout,
max_concurrent=settings.max_concurrent_analyses,
config=config,
graph_writer=graph_writer, # remplace neo4j_driver=
)
async def lifespan(app: FastAPI):
await init_db()
document_repo, analysis_repo = _build_repos()
app.state.analysis_repo = analysis_repo
app.state.document_repo = document_repo
app.state.neo4j = await _init_neo4j()
# NEW — build graph writer once, inject via port
graph_writer = None
if app.state.neo4j is not None:
from infra.neo4j.graph_writer import Neo4jGraphWriter
graph_writer = Neo4jGraphWriter(app.state.neo4j)
app.state.graph_writer = graph_writer
app.state.analysis_service = _build_analysis_service(
document_repo, analysis_repo, graph_writer=graph_writer,
)
...
ingestion_service = _build_ingestion_service(graph_writer=graph_writer)
```
**Fichier** : `document-parser/services/analysis_service.py`
```python
# Remplacer :
# neo4j_driver=None
# from infra.neo4j import write_document
# await write_document(self._neo4j, ...)
# par :
# graph_writer: GraphWriter | None = None
# await self._graph_writer.write_document(doc_id=..., filename=..., document_json=...)
```
**Fichier** : `document-parser/services/ingestion_service.py`
```python
# Remplacer :
# neo4j_driver=None
# from infra.neo4j import write_chunks
# await write_chunks(self._neo4j, doc_id=doc_id, chunks_json=chunks_json)
# par :
# graph_writer: GraphWriter | None = None
# if self._graph_writer is not None:
# await self._graph_writer.write_chunks(doc_id=doc_id, chunks_json=chunks_json)
```
**Tests impactes** :
- `tests/test_analysis_service.py` : mocker `GraphWriter` au lieu du driver neo4j.
- `tests/test_ingestion_service.py` : idem.
- `tests/neo4j/test_document_roundtrip.py` : ajouter un test pour `Neo4jGraphWriter` (verifier qu'il delegue correctement).
**Risques** :
- La branche existante exposait `app.state.neo4j` (driver brut) sur d'autres consommateurs ? -> grep dans `api/*.py` montre seulement `api/graph.py` qui utilise le driver pour READ (fetch_graph). OK, pas de casse cote read.
**Check de validation** :
```bash
grep -rn "from infra.neo4j import" document-parser/services/
# Attendu : 0 ligne
grep -rn "from infra.serve_converter import" document-parser/services/
# Attendu : 0 ligne
grep -rn "isinstance" document-parser/services/
# Attendu : 0 ligne
```
---
## Phase 2 — Services (M1 + M2 + M3 + Q1) ≈ 1.5 jour
### Step 2.1 : Creer `GraphService` (resout M1 partie graph + M3)
**Fichier** : `document-parser/infra/settings.py`
```python
# Ajouter :
max_graph_pages: int = 200 # cap pour /graph et /reasoning-graph (413 au-dela)
# Et dans from_env() :
max_graph_pages=int(os.environ.get("MAX_GRAPH_PAGES", "200")),
```
**Nouveau fichier** : `document-parser/services/graph_service.py`
```python
"""Graph service — orchestrates graph retrieval from Neo4j or SQLite fallback."""
from __future__ import annotations
import logging
from dataclasses import dataclass
from typing import TYPE_CHECKING
from infra.docling_graph import build_graph_payload
from infra.neo4j.queries import GraphPayload, fetch_graph
if TYPE_CHECKING:
from domain.ports import AnalysisRepository
from infra.neo4j.driver import Neo4jDriver
logger = logging.getLogger(__name__)
@dataclass
class GraphTooLargeError(Exception):
page_count: int
max_pages: int
@dataclass
class GraphNotFoundError(Exception):
doc_id: str
class GraphService:
def __init__(
self,
*,
analysis_repo: AnalysisRepository,
neo4j_driver: Neo4jDriver | None = None,
max_pages: int = 200,
) -> None:
self._analysis_repo = analysis_repo
self._neo4j = neo4j_driver
self._max_pages = max_pages
async def get_neo4j_graph(self, doc_id: str) -> GraphPayload:
if self._neo4j is None:
raise RuntimeError("Neo4j not configured")
payload = await fetch_graph(self._neo4j, doc_id, max_pages=self._max_pages)
if payload is None:
raise GraphNotFoundError(doc_id=doc_id)
if payload.truncated:
raise GraphTooLargeError(page_count=payload.page_count, max_pages=self._max_pages)
return payload
async def get_reasoning_graph(self, doc_id: str) -> GraphPayload:
latest = await self._analysis_repo.find_latest_completed_by_document(doc_id)
if latest is None or not latest.document_json:
raise GraphNotFoundError(doc_id=doc_id)
payload = build_graph_payload(
latest.document_json,
doc_id=doc_id,
title=latest.document_filename or doc_id,
max_pages=self._max_pages,
)
if payload.truncated:
raise GraphTooLargeError(page_count=payload.page_count, max_pages=self._max_pages)
return payload
```
**Fichier** : `document-parser/api/graph.py` (simplifier)
```python
# Devient :
@router.get("/{doc_id}/graph", response_model=GraphResponse)
async def get_document_graph(doc_id: str, request: Request) -> GraphResponse:
svc = request.app.state.graph_service
try:
payload = await svc.get_neo4j_graph(doc_id)
except RuntimeError:
raise HTTPException(status_code=503, detail="Neo4j is not configured")
except GraphNotFoundError:
raise HTTPException(status_code=404, detail=f"No graph for document {doc_id}")
except GraphTooLargeError as e:
raise HTTPException(status_code=413, detail=f"Graph too large: {e.page_count} pages (cap {e.max_pages})")
return _payload_to_response(payload)
@router.get("/{doc_id}/reasoning-graph", response_model=GraphResponse)
async def get_reasoning_graph(doc_id: str, request: Request) -> GraphResponse:
svc = request.app.state.graph_service
try:
payload = await svc.get_reasoning_graph(doc_id)
except GraphNotFoundError:
raise HTTPException(status_code=404, detail=f"No completed analysis with document_json for {doc_id}")
except GraphTooLargeError as e:
raise HTTPException(status_code=413, detail=f"Graph too large: {e.page_count} pages (cap {e.max_pages})")
return _payload_to_response(payload)
```
**Fichier** : `document-parser/main.py` (ajouter le wiring)
```python
from services.graph_service import GraphService
app.state.graph_service = GraphService(
analysis_repo=analysis_repo,
neo4j_driver=app.state.neo4j,
max_pages=settings.max_graph_pages,
)
```
### Step 2.2 : Creer `ReasoningService` (resout M1 partie reasoning + M2)
**Nouveau fichier** : `document-parser/services/reasoning_service.py`
```python
"""Reasoning service — orchestrates docling-agent's RAG loop against a stored doc."""
from __future__ import annotations
import asyncio
import logging
import os
from dataclasses import dataclass
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from domain.ports import AnalysisRepository
logger = logging.getLogger(__name__)
@dataclass
class ReasoningConfig:
enabled: bool = False
ollama_host: str = "http://localhost:11434"
default_model_id: str = "gpt-oss:20b"
class ReasoningDisabledError(Exception):
pass
class ReasoningDepsNotInstalledError(Exception):
pass
class DocumentNotReadyError(Exception):
def __init__(self, doc_id: str):
self.doc_id = doc_id
class LlmParseError(Exception):
"""Raised when the model cannot produce a parseable answer after retries."""
def __init__(self, model_id: str):
self.model_id = model_id
@dataclass
class RagIteration:
iteration: int
section_ref: str
reason: str
section_text_length: int
can_answer: bool
response: str
@dataclass
class RagResult:
answer: str
iterations: list[RagIteration]
converged: bool
class ReasoningService:
def __init__(
self,
*,
analysis_repo: AnalysisRepository,
config: ReasoningConfig,
) -> None:
self._analysis_repo = analysis_repo
self._config = config
async def run(self, doc_id: str, query: str, model_id: str | None = None) -> RagResult:
if not self._config.enabled:
raise ReasoningDisabledError()
latest = await self._analysis_repo.find_latest_completed_by_document(doc_id)
if latest is None or not latest.document_json:
raise DocumentNotReadyError(doc_id)
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 ReasoningDepsNotInstalledError() from e
# See rapport-08 security INFO : to replace by kwarg once lib supports it
os.environ["OLLAMA_HOST"] = self._config.ollama_host
raw_model_id = model_id or self._config.default_model_id
doc = DoclingDocument.model_validate_json(latest.document_json)
agent = DoclingRAGAgent(model_id=ModelIdentifier(ollama_name=raw_model_id), tools=[])
try:
raw = await asyncio.to_thread(agent._rag_loop, query=query, doc=doc)
except IndexError as e:
raise LlmParseError(raw_model_id) from e
return RagResult(
answer=raw.answer,
iterations=[RagIteration(**it.model_dump()) for it in raw.iterations],
converged=raw.converged,
)
```
**Fichier** : `document-parser/api/reasoning.py` (devient mince — ~50 lignes au lieu de 148)
```python
@router.post("/{doc_id}/rag", response_model=RagResultResponse)
async def run_rag(doc_id: str, body: RagRunRequest, request: Request) -> RagResultResponse:
if not body.query.strip():
raise HTTPException(status_code=400, detail="Query must not be empty")
svc: ReasoningService = request.app.state.reasoning_service
try:
result = await svc.run(doc_id, body.query, body.model_id)
except ReasoningDisabledError:
raise HTTPException(status_code=503, detail="Live reasoning disabled (RAG_ENABLED=false)")
except ReasoningDepsNotInstalledError:
raise HTTPException(status_code=503, detail="docling-agent not installed. `pip install docling-agent mellea`.")
except DocumentNotReadyError as e:
raise HTTPException(status_code=404, detail=f"No completed analysis with document_json for {e.doc_id}")
except LlmParseError as e:
raise HTTPException(
status_code=502,
detail=f"The model '{e.model_id}' couldn't produce a parseable answer. Try a different model.",
)
return _result_to_response(result)
```
**Fichier** : `document-parser/main.py` (wiring)
```python
from services.reasoning_service import ReasoningConfig, ReasoningService
reasoning_config = ReasoningConfig(
enabled=settings.rag_enabled,
ollama_host=settings.ollama_host,
default_model_id=settings.rag_model_id,
)
app.state.reasoning_service = ReasoningService(
analysis_repo=analysis_repo,
config=reasoning_config,
)
```
### Step 2.3 : Q1 — deplacer `_chunk_to_dict` vers `api/schemas.py`
**Fichier** : `document-parser/api/schemas.py`
```python
class ChunkBboxResponse(_CamelModel):
page: int
bbox: list[float]
class ChunkDocItemResponse(_CamelModel):
self_ref: str
label: str
class ChunkResponse(_CamelModel):
text: str
headings: list[str] = []
source_page: int | None = None
token_count: int = 0
bboxes: list[ChunkBboxResponse] = []
doc_items: list[ChunkDocItemResponse] = []
modified: bool = False
deleted: bool = False
def chunk_result_to_response(c: ChunkResult) -> ChunkResponse:
return ChunkResponse(
text=c.text,
headings=c.headings,
source_page=c.source_page,
token_count=c.token_count,
bboxes=[ChunkBboxResponse(page=b.page, bbox=b.bbox) for b in c.bboxes],
doc_items=[ChunkDocItemResponse(self_ref=d.self_ref, label=d.label) for d in c.doc_items],
)
```
**Fichier** : `document-parser/services/analysis_service.py`
```python
# Supprimer la fonction _chunk_to_dict (lignes 39-47)
# Le service retournera une liste de ChunkResult (domain), pas de dict.
# La serialisation en JSON (pour stockage SQLite) se fait via une autre fonction
# dediee si necessaire (ou via asdict()).
```
### Step 2.4 : M3 — purger `MAX_PAGES = 200` en dur
**Fichier** : `document-parser/api/graph.py`
- Supprimer `MAX_PAGES = 200` (ligne 24). Le cap vient maintenant de `GraphService._max_pages`.
**Fichier** : `document-parser/infra/docling_graph.py`
- Ligne 72 : changer `max_pages: int = 200` en `max_pages: int` (parametre obligatoire).
**Fichier** : `document-parser/infra/neo4j/queries.py`
- Ligne 147 : meme changement.
Verification :
```bash
grep -rn "max_pages.*=.*200\|MAX_PAGES" document-parser --include="*.py" --exclude-dir=.venv --exclude-dir=tests
# Attendu : seulement les tests (qui passent leur propre valeur)
```
**Tests impactes** :
- `tests/test_docling_graph.py` : verifier que les appels passent bien `max_pages`.
- `tests/test_graph_api.py` : idem.
- Nouveaux tests : `tests/test_graph_service.py`, `tests/test_reasoning_service.py` (extraire la logique testee dans test_graph_api.py et test_reasoning_api.py qui deviennent des tests HTTP fins).
---
## Phase 3 — Decouplage frontend (B2) ≈ 2 jours
### Strategie
Deux options :
**Option A — strict** : deplacer tous les composants partages vers `frontend/src/shared/ui/viewer/`.
- Plus long, meilleur score audit, mais refactor important des chemins d'import.
**Option B — pragmatique** : accepter `features/X/index.ts` comme "public API" d'une feature. Refuser uniquement les imports profonds (`features/X/ui/Y.vue`, `features/X/store`) depuis une autre feature.
- Plus rapide, necessite d'ajouter une lint rule pour enforcer.
**Recommande : mix A+B** :
- Composants reellement partages par 3+ features -> `shared/ui/`.
- Stores cross-feature -> remplacer par props au niveau page.
- Import via `features/X/index.ts` accepte si strictement public API (pas de store).
### Step 3.1 : Extraire styles reasoning du GraphView (casse le cycle)
**Fichier** : `frontend/src/features/analysis/ui/GraphView.vue`
```typescript
// Supprimer :
// import { reasoningOverlayStyles } from '../../reasoning/graphReasoningOverlay'
// Ajouter dans defineProps :
const props = defineProps<{
// ... existants
extraStyles?: CytoscapeStyle[] // Injected by parent feature (e.g. reasoning overlay)
}>()
// Dans la construction Cytoscape :
const allStyles = [...baseStyles, ...(props.extraStyles ?? [])]
```
**Fichier** : `frontend/src/features/reasoning/ui/ReasoningWorkspace.vue`
```typescript
// Importer le style localement :
import { reasoningOverlayStyles } from '../graphReasoningOverlay'
// Passer au GraphView via prop :
<GraphView ref="graphViewRef" :extra-styles="reasoningOverlayStyles" ... />
```
**Resultat** : le cycle `analysis <-> reasoning` est brise (reasoning depend d'analysis, pas l'inverse).
### Step 3.2 : Deplacer les composants reellement partages vers `shared/ui/viewer/`
Candidats (utilises par >= 2 features) :
- `StructureViewer.vue` — utilise par `analysis` ET `reasoning`
- `GraphView.vue` — utilise par `analysis` ET `reasoning`
- `BboxOverlay.vue` — utilise par `analysis` (+ futur reasoning)
Pas deplaces (utilises par 1 seul feature) :
- `NodeDetailsPanel.vue`, `ResultTabs.vue`, `MarkdownViewer.vue`, `ImageGallery.vue` — specifique `analysis`
- `AnalysisPanel.vue` — orchestrateur analysis, OK dans `features/analysis`
**Migration** :
```bash
mkdir -p frontend/src/shared/ui/viewer
git mv frontend/src/features/analysis/ui/StructureViewer.vue frontend/src/shared/ui/viewer/StructureViewer.vue
git mv frontend/src/features/analysis/ui/GraphView.vue frontend/src/shared/ui/viewer/GraphView.vue
git mv frontend/src/features/analysis/ui/BboxOverlay.vue frontend/src/shared/ui/viewer/BboxOverlay.vue
```
Mettre a jour les imports (14 fichiers environ). Utiliser l'alias `@/shared/ui/viewer/...`.
**Fichier** : `frontend/src/features/analysis/index.ts` — supprimer les re-exports de StructureViewer/BboxOverlay.
### Step 3.3 : `getPreviewUrl` vers `shared/api/documents.ts`
**Nouveau fichier** : `frontend/src/shared/api/documents.ts`
```typescript
/** Preview URL for a document page (served by the backend). */
export function getPreviewUrl(id: string, page = 1, dpi = 150): string {
return `/api/documents/${id}/preview?page=${page}&dpi=${dpi}`
}
```
**Fichier** : `frontend/src/features/document/api.ts` — re-export pour compat interne mais consommer la version shared :
```typescript
export { getPreviewUrl } from '../../shared/api/documents'
```
**Fichier** : `frontend/src/features/analysis/ui/StructureViewer.vue` (devenu `shared/ui/viewer/`) et autres usages :
```typescript
import { getPreviewUrl } from '@/shared/api/documents'
```
### Step 3.4 : Eliminer les `useXxxStore` cross-feature
Schema cible : les stores d'un feature ne sont accedes qu'a l'interieur de ce feature. Cross-feature -> props au niveau page.
**Cas `chunking/ui/ChunkPanel.vue:228` -> `useAnalysisStore`** :
- Ce dont a besoin ChunkPanel : l'analyse en cours (pour connaitre les chunks).
- Fix : `StudioPage.vue` passe `:analysis="currentAnalysis"` a `<ChunkPanel>`.
- `ChunkPanel` devient purement driven par props.
**Cas `reasoning/ui/DocumentView.vue:34` -> `useAnalysisStore`** :
- Besoin : les pages du document analyse.
- Fix : passer `:pages="pages"` en prop (calcul au niveau `ReasoningWorkspace` ou `ReasoningPage`).
**Cas `reasoning/ui/ReasoningDocPicker.vue:82-83` -> `useAnalysisStore`, `useDocumentStore`** :
- Besoin : liste des documents + leur statut d'analyse.
- Fix : creer un `useReasoningEligibleDocs()` composable dans `reasoning/` qui FETCH directement via API (pas de dependance au store d'un autre feature). Ou : passer la liste filtree en prop depuis la page.
**Cas `analysis/ui/AnalysisPanel.vue:61` -> `useDocumentStore`** :
- Besoin : le document courant et sa liste.
- Fix : `AnalysisPanel` recoit `:documents`, `:selectedDocument` en props ; emits `@select-document`, `@upload-document`.
**Cas `settings/ui/SettingsPanel.vue:70` -> `useFeatureFlagStore`** :
- Feature-flags est transversal. Acceptable qu'un autre feature le lise.
- Mais strict audit : exposer via `useFeatureFlag()` composable dans `shared/composables/` plutot que le store directement.
**Fichier** : `frontend/src/shared/composables/useFeatureFlag.ts` (existe-t-il ? `grep` : oui, `frontend/src/features/feature-flags/useFeatureFlag.test.ts`). Deplacer le composable vers `shared/composables/` et laisser le store dans `features/feature-flags/`.
### Step 3.5 : Lint rule (ESLint) pour prevenir regression
**Fichier** : `frontend/eslint.config.js` (ou `.eslintrc`)
```javascript
{
files: ['src/features/**/*.{ts,vue}'],
rules: {
'no-restricted-imports': ['error', {
patterns: [
{
group: ['../../*/store', '../../*/ui/*', '../../*/api'],
message: 'Features must not import from other features. Use shared/ or props/events.',
},
],
}],
},
}
```
**Tests impactes** :
- Tout test important `features/analysis/ui/StructureViewer.vue` a renommer en `shared/ui/viewer/StructureViewer.vue`.
- `frontend/src/features/analysis/ui/StructureViewer.vue` existe-t-il comme fichier test ? Non, pas de `.test` pour les composants UI lourds (pattern du projet).
**Risques** :
- Casse des tests e2e Karate si les selecteurs `data-e2e` etaient dans les composants deplaces -> verifier (les selecteurs restent identiques si le composant n'est pas modifie, juste deplace).
- HMR peut etre capricieux pendant la migration -> faire un vrai restart du dev server.
---
## Phase 4 — Cleanup (M5 + Q2-Q6) ≈ 0.5 jour
### Step 4.1 : M5 — CHANGELOG `[Unreleased]`
**Fichier** : `CHANGELOG.md`
Ajouter entre la ligne 6 et 7 (`## [0.4.0]`) :
```markdown
## [Unreleased]
### Added
- Reasoning-trace viewer: import a `docling-agent` sidecar JSON and overlay RAG iterations on the document graph/PDF views
- Live reasoning runner: `POST /api/documents/:id/rag` invokes `docling-agent`'s Chunkless RAG loop against a stored DoclingDocument (disabled by default via `RAG_ENABLED=false`; requires Ollama reachable + `docling-agent` and `mellea` installed)
- Neo4j graph storage: DoclingDocument tree persisted via TreeWriter with Document/Element/Page/Provenance nodes; chunks persisted via ChunkWriter with DERIVED_FROM edges
- Graph API endpoints: `GET /api/documents/:id/graph` (Neo4j-backed, full graph with chunks) and `GET /api/documents/:id/reasoning-graph` (SQLite-only, no Neo4j dep)
- Frontend feature `reasoning/` with focus mode, iteration navigation, bidirectional graph/document sync
- Env vars: `RAG_ENABLED`, `OLLAMA_HOST`, `RAG_MODEL_ID`, `NEO4J_URI`, `NEO4J_USER`, `NEO4J_PASSWORD`, `MAX_GRAPH_PAGES`
- Domain ports: `GraphWriter` (Neo4j-backed), `EmbeddingService`, `VectorStore`
### Changed
- Services no longer import `infra.neo4j` or `infra.serve_converter` directly — graph persistence goes through `GraphWriter` port; batching capability is exposed as `DocumentConverter.supports_batching` property (audit remediation 06-SOLID)
- `StructureViewer`, `GraphView`, `BboxOverlay` moved to `frontend/src/shared/ui/viewer/` (audit remediation 07-decoupling)
### Fixed
- (a completer)
```
### Step 4.2 : Q5 — `.env.example` complet
**Fichier** : `.env.example` (ajout en fin)
```bash
# --- Live reasoning (docling-agent runner) — disabled by default ---
# RAG_ENABLED=false
# OLLAMA_HOST=http://localhost:11434
# RAG_MODEL_ID=gpt-oss:20b
# --- Rate limiting (requests per minute per IP, 0 = disabled) ---
# RATE_LIMIT_RPM=100
# --- Timeouts (seconds) — must satisfy document < lock < conversion ---
# DOCUMENT_TIMEOUT=120
# LOCK_TIMEOUT=300
# CONVERSION_TIMEOUT=900
# --- Batch processing for very large PDFs (0 = disabled) ---
# BATCH_PAGE_SIZE=0
# --- OpenSearch max chunks returned per document ---
# OPENSEARCH_DEFAULT_LIMIT=1000
# --- Max pages per graph query (returns 413 beyond) ---
# MAX_GRAPH_PAGES=200
# --- Default table analysis mode: "accurate" or "fast" ---
# DEFAULT_TABLE_MODE=accurate
```
### Step 4.3 : Q6 — Nginx cache statique
**Fichier** : `nginx.conf` (inserer avant `location / {`)
```nginx
# Hashed assets (Vite emits content-hashed filenames) — cache 1 year
location ~* \.(?:js|css|woff2?|ttf|otf|svg|png|jpg|jpeg|webp|gif|ico)$ {
expires 1y;
add_header Cache-Control "public, immutable";
try_files $uri =404;
}
```
### Step 4.4 : Q2 — decouper les fonctions longues (non bloquant, best effort)
**Cibles prioritaires** (ordre de gain pedagogique) :
1. `infra/neo4j/tree_writer.py:67` `write_document` (228L) — decouper en :
- `_wipe_existing(tx, doc_id)`
- `_write_document_node(tx, doc_id, filename, ...)`
- `_write_pages(tx, doc_id, pages)`
- `_write_elements_and_provenances(tx, ...)`
- `_write_structural_edges(tx, ...)`
2. `infra/neo4j/queries.py:143` `fetch_graph` (126L) — une helper par groupe de nodes/edges.
3. Si le refactor Phase 2 a bien fait son job, `api/reasoning.py:run_rag` et `api/graph.py:get_reasoning_graph` sont deja < 30L.
### Step 4.5 : Q3 — signatures avec dataclass context
**Fichier** : `document-parser/services/analysis_service.py`
```python
@dataclass
class AnalysisContext:
job_id: str
file_path: str
filename: str
pipeline_options: dict | None = None
chunking_options: dict | None = None
# Remplacer :
# async def _run_analysis(self, job_id, file_path, filename, pipeline_options, chunking_options)
# par :
# async def _run_analysis(self, ctx: AnalysisContext)
```
**Fichier** : `document-parser/domain/models.py`
```python
@dataclass
class CompletionPayload:
markdown: str
html: str
pages_json: str
document_json: str | None = None
chunks_json: str | None = None
# Sur AnalysisJob :
def mark_completed(self, payload: CompletionPayload) -> None:
...
```
### Step 4.6 : Q4 — splitter les gros composants Vue (planification)
Hors scope immediate — trop gros pour la fenetre 0.5.0. **Acter en dette** :
- `StudioPage.vue` (1450L) : a decouper en `StudioUploadSection.vue`, `StudioAnalysisSection.vue`, `StudioResultsSection.vue` en 0.6.
- `ChunkPanel.vue` (801L), `GraphView.vue` (695L), `ResultTabs.vue` (690L) : ticket dedie post-0.5.
---
## Re-audit delta apres remediations
Apres P1 + P2 + P3 + P4, **re-lancer uniquement** :
| Audit | Raison |
|-------|--------|
| 01 Hexa Arch | Verifier que M1 (graph+reasoning services) a elimine le MAJ |
| 03 Clean Code | Verifier run_rag < 30L et SRP ok |
| 05 DRY | Verifier MAX_PAGES purge |
| 06 SOLID | Verifier CRIT B1 resolu + MAJ M4 resolu |
| 07 Decouplage | Verifier CRIT B2 resolu (grep imports cross-feature hors `shared/`) |
| 10 CI/Build | Verifier `.env.example` complet |
| 11 Documentation | Verifier CHANGELOG + version bump |
Audits **02, 04, 08, 09, 12** : pas de changement attendu, on peut les skipper au re-audit.
Commande :
```
Re-audite uniquement les audits 01, 03, 05, 06, 07, 10, 11 sur la branche courante en suivant docs/audit/master.md
```
---
## Ordonnancement git/PR recommande
| PR | Branche | Contenu | Audits concernes |
|----|---------|---------|------------------|
| PR-A | `fix/0.5.0-port-graphwriter` | Phase 1 (B1 + M4) | 06 |
| PR-B | `fix/0.5.0-extract-services` | Phase 2 (M1 + M2 + M3 + Q1) | 01, 03, 05 |
| PR-C | `fix/0.5.0-frontend-decoupling` | Phase 3 (B2) | 07 |
| PR-D | `chore/0.5.0-release-prep` | Phase 4 (M5 + Q5 + Q6 + Q2 + Q3) | 10, 11 |
Chaque PR doit rester petit (revue + CI courte). Base : toutes branchees sur `release/0.5.0` cree depuis `feature/reasoning-trace` (en suivant la convention git flow du projet).
---
## Estimation globale
| Phase | Duree | Effort (dev-jour) |
|-------|-------|-------------------|
| 1 — Ports | 1j | 1 |
| 2 — Services | 1.5j | 1.5 |
| 3 — Frontend | 2j | 2 |
| 4 — Cleanup | 0.5j | 0.5 |
| **Total** | **5j** | **5 dev-jour** |
Avec 2 devs en parallele (un backend PR-A+B, un frontend PR-C+D), **3 jours calendaires** suffisent.
---
## Validation finale avant tag 0.5.0
- [ ] PR-A, B, C, D mergees dans `release/0.5.0`
- [ ] `ruff check . && ruff format --check .` vert
- [ ] `pytest tests/ -v` vert (backend)
- [ ] `npm run lint && npm run type-check && npm run test:run` vert (frontend)
- [ ] `npm run build` produit un bundle sans warning
- [ ] CI GitHub Actions verte sur `release/0.5.0`
- [ ] Re-audit delta ci-dessus repasse GO (CRIT = 0, MAJ <= 3)
- [ ] `CHANGELOG.md` : renommer `[Unreleased]` en `[0.5.0] - 2026-04-XX`
- [ ] `frontend/package.json` : bump `"version": "0.5.0"`
- [ ] Tag git `v0.5.0` sur `release/0.5.0`

View file

@ -1,107 +0,0 @@
# Re-audit ciblé — Release 0.5.0
**Date** : 2026-04-29
**Branche** : `fix/release-0.5.0-audit-remediation` (off `release/0.5.0`)
**Périmètre** : tous les écarts CRITICAL et MAJOR du `summary.md` initial (regle master §7).
**Auditeur** : claude-code
---
## Tableau de bord — avant / après
| # | Audit | Score initial | CRIT initial | MAJ initial | Score post-remédiation | CRIT | MAJ | Verdict |
|----|----------------------|--------------:|-------------:|------------:|-----------------------:|-----:|----:|---------|
| 01 | Clean Architecture | 100 | 0 | 0 | **100** | 0 | 0 | GO |
| 02 | DDD | 91 | 0 | 1 | **~95** | 0 | 0 | GO |
| 03 | Clean Code | 83 | 0 | 0 | 83 *(inchangé)* | 0 | 0 | GO |
| 04 | KISS | 87.5 | 0 | 0 | 87.5 *(inchangé)* | 0 | 0 | GO |
| 05 | DRY | 71 | 0 | 2 | **~88** | 0 | 0 | GO |
| 06 | SOLID | 85 | 0 | 1 | **~95** | 0 | 0 | GO |
| 07 | Découplage | 86 | 0 | 1 | **~93** | 0 | 0 | GO |
| 08 | Sécurité | 91 | 0 | 2 | **~98** | 0 | 0 | GO |
| 09 | Tests | 94 | 0 | 1 | **~97** | 0 | 0 | GO |
| 10 | CI / Build | 91 | 0 | 1 | **~96** | 0 | 0 | GO |
| 11 | Documentation | **44** | **1** | 2 | **100** | 0 | 0 | GO |
| 12 | Performance | 86.67 | 0 | 1 | **~93** | 0 | 0 | GO |
**Score global (moyenne simple)** : **84.2 → ~94/100**
**CRIT totaux** : 1 → **0**
**MAJ totaux** : 12 → **0**
---
## Vérification item par item
### CRITICAL (1)
| # | Item | Statut | Preuve |
|---|------|--------|--------|
| 11.CRIT | `CHANGELOG.md` sans section `[0.5.0]` | ✅ Résolu | [CHANGELOG.md:7](CHANGELOG.md:7) — `## [0.5.0] - 2026-04-28` |
### MAJOR (12)
| # | Item | Statut | Preuve |
|---|------|--------|--------|
| 11.MAJ.1 | `frontend/package.json` à `0.4.0` | ✅ Résolu | [frontend/package.json:3](frontend/package.json:3) — `"version": "0.5.0"` |
| 11.MAJ.2 | Modifications 0.5.0 non documentées | ✅ Résolu | CHANGELOG `[0.5.0]` détaille Added / Changed / Fixed |
| 08.MAJ.1 | Mot de passe Neo4j `changeme` | ✅ Résolu (cadrage dev) | [docker-compose.yml:1-26](docker-compose.yml:1) — header dev-only ; [main.py:104-110](document-parser/main.py:104) — warning au boot si `NEO4J_URI` set + password=`changeme` |
| 08.MAJ.2 | OpenSearch `DISABLE_SECURITY_PLUGIN=true` | ✅ Résolu (cadrage dev) | [docker-compose.yml:47-52](docker-compose.yml:47) — commentaire "DEV ONLY" + lien doc OpenSearch |
| 05.MAJ.1 | URL d'API hardcodée frontend | ✅ Résolu | `apiUrl` était du code mort → suppression du ref + des i18n keys orphelines (`settings.apiUrl`) |
| 05.MAJ.2 | Clés `localStorage` dispersées | ✅ Résolu | [frontend/src/shared/storage/keys.ts](frontend/src/shared/storage/keys.ts) — `STORAGE_KEYS` ; [features/settings/store.ts](frontend/src/features/settings/store.ts) consomme |
| 02.MAJ | Ubiquitous language `job``analysis` | ✅ Résolu | Path params `{job_id}``{analysis_id}` dans [api/analyses.py](document-parser/api/analyses.py) + [api/ingestion.py](document-parser/api/ingestion.py) (URLs identiques côté client) |
| 06.MAJ | LSP — `isinstance(ServeConverter)` | ✅ Résolu | [domain/ports.py:DocumentConverter](document-parser/domain/ports.py) — `supports_page_batching: bool` exposé via le port ; [services/analysis_service.py:340](document-parser/services/analysis_service.py:340) lit le port |
| 07.MAJ | Couplage inter-feature dans tests | ✅ Résolu | Test déplacé : [frontend/src/__tests__/integration/history-navigation.test.ts](frontend/src/__tests__/integration/history-navigation.test.ts) ; les feature folders restent self-contained |
| 09.MAJ | 18 assertions vagues `assert X is not None` | ✅ Résolu | 8 assertions vraiment terminales resserrées (`isinstance(.., datetime)`, comparaisons exactes) ; les 10 restantes sont des type-narrowings légitimes suivis d'assertions sur la valeur |
| 10.MAJ | Ruff UP038 (`isinstance` union syntax) | ✅ Résolu | [infra/docling_tree.py:101](document-parser/infra/docling_tree.py:101) — `list \| tuple` ; `ruff check` passe (0 erreurs) |
| 12.MAJ | I/O sync dans endpoint async | ✅ Résolu | [api/documents.py:119-122](document-parser/api/documents.py:119) — `Path(...).read_bytes` et `generate_preview` wrappés dans `asyncio.to_thread` |
---
## Renforcements indirects (volet 1 — reasoning)
Au-delà des écarts ciblés, la refacto reasoning consolide plusieurs audits :
- **01 Clean Architecture** : confirmation `grep -rE "^from docling_agent|^from docling_core|^from mellea" api/ domain/ services/`**0 résultat**. Couplage upstream confiné à `infra/docling_agent_reasoning.py` + `infra/llm/ollama_provider.py`.
- **06 SOLID — DIP** : `api/reasoning.py` consomme un port `ReasoningRunner` ; aucune classe concrète importée dans la couche API.
- **07 Découplage** : un seul point de couplage à la lib upstream (l'adapter). Le `_rag_loop` privé est encapsulé + tracé via [docling-agent#26](https://github.com/docling-project/docling-agent/issues/26).
- **08 Sécurité** : la mutation `os.environ["OLLAMA_HOST"]` par requête (race) est éliminée — l'adapter commit le host **une fois** au boot.
- **09 Tests** : +17 tests adaptés (`test_docling_agent_reasoning.py`, `test_ollama_provider.py`), incluant un test d'isolation concurrence (R3).
---
## Items MIN / INFO restants (non-bloquants, planifiés 0.5.1+)
| # | Origine | Item | Plan |
|---|---------|------|------|
| 03.MIN.1 | Clean Code | `StudioPage.vue` (~1450L), `ChunkPanel.vue` (~801L), `ResultTabs.vue` (~690L) | Bloc E — découper en sous-composants en 0.5.1 |
| 03.MIN.2 | Clean Code | 3 fonctions > 30 lignes / 4 fonctions > 4 paramètres | Bloc E — décomposition locale en 0.5.1 |
| 04.MIN | KISS | Wrapper `_to_response`, accessors redondants `DocumentService` | Bloc F — arbitrage en 0.5.1 |
| 05.MIN | DRY | Magic string [api/schemas.py:54](document-parser/api/schemas.py:54) | ✅ Résolu — `DOCUMENT_STATUS_UPLOADED` |
| 04.INFO | KISS | Polling `setInterval/setTimeout` imbriqués | Bloc F |
| 04.INFO | KISS | Overhead `DocumentConfig` / `IngestionConfig` dataclasses | Bloc F |
| 08.INFO | Sécurité | Default LOG_LEVEL non-borné | Sera traité avec la prochaine vague observabilité |
| 12.INFO | Performance | `find_all` implicite (pas de pagination forte) | Bloc E (split graph endpoint) en 0.5.1 |
---
## Verdict final post-remédiation : **GO**
**Justification** :
- Zéro écart CRITICAL (la règle absolue du master §3 est levée).
- Zéro écart MAJOR — tous les MAJ initiaux sont résolus.
- Score global ~94/100 ≥ 80 (seuil GO).
- Tous les audits individuels passent en GO.
- La pipeline de validation est verte : ruff + format + 446 pytest backend, ESLint + Prettier + vue-tsc + 202 vitest frontend.
**Conditions tenues** :
1. ✅ CHANGELOG `[0.5.0]` complet
2. ✅ `frontend/package.json` à `0.5.0`
3. ✅ Sécurité dev-only documentée + warning au boot sur `changeme`
4. ✅ DRY frontend (storage keys + dead apiUrl supprimé)
5. ✅ Refacto archi reasoning (port + adapter + DI) — bonus volet 1
**Reste planifié hors release 0.5.0** :
- Bloc E (découpage Vue files volumineux + signatures de fonctions back) → 0.5.1
- Bloc F (KISS micro-optimisations + INFO) → 0.5.1
La release **0.5.0 peut être taguée** depuis `release/0.5.0` une fois la branche `fix/release-0.5.0-audit-remediation` mergée.

View file

@ -1,106 +0,0 @@
# Synthese d'audit — Release 0.5.0
**Date** : 2026-04-28
**Branche** : `release/0.5.0`
**Commit audite** : `b2e0af3`
**Auditeur** : claude-code
---
## Tableau de bord
| # | Audit | Score | CRIT | MAJ | MIN | INFO | Verdict |
|----|----------------------|---------|------|-----|-----|------|------------------|
| 01 | Clean Architecture | 100 | 0 | 0 | 0 | 0 | GO |
| 02 | DDD | 91 | 0 | 1 | 1 | 0 | GO |
| 03 | Clean Code | 83 | 0 | 0 | 3 | 0 | GO |
| 04 | KISS | 87.5 | 0 | 0 | 1 | 2 | GO |
| 05 | DRY | 71 | 0 | 2 | 1 | 2 | GO CONDITIONNEL |
| 06 | SOLID | 85 | 0 | 1 | 1 | 0 | GO |
| 07 | Decouplage | 86 | 0 | 1 | 1 | 0 | GO CONDITIONNEL |
| 08 | Securite | 91 | 0 | 2 | 0 | 1 | GO CONDITIONNEL |
| 09 | Tests | 94 | 0 | 1 | 0 | 0 | GO |
| 10 | CI / Build | 91 | 0 | 1 | 0 | 0 | GO CONDITIONNEL |
| 11 | Documentation | **44** | **1**| 2 | 0 | 0 | **NO-GO** |
| 12 | Performance | 86.67 | 0 | 1 | 1 | 1 | GO CONDITIONNEL |
**Score global (moyenne simple)** : **84.2 / 100**
**Ecarts CRITICAL totaux** : **1**
**Ecarts MAJOR totaux** : **12**
**Ecarts MINOR totaux** : 8
**Ecarts INFO totaux** : 6
---
## Ecarts CRITICAL (tous audits confondus)
1. **[11] CHANGELOG.md sans section `[0.5.0]`** — `CHANGELOG.md:7`
La derniere section listee est `## [0.4.0] - 2026-04-13`. Aucune entree `[Unreleased]` ni `[0.5.0]`. Tagger 0.5.0 depuis ce HEAD livrerait un changelog mensonger qui omet les nouveautes de la release (reasoning-trace viewer, Neo4j graph storage, RAG endpoints, feature flags). **Bloquant absolu** par regle master §3.
---
## Top blockers (poids 3 / poids 2)
### Bloquant (poids 3)
- **[11] CHANGELOG sans section 0.5.0** — `CHANGELOG.md:7`
Ajouter `## [0.5.0] - 2026-04-28` avec sous-sections `Added` / `Changed` / `Fixed` listant les changements depuis 0.4.0.
### Majeurs (poids 2) — a remediar pour passer a GO
- **[11] `frontend/package.json` toujours a `0.4.0`** — `frontend/package.json:3`
Bumper a `"version": "0.5.0"`.
- **[11] Modifications de la release 0.5.0 non documentees** — `CHANGELOG.md`
Resolu en meme temps que le CRIT.
- **[08] Mot de passe Neo4j par defaut `"changeme"`** — `document-parser/infra/settings.py:30,133`
Forcer la lecture d'une variable d'environnement, supprimer le defaut, faire echouer le boot si absent en prod.
- **[08] OpenSearch security plugin desactive** — `docker-compose.yml:26`
`DISABLE_SECURITY_PLUGIN=true` doit etre off pour tout deploiement non-dev ; documenter explicitement le perimetre.
- **[05] URL d'API hardcodee en frontend** — `frontend/src/features/settings/store.ts:23`
Centraliser dans une constante `API_BASE_URL` lue depuis `import.meta.env`.
- **[05] Cles localStorage en clair, dispersees** — `frontend/src/features/settings/store.ts:24-27`
Centraliser dans une enum/objet `STORAGE_KEYS`.
- **[02] Ubiquitous language "job" vs "analysis"** — `document-parser/api/ingestion.py:49-67`
Aligner le vocabulaire HTTP sur le langage du domaine.
- **[06] LSP — `isinstance()` sur adaptateur** — `document-parser/services/analysis_service.py:356`
Supprimer le check de type concret ou exposer la capacite via le port.
- **[07] Couplage inter-feature dans les tests** — `frontend/src/features/history/navigation.test.ts:30-31,67,82-83,143-144,169-170,188`
Stubber les stores via injection au lieu d'imports directs.
- **[09] Assertions vagues `assert X is not None`** — 18 occurrences en backend
Ajouter une assertion sur la valeur attendue, pas seulement sur la presence.
- **[10] Ruff UP038 — syntaxe `isinstance()` union** — `document-parser/infra/docling_tree.py:101`
Appliquer la suggestion pyupgrade.
- **[12] I/O synchrone dans endpoint async** — `document-parser/api/documents.py:115-116`
Wrapper l'acces fichier dans `asyncio.to_thread(...)` pour ne pas bloquer la loop FastAPI.
---
## Quick wins (poids 1 — ameliorations rapides)
- **[03] Trois fichiers Vue > 300 lignes** — `frontend/src/views/StudioPage.vue` (~1450L), `frontend/src/.../ChunkPanel.vue` (~801L), `frontend/src/.../ResultTabs.vue` (~690L). Extraire des sous-composants.
- **[03] Fonctions > 30 lignes** (3 cas) et **fonctions > 4 parametres** (4 cas). Decoupage local.
- **[04] Wrapper trivial `_to_response`** — utiliser `Pydantic.model_validate()` directement.
- **[04] Accessors redondants dans `DocumentService`**.
- **[05] Magic string isolee** — `document-parser/api/schemas.py:49`.
- **[12] Polling avec setInterval/setTimeout imbriques** — `frontend/src/features/settings/store.ts:27-39` et store analysis.
---
## Verdict final : **NO-GO**
**Justification** : 1 ecart CRITICAL non resolu dans l'audit 11 (Documentation) → regle absolue du master.md §3 : `tout ecart [CRIT] non resolu = NO-GO quel que soit le score`.
Bien que le score global (84.2/100) soit confortablement au-dessus du seuil GO et que 11 audits sur 12 soient au moins en GO CONDITIONNEL, le release 0.5.0 **ne peut pas etre tague** dans cet etat.
### Conditions pour passer a GO
**Bloquant absolu** (1 action) :
1. Ajouter la section `## [0.5.0] - 2026-04-28` dans `CHANGELOG.md` avec `Added` / `Changed` / `Fixed`.
**Pour passer de GO CONDITIONNEL a GO** (4 actions, ~30 min) :
2. Bumper `frontend/package.json` a `0.5.0`.
3. Remplacer `"changeme"` par lecture d'env var obligatoire (`document-parser/infra/settings.py`).
4. Documenter / corriger la desactivation du security plugin OpenSearch (`docker-compose.yml`).
5. Centraliser l'URL d'API et les cles localStorage du frontend (`frontend/src/features/settings/store.ts`).
**Recommandation** : apres remediation des 5 points ci-dessus, re-auditer **uniquement** les audits 11, 08 et 05 (commande : `Re-audite uniquement les ecarts CRITICAL et MAJOR du rapport docs/audit/reports/release-0.5.0/summary.md`). Les autres MAJ peuvent etre planifies pour 0.5.1 sans bloquer le tag.

View file

@ -1,404 +0,0 @@
# Design: Copy paste image in Verify mode
<!--
Design doc template for Docling Studio.
One design doc per tracked issue. File path convention:
docs/design/<issue-number>-<kebab-slug>.md
Status lifecycle: Draft → In review → Accepted → Implemented (or Superseded).
Bump the Status line as the doc progresses; do not delete sections on the way.
This template is tailored to the project's architecture and conventions:
- Backend Hexagonal Architecture / ports & adapters
(domain → api/services/persistence/infra)
see docs/architecture.md
- Backend coding standards (FastAPI + Pydantic camelCase, aiosqlite,
Python snake_case internal, max 300 lines/file, 30 lines/function)
see docs/architecture/coding-standards.md
- Frontend feature-based organization (Vue 3 + Pinia, one store per
feature, Composition API, TypeScript strict, data-e2e selectors)
- E2E with Karate UI (NOT Playwright) — see e2e/CONVENTIONS.md
- Audit dimensions used at release gate — see docs/audit/master.md
- ADR process for load-bearing decisions — see docs/architecture/adr-guide.md
The `/conception` command pre-fills the header block and §1 / §2 / §12 from
the linked issue. Everything else is on the author.
-->
- **Issue:** #195
- **Title on issue:** [ENHANCEMENT] Copy paste image in Verify mode
- **Author:** Pier-Jean Malandrino
- **Date:** 2026-04-23
- **Status:** Accepted
- **Target milestone:** 0.5.0
- **Impacted layers:** <backend: domain | api | services | persistence | infra> · <frontend: features/<name> | shared | app> · <e2e> · <infra/CI>
- **Audit dimensions likely touched:** <pick from: Hexagonal Architecture · DDD · Clean Code · KISS · DRY · SOLID · Decoupling · Security · Tests · CI/Build · Documentation · Performance>
- **ADR spawned?:** <no> *(write an ADR when choosing a library, moving a boundary, or deciding **not** to do something — see `docs/architecture/adr-guide.md`)*
---
## 1. Problem
<!--
What hurts today, and for whom. Pull from the issue's Context + Current
behavior sections — keep the user's voice, do not paraphrase aggressively.
Two or three short paragraphs is usually enough. If you can't state the
problem in plain language, you are not ready to design a solution.
-->
TODO: Why this issue exists. Link the user story, incident, or upstream discussion that motivates it.
Today in Verify mode regarding image handling: TODO — describe the current baseline (upload-only? no paste target? no drag-drop?).
## 2. Goals
<!--
Concrete, verifiable outcomes. Convert the issue's acceptance criteria into
checkboxes here; the design is "done" when all are satisfied. Keep the list
small — five or fewer goals is a good smell.
-->
Users should be able to copy/paste images directly into Verify mode (e.g. from clipboard) instead of only via file upload.
- [ ] Define paste source (OS clipboard, drag-drop, screenshot)
- [ ] Define target area in Verify mode UI
- [ ] Define supported image formats and size limits
- [ ] Size / type limits are env-var configurable, carry sane defaults, and are documented in `README.md` + `docs/deployment/*` (e.g. `MAX_PASTE_IMAGE_SIZE_MB`, `PASTE_ALLOWED_IMAGE_TYPES`)
## 3. Non-goals
<!--
What this design explicitly does NOT try to solve — and, for each, where it
*should* be solved (follow-up issue, next milestone, different audit area).
This is the section that saves the review: naming the off-ramps up front
prevents scope creep. If you leave this empty, reviewers will fill it in
for you, badly.
-->
- ...
- ...
## 4. Context & constraints
<!--
The surrounding reality the design has to live in.
### Existing code surface
List the modules / files / stores this change touches. Prefer concrete paths
over prose:
- Backend: document-parser/<layer>/<file>.py
- Frontend: frontend/src/features/<name>/{store,api,ui}.ts|.vue
- Persistence: document-parser/persistence/<repo>.py + schema in database.py
- E2E: e2e/<feature>.feature
### Hexagonal Architecture constraints (backend)
The domain layer has zero imports from api / persistence / infra, and
defines ports (abstract protocols) that `infra/` adapters implement.
Persistence imports only from domain. API never imports persistence
directly — it goes through services. Call out any change that crosses
these lines or adds / moves a port.
### Deployment modes
Docling Studio ships two images (`latest-local`, `latest-remote`) driven by
`CONVERSION_ENGINE` — and a HF Space deployment on top of `latest-remote`.
State which modes this design supports, which it does not, and how the
frontend's feature flags (`chunking`, `disclaimer`) are affected.
### Hard constraints
Compatibility (SQLite schema, API contract, Pydantic DTOs), deadlines
(milestone due date), deployment target (Docker Compose, HF Space),
performance budget (matters for Performance audit), license / privacy
(matters for Security audit).
-->
### SQLite & storage limits (must be enforced upstream of the DB)
Pasted images follow the existing `documents` pattern: bytes land on disk
under `UPLOAD_DIR`, the row stores `storage_path: TEXT`. We do **not**
store base64 or BLOBs. Even so, app-level size guards must stay below
SQLite's structural limits so a malformed request can never wedge the
engine.
Relevant SQLite defaults (see https://www.sqlite.org/limits.html):
| SQLite limit | Default | Relevance |
|---|---|---|
| `SQLITE_MAX_LENGTH` | 1 GB (1 × 10⁹ bytes) | Max size of any single `TEXT` / `BLOB` cell. Irrelevant as long as we keep bytes off the DB. |
| `SQLITE_MAX_SQL_LENGTH` | 1 MB | Max length of an SQL statement incl. inlined literals. Always use parameter binding — never inline image bytes. |
| Page cache / WAL growth | n/a | Large writes bloat WAL until checkpoint; another reason to stay off-DB. |
Our own app-level limits guard against ever reaching those ceilings.
All such limits **must**: (1) carry a sane default in `infra/settings.py`,
(2) be overridable via env var, (3) be documented in `README.md` and
`docs/deployment/*`. This is consistent with how `MAX_FILE_SIZE_MB`
(default 50) is handled today.
## 5. Proposed design
<!--
The recommended approach, in enough detail that a competent engineer
outside the immediate context can implement it. Describe contracts, not
code — the PR is where code lives.
Structure this section by layer. Skip a layer if it is genuinely untouched;
do not pad.
### 5.1 Domain
New or changed dataclasses / value objects / ports in `document-parser/domain/`.
No HTTP or DB concerns here. If you are adding a port (`Protocol`), give its
full signature.
### 5.2 Persistence
Schema changes (table, columns, indexes), migration plan, aiosqlite query
shape. Note whether existing rows need a backfill.
### 5.3 Infra adapters
New or changed adapters in `document-parser/infra/` (converter, chunker,
rate limiter, settings). For new env vars, give name / default / allowed
values.
### 5.4 Services
Use-case orchestration in `document-parser/services/`. Services do NOT
implement — they delegate. Describe the call sequence, error handling,
and concurrency (how does this interact with `MAX_CONCURRENT_ANALYSES`?).
### 5.5 API
Endpoint additions / changes in `document-parser/api/`. For each:
- Method + path
- Request DTO (Pydantic, camelCase via alias_generator)
- Response DTO (camelCase; remember `pages_json` stays snake_case)
- Error responses (status codes, shape)
- Whether it is excluded from the rate limiter (like `/api/health`)
### 5.6 Frontend — feature module
Which `frontend/src/features/<name>/` folder, which Pinia store actions,
which API client calls in `api.ts`, which Vue components in `ui/`. Name
new `data-e2e` attributes here (Karate needs them).
### 5.7 Cross-cutting
Feature flags (how the backend advertises capability via `/api/health` and
how the frontend reacts), i18n strings (`shared/i18n.ts`), shared types
(`shared/types.ts`).
Prefer mermaid / ASCII for sequence and data flow. Interfaces are more
valuable than pseudocode.
-->
### 5.1 Domain
### 5.2 Persistence
### 5.3 Infra adapters
Extend `document-parser/infra/settings.py` with paste-specific limits.
Follow the existing `MAX_FILE_SIZE_MB` pattern: typed field on the
settings dataclass, `os.environ.get(...)` with a string default, cast
at load time.
| Setting | Env var | Default | Allowed | Notes |
|---|---|---|---|---|
| `max_paste_image_size_mb` | `MAX_PASTE_IMAGE_SIZE_MB` | `10` | positive int, `0` = unlimited | Must be ≤ `MAX_FILE_SIZE_MB`; upload validator rejects larger payloads before any DB write. |
| `paste_allowed_image_types` | `PASTE_ALLOWED_IMAGE_TYPES` | `image/png,image/jpeg,image/webp` | comma-separated MIME list | Enforced server-side; frontend uses the same list via `/api/health`. |
Validation happens in the API layer (upload handler) **before** the
bytes reach persistence. Any future move to BLOB storage would still
rely on these guards — they are the contract that prevents us ever
approaching `SQLITE_MAX_LENGTH`.
### 5.4 Services
### 5.5 API
### 5.6 Frontend — feature module
### 5.7 Cross-cutting (feature flags, i18n, shared types)
## 6. Alternatives considered
<!--
At least two genuine alternatives, each with a one-paragraph description
and the reason it was rejected. "Do nothing" is often a legitimate
alternative — name it if it is. Reviewers use this section to sanity-check
that the recommended design was a choice and not the first thing that
came to mind.
If one of the alternatives represents a significant architectural fork
(e.g. introducing a new service, replacing a library), spawn an ADR under
`docs/architecture/adrs/` and link it in §12 — the design doc captures the
local decision, the ADR captures the cross-cutting one.
-->
### Alternative A — <name>
- **Summary:**
- **Why not:**
### Alternative B — <name>
- **Summary:**
- **Why not:**
## 7. API & data contract
<!--
Make the wire contract explicit — this is what the frontend, e2e tests,
and any external consumer will code against.
### Endpoints
| Method | Path | Request | Response | Breaking? |
|--------|------|---------|----------|-----------|
| | | | | |
Remember:
- API serialization is camelCase (Pydantic `alias_generator`).
- Backend internals stay snake_case.
- `pages_json` is the documented exception — it carries raw
`dataclasses.asdict()` output (snake_case).
- Health endpoint (`/api/health`) may need new fields if this design adds
a feature flag.
### Persistence schema
```sql
-- ALTER TABLE / CREATE TABLE statements, with reasoning
```
### Env vars / config
All new knobs must land in `README.md` and `docs/deployment/*` (same
tables that already list `MAX_FILE_SIZE_MB`, `UPLOAD_DIR`, etc.).
| Name | Default | Allowed | Notes |
|------|---------|---------|-------|
| `MAX_PASTE_IMAGE_SIZE_MB` | `10` | positive int (`0` = unlimited) | Guards app-level payload size; must stay ≤ `MAX_FILE_SIZE_MB` to avoid double-gating confusion. |
| `PASTE_ALLOWED_IMAGE_TYPES` | `image/png,image/jpeg,image/webp` | comma-separated MIME list | Surfaced to the frontend via `/api/health` so the paste handler can reject client-side. |
SQLite ceilings (`SQLITE_MAX_LENGTH` = 1 GB, `SQLITE_MAX_SQL_LENGTH` =
1 MB) are **not** env-configurable — they are compile-time properties
of the bundled engine. Document them in `docs/deployment/*` as
background so operators understand why the app-level limits exist.
### Breaking changes
Enumerate anything a consumer must change. If there are none, say so
explicitly — "additive only" is a useful commitment.
-->
## 8. Risks & mitigations
<!--
One row per non-trivial risk. Map each to an audit dimension so the
release-gate audit has a clear hook:
| Risk | Audit dimension | Likelihood | Impact | How we notice | Mitigation / rollback |
|------|-----------------|-----------|--------|---------------|------------------------|
| | Security | | | | |
| | Performance | | | | |
| | Decoupling | | | | |
Common families to scan for:
- **Hexagonal Architecture:** cross-layer imports, leaking HTTP into domain, adapter bypassing its port
- **Security:** rate limiter bypass, path traversal on uploads, SSRF via
the remote converter, unauthenticated data exposure
- **Performance:** synchronous work on the FastAPI event loop,
unbounded queries, new work inside `MAX_CONCURRENT_ANALYSES` budget
- **Tests:** coverage gap on a critical path
- **Documentation:** missing README / env var / i18n entry
A design with "no risks identified" is a design that has not been read
carefully.
-->
| Risk | Audit dimension | Likelihood | Impact | How we notice | Mitigation / rollback |
|------|-----------------|------------|--------|---------------|------------------------|
| | | | | | |
## 9. Testing strategy
<!--
How this design will be verified. Be specific — name files / suites.
### Backend — pytest (`document-parser/tests/`)
- Unit: per-layer (`tests/domain/`, `tests/persistence/`, `tests/services/`)
- Integration: services wired with real aiosqlite + real adapters
- Architecture tests (if applicable): enforce import boundaries
### Frontend — Vitest (`frontend/src/**/*.test.ts`)
- Stores: actions / getters / mocked API
- Pure helpers (e.g. `bboxScaling.ts`-style modules): deterministic
- Components only when behavior is non-trivial; do not test markup
### E2E — Karate UI (`e2e/`)
- Use `data-e2e` selectors — never CSS classes (see e2e/CONVENTIONS.md)
- `retry()` / `waitFor()` — never `Thread.sleep()` / `delay()`
- Setup via API, verify via UI, cleanup via API
- Tag appropriately: `@critical` / `@ui` / `@smoke` / `@regression` / `@e2e`
- **Never Playwright** — Karate is the tool here.
### Manual QA
Steps the reviewer can run locally (`docker-compose.dev.yml` up, scenario
to reproduce). Keep it short — if the manual list is long, automate more.
### Performance / load
Required when the design claims a latency / throughput / memory property,
or touches the conversion hot path.
-->
## 10. Rollout & observability
<!--
How this change gets to production safely.
### Release branch
Which `release/X.Y.Z` is the target? Any coordination with a parallel
release (e.g. R&D branch)?
### Feature flag / staged rollout
Does the change hide behind a flag surfaced via `/api/health`? If so, what
flips the flag, and what is the default? HF Space deployments often need
`deploymentMode === 'huggingface'` gating.
### Observability
- Logs to add / extend (structured, low-cardinality keys)
- Metrics / counters (if added — call out any new Prometheus names)
- New error modes to watch for in `analysis_jobs.status = FAILED`
### Rollback plan
The revert that is safe to apply at any time:
- Which migration is reversible? Which is not?
- Which env var flip disables the feature without a redeploy?
- Any data cleanup needed after rollback?
Link to the existing release / ops playbooks:
- Deployment: `docs/release/*` (also surfaced via `/release:deploy`)
- Rollback: also surfaced via `/release:rollback`
- Incident: `docs/operations/*` (also surfaced via `/ops:incident`)
-->
## 11. Open questions
<!--
Things the author explicitly does not know yet, phrased as questions the
reviewer can answer or redirect. Empty is allowed once the design is
Accepted — during Draft / In review, this section is where the honest
uncertainty lives. Resolve or delete each entry before shipping.
-->
- ...
- ...
## 12. References
<!--
Links to everything a future reader would want.
-->
- **Issue:** https://github.com/scub-france/Docling-Studio/issues/195
- **Related PRs / commits:**
- **ADRs:** <ADR-NNN or "none planned">
- **Project docs:**
- Architecture: `docs/architecture.md`
- Coding standards: `docs/architecture/coding-standards.md`
- ADR guide / template: `docs/architecture/adr-guide.md`, `docs/architecture/adr-template.md`
- Audit master: `docs/audit/master.md`
- E2E conventions: `e2e/CONVENTIONS.md`
- **External:** <specs, upstream issues, dashboards, third-party docs>

View file

@ -1,253 +0,0 @@
# Reasoning Trace Viewer — Docling-Studio v0.6.0 (R&D preview)
Design doc for the `docling-agent` reasoning trace viewer.
Targeted release: **v0.6.0** (R&D branched from `release/0.5.0` in parallel to the
0.5 build, so the Neo4j foundation can be leveraged without blocking the hackathon deliverable).
Positioning one-liner:
> Studio becomes the **reference viewer for any `docling-agent` run** — not another
> chatbot. The PDF is the debug surface.
---
## 1. Context
### Upstream trigger
Peter Staar (IBM) suggested surfacing the LLM reasoning trace as `docling-agent`
walks a `DoclingDocument` outline (chunkless RAG, new IBM repo).
### `docling-agent` in one paragraph
`DoclingRAGAgent(model_id, tools, max_iterations=5).run(task, sources=[doc])` returns
a `RAGResult` with `answer`, `converged`, and `iterations: list[RAGIteration]`.
Each `RAGIteration` carries: `iteration`, `section_ref` (JSON-pointer, e.g. `#/texts/3`),
`reason`, `can_answer`, `response`, `section_text_length`. No bbox — must be resolved
through `DoclingDocument.<items>[i].prov[0].bbox` + `page_no`. Runs on Mellea
(Ollama / OpenAI / HF / WatsonX / LiteLLM / Bedrock). Observability is stdout logs only.
### What Studio already brings
- **Neo4j graph of the document** (0.5.0, just landed) — every `Element` is keyed by
`(doc_id, self_ref)`, Cytoscape node id is `elem::${self_ref}`. **This is the
killer enabler.** `RAGIteration.section_ref → node` is a string concat, no resolver.
- `GraphView.vue` (Cytoscape + dagre) already handles styles via selectors
(`selector: 'edge[type = "NEXT"]'`, `selector: 'node[kind = "section"]'`) — adding
a `visited` class + `REASONING_NEXT` synthetic edge type is ~20 LOC of style.
- `analysis_jobs.document_json` in SQLite → DoclingDocument available for the sidecar
runner (no PDF re-conversion). Not used by the viewer itself.
### Personas
- **v1 (this plan)**: dev / integrator of `docling-agent` debugging a run that went wrong.
- **v2 (roadmap)**: live runner with synchronized demo UX.
- v3+ (non-goals here): business analyst for semantic navigation, batch QA.
---
## 2. Scope split — **debug first, demo second**
Rendering surface pivoted: **the trace is drawn on the Neo4j graph**, not on the PDF.
See §1. This kills the whole bbox-resolution stack from v1.
| Phase | Value | Runtime deps | Surface |
|---|---|---|---|
| **v1 — Debug (this plan)** | Import externally-produced `RAGResult` JSON, overlay trace on the existing GraphView | **None** server-side. Pure frontend. | GraphView: visited nodes highlighted in order, synthetic `REASONING_NEXT` edges |
| **v2 — Demo (follow-up)** | Run the agent live against a loaded document | Ollama + Mellea + `docling-agent` (new opt-dep group `rag`) | Same GraphView + SSE streaming of iterations, staggered reveal |
Building v1 first de-risks the **graph-trace UX** on real runs (produced by the
R&D sidecar — see `experiments/reasoning-trace/`) before wiring the live runner.
Code shared between v1 and v2 is the GraphView overlay itself — 100 % reused.
**Prerequisite for v1**: the target document must have been processed through the
"Maintain" step (Neo4j pipeline). Otherwise the graph is empty and the trace has
nowhere to render — surface an explicit "Run the Maintain step first" empty state.
---
## 3. v1 — Debug mode (frontend-only)
### 3.1 No backend changes in v1
The GraphView already loads nodes keyed by `self_ref` via `GET /api/documents/{doc_id}/graph`.
Iteration `section_ref` → Cytoscape node id is `` `elem::${section_ref}` `` — a client-side
string concat. Nothing to compute server-side.
Consequences:
- No new router, no new service, no new pydantic model, no new migration.
- No dependency on `docling-agent` in `document-parser/requirements.txt`.
- `RAGResult` JSON (as produced by `experiments/reasoning-trace/`) is consumed
entirely by the frontend.
### 3.2 Frontend — feature folder
New `frontend/src/features/reasoning/`:
```
reasoning/
├── store/reasoningStore.ts # Pinia: trace, activeIteration, importDialogOpen
├── ui/
│ ├── ReasoningPanel.vue # Side panel: query, answer, iteration list
│ ├── IterationCard.vue # Single iteration row (reason + can_answer badge)
│ ├── ImportTraceDialog.vue # Drag-drop / paste RAGResult JSON
│ └── GraphReasoningOverlay.ts # NOT a component — a plugin that decorates cy
└── types.ts # RAGIteration, RAGResult mirror types
```
### 3.3 Graph overlay — how it's drawn
`GraphReasoningOverlay` takes the existing `cy` Cytoscape instance (exposed from
`GraphView.vue` via `defineExpose`) and:
1. For each `iteration[i].section_ref`, find node `` `elem::${section_ref}` ``. If
missing, tag as `resolution_status: "not_in_graph"` and show a warning in the panel
(common cause: doc not processed through Maintain, or agent returned a ref that
points at a non-Element node).
2. Add class `visited` + data attribute `visitOrder: i+1` on matched nodes.
3. Insert **synthetic edges** between successive visited nodes with `type: "REASONING_NEXT"`
and `data: { order: i }`. These edges are UI-only, never written to Neo4j.
4. On import, fit viewport to the visited subgraph (`cy.fit(cy.$('.visited'), 80)`).
5. On iteration card click → `cy.$(`#elem::${ref}`).flashClass('pulse', 800)` +
centered pan.
Cytoscape styles (append to the existing stylesheet array in `GraphView.vue`):
```js
{ selector: 'node.visited',
style: { 'border-color': '#EA580C', 'border-width': 3, 'overlay-opacity': 0 } },
{ selector: 'node.visited[visitOrder]',
style: { label: 'data(visitOrder)', 'text-valign': 'top',
'text-background-color': '#EA580C', 'text-background-opacity': 1,
'color': '#FFFFFF', 'font-weight': 700 } },
{ selector: 'edge[type = "REASONING_NEXT"]',
style: { 'line-color': '#EA580C', 'target-arrow-color': '#EA580C',
'target-arrow-shape': 'triangle', 'curve-style': 'bezier',
width: 2, 'z-index': 99 } },
```
Color ramp: single warm color (`#EA580C`) for v1. Gradient cold→warm is v2 polish.
### 3.4 Integration points
- `StudioPage.vue` → "Maintain" tab gains an **"Import reasoning trace"** action
(don't add a 3rd mode — the viz lives inside the graph view, not a new workspace).
- `GraphView.vue` → add `defineExpose({ cy })` + a `<slot name="overlay" :cy="cy"/>`
that the parent can populate with `<ReasoningPanel>`.
- `ReasoningPanel` appears as a right rail when a trace is loaded; collapsible.
### 3.5 Empty / error states
- **Graph empty for this doc** → "Run the Maintain step first. Neo4j has no graph for
this document yet." (the Maintain button is literally next to it.)
- **All `section_ref`s unresolved in graph** → "None of the visited sections exist in
the graph. The agent may have been run against a different document, or the doc was
re-analyzed since. Re-run Maintain or re-run the agent."
- **Some resolved, some not** → show trace with the missing ones greyed out in the panel.
### 3.6 Tests
No backend tests in v1 (no backend code).
Frontend (Vitest):
- `reasoningStore.test.ts` — import trace, active iteration transitions, reset on doc change.
- `graphReasoningOverlay.test.ts` — given a mock `cy` (`cytoscape({ headless: true })`)
with a known node set, verify `visited` class applied to the right ids and the
correct synthetic edges added.
- `ReasoningPanel.test.ts` — empty / loaded / partial-resolution states.
### 3.7 Out of scope for v1
- Live agent runner (v2).
- Multi-doc queries — reject import if `RAGResult` was produced against `len(sources) > 1`.
- Phrase-level attribution — `docling-agent` doesn't emit it.
- Persisting traces in Neo4j — see §7.
- PDF highlighting — dropped from v1. Could come back as v2.5 if demand exists.
---
## 4. File inventory (v1)
**New — R&D sidecar** (already scaffolded on this branch)
- `experiments/reasoning-trace/inspect_doc.py` — self-contained `uv run` script.
- `experiments/reasoning-trace/README.md`
- `experiments/reasoning-trace/.gitignore`
**New — frontend**
- `frontend/src/features/reasoning/**` (see §3.2)
- Vitest siblings under `**/*.test.ts`
**Touched**
- `frontend/src/features/analysis/ui/GraphView.vue``defineExpose({ cy })` +
`<slot name="overlay">` + 3 new style selectors.
- `frontend/src/pages/StudioPage.vue` — "Import reasoning trace" action in the
Maintain tab rail.
**Untouched**
- Entire `document-parser/` backend — no new router, service, schema, or dep.
- `pyproject.toml` / `requirements.txt`**no new runtime dep in v1**.
- Neo4j schema — synthetic edges are client-side Cytoscape only.
- OpenSearch / ingestion — untouched.
- SQLite schema — no migration.
---
## 5. Risks & mitigations
| Risk | Mitigation |
|---|---|
| `RAGResult` schema drifts in `docling-agent` | `schema_version` discriminator; strict pydantic; one canonical fixture from Peter pinned in CI. |
| `section_ref` variants (`#/texts/3` vs `#/body/texts/3`) | Normalize in parser; regex test matrix. |
| Synthetic groups without `prov` | Documented child-walk fallback + `resolved_via_child` status surfaced in UI. |
| Large `RAGResult` (hundreds of iterations) | Hard-cap `iterations` at 50 in v1 (Peter's agent uses `max_iterations=5` by default) — return 413 above. |
| `document_json` blob large (some docs > 5 MB) | `analysis_repo` already handles it; but **do not** log the blob. Add redaction test. |
| Section ref not in graph (doc not through Maintain, or re-analyzed) | Explicit empty-state in `ReasoningPanel` with a link to the Maintain tab. Partial resolution shown as grey in the trace list. |
| Feature creeping into 0.5.0 | This branch targets **v0.6.0**. Do not merge into `release/0.5.0`. Rebase onto the next release branch when cut. |
---
## 6. Spec anchoring
Pin the `RAGResult` shape to **docling-agent commit SHA at the time of v1 merge** in
a short ADR `docs/architecture/adrs/ADR-002-rag-result-schema.md`. The schema is
upstream, unversioned, and will move — this doc freezes the contract Studio imports.
---
## 7. v2 preview — demo mode (not in this plan)
Kept here to constrain v1 interfaces so nothing needs rewriting:
- `POST /api/rag/answer` — server-side runner. Accepts `{doc_id, question, model_id}`.
Streams iterations via SSE. Frontend consumes the stream with the same
`GraphReasoningOverlay` used by v1 import — iterations appear one by one with
staggered reveal (~400 ms) as the SSE stream drips them in.
- Ollama wired through `Mellea` — new optional dep group `rag`.
- Persist traces in Neo4j as `(:ReasoningRun {id, query, converged})-[:VISITED {order,
reason, can_answer}]->(:Element)` for replay + cross-run analytics. Leverages
`TreeWriter` pattern already present. This is where the synthetic UI edges become
real graph edges.
- Cross-run comparison view: overlay multiple runs on the same graph, diff the paths.
---
## 8. Branch & workflow
- Branch: **`feature/reasoning-trace`** off `origin/release/0.5.0`.
- Merge target: **next release branch (`release/0.6.0`)** once cut — *not* `0.5.0`.
- Until then: live on the feature branch; rebase onto `release/0.5.0` periodically to
absorb Neo4j fixes.
- Issues: one umbrella + one per §4 subsystem (resolver, endpoint, UI panel, overlay,
import dialog, tests). Commit with `Closes #NNN` per project convention.
- PR: opened against `release/0.6.0` when available; draft in the meantime.
---
## 9. Open questions (answered by the sidecar first run)
1. Are emitted `section_ref`s reachable as `elem::${ref}` in the Neo4j graph built
by `TreeWriter`? I.e. is the `self_ref` the agent sees the same `self_ref` we
wrote to the graph? (Expected yes — both come from the same `DoclingDocument`
but the sidecar on a real doc from SQLite will confirm in one run.)
2. Hit rate of the agent: with `max_iterations=5` and `granite4:micro-h`, does it
converge, and how many sections does it actually visit? Determines if the overlay
ever has more than 12 marked nodes (and whether `REASONING_NEXT` edges are worth
the effort vs just node markers).
3. Quality of `iteration.reason` — is it substantive enough to show in the panel, or
LLM filler we should hide? Sidecar output will tell.
4. Fallback when no section headers exist (`RAGResult(iterations=[], converged=True,
answer=<full md>)` — see rag.py): what does the panel show? Probably a degraded
"no trace available, full-doc answer" state.

View file

@ -81,22 +81,22 @@ async def list_analyses(service: ServiceDep) -> list[AnalysisResponse]:
return [_to_response(j) for j in jobs]
@router.get("/{analysis_id}", response_model=AnalysisResponse)
async def get_analysis(analysis_id: str, service: ServiceDep) -> AnalysisResponse:
@router.get("/{job_id}", response_model=AnalysisResponse)
async def get_analysis(job_id: str, service: ServiceDep) -> AnalysisResponse:
"""Get a single analysis job."""
job = await service.find_by_id(analysis_id)
job = await service.find_by_id(job_id)
if not job:
raise HTTPException(status_code=404, detail="Analysis not found")
return _to_response(job)
@router.post("/{analysis_id}/rechunk", response_model=list[ChunkResponse])
@router.post("/{job_id}/rechunk", response_model=list[ChunkResponse])
async def rechunk_analysis(
analysis_id: str, body: RechunkRequest, service: ServiceDep
job_id: str, body: RechunkRequest, service: ServiceDep
) -> list[ChunkResponse]:
"""Re-chunk a completed analysis with new chunking options."""
try:
chunks = await service.rechunk(analysis_id, body.chunkingOptions.model_dump())
chunks = await service.rechunk(job_id, body.chunkingOptions.model_dump())
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
return [
@ -111,13 +111,13 @@ async def rechunk_analysis(
]
@router.patch("/{analysis_id}/chunks/{chunk_index}", response_model=list[ChunkResponse])
@router.patch("/{job_id}/chunks/{chunk_index}", response_model=list[ChunkResponse])
async def update_chunk_text(
analysis_id: str, chunk_index: int, body: UpdateChunkTextRequest, service: ServiceDep
job_id: str, chunk_index: int, body: UpdateChunkTextRequest, service: ServiceDep
) -> list[ChunkResponse]:
"""Update the text of a single chunk by index."""
try:
chunks = await service.update_chunk_text(analysis_id, chunk_index, body.text)
chunks = await service.update_chunk_text(job_id, chunk_index, body.text)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
return [
@ -134,13 +134,11 @@ async def update_chunk_text(
]
@router.delete("/{analysis_id}/chunks/{chunk_index}", response_model=list[ChunkResponse])
async def delete_chunk(
analysis_id: str, chunk_index: int, service: ServiceDep
) -> list[ChunkResponse]:
@router.delete("/{job_id}/chunks/{chunk_index}", response_model=list[ChunkResponse])
async def delete_chunk(job_id: str, chunk_index: int, service: ServiceDep) -> list[ChunkResponse]:
"""Soft-delete a chunk by index (marks it as deleted)."""
try:
chunks = await service.delete_chunk(analysis_id, chunk_index)
chunks = await service.delete_chunk(job_id, chunk_index)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
return [
@ -157,9 +155,9 @@ async def delete_chunk(
]
@router.delete("/{analysis_id}", status_code=204, response_model=None)
async def delete_analysis(analysis_id: str, service: ServiceDep) -> None:
@router.delete("/{job_id}", status_code=204, response_model=None)
async def delete_analysis(job_id: str, service: ServiceDep) -> None:
"""Delete an analysis job."""
deleted = await service.delete(analysis_id)
deleted = await service.delete(job_id)
if not deleted:
raise HTTPException(status_code=404, detail="Analysis not found")

View file

@ -2,9 +2,7 @@
from __future__ import annotations
import asyncio
import logging
from pathlib import Path
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Query, Request, UploadFile
@ -114,12 +112,9 @@ async def preview(
)
try:
# File read + PDF rasterisation are both blocking; offload to a
# worker thread so the event loop stays free for other requests.
file_content = await asyncio.to_thread(Path(doc.storage_path).read_bytes)
png_bytes = await asyncio.to_thread(
DocumentService.generate_preview, file_content, page=page, dpi=dpi
)
with open(doc.storage_path, "rb") as f:
file_content = f.read()
png_bytes = DocumentService.generate_preview(file_content, page=page, dpi=dpi)
return Response(content=png_bytes, media_type="image/png")
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e)) from e

View file

@ -1,11 +1,9 @@
"""Graph API — returns a cytoscape-shaped view of the document structure.
"""Graph API — returns a cytoscape-shaped view of the Neo4j graph for a doc.
Two endpoints:
- `/graph` read from Neo4j. Rich graph (elements + chunks + pages + merges).
Requires the Maintain step (IngestionPipeline) to have run for the document.
- `/reasoning-graph` built on-the-fly from the SQLite `document_json` blob.
No Neo4j dependency. Lighter graph (no chunks) but enough to render the
reasoning-trace overlay on top of `GraphView`.
v0.5 contract:
- Returns the **full** graph for the document (see design §8.4)
- Hard cap at 200 pages; beyond that, HTTP 413 with `truncated: true`
- No pagination (ships in v0.6)
"""
from __future__ import annotations
@ -15,7 +13,6 @@ import logging
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel
from infra.docling_graph import build_graph_payload
from infra.neo4j import fetch_graph
logger = logging.getLogger(__name__)
@ -77,47 +74,3 @@ async def get_document_graph(doc_id: str, request: Request) -> GraphResponse:
truncated=payload.truncated,
page_count=payload.page_count,
)
@router.get("/{doc_id}/reasoning-graph", response_model=GraphResponse)
async def get_reasoning_graph(doc_id: str, request: Request) -> GraphResponse:
"""Graph projection built from SQLite `document_json` — no Neo4j needed.
Serves the reasoning-trace viewer, which only needs the element/page/edge
structure to overlay iterations onto.
"""
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}",
)
payload = build_graph_payload(
latest.document_json,
doc_id=doc_id,
title=latest.document_filename or doc_id,
max_pages=MAX_PAGES,
)
if payload.truncated:
raise HTTPException(
status_code=413,
detail=(
f"Graph too large: document has {payload.page_count} pages "
f"(cap {MAX_PAGES}). Pagination ships in v0.6."
),
)
return GraphResponse(
doc_id=payload.doc_id,
nodes=[GraphNode(**n) for n in payload.nodes],
edges=[GraphEdge(**e) for e in payload.edges],
node_count=payload.node_count,
edge_count=payload.edge_count,
truncated=payload.truncated,
page_count=payload.page_count,
)

View file

@ -38,18 +38,18 @@ IngestionDep = Annotated[IngestionService, Depends(_get_ingestion_service)]
AnalysisDep = Annotated[AnalysisService, Depends(_get_analysis_service)]
@router.post("/{analysis_id}", response_model=IngestionResponse)
@router.post("/{job_id}", response_model=IngestionResponse)
async def ingest_analysis(
analysis_id: str,
job_id: str,
ingestion: IngestionDep,
analysis: AnalysisDep,
) -> IngestionResponse:
"""Ingest a completed analysis into the vector index.
Takes the chunks from an existing analysis, embeds them,
Takes the chunks from an existing analysis job, embeds them,
and indexes them into OpenSearch.
"""
job = await analysis.find_by_id(analysis_id)
job = await analysis.find_by_id(job_id)
if not job:
raise HTTPException(status_code=404, detail="Analysis not found")
if job.status.value != "COMPLETED":
@ -64,7 +64,7 @@ async def ingest_analysis(
chunks_json=job.chunks_json,
)
except Exception as e:
logger.exception("Ingestion failed for analysis %s", analysis_id)
logger.exception("Ingestion failed for job %s", job_id)
raise HTTPException(status_code=500, detail=f"Ingestion failed: {e}") from e
return IngestionResponse(

View file

@ -1,113 +0,0 @@
"""Reasoning API — HTTP layer over a `ReasoningRunner` port.
`POST /api/documents/:id/reasoning` invokes the wired-up `ReasoningRunner`
against the stored `DoclingDocument` and returns a `ReasoningResultResponse`
in the same shape the v1 import dialog already consumes so the frontend
overlay code is fully reused.
This module has zero coupling to docling-agent / mellea / docling-core. The
runner (concrete adapter in `infra/docling_agent_reasoning.py`) is set on
`app.state.reasoning_runner` at boot when `REASONING_ENABLED=true` and the
deps are importable. Otherwise it stays `None` and we 503.
Sync blocking call offloaded to a thread by the adapter 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 logging
from fastapi import APIRouter, HTTPException, Request
from pydantic import BaseModel
from domain.ports import ReasoningParseError, ReasoningRunner
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/documents", tags=["reasoning"])
class ReasoningRunRequest(BaseModel):
query: str
# Optional per-run override; falls back to the runner's default model.
model_id: str | None = None
class ReasoningIterationResponse(BaseModel):
iteration: int
section_ref: str
reason: str
section_text_length: int
can_answer: bool
response: str
class ReasoningResultResponse(BaseModel):
answer: str
iterations: list[ReasoningIterationResponse]
converged: bool
@router.post("/{doc_id}/reasoning", response_model=ReasoningResultResponse)
async def run_reasoning(
doc_id: str, body: ReasoningRunRequest, request: Request
) -> ReasoningResultResponse:
runner: ReasoningRunner | None = getattr(request.app.state, "reasoning_runner", None)
if runner is None or not runner.is_available:
raise HTTPException(
status_code=503,
detail=(
"Live reasoning disabled (REASONING_ENABLED=false or docling-agent not installed)"
),
)
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}",
)
try:
result = await runner.run(
document_json=latest.document_json,
query=body.query,
model_id=body.model_id,
)
except ReasoningParseError as e:
# The upstream LLM couldn't produce a parseable answer after retries.
# 502 Bad Gateway — not our fault — with guidance the UI can show.
raise HTTPException(
status_code=502,
detail=(
f"The model '{e.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("Reasoning loop failed for doc %s", doc_id)
raise HTTPException(status_code=500, detail=f"Reasoning loop failed: {e}") from e
return ReasoningResultResponse(
answer=result.answer,
iterations=[
ReasoningIterationResponse(
iteration=it.iteration,
section_ref=it.section_ref,
reason=it.reason,
section_text_length=it.section_text_length,
can_answer=it.can_answer,
response=it.response,
)
for it in result.iterations
],
converged=result.converged,
)

View file

@ -10,11 +10,6 @@ from datetime import datetime
from pydantic import AliasChoices, BaseModel, ConfigDict, Field, field_validator
# Document lifecycle status — currently single-state (uploaded). Kept as a
# constant so future statuses (e.g. "archived", "deleted") can extend the
# vocabulary without hunting magic strings across the codebase.
DOCUMENT_STATUS_UPLOADED = "uploaded"
def _to_camel(name: str) -> str:
parts = name.split("_")
@ -39,19 +34,13 @@ class HealthResponse(_CamelModel):
database: str
max_page_count: int | None = None
max_file_size_mb: int | None = None
max_paste_image_size_mb: int | None = None
paste_allowed_image_types: list[str] = Field(default_factory=list)
ingestion_available: bool = False
# True when the live-reasoning runner (docling-agent + Ollama) is
# available: REASONING_ENABLED=true AND deps importable. Doesn't imply
# Ollama itself is reachable — that's checked per-call.
reasoning_available: bool = False
class DocumentResponse(_CamelModel):
id: str
filename: str
status: str = DOCUMENT_STATUS_UPLOADED
status: str = "uploaded" # Document status (always "uploaded" for now)
content_type: str | None = None
file_size: int | None = None
page_count: int | None = None

View file

@ -15,27 +15,10 @@ if TYPE_CHECKING:
ChunkResult,
ConversionOptions,
ConversionResult,
LLMProviderType,
ReasoningResult,
)
from domain.vector_schema import IndexedChunk, SearchResult
class ReasoningParseError(Exception):
"""Raised by a `ReasoningRunner` when the upstream LLM couldn't produce a
parseable answer after retries e.g. docling-agent's known IndexError on
`find_json_dicts(...)[0]` when the model fails rejection-sampling.
Carries the model identifier so the API layer can surface it to the user
without leaking adapter internals.
"""
def __init__(self, model_id: str, reason: str = "no parseable answer") -> None:
super().__init__(f"{model_id}: {reason}")
self.model_id = model_id
self.reason = reason
class DocumentConverter(Protocol):
"""Port for document conversion.
@ -51,15 +34,6 @@ class DocumentConverter(Protocol):
page_range: tuple[int, int] | None = None,
) -> ConversionResult: ...
@property
def supports_page_batching(self) -> bool:
"""True if the orchestrator may slice a long document into page
batches (calling `convert` with a `page_range`) and merge the
results. Local in-process converters set this to True; remote
converters that handle batching themselves return False so the
orchestrator passes the full document through in one call."""
...
class DocumentChunker(Protocol):
"""Port for document chunking.
@ -168,73 +142,3 @@ class VectorStore(Protocol):
async def delete_document(self, index_name: str, doc_id: str) -> int:
"""Delete all chunks for a document from the index. Returns count deleted."""
...
async def ping(self) -> bool:
"""Cheap reachability probe — True if the backing store responds.
Used by health checks; should not throw."""
...
@runtime_checkable
class LLMProvider(Protocol):
"""Connection-level abstraction over an LLM backend.
A provider carries the host/base-URL, the default model identifier, and a
type tag that adapters can dispatch on. The reasoning runner consumes a
provider it doesn't construct one — so the runner stays decoupled from
Ollama-vs-OpenAI-vs-WatsonX wiring.
Today only `OllamaProvider` (in `infra/llm/`) is implemented because
docling-agent v0.1.0 is hardwired to Ollama via mellea's
`setup_local_session`. Adding a non-Ollama provider requires either
docling-agent upstream support or a fork (track
https://github.com/docling-project/docling-agent/issues/26 + provider
abstraction work upstream).
"""
@property
def type(self) -> LLMProviderType: ...
@property
def host(self) -> str: ...
@property
def default_model_id(self) -> str: ...
def health_check(self) -> bool:
"""Lightweight reachability probe. Returns True if the provider looks
usable. Implementations should be cheap (no model load, no inference).
"""
...
@runtime_checkable
class ReasoningRunner(Protocol):
"""Port for live reasoning over a previously-converted document.
Takes the serialized DoclingDocument JSON + a user query + optional
per-call model override, returns a `ReasoningResult` (answer + iteration
trace + convergence flag).
Adapters MUST translate upstream parsing failures into
`ReasoningParseError`. Other exceptions propagate as-is the API layer
maps them to 5xx.
"""
@property
def is_available(self) -> bool:
"""True if the runner can serve requests (deps importable + provider
wired). Used by the API layer to short-circuit with a 503 instead of
attempting a doomed call."""
...
async def run(
self,
*,
document_json: str,
query: str,
model_id: str | None = None,
) -> ReasoningResult:
"""Execute the reasoning loop. `model_id` overrides the provider's
default for this call only."""
...

View file

@ -7,7 +7,6 @@ They have ZERO external dependencies (no docling, no HTTP, no DB).
from __future__ import annotations
from dataclasses import dataclass, field
from enum import StrEnum
# US Letter page dimensions (points) — fallback when page size is unknown
DEFAULT_PAGE_WIDTH: float = 612.0
@ -20,11 +19,6 @@ class PageElement:
bbox: list[float]
content: str
level: int = 0
# Docling `self_ref` ("#/texts/12", "#/tables/3", …). Empty for items
# that don't have one (rare — defensive default). Lets callers correlate
# a rendered bbox with the corresponding node in the graph without
# resorting to fuzzy bbox matching.
self_ref: str = ""
@dataclass(frozen=True)
@ -97,43 +91,3 @@ class ChunkResult:
token_count: int = 0
bboxes: list[ChunkBbox] = field(default_factory=list)
doc_items: list[ChunkDocItem] = field(default_factory=list)
# --- Reasoning (live docling-agent runner) -----------------------------------
class LLMProviderType(StrEnum):
"""LLM backends the reasoning runner can talk to.
Today only OLLAMA is realizable: docling-agent v0.1.0 is hardwired to
Ollama via mellea's `setup_local_session`. Other variants are kept here
to make the abstraction visible and prepare future backends adding one
requires either docling-agent upstream support (see
https://github.com/docling-project/docling-agent/issues/26) or a fork.
"""
OLLAMA = "ollama"
@dataclass(frozen=True)
class ReasoningIteration:
"""One step of the reasoning loop — section the agent visited and what
it concluded. Mirrors the upstream docling-agent `RAGIteration` shape so
serialization stays 1:1 with externally-produced traces."""
iteration: int
section_ref: str
reason: str
section_text_length: int
can_answer: bool
response: str
@dataclass(frozen=True)
class ReasoningResult:
"""Full output of a reasoning run: final answer, the path the agent
walked through the document, and whether the loop converged."""
answer: str
iterations: list[ReasoningIteration]
converged: bool

View file

@ -1,129 +0,0 @@
"""docling-agent reasoning runner adapter.
Implements `ReasoningRunner` for an `OllamaProvider`-backed `LLMProvider`.
Encapsulates everything that talks to docling-agent / mellea so neither the
domain nor the API layer depends on those packages.
Why we still call the private `_rag_loop`: `DoclingRAGAgent.run()` wraps the
answer in a synthetic `DoclingDocument` and discards the iteration trace.
Tracked upstream at https://github.com/docling-project/docling-agent/issues/26
switch to the public surface once the issue lands.
"""
from __future__ import annotations
import asyncio
import logging
import os
from domain.ports import LLMProvider, ReasoningParseError
from domain.value_objects import (
LLMProviderType,
ReasoningIteration,
ReasoningResult,
)
logger = logging.getLogger(__name__)
def deps_present() -> bool:
"""Import-check for the heavy reasoning deps. Used by the DI wire-up to
decide whether to instantiate the runner at all (so the backend boots
cleanly when docling-agent + mellea aren't installed)."""
try:
import docling_agent.agents # noqa: F401
import mellea # noqa: F401
except ImportError:
return False
return True
class DoclingAgentReasoningRunner:
"""ReasoningRunner adapter wrapping docling-agent + mellea.
The provider's host is committed to the process-wide `OLLAMA_HOST` env
var at construction time Ollama's Python client reads it on session
creation. Setting it once at boot (instead of per-request) eliminates the
cross-request race the previous implementation exposed.
"""
def __init__(self, provider: LLMProvider) -> None:
if provider.type is not LLMProviderType.OLLAMA:
raise NotImplementedError(
f"docling-agent v0.1.0 only supports Ollama, got provider type "
f"{provider.type!r}. See "
f"https://github.com/docling-project/docling-agent/issues/26"
)
self._provider = provider
self._deps_ok = deps_present()
# Commit the host at boot — concurrent `run()` calls then share the
# same value with no racy mutation.
os.environ["OLLAMA_HOST"] = provider.host
@property
def is_available(self) -> bool:
return self._deps_ok
async def run(
self,
*,
document_json: str,
query: str,
model_id: str | None = None,
) -> ReasoningResult:
if not self._deps_ok:
raise RuntimeError("docling-agent / mellea not importable — cannot run reasoning")
# Lazy imports keep the module loadable when deps are missing (the
# runner is only ever instantiated when `deps_present()` is True, but
# this also makes the import surface explicit).
from docling_agent.agents import DoclingRAGAgent
from docling_core.types.doc.document import DoclingDocument
from mellea.backends.model_ids import ModelIdentifier
raw_model_id = model_id or self._provider.default_model_id
# `DoclingRAGAgent` (pydantic) validates `model_id` strictly against
# `ModelIdentifier` from mellea. Wrapping on the Ollama axis is the
# only realizable path today (cf. LLMProvider docstring).
wrapped_model_id = ModelIdentifier(ollama_name=raw_model_id)
try:
doc = DoclingDocument.model_validate_json(document_json)
except Exception as e:
raise RuntimeError(f"Failed to parse document_json: {e}") from e
agent = DoclingRAGAgent(model_id=wrapped_model_id, tools=[])
logger.info(
"Reasoning run: model_id=%s ollama_host=%s query=%r",
raw_model_id,
self._provider.host,
query[:120],
)
try:
# `_rag_loop` is sync + LLM-heavy (N * model latency). Offload to
# a worker thread so concurrent calls don't block the event loop.
# Private API kept until docling-agent#26 lands.
raw_result = await asyncio.to_thread(agent._rag_loop, query=query, doc=doc)
except IndexError as e:
# docling-agent v0.1.0 bug: `_attempt_answer` / `_select_section`
# call `find_json_dicts(answer.value)[0]` without handling 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 `[0]` raises IndexError. Translate to a
# domain-level error the API can map to 502.
logger.warning(
"docling-agent produced no parseable JSON for model=%s query=%r",
raw_model_id,
query[:120],
)
raise ReasoningParseError(
model_id=raw_model_id,
reason="no parseable answer after retries",
) from e
return ReasoningResult(
answer=raw_result.answer,
iterations=[ReasoningIteration(**it.model_dump()) for it in raw_result.iterations],
converged=raw_result.converged,
)

View file

@ -1,178 +0,0 @@
"""Build a Cytoscape-shaped graph payload straight from a serialized
`DoclingDocument` (i.e. the `document_json` blob stored in SQLite).
Mirrors `infra.neo4j.queries.fetch_graph` so the frontend can reuse the same
`GraphView` component the only intentional difference is the absence of
Chunk nodes / HAS_CHUNK / DERIVED_FROM edges, since chunks are a product of
the Maintain step and don't exist in `document_json` alone.
Used by the reasoning-trace viewer, which needs the structural graph to
overlay iterations onto but does NOT need (and should not require) Neo4j.
"""
from __future__ import annotations
import json
from itertools import pairwise
from typing import Any
from infra.docling_tree import (
build_collapse_index,
dfs_order,
element_label,
is_inline_group,
iter_items,
iter_pages,
iter_provs,
parent_ref,
)
from infra.neo4j.queries import GraphPayload
def _element_node(
doc_id: str,
item: dict[str, Any],
provs: list[dict[str, Any]],
*,
text_override: str | None = None,
) -> dict[str, Any]:
first_page = provs[0].get("page_no") if provs else None
raw_text = text_override if text_override is not None else (item.get("text") or "")
return {
"id": f"elem::{item.get('self_ref')}",
"group": "element",
"label": element_label(item.get("label") or ""),
"docling_label": (item.get("label") or "").lower(),
"self_ref": item.get("self_ref"),
"text": raw_text[:200],
"prov_page": first_page,
"provs": provs,
"level": item.get("level"),
"doc_id": doc_id,
}
def _page_node(doc_id: str, page: dict[str, Any]) -> dict[str, Any]:
return {
"id": f"page::{page.get('page_no')}",
"group": "page",
"page_no": page.get("page_no"),
"width": page.get("width"),
"height": page.get("height"),
"doc_id": doc_id,
}
def _edge(source: str, target: str, edge_type: str, *, order: int | None = None) -> dict[str, Any]:
return {
"id": f"{edge_type}::{source}::{target}",
"source": source,
"target": target,
"type": edge_type,
"order": order,
}
def build_graph_payload(
document_json: str,
*,
doc_id: str,
title: str | None = None,
max_pages: int = 200,
) -> GraphPayload:
"""Build a `GraphPayload` equivalent to `fetch_graph(neo4j, doc_id)` from
the raw `DoclingDocument` JSON.
Returns `truncated=True` with empty node/edge lists beyond `max_pages`, so
the caller can mirror the Neo4j endpoint's 413 behavior.
"""
doc_data = json.loads(document_json)
pages_raw = list(iter_pages(doc_data))
page_count = len(pages_raw)
if page_count > max_pages:
return GraphPayload(
doc_id=doc_id,
nodes=[],
edges=[],
node_count=0,
edge_count=0,
truncated=True,
page_count=page_count,
)
nodes: list[dict[str, Any]] = []
edges: list[dict[str, Any]] = []
doc_node_id = f"doc::{doc_id}"
nodes.append(
{
"id": doc_node_id,
"group": "document",
"doc_id": doc_id,
"title": title,
# `stages_applied` is a Neo4j-only artifact; keep the key present
# for shape parity but leave it empty since SQLite doesn't track it.
"stages_applied": [],
}
)
# Page nodes.
for p in pages_raw:
nodes.append(_page_node(doc_id, p))
# Issue #197: collapse Docling noise — InlineGroup style runs and the
# internal text labels Docling extracts from pictures/charts.
skip_refs, inline_meta = build_collapse_index(doc_data)
# Element nodes + collect parent/body metadata for edges below. The
# `element_idx` mirrors TreeWriter's `enumerate(elements)` so PARENT_OF
# carries the same `order` the Neo4j projection does.
by_ref: dict[str, dict[str, Any]] = {}
element_idx = 0
for _, item in iter_items(doc_data):
ref = item.get("self_ref")
if not ref or ref in skip_refs:
continue
by_ref[ref] = item
if is_inline_group(item):
meta = inline_meta.get(ref, {"text": "", "provs": []})
provs = meta["provs"]
text_override: str | None = meta["text"]
else:
provs = iter_provs(item)
text_override = None
nodes.append(_element_node(doc_id, item, provs, text_override=text_override))
pref = parent_ref(item)
if pref == "#/body":
edges.append(_edge(doc_node_id, f"elem::{ref}", "HAS_ROOT"))
elif pref:
edges.append(_edge(f"elem::{pref}", f"elem::{ref}", "PARENT_OF", order=element_idx))
# ON_PAGE, dedup'd per (element, page) — matches the Neo4j query's
# DISTINCT projection through Provenance.
seen_pages: set[int] = set()
for prov in provs:
page_no = prov.get("page_no")
if page_no is None or page_no in seen_pages:
continue
seen_pages.add(page_no)
edges.append(_edge(f"elem::{ref}", f"page::{page_no}", "ON_PAGE"))
element_idx += 1
# NEXT chain (DFS pre-order from body), inline-group children skipped.
for a, b in pairwise(dfs_order(doc_data, skip_refs)):
if a in by_ref and b in by_ref:
edges.append(_edge(f"elem::{a}", f"elem::{b}", "NEXT"))
return GraphPayload(
doc_id=doc_id,
nodes=nodes,
edges=edges,
node_count=len(nodes),
edge_count=len(edges),
truncated=False,
page_count=page_count,
)

View file

@ -1,276 +0,0 @@
"""Pure helpers over a serialized `DoclingDocument` dict.
No I/O, no Neo4j. Shared between:
- `infra.neo4j.tree_writer` persists the tree into Neo4j during the Maintain
step (IngestionPipeline).
- `infra.docling_graph` builds an in-memory `GraphPayload` from the SQLite
`document_json` blob for the reasoning-trace viewer.
Keep this module the single source of truth for how we read Docling's own
structure, so the two consumers can't drift.
"""
from __future__ import annotations
from collections.abc import Iterator
from typing import Any
# Docling label -> specific Neo4j/Cytoscape label. Every element carries the
# generic :Element tag too. Kept 1:1 with docling-core's label taxonomy so the
# projection is a faithful mirror of the DoclingDocument.
LABEL_MAP: dict[str, str] = {
"section_header": "SectionHeader",
"title": "SectionHeader",
"paragraph": "Paragraph",
"text": "Paragraph",
"list_item": "ListItem",
"list": "List", # distinct from :ListItem — a list is a container
"inline": "Paragraph", # see issue #197 — collapsed into one paragraph node
"table": "Table",
"picture": "Figure",
"formula": "Formula",
"code": "Code",
"caption": "Caption",
"footnote": "Footnote",
"page_header": "PageHeader",
"page_footer": "PageFooter",
"key_value_area": "KeyValueArea",
"form_area": "FormArea",
"document_index": "DocumentIndex",
}
DEFAULT_LABEL = "TextElement"
def element_label(docling_label: str) -> str:
return LABEL_MAP.get(docling_label.lower(), DEFAULT_LABEL)
def is_inline_group(item: dict[str, Any]) -> bool:
"""True iff `item` is a Docling InlineGroup (paragraph of mixed style runs).
Docling represents an inline-styled paragraph as one entry in `groups[]`
(label `inline`) plus N entries in `texts[]` (label `text`), one per style
run. We collapse them into a single Paragraph projection see #197.
"""
return (item.get("label") or "").lower() == "inline"
def is_picture(item: dict[str, Any]) -> bool:
"""True iff `item` is a Docling PictureItem (figure or chart).
A `picture` keeps its node in the graph (it IS the figure), but its
`children` internal text labels extracted from a flowchart, diagram,
chart axis labels are noise for graph readability and are skipped.
Captions live in a separate `captions` field on the picture, not in
`children`, so they are unaffected by this skip.
"""
return (item.get("label") or "").lower() in {"picture", "chart"}
def iter_items(doc_data: dict[str, Any]) -> Iterator[tuple[str, dict[str, Any]]]:
"""Yield every item from texts/tables/pictures/groups with its source list key."""
for key in ("texts", "tables", "pictures", "groups"):
for item in doc_data.get(key, []) or []:
yield key, item
def parent_ref(item: dict[str, Any]) -> str | None:
parent = item.get("parent")
if isinstance(parent, dict):
return parent.get("$ref") or parent.get("cref")
return None
def iter_provs(item: dict[str, Any]) -> list[dict[str, Any]]:
"""Flatten a Docling item's `prov[]` into a list of dict rows.
A single item may have multiple provs when it spans page breaks or appears
more than once in the layout. The returned dicts carry the original index
under `order` so sequence is preserved.
"""
provs = item.get("prov") or []
rows: list[dict[str, Any]] = []
for idx, p in enumerate(provs):
bbox = p.get("bbox")
l_, t_, r_, b_ = 0.0, 0.0, 0.0, 0.0
if isinstance(bbox, dict):
l_ = float(bbox.get("l", 0.0) or 0.0)
t_ = float(bbox.get("t", 0.0) or 0.0)
r_ = float(bbox.get("r", 0.0) or 0.0)
b_ = float(bbox.get("b", 0.0) or 0.0)
elif isinstance(bbox, list | tuple) and len(bbox) >= 4:
l_, t_, r_, b_ = (float(x) for x in bbox[:4])
coord_origin = (bbox.get("coord_origin") if isinstance(bbox, dict) else None) or "TOPLEFT"
charspan = p.get("charspan") or []
rows.append(
{
"order": idx,
"page_no": p.get("page_no"),
"bbox_l": l_,
"bbox_t": t_,
"bbox_r": r_,
"bbox_b": b_,
"coord_origin": coord_origin,
"charspan_start": int(charspan[0]) if len(charspan) >= 1 else None,
"charspan_end": int(charspan[1]) if len(charspan) >= 2 else None,
}
)
return rows
def dfs_order(doc_data: dict[str, Any], skip_refs: set[str] | None = None) -> list[str]:
"""Return `self_ref`s in reading order (DFS pre-order from body).
`skip_refs` (typically the set returned by `build_inline_index`) is omitted
from the chain. Inline groups themselves are emitted but the walk does not
recurse into their style-run children, so the resulting order references
only nodes that survive the InlineGroup collapse.
"""
skip = skip_refs or set()
by_ref: dict[str, dict[str, Any]] = {}
for _, item in iter_items(doc_data):
ref = item.get("self_ref")
if ref:
by_ref[ref] = item
body = doc_data.get("body") or {}
order: list[str] = []
def walk(children: list[dict[str, Any]] | None) -> None:
if not children:
return
for ch in children:
ref = ch.get("$ref") or ch.get("cref")
if not ref or ref in skip:
continue
order.append(ref)
child = by_ref.get(ref)
if child and not is_inline_group(child):
walk(child.get("children"))
walk(body.get("children"))
return order
def build_collapse_index(
doc_data: dict[str, Any],
) -> tuple[set[str], dict[str, dict[str, Any]]]:
"""Pre-compute graph-projection collapses for a serialized DoclingDocument.
Two cases produce noise nodes if mirrored 1:1 see issue #197:
1. **InlineGroup** Docling emits one `groups[]` entry (label `inline`)
plus N `texts[]` style runs. We collapse the children into the group,
which is then projected as a single `:Paragraph` with concatenated
text and the union of children's provs.
2. **Picture / Chart** internal text labels extracted from flowcharts,
diagrams or chart axes hang off the picture's `children`. The picture
node itself stays, but its descendants are skipped so the graph isn't
drowned in dozens of tiny labels.
Returns `(skip_refs, inline_meta)`:
- `skip_refs`: every `self_ref` to drop from element / edge projections.
- `inline_meta[group_ref]`: `{"text": str, "provs": list[dict]}`
override values for the inline group projection. Pictures don't have
an entry here; they keep their own text/prov.
"""
by_ref: dict[str, dict[str, Any]] = {}
for _, item in iter_items(doc_data):
ref = item.get("self_ref")
if ref:
by_ref[ref] = item
skip_refs: set[str] = set()
inline_meta: dict[str, dict[str, Any]] = {}
for item in by_ref.values():
ref = item.get("self_ref") or ""
if not ref:
continue
if is_inline_group(item):
text_parts, provs = _collect_inline_descendants(ref, by_ref, skip_refs)
# Re-index prov order so the resulting :Provenance nodes are 0..N-1
# contiguous instead of carrying each child's individual indices.
for idx, prov in enumerate(provs):
prov["order"] = idx
inline_meta[ref] = {
"text": " ".join(text_parts),
"provs": provs,
}
elif is_picture(item):
_collect_descendants(ref, by_ref, skip_refs)
return skip_refs, inline_meta
def _collect_descendants(
root_ref: str,
by_ref: dict[str, dict[str, Any]],
skip_refs: set[str],
) -> None:
"""DFS `root_ref`'s subtree and add every descendant to `skip_refs`.
Used for picture children we just want them dropped, not aggregated.
"""
def walk(ref: str) -> None:
item = by_ref.get(ref)
if item is None:
return
for ch in item.get("children") or []:
child_ref = ch.get("$ref") or ch.get("cref")
if not child_ref or child_ref in skip_refs:
continue
skip_refs.add(child_ref)
walk(child_ref)
walk(root_ref)
def _collect_inline_descendants(
group_ref: str,
by_ref: dict[str, dict[str, Any]],
skip_refs: set[str],
) -> tuple[list[str], list[dict[str, Any]]]:
"""DFS an inline group's subtree, returning its text parts and provs in
document order. `skip_refs` is mutated with every visited descendant."""
text_parts: list[str] = []
provs: list[dict[str, Any]] = []
def walk(ref: str) -> None:
item = by_ref.get(ref)
if item is None:
return
for ch in item.get("children") or []:
child_ref = ch.get("$ref") or ch.get("cref")
if not child_ref or child_ref in skip_refs:
continue
skip_refs.add(child_ref)
child = by_ref.get(child_ref)
if child is None:
continue
if is_inline_group(child):
walk(child_ref)
continue
text = child.get("text") or ""
if text:
text_parts.append(text)
provs.extend(iter_provs(child))
walk(group_ref)
return text_parts, provs
def iter_pages(doc_data: dict[str, Any]) -> Iterator[dict[str, Any]]:
"""Yield page dicts with `page_no`, `width`, `height` from the `pages` map."""
for page_no_str, page_obj in (doc_data.get("pages") or {}).items():
try:
page_no = int(page_no_str)
except (TypeError, ValueError):
continue
size = (page_obj or {}).get("size") or {}
yield {
"page_no": page_no,
"width": size.get("width"),
"height": size.get("height"),
}

View file

@ -1,47 +0,0 @@
"""Ollama LLM provider adapter.
Implements `LLMProvider` for a locally-reachable Ollama instance. Holds the
host URL + default model id; exposes a cheap `health_check` (HEAD `/api/tags`)
that doesn't load any model.
"""
from __future__ import annotations
import logging
import httpx
from domain.value_objects import LLMProviderType
logger = logging.getLogger(__name__)
# Ollama's tags endpoint returns 200 with an empty list even on a fresh
# install — perfect for a "is the daemon up?" probe.
_HEALTH_PATH = "/api/tags"
_HEALTH_TIMEOUT_SECONDS = 1.5
class OllamaProvider:
def __init__(self, host: str, default_model_id: str) -> None:
self._host = host.rstrip("/")
self._default_model_id = default_model_id
@property
def type(self) -> LLMProviderType:
return LLMProviderType.OLLAMA
@property
def host(self) -> str:
return self._host
@property
def default_model_id(self) -> str:
return self._default_model_id
def health_check(self) -> bool:
try:
resp = httpx.get(f"{self._host}{_HEALTH_PATH}", timeout=_HEALTH_TIMEOUT_SECONDS)
except httpx.HTTPError as e:
logger.debug("Ollama health check failed for %s: %s", self._host, e)
return False
return resp.status_code == 200

View file

@ -194,13 +194,7 @@ def _process_content_item(
content = item.export_to_markdown()
pages[page_no].elements.append(
PageElement(
type=element_type,
bbox=bbox,
content=content,
level=level,
self_ref=getattr(item, "self_ref", "") or "",
)
PageElement(type=element_type, bbox=bbox, content=content, level=level)
)
except (AttributeError, KeyError, TypeError, ValueError):
logger.warning(
@ -281,10 +275,6 @@ def _convert_sync(
class LocalConverter:
"""Adapter that runs Docling locally as a Python library."""
# In-process — the orchestrator may slice long docs into page batches
# and merge results (cf. AnalysisService._run_batched_conversion).
supports_page_batching: bool = True
async def convert(
self,
file_path: str,

View file

@ -25,31 +25,9 @@ class GraphPayload:
# block contributes a single row — avoids the cartesian product that chained
# OPTIONAL MATCH on 6+ edge types would produce (hangs on multi-page docs).
# See: https://neo4j.com/developer/kb/using-subqueries-to-control-the-scope-of-aggregations/
#
# Provenance nodes (post-v0.6 refactor) are NOT returned as top-level graph
# nodes — they're metadata of their owning Element. We aggregate them inline
# per element, and derive a dedup'd ON_PAGE edge set from them.
_FETCH_GRAPH = """
MATCH (d:Document {id: $doc_id})
CALL {
WITH d
MATCH (e:Element {doc_id: d.id})
OPTIONAL MATCH (e)-[hp:HAS_PROV]->(pv:Provenance)
WITH e, pv ORDER BY hp.order
WITH e,
collect(
CASE WHEN pv IS NULL THEN NULL ELSE {
order: pv.prov_order,
page_no: pv.page_no,
bbox_l: pv.bbox_l, bbox_t: pv.bbox_t,
bbox_r: pv.bbox_r, bbox_b: pv.bbox_b,
coord_origin: pv.coord_origin,
charspan_start: pv.charspan_start,
charspan_end: pv.charspan_end
} END
) AS all_provs
RETURN collect({element: e, provs: [p IN all_provs WHERE p IS NOT NULL]}) AS elements
}
CALL { WITH d MATCH (e:Element {doc_id: d.id}) RETURN collect(e) AS elements }
CALL { WITH d MATCH (p:Page {doc_id: d.id}) RETURN collect(p) AS pages }
CALL { WITH d MATCH (c:Chunk {doc_id: d.id}) RETURN collect(c) AS chunks }
CALL {
@ -64,10 +42,7 @@ CALL {
}
CALL {
WITH d
// ON_PAGE is stored on Provenance since v0.6; surface it at the Element
// level (dedup'd per Element/Page pair) for the Cytoscape viz.
MATCH (er:Element {doc_id: d.id})-[:HAS_PROV]->(:Provenance)-[:ON_PAGE]->(pr:Page)
WITH DISTINCT er, pr
MATCH (er:Element {doc_id: d.id})-[:ON_PAGE]->(pr:Page)
RETURN collect({from: er.self_ref, to: pr.page_no, type: 'ON_PAGE'}) AS on_page_edges
}
CALL {
@ -91,25 +66,17 @@ RETURN d AS document, elements, pages, chunks,
"""
def _element_node(
doc_id: str, e: dict[str, Any], provs: list[dict[str, Any]] | None = None
) -> dict[str, Any]:
def _element_node(doc_id: str, e: dict[str, Any]) -> dict[str, Any]:
# Determine the specific element label: Neo4j returns it via labels(e) on the
# driver side; when we project nodes via RETURN, the driver wraps them as Node
# objects, so we convert below.
first_page: int | None = None
if provs:
# Convenience: the first provenance's page — the old `prov_page` property,
# useful for label rendering in Cytoscape. Full list is in `provs`.
first_page = provs[0].get("page_no")
return {
"id": f"elem::{e.get('self_ref')}",
"group": "element",
"docling_label": e.get("docling_label"),
"self_ref": e.get("self_ref"),
"text": (e.get("text") or "")[:200],
"prov_page": first_page,
"provs": provs or [],
"prov_page": e.get("prov_page"),
"level": e.get("level"),
"doc_id": doc_id,
}
@ -204,17 +171,11 @@ async def fetch_graph(
)
# Element nodes, keeping the specific label (:SectionHeader, etc.).
# Each row is a {element, provs} dict from the CALL above; provs is a list
# of per-provenance dicts in original order.
for row in record["elements"] or []:
if row is None:
continue
e = row.get("element") if isinstance(row, dict) else None
for e in record["elements"] or []:
if e is None:
continue
provs = [p for p in (row.get("provs") or []) if p is not None]
labels = [label for label in e.labels if label != "Element"]
node = _element_node(doc_id, dict(e), provs=provs)
node = _element_node(doc_id, dict(e))
node["label"] = labels[0] if labels else "TextElement"
nodes.append(node)

View file

@ -27,12 +27,6 @@ CONSTRAINTS: tuple[str, ...] = (
INDEXES: tuple[str, ...] = (
"CREATE INDEX element_doc IF NOT EXISTS FOR (e:Element) ON (e.doc_id)",
"CREATE INDEX chunk_doc IF NOT EXISTS FOR (c:Chunk) ON (c.doc_id)",
# Reasoning tunnel / bbox-highlight: looking up a Provenance by its owner
# element is a hot path (one lookup per visited section). Composite index
# avoids a full scan of every Provenance in the DB.
"CREATE INDEX provenance_element IF NOT EXISTS "
"FOR (pv:Provenance) ON (pv.doc_id, pv.element_ref)",
"CREATE INDEX provenance_page IF NOT EXISTS FOR (pv:Provenance) ON (pv.doc_id, pv.page_no)",
)
FULLTEXT_INDEXES: tuple[str, ...] = (

View file

@ -16,43 +16,80 @@ from dataclasses import dataclass
from datetime import UTC, datetime
from typing import TYPE_CHECKING, Any
from infra.docling_tree import (
build_collapse_index,
dfs_order,
element_label,
is_inline_group,
iter_items,
iter_pages,
iter_provs,
parent_ref,
)
if TYPE_CHECKING:
from infra.neo4j.driver import Neo4jDriver
logger = logging.getLogger(__name__)
# Docling label → specific Neo4j label. Every node also carries :Element.
_LABEL_MAP: dict[str, str] = {
"section_header": "SectionHeader",
"title": "SectionHeader",
"paragraph": "Paragraph",
"text": "Paragraph",
"list_item": "ListItem",
"list": "ListItem",
"table": "Table",
"picture": "Figure",
"formula": "Formula",
"code": "Code",
"caption": "Caption",
"footnote": "Footnote",
"page_header": "PageHeader",
"page_footer": "PageFooter",
}
_DEFAULT_LABEL = "TextElement"
def _element_label(docling_label: str) -> str:
return _LABEL_MAP.get(docling_label.lower(), _DEFAULT_LABEL)
@dataclass
class TreeWriteResult:
doc_id: str
elements_written: int
pages_written: int
provenances_written: int = 0
def _iter_items(doc_data: dict[str, Any]):
"""Yield every item from texts/tables/pictures/groups with its source list."""
for key in ("texts", "tables", "pictures", "groups"):
for item in doc_data.get(key, []) or []:
yield key, item
def _first_prov(item: dict[str, Any]) -> tuple[int | None, list[float] | None]:
prov = item.get("prov") or []
if not prov:
return None, None
p0 = prov[0]
bbox = p0.get("bbox")
bbox_list: list[float] | None = None
if isinstance(bbox, dict):
bbox_list = [bbox.get("l", 0.0), bbox.get("t", 0.0), bbox.get("r", 0.0), bbox.get("b", 0.0)]
elif isinstance(bbox, list):
bbox_list = list(bbox)
return p0.get("page_no"), bbox_list
def _parent_ref(item: dict[str, Any]) -> str | None:
parent = item.get("parent")
if isinstance(parent, dict):
return parent.get("$ref") or parent.get("cref")
return None
def _element_props(item: dict[str, Any], doc_id: str) -> dict[str, Any]:
"""Properties stored on the `:Element` node itself.
Provenance (page + bbox) is NOT here anymore see `_iter_provs` and the
`:Provenance` nodes. Keeping it out of the element matches DoclingDocument's
own model (`prov` is a list of objects, not a scalar).
"""
page, bbox = _first_prov(item)
props: dict[str, Any] = {
"doc_id": doc_id,
"self_ref": item.get("self_ref") or "",
"docling_label": (item.get("label") or "").lower(),
"text": item.get("text") or "",
"prov_page": page,
"prov_bbox": bbox,
}
# Type-specific extras.
if "level" in item:
@ -66,6 +103,32 @@ def _element_props(item: dict[str, Any], doc_id: str) -> dict[str, Any]:
return props
def _dfs_order(doc_data: dict[str, Any]) -> list[str]:
"""Return self_refs in reading order (DFS pre-order from body)."""
by_ref: dict[str, dict[str, Any]] = {}
for _, item in _iter_items(doc_data):
ref = item.get("self_ref")
if ref:
by_ref[ref] = item
body = doc_data.get("body") or {}
order: list[str] = []
def walk(children: list[dict[str, Any]] | None) -> None:
if not children:
return
for ch in children:
ref = ch.get("$ref") or ch.get("cref")
if not ref:
continue
order.append(ref)
child = by_ref.get(ref)
if child:
walk(child.get("children"))
walk(body.get("children"))
return order
async def write_document(
neo: Neo4jDriver,
*,
@ -84,42 +147,37 @@ async def write_document(
doc_data = json.loads(document_json)
ingested_at = datetime.now(tz=UTC).isoformat()
# Issue #197: collapse two noise patterns from Docling into the projection.
# InlineGroups (paragraph style runs) are merged into a single :Paragraph,
# and Pictures' internal text labels (flowchart/diagram/chart annotations)
# are dropped. Both produce refs that land in `skip_refs`.
skip_refs, inline_meta = build_collapse_index(doc_data)
elements: list[dict[str, Any]] = []
# Parallel list: one row per Provenance — each refers back to its owner
# element via `self_ref`, so we can batch MATCH-and-link after both node
# sets are created.
provenances: list[dict[str, Any]] = []
for _, item in iter_items(doc_data):
for _, item in _iter_items(doc_data):
ref = item.get("self_ref")
if not ref or ref in skip_refs:
if not ref:
continue
specific = element_label(item.get("label") or "")
props = _element_props(item, doc_id)
if is_inline_group(item):
meta = inline_meta.get(ref, {"text": "", "provs": []})
props["text"] = meta["text"]
item_provs = meta["provs"]
else:
item_provs = iter_provs(item)
specific = _element_label(item.get("label") or "")
elements.append(
{
"specific_label": specific,
"parent_ref": parent_ref(item),
**props,
"parent_ref": _parent_ref(item),
**_element_props(item, doc_id),
}
)
for prov in item_provs:
provenances.append({"doc_id": doc_id, "self_ref": ref, **prov})
pages: list[dict[str, Any]] = [{"doc_id": doc_id, **p} for p in iter_pages(doc_data)]
pages: list[dict[str, Any]] = []
for page_no_str, page_obj in (doc_data.get("pages") or {}).items():
try:
page_no = int(page_no_str)
except (TypeError, ValueError):
continue
size = page_obj.get("size") or {}
pages.append(
{
"doc_id": doc_id,
"page_no": page_no,
"width": size.get("width"),
"height": size.get("height"),
}
)
reading_order = dfs_order(doc_data, skip_refs)
reading_order = _dfs_order(doc_data)
async with (
neo.driver.session(database=neo.database) as session,
@ -132,9 +190,7 @@ async def write_document(
"DETACH DELETE d, n",
doc_id=doc_id,
)
# Orphan sweep — covers Provenance/Element/Page/Chunk that may linger
# from an interrupted write or a pre-refactor schema.
await tx.run("MATCH (pv:Provenance {doc_id: $doc_id}) DETACH DELETE pv", doc_id=doc_id)
# Also wipe orphan elements/chunks that may still reference this doc.
await tx.run("MATCH (e:Element {doc_id: $doc_id}) DETACH DELETE e", doc_id=doc_id)
await tx.run("MATCH (p:Page {doc_id: $doc_id}) DETACH DELETE p", doc_id=doc_id)
@ -185,6 +241,8 @@ async def write_document(
self_ref: e.self_ref,
docling_label: e.docling_label,
text: e.text,
prov_page: e.prov_page,
prov_bbox: e.prov_bbox,
level: e.level,
caption: e.caption,
cells_json: e.cells_json
@ -233,48 +291,21 @@ async def write_document(
rows=root_rows,
)
# 7. Provenance nodes — one per (element, prov-entry) pair. Mirrors
# Docling's `item.prov = list[ProvenanceItem]` 1:1 so a single item
# that spans page breaks (or appears twice in the layout) keeps every
# (page, bbox, charspan) without losing data.
if provenances:
# 7. ON_PAGE from first provenance.
on_page_rows = [
{"doc_id": doc_id, "self_ref": e["self_ref"], "page_no": e["prov_page"]}
for e in elements
if e["prov_page"] is not None
]
if on_page_rows:
await tx.run(
"""
UNWIND $rows AS r
MATCH (e:Element {doc_id: r.doc_id, self_ref: r.self_ref})
CREATE (pv:Provenance {
doc_id: r.doc_id,
element_ref: r.self_ref,
prov_order: r.order,
page_no: r.page_no,
bbox_l: r.bbox_l,
bbox_t: r.bbox_t,
bbox_r: r.bbox_r,
bbox_b: r.bbox_b,
coord_origin: r.coord_origin,
charspan_start: r.charspan_start,
charspan_end: r.charspan_end
})
CREATE (e)-[:HAS_PROV {order: r.order}]->(pv)
""",
rows=provenances,
)
# ON_PAGE now attaches the Provenance to its Page — lets downstream
# queries ("what's on page 3?") stay simple without walking through
# the Element. A Provenance with no page_no (rare) yields no edge.
await tx.run(
"""
UNWIND $rows AS r
WITH r WHERE r.page_no IS NOT NULL
MATCH (pv:Provenance {
doc_id: r.doc_id,
element_ref: r.self_ref,
prov_order: r.order
})
MATCH (p:Page {doc_id: r.doc_id, page_no: r.page_no})
MERGE (pv)-[:ON_PAGE]->(p)
MERGE (e)-[:ON_PAGE]->(p)
""",
rows=provenances,
rows=on_page_rows,
)
# 8. NEXT chain in DFS pre-order.
@ -296,15 +327,9 @@ async def write_document(
await tx.commit()
logger.info(
"Neo4j: wrote doc %s (%d elements, %d pages, %d provenances)",
"Neo4j: wrote doc %s (%d elements, %d pages)",
doc_id,
len(elements),
len(pages),
len(provenances),
)
return TreeWriteResult(
doc_id=doc_id,
elements_written=len(elements),
pages_written=len(pages),
provenances_written=len(provenances),
)
return TreeWriteResult(doc_id=doc_id, elements_written=len(elements), pages_written=len(pages))

View file

@ -84,14 +84,6 @@ class OpenSearchStore:
"""Close the underlying HTTP connection pool."""
await self._client.close()
async def ping(self) -> bool:
"""Reachability probe — calls OpenSearch `/` (cluster info) once."""
try:
info = await self._client.info()
return bool(info)
except Exception:
return False
# -- VectorStore protocol methods ------------------------------------------
async def ensure_index(self, index_name: str, mapping: dict) -> None:

View file

@ -57,11 +57,6 @@ _LABEL_MAP = {
class ServeConverter:
"""Adapter that delegates document conversion to a remote Docling Serve instance."""
# Docling Serve handles batching server-side; slicing into page batches
# client-side would just multiply HTTP roundtrips for no benefit, so
# the orchestrator passes the full document through in a single call.
supports_page_batching: bool = False
def __init__(
self,
base_url: str,

View file

@ -27,31 +27,11 @@ class Settings:
embedding_url: str = "" # empty = disabled (e.g. http://localhost:8001)
neo4j_uri: str = "" # empty = disabled (e.g. bolt://neo4j:7687)
neo4j_user: str = "neo4j"
# DEV DEFAULT — the dev compose stack uses "changeme" so `docker compose
# up` works out of the box. The backend logs a loud warning at boot if
# Neo4j is wired (NEO4J_URI set) AND the password is still the default,
# so prod operators notice if they inherited it by accident. Real
# deployments must override NEO4J_PASSWORD.
neo4j_password: str = "changeme"
# Live reasoning via docling-agent — off by default (heavy deps, needs an
# Ollama host reachable from the backend). Toggle REASONING_ENABLED=true +
# point OLLAMA_HOST at a running instance (default http://localhost:11434).
reasoning_enabled: bool = False
# LLM backend the reasoning runner talks to. Today only "ollama" is
# realizable (docling-agent is hardwired to Ollama via mellea); kept as a
# config knob to make the LLMProvider abstraction visible and prepare the
# ground for additional backends.
llm_provider_type: str = "ollama"
ollama_host: str = "http://localhost:11434"
reasoning_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"
db_path: str = "./data/docling_studio.db"
max_paste_image_size_mb: int = 10 # clipboard-paste image limit in MB (0 = unlimited)
paste_allowed_image_types: list[str] = field(
default_factory=lambda: ["image/png", "image/jpeg", "image/webp"]
)
cors_origins: list[str] = field(
default_factory=lambda: ["http://localhost:3000", "http://localhost:5173"]
)
@ -74,12 +54,6 @@ class Settings:
errors.append(f"max_file_size must be >= 0 (got {self.max_file_size})")
if self.max_file_size_mb < 0:
errors.append(f"max_file_size_mb must be >= 0 (got {self.max_file_size_mb})")
if self.max_paste_image_size_mb < 0:
errors.append(
f"max_paste_image_size_mb must be >= 0 (got {self.max_paste_image_size_mb})"
)
if not self.paste_allowed_image_types:
errors.append("paste_allowed_image_types must not be empty")
if self.rate_limit_rpm < 0:
errors.append(f"rate_limit_rpm must be >= 0 (got {self.rate_limit_rpm})")
if self.batch_page_size < 0:
@ -113,9 +87,6 @@ class Settings:
def from_env(cls) -> Settings:
"""Build a Settings instance from environment variables."""
cors_raw = os.environ.get("CORS_ORIGINS", "http://localhost:3000,http://localhost:5173")
paste_types_raw = os.environ.get(
"PASTE_ALLOWED_IMAGE_TYPES", "image/png,image/jpeg,image/webp"
)
return cls(
app_version=os.environ.get("APP_VERSION", "dev"),
conversion_engine=os.environ.get("CONVERSION_ENGINE", "local"),
@ -131,27 +102,16 @@ 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")),
# 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")),
batch_page_size=int(os.environ.get("BATCH_PAGE_SIZE", "10")),
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"),
reasoning_enabled=os.environ.get("REASONING_ENABLED", "false").lower()
in ("1", "true", "yes", "on"),
llm_provider_type=os.environ.get("LLM_PROVIDER_TYPE", "ollama"),
ollama_host=os.environ.get("OLLAMA_HOST", "http://localhost:11434"),
reasoning_model_id=os.environ.get("REASONING_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"),
db_path=os.environ.get("DB_PATH", "./data/docling_studio.db"),
max_paste_image_size_mb=int(os.environ.get("MAX_PASTE_IMAGE_SIZE_MB", "10")),
paste_allowed_image_types=[t.strip() for t in paste_types_raw.split(",") if t.strip()],
cors_origins=[o.strip() for o in cors_raw.split(",")],
)

View file

@ -101,15 +101,6 @@ async def _init_neo4j():
logger.info("Neo4j disabled (NEO4J_URI not set)")
return None
if settings.neo4j_password == "changeme":
# The dev compose stack ships with "changeme" so `docker compose up`
# works immediately. Anyone running the backend against a non-dev
# Neo4j with this password almost certainly forgot to override it.
logger.warning(
"Neo4j is configured with the dev default password 'changeme'. "
"Override NEO4J_PASSWORD before deploying outside localhost."
)
from infra.neo4j import bootstrap_schema, get_driver
try:
@ -176,12 +167,6 @@ def _build_document_service(
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
await init_db()
document_repo, analysis_repo = _build_repos()
# Exposed on app.state so routers that need direct repo access (e.g. the
# reasoning-graph endpoint, which reads `document_json` from SQLite to
# build the graph without touching Neo4j) can reach them without going
# through a service.
app.state.analysis_repo = analysis_repo
app.state.document_repo = document_repo
app.state.neo4j = await _init_neo4j()
app.state.analysis_service = _build_analysis_service(
document_repo, analysis_repo, neo4j_driver=app.state.neo4j
@ -230,49 +215,6 @@ 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
# `REASONING_ENABLED` is off or the deps aren't installed.
from api.reasoning import router as reasoning_router # noqa: E402
from infra.docling_agent_reasoning import DoclingAgentReasoningRunner # noqa: E402
from infra.docling_agent_reasoning import deps_present as _reasoning_deps_present # noqa: E402
from infra.llm.ollama_provider import OllamaProvider # noqa: E402
app.include_router(reasoning_router)
def _build_reasoning_runner() -> DoclingAgentReasoningRunner | None:
"""Wire the reasoning runner if `REASONING_ENABLED=true` and deps are
importable. Today only `LLM_PROVIDER_TYPE=ollama` is supported (cf.
`LLMProvider` docstring); other values fall through to a logged warning
+ None so the rest of the app boots cleanly.
"""
if not settings.reasoning_enabled:
return None
if not _reasoning_deps_present():
logger.warning(
"REASONING_ENABLED=true but docling-agent / mellea not importable — "
"reasoning runner disabled"
)
return None
if settings.llm_provider_type != "ollama":
logger.warning(
"Unsupported LLM_PROVIDER_TYPE=%s — reasoning runner disabled (only "
"'ollama' is realizable today, see "
"https://github.com/docling-project/docling-agent/issues/26)",
settings.llm_provider_type,
)
return None
provider = OllamaProvider(
host=settings.ollama_host,
default_model_id=settings.reasoning_model_id,
)
return DoclingAgentReasoningRunner(provider=provider)
app.state.reasoning_runner = _build_reasoning_runner()
@app.get("/api/health", response_model=HealthResponse)
async def health() -> HealthResponse:
@ -286,7 +228,6 @@ async def health() -> HealthResponse:
logger.warning("Health check: database unreachable", exc_info=True)
status = "ok" if db_status == "ok" else "degraded"
runner = getattr(app.state, "reasoning_runner", None)
return HealthResponse(
status=status,
version=settings.app_version,
@ -295,13 +236,5 @@ async def health() -> HealthResponse:
database=db_status,
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_paste_image_size_mb=(
settings.max_paste_image_size_mb if settings.max_paste_image_size_mb > 0 else None
),
paste_allowed_image_types=settings.paste_allowed_image_types,
ingestion_available=getattr(app.state, "ingestion_service", None) is not None,
# True when the runner is wired and reports itself available. The
# actual Ollama reachability is checked lazily at call-time to avoid
# blocking health checks on the LLM host.
reasoning_available=runner is not None and runner.is_available,
)

View file

@ -73,23 +73,6 @@ class SqliteAnalysisRepository:
row = await cursor.fetchone()
return _row_to_job(row) if row else None
async def find_latest_completed_by_document(self, document_id: str) -> AnalysisJob | None:
"""Latest COMPLETED analysis with a non-null `document_json` for a doc.
Used by the reasoning-trace tunnel to prime Neo4j from an existing
analysis when the graph doesn't yet exist (e.g. analysis ran before
Neo4j was wired in).
"""
async with get_connection() as db:
cursor = await db.execute(
f"{_SELECT_WITH_DOC} WHERE aj.document_id = ? "
"AND aj.status = 'COMPLETED' AND aj.document_json IS NOT NULL "
"ORDER BY aj.completed_at DESC LIMIT 1",
(document_id,),
)
row = await cursor.fetchone()
return _row_to_job(row) if row else None
async def update_status(self, job: AnalysisJob) -> None:
"""Persist all mutable fields of an analysis job (status, results, timestamps)."""
async with get_connection() as db:

View file

@ -9,9 +9,3 @@ 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 `REASONING_ENABLED`; pulls ~60MB
# of deps. See https://github.com/docling-project/docling-agent/issues/26 for
# the public-API replacement of `_rag_loop`.
docling-agent==0.1.0
mellea==0.4.2

View file

@ -337,7 +337,9 @@ class AnalysisService:
"""
total_pages = _count_pdf_pages(file_path)
batch_size = self._config.batch_page_size
if batch_size > 0 and total_pages > batch_size and self._converter.supports_page_batching:
is_remote = self._is_remote_converter()
if batch_size > 0 and total_pages > batch_size and not is_remote:
return await self._run_batched_conversion(
job_id, file_path, options, total_pages, batch_size
)
@ -346,6 +348,15 @@ class AnalysisService:
timeout=self._conversion_timeout,
)
def _is_remote_converter(self) -> bool:
"""Check if the converter is a remote (Serve) adapter."""
try:
from infra.serve_converter import ServeConverter
return isinstance(self._converter, ServeConverter)
except ImportError:
return False
async def _finalize_analysis(
self,
job_id: str,

View file

@ -192,9 +192,10 @@ class IngestionService:
)
async def ping(self) -> bool:
"""Check if the underlying vector store is reachable."""
"""Check if the OpenSearch cluster is reachable."""
try:
return await self._vector_store.ping()
info = await self._vector_store._client.info()
return bool(info)
except Exception:
logger.debug("Vector store ping failed", exc_info=True)
logger.debug("OpenSearch ping failed", exc_info=True)
return False

View file

@ -102,10 +102,8 @@ async def test_fetch_graph_returns_full_payload(neo4j_driver):
assert groups == {"document", "element", "page", "chunk"}
edge_types = {e["type"] for e in payload.edges}
# Every edge kind this fixture can produce should be present.
# PARENT_OF is intentionally excluded: all FIXTURE items have
# `parent = #/body`, so they're roots (→ HAS_ROOT) with no nested hierarchy.
assert {"HAS_ROOT", "NEXT", "ON_PAGE", "HAS_CHUNK", "DERIVED_FROM"} <= edge_types
# Every edge kind written by TreeWriter and ChunkWriter should be present.
assert {"HAS_ROOT", "PARENT_OF", "NEXT", "ON_PAGE", "HAS_CHUNK", "DERIVED_FROM"} <= edge_types
async def test_fetch_graph_missing_doc_returns_none(neo4j_driver):

View file

@ -86,8 +86,6 @@ async def test_write_creates_expected_structure(neo4j_driver):
assert result.elements_written == 4
assert result.pages_written == 2
# Every item in FIXTURE has exactly one prov entry → 4 Provenance nodes.
assert result.provenances_written == 4
async with neo4j_driver.driver.session(database=neo4j_driver.database) as s:
assert (
@ -133,28 +131,16 @@ async def test_write_creates_expected_structure(neo4j_driver):
)
== 3
)
# Post-v0.6: ON_PAGE attaches Provenance to Page, not Element directly.
# Traverse through the Provenance node.
# ON_PAGE: one per element with prov.
assert (
await _count(
s,
"MATCH (:Element {doc_id: $id})-[:HAS_PROV]->"
"(:Provenance)-[:ON_PAGE]->(:Page {doc_id: $id}) "
"MATCH (:Element {doc_id: $id})-[:ON_PAGE]->(:Page {doc_id: $id}) "
"RETURN count(*) AS n",
id="doc-fixture",
)
== 4
)
# Each element has exactly one Provenance here (single-page fixture).
assert (
await _count(
s,
"MATCH (e:Element {doc_id: $id})-[:HAS_PROV]->(pv:Provenance) "
"RETURN count(pv) AS n",
id="doc-fixture",
)
== 4
)
async def test_rewrite_is_idempotent_replace(neo4j_driver):
@ -208,262 +194,3 @@ async def test_reader_returns_verbatim_json(neo4j_driver):
async def test_reader_missing_doc_returns_none(neo4j_driver):
await bootstrap_schema(neo4j_driver)
assert await read_document_json(neo4j_driver, "no-such-doc") is None
# Issue #197: Docling emits one InlineGroup per inline-styled paragraph plus N
# child `text` items (one per style run). Naive 1:1 mirroring blew up section
# graphs into per-style-run nodes. The writer now collapses an InlineGroup into
# a single :Paragraph node carrying the concatenated text of its children, and
# skips those children entirely.
INLINE_FIXTURE = {
"name": "inline.html",
"pages": {
"1": {"page_no": 1, "size": {"width": 595, "height": 842}},
},
"body": {
"self_ref": "#/body",
"children": [
{"$ref": "#/texts/0"},
{"$ref": "#/groups/0"},
],
},
"texts": [
{
"self_ref": "#/texts/0",
"parent": {"$ref": "#/body"},
"label": "section_header",
"text": "Heading",
"level": 1,
"prov": [{"page_no": 1, "bbox": {"l": 10, "t": 10, "r": 100, "b": 30}}],
},
{
"self_ref": "#/texts/1",
"parent": {"$ref": "#/groups/0"},
"label": "text",
"text": "Hello",
"prov": [{"page_no": 1, "bbox": {"l": 10, "t": 40, "r": 50, "b": 60}}],
},
{
"self_ref": "#/texts/2",
"parent": {"$ref": "#/groups/0"},
"label": "text",
"text": "world",
"prov": [{"page_no": 1, "bbox": {"l": 55, "t": 40, "r": 100, "b": 60}}],
},
{
"self_ref": "#/texts/3",
"parent": {"$ref": "#/groups/0"},
"label": "text",
"text": "!",
"prov": [{"page_no": 1, "bbox": {"l": 105, "t": 40, "r": 110, "b": 60}}],
},
],
"tables": [],
"pictures": [],
"groups": [
{
"self_ref": "#/groups/0",
"parent": {"$ref": "#/body"},
"label": "inline",
"children": [
{"$ref": "#/texts/1"},
{"$ref": "#/texts/2"},
{"$ref": "#/texts/3"},
],
},
],
}
async def test_inline_group_collapses_into_single_paragraph(neo4j_driver):
await bootstrap_schema(neo4j_driver)
doc_json = json.dumps(INLINE_FIXTURE)
result = await write_document(
neo4j_driver,
doc_id="doc-inline",
filename="inline.html",
document_json=doc_json,
)
# Section header + collapsed inline group only — NOT 5 (+3 style runs).
assert result.elements_written == 2
# The inline group inherits its 3 children's provs (1 each); the section
# header has its own prov → 4 Provenance nodes total.
assert result.provenances_written == 4
async with neo4j_driver.driver.session(database=neo4j_driver.database) as s:
r = await s.run(
"MATCH (e:Element:Paragraph {doc_id: $id, self_ref: '#/groups/0'}) "
"RETURN e.text AS text",
id="doc-inline",
)
rec = await r.single()
assert rec is not None, "InlineGroup should write a :Paragraph node"
assert rec["text"] == "Hello world !"
for child_ref in ("#/texts/1", "#/texts/2", "#/texts/3"):
assert (
await _count(
s,
"MATCH (e:Element {doc_id: $id, self_ref: $ref}) RETURN count(e) AS n",
id="doc-inline",
ref=child_ref,
)
== 0
), f"Style-run {child_ref} should be skipped, not written as a node"
# Inline group inherits provs from all children (order preserved).
assert (
await _count(
s,
"MATCH (e:Element:Paragraph {doc_id: $id, self_ref: '#/groups/0'})"
"-[:HAS_PROV]->(pv:Provenance) RETURN count(pv) AS n",
id="doc-inline",
)
== 3
)
# Reading order: section_header → inline-as-paragraph (1 NEXT edge).
assert (
await _count(
s,
"MATCH (a:Element {doc_id: $id})-[:NEXT]->(b:Element) RETURN count(*) AS n",
id="doc-inline",
)
== 1
)
assert (
await _count(
s,
"MATCH (:Document {id: $id})-[:HAS_ROOT]->(:Element) RETURN count(*) AS n",
id="doc-inline",
)
== 2
)
# ON_PAGE through Provenance → Page (matches the new schema).
assert (
await _count(
s,
"MATCH (:Element {doc_id: $id})-[:HAS_PROV]->"
"(:Provenance)-[:ON_PAGE]->(:Page) RETURN count(*) AS n",
id="doc-inline",
)
== 4
)
# Same issue (#197): a Picture's `children` are internal text labels (flowchart
# boxes, chart axis labels, diagram callouts) extracted by Docling's layout
# model. Mirroring them 1:1 drowns the figure in dozens of tiny nodes.
PICTURE_FIXTURE = {
"name": "figure.pdf",
"pages": {
"1": {"page_no": 1, "size": {"width": 595, "height": 842}},
},
"body": {
"self_ref": "#/body",
"children": [
{"$ref": "#/texts/0"},
{"$ref": "#/pictures/0"},
],
},
"texts": [
{
"self_ref": "#/texts/0",
"parent": {"$ref": "#/body"},
"label": "caption",
"text": "Figure 1: Pipeline overview.",
"prov": [{"page_no": 1, "bbox": {"l": 0, "t": 700, "r": 500, "b": 720}}],
},
# Internal labels — children of #/pictures/0.
{
"self_ref": "#/texts/1",
"parent": {"$ref": "#/pictures/0"},
"label": "text",
"text": "Parse",
"prov": [{"page_no": 1, "bbox": {"l": 100, "t": 200, "r": 130, "b": 220}}],
},
{
"self_ref": "#/texts/2",
"parent": {"$ref": "#/pictures/0"},
"label": "text",
"text": "Build",
"prov": [{"page_no": 1, "bbox": {"l": 140, "t": 200, "r": 170, "b": 220}}],
},
{
"self_ref": "#/texts/3",
"parent": {"$ref": "#/pictures/0"},
"label": "text",
"text": "Enrich",
"prov": [{"page_no": 1, "bbox": {"l": 180, "t": 200, "r": 220, "b": 220}}],
},
],
"tables": [],
"pictures": [
{
"self_ref": "#/pictures/0",
"parent": {"$ref": "#/body"},
"label": "picture",
"children": [
{"$ref": "#/texts/1"},
{"$ref": "#/texts/2"},
{"$ref": "#/texts/3"},
],
"captions": [{"$ref": "#/texts/0"}],
"prov": [{"page_no": 1, "bbox": {"l": 90, "t": 100, "r": 510, "b": 600}}],
}
],
"groups": [],
}
async def test_picture_internal_labels_are_skipped(neo4j_driver):
await bootstrap_schema(neo4j_driver)
doc_json = json.dumps(PICTURE_FIXTURE)
result = await write_document(
neo4j_driver,
doc_id="doc-pic",
filename="figure.pdf",
document_json=doc_json,
)
# Caption + picture only — the 3 internal labels are skipped.
assert result.elements_written == 2
async with neo4j_driver.driver.session(database=neo4j_driver.database) as s:
for child_ref in ("#/texts/1", "#/texts/2", "#/texts/3"):
assert (
await _count(
s,
"MATCH (e:Element {doc_id: $id, self_ref: $ref}) RETURN count(e) AS n",
id="doc-pic",
ref=child_ref,
)
== 0
), f"Picture child {child_ref} should be skipped"
# Picture stays a :Figure node with its own prov.
assert (
await _count(
s,
"MATCH (e:Element:Figure {doc_id: $id, self_ref: '#/pictures/0'}) "
"RETURN count(e) AS n",
id="doc-pic",
)
== 1
)
# No PARENT_OF from the picture to its dropped children.
assert (
await _count(
s,
"MATCH (:Element {doc_id: $id, self_ref: '#/pictures/0'})-[:PARENT_OF]->"
"(:Element) RETURN count(*) AS n",
id="doc-pic",
)
== 0
)

View file

@ -196,9 +196,7 @@ class TestAnalysisEndpoints:
assert resp.status_code == 200
data = resp.json()
assert data["status"] == "RUNNING"
# ISO-8601 datetime string from `mark_running()`
assert isinstance(data["startedAt"], str)
assert data["startedAt"] # not empty
assert data["startedAt"] is not None
def test_get_analysis_not_found(self, client, mock_analysis_service):
mock_analysis_service.find_by_id = AsyncMock(return_value=None)

View file

@ -501,31 +501,25 @@ class TestRemoteChunkingPath:
markdown="# Title\nParagraph text here.",
html="<h1>Title</h1><p>Paragraph text here.</p>",
pages_json="[]",
document_json=json.dumps(
{
"schema_name": "DoclingDocument",
"version": "1.0.0",
"name": "test",
"origin": {
"mimetype": "application/pdf",
"filename": "test.pdf",
"binary_hash": 0,
},
"furniture": {
"self_ref": "#/furniture",
"children": [],
"content_layer": "furniture",
},
"body": {"self_ref": "#/body", "children": [], "content_layer": "body"},
"groups": [],
"texts": [],
"pictures": [],
"tables": [],
"key_value_items": [],
"form_items": [],
"pages": {},
}
),
document_json=json.dumps({
"schema_name": "DoclingDocument",
"version": "1.0.0",
"name": "test",
"origin": {
"mimetype": "application/pdf",
"filename": "test.pdf",
"binary_hash": 0,
},
"furniture": {"self_ref": "#/furniture", "children": [], "content_layer": "furniture"},
"body": {"self_ref": "#/body", "children": [], "content_layer": "body"},
"groups": [],
"texts": [],
"pictures": [],
"tables": [],
"key_value_items": [],
"form_items": [],
"pages": {},
}),
)
analysis_repo.find_by_id = AsyncMock(return_value=job)
analysis_repo.update_chunks = AsyncMock(return_value=True)

View file

@ -1,265 +0,0 @@
"""Tests for `infra.docling_agent_reasoning.DoclingAgentReasoningRunner`.
docling-agent + mellea + docling-core are NOT installed in the CI test env
(heavy deps). We stub the modules via `sys.modules` injection so the tests
cover the real adapter code path without bringing in Ollama or any LLM
client.
Concurrency check (R3): the previous implementation mutated
`os.environ["OLLAMA_HOST"]` on every request, which raced when two requests
arrived with different hosts. The new design commits the host once at
construction time these tests assert that property holds.
"""
from __future__ import annotations
import asyncio
import os
import sys
import types
from unittest.mock import MagicMock
import pytest
from domain.ports import ReasoningParseError, ReasoningRunner
from infra.llm.ollama_provider import OllamaProvider
@pytest.fixture
def stub_docling_agent(monkeypatch: pytest.MonkeyPatch):
"""Inject fake `docling_agent.agents` + `docling_core.types.doc.document`
+ `mellea.backends.model_ids` modules so the adapter's lazy imports
resolve to our stubs.
Returns the stub `DoclingRAGAgent` class + agent instance + result so
tests can configure return values / side effects.
"""
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
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
def _make_runner_with_stubbed_deps(
*,
host: str = "http://ollama:11434",
default_model_id: str = "gpt-oss:20b",
):
"""Build the runner bypassing the deps-check (we stubbed deps via
sys.modules but `import` inside `deps_present()` runs at import time
see how the adapter handles it)."""
from infra.docling_agent_reasoning import DoclingAgentReasoningRunner
# Force the deps_present check to True regardless of the test env.
runner = DoclingAgentReasoningRunner.__new__(DoclingAgentReasoningRunner)
runner._provider = OllamaProvider(host=host, default_model_id=default_model_id)
runner._deps_ok = True
os.environ["OLLAMA_HOST"] = host
return runner
class TestProtocolConformance:
def test_runner_satisfies_reasoning_runner_protocol(self) -> None:
"""R13 — adapter is structurally a `ReasoningRunner`."""
runner = _make_runner_with_stubbed_deps()
assert isinstance(runner, ReasoningRunner)
class TestProviderRejection:
def test_rejects_non_ollama_provider(self) -> None:
from infra.docling_agent_reasoning import DoclingAgentReasoningRunner
class _FakeProvider:
type = "openai"
host = "x"
default_model_id = "y"
def health_check(self) -> bool:
return True
with pytest.raises(NotImplementedError, match="Ollama"):
DoclingAgentReasoningRunner(provider=_FakeProvider()) # type: ignore[arg-type]
class TestRunHappyPath:
@pytest.mark.asyncio
async def test_returns_domain_reasoning_result(self, stub_docling_agent) -> None:
runner = _make_runner_with_stubbed_deps()
result = await runner.run(
document_json='{"stub": true}',
query="What is this?",
)
assert result.answer == "stub answer"
assert result.converged is True
assert len(result.iterations) == 1
it = result.iterations[0]
assert it.iteration == 1
assert it.section_ref == "#/texts/0"
assert it.can_answer is True
@pytest.mark.asyncio
async def test_uses_default_model_id_when_not_overridden(self, stub_docling_agent) -> None:
agent_class, _, _ = stub_docling_agent
runner = _make_runner_with_stubbed_deps(default_model_id="custom-model:7b")
await runner.run(document_json="{}", query="Q")
agent_class.assert_called_once()
passed = agent_class.call_args.kwargs["model_id"]
assert passed.ollama_name == "custom-model:7b"
@pytest.mark.asyncio
async def test_per_call_model_id_override_wins(self, stub_docling_agent) -> None:
agent_class, _, _ = stub_docling_agent
runner = _make_runner_with_stubbed_deps(default_model_id="default:7b")
await runner.run(document_json="{}", query="Q", model_id="override:13b")
passed = agent_class.call_args.kwargs["model_id"]
assert passed.ollama_name == "override:13b"
@pytest.mark.asyncio
async def test_calls_rag_loop_with_query_and_doc(self, stub_docling_agent) -> None:
_, agent_instance, _ = stub_docling_agent
runner = _make_runner_with_stubbed_deps()
await runner.run(document_json="{}", query="Hello?")
agent_instance._rag_loop.assert_called_once()
kwargs = agent_instance._rag_loop.call_args.kwargs
assert kwargs["query"] == "Hello?"
assert kwargs["doc"] == "fake-doc-instance"
class TestRunUpstreamFailure:
@pytest.mark.asyncio
async def test_indexerror_translates_to_parse_error(self, stub_docling_agent) -> None:
"""docling-agent's known IndexError → domain `ReasoningParseError`."""
_, agent_instance, _ = stub_docling_agent
agent_instance._rag_loop.side_effect = IndexError("list index out of range")
runner = _make_runner_with_stubbed_deps(default_model_id="granite4:micro-h")
with pytest.raises(ReasoningParseError) as exc_info:
await runner.run(document_json="{}", query="Q")
assert exc_info.value.model_id == "granite4:micro-h"
@pytest.mark.asyncio
async def test_other_exceptions_propagate(self, stub_docling_agent) -> None:
_, agent_instance, _ = stub_docling_agent
agent_instance._rag_loop.side_effect = RuntimeError("Ollama unreachable")
runner = _make_runner_with_stubbed_deps()
with pytest.raises(RuntimeError, match="Ollama unreachable"):
await runner.run(document_json="{}", query="Q")
class TestHostIsolation:
"""R3 — host is committed at construction; concurrent calls don't race
on `os.environ["OLLAMA_HOST"]` because it isn't mutated per-request.
"""
def test_host_committed_at_construction(self) -> None:
os.environ.pop("OLLAMA_HOST", None)
runner = _make_runner_with_stubbed_deps(host="http://specific-host:11434")
assert os.environ["OLLAMA_HOST"] == "http://specific-host:11434"
# Explicit ref to silence unused-var lint
assert runner._provider.host == "http://specific-host:11434"
@pytest.mark.asyncio
async def test_concurrent_runs_do_not_mutate_env(self, stub_docling_agent) -> None:
"""Two concurrent `run()` calls on the SAME runner — env stays put.
With the previous design, each request mutated OLLAMA_HOST per-call.
Now nothing in `run()` should touch os.environ assert that.
"""
runner = _make_runner_with_stubbed_deps(host="http://stable:11434")
os.environ["OLLAMA_HOST"] = "http://stable:11434"
observed: list[str] = []
# Use a fresh side_effect that records env at call time.
_, agent_instance, _ = stub_docling_agent
original = agent_instance._rag_loop
def record_env(*args, **kwargs):
observed.append(os.environ.get("OLLAMA_HOST", ""))
return original.return_value
agent_instance._rag_loop = record_env
await asyncio.gather(
runner.run(document_json="{}", query="Q1"),
runner.run(document_json="{}", query="Q2"),
runner.run(document_json="{}", query="Q3"),
)
assert observed == [
"http://stable:11434",
"http://stable:11434",
"http://stable:11434",
]
class TestDepsPresent:
def test_deps_present_returns_false_when_modules_missing(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
"""When docling-agent/mellea aren't importable, the wiring code
should know not to instantiate the runner."""
monkeypatch.setitem(sys.modules, "docling_agent", None)
monkeypatch.setitem(sys.modules, "docling_agent.agents", None)
monkeypatch.setitem(sys.modules, "mellea", None)
from infra.docling_agent_reasoning import deps_present
assert deps_present() is False

View file

@ -1,398 +0,0 @@
"""Tests for `infra.docling_graph.build_graph_payload`.
The fixture mirrors the DoclingDocument shape used in
`tests/neo4j/test_tree_writer.py` so any structural drift between the two
consumers (TreeWriter -> Neo4j, builder -> SQLite reasoning-graph) surfaces
immediately.
"""
from __future__ import annotations
import json
from infra.docling_graph import build_graph_payload
FIXTURE = {
"name": "fixture.pdf",
"pages": {
"1": {"page_no": 1, "size": {"width": 595, "height": 842}},
"2": {"page_no": 2, "size": {"width": 595, "height": 842}},
},
"body": {
"self_ref": "#/body",
"children": [
{"$ref": "#/texts/0"},
{"$ref": "#/texts/1"},
{"$ref": "#/texts/2"},
{"$ref": "#/tables/0"},
],
},
"texts": [
{
"self_ref": "#/texts/0",
"parent": {"$ref": "#/body"},
"label": "section_header",
"text": "Introduction",
"level": 1,
"prov": [{"page_no": 1, "bbox": {"l": 10, "t": 10, "r": 100, "b": 30}}],
},
{
"self_ref": "#/texts/1",
"parent": {"$ref": "#/body"},
"label": "paragraph",
"text": "First paragraph on page 1.",
"prov": [{"page_no": 1, "bbox": {"l": 10, "t": 40, "r": 500, "b": 80}}],
},
{
"self_ref": "#/texts/2",
"parent": {"$ref": "#/body"},
"label": "paragraph",
"text": "Continued on page 2.",
"prov": [{"page_no": 2, "bbox": {"l": 10, "t": 40, "r": 500, "b": 80}}],
},
],
"tables": [
{
"self_ref": "#/tables/0",
"parent": {"$ref": "#/body"},
"label": "table",
"text": "",
"data": {"num_rows": 2, "num_cols": 2},
"prov": [{"page_no": 2, "bbox": {"l": 10, "t": 90, "r": 500, "b": 200}}],
}
],
"pictures": [],
"groups": [],
}
def _ids(items, group=None):
return [i["id"] for i in items if group is None or i.get("group") == group]
def _types(edges):
return [e["type"] for e in edges]
def test_builds_document_page_and_element_nodes():
payload = build_graph_payload(json.dumps(FIXTURE), doc_id="doc-fixture", title="fixture.pdf")
assert payload.doc_id == "doc-fixture"
assert payload.page_count == 2
assert payload.truncated is False
assert _ids(payload.nodes, group="document") == ["doc::doc-fixture"]
assert set(_ids(payload.nodes, group="page")) == {"page::1", "page::2"}
assert set(_ids(payload.nodes, group="element")) == {
"elem::#/texts/0",
"elem::#/texts/1",
"elem::#/texts/2",
"elem::#/tables/0",
}
def test_element_keeps_docling_label_and_specific_label():
payload = build_graph_payload(json.dumps(FIXTURE), doc_id="doc-fixture")
by_id = {n["id"]: n for n in payload.nodes}
section = by_id["elem::#/texts/0"]
para = by_id["elem::#/texts/1"]
table = by_id["elem::#/tables/0"]
# Specific label mirrors TreeWriter's `_LABEL_MAP`.
assert section["label"] == "SectionHeader"
assert para["label"] == "Paragraph"
assert table["label"] == "Table"
# Docling's original label is preserved (lowercased) for filtering.
assert section["docling_label"] == "section_header"
assert para["docling_label"] == "paragraph"
def test_provs_are_carried_on_element_nodes():
payload = build_graph_payload(json.dumps(FIXTURE), doc_id="doc-fixture")
by_id = {n["id"]: n for n in payload.nodes}
para = by_id["elem::#/texts/1"]
assert para["prov_page"] == 1
assert len(para["provs"]) == 1
assert para["provs"][0]["bbox_l"] == 10.0
assert para["provs"][0]["page_no"] == 1
def test_has_root_parent_next_and_on_page_edges():
payload = build_graph_payload(json.dumps(FIXTURE), doc_id="doc-fixture")
types = _types(payload.edges)
# 4 top-level children => 4 HAS_ROOT.
assert types.count("HAS_ROOT") == 4
# 4 elements in reading order => 3 NEXT edges.
assert types.count("NEXT") == 3
# 4 elements each on 1 page => 4 ON_PAGE edges.
assert types.count("ON_PAGE") == 4
# No PARENT_OF in this flat fixture (all parents are #/body).
assert types.count("PARENT_OF") == 0
def test_on_page_edges_point_to_correct_pages():
payload = build_graph_payload(json.dumps(FIXTURE), doc_id="doc-fixture")
on_page = [(e["source"], e["target"]) for e in payload.edges if e["type"] == "ON_PAGE"]
assert ("elem::#/texts/0", "page::1") in on_page
assert ("elem::#/texts/2", "page::2") in on_page
assert ("elem::#/tables/0", "page::2") in on_page
def test_on_page_dedups_when_element_has_multiple_provs_same_page():
# Paragraph with two provs on the same page — we expect ONE ON_PAGE edge.
fixture = {
**FIXTURE,
"texts": [
{
"self_ref": "#/texts/0",
"parent": {"$ref": "#/body"},
"label": "paragraph",
"text": "Split across two provs same page",
"prov": [
{"page_no": 1, "bbox": {"l": 0, "t": 0, "r": 10, "b": 10}},
{"page_no": 1, "bbox": {"l": 0, "t": 20, "r": 10, "b": 30}},
],
},
],
"tables": [],
"body": {"self_ref": "#/body", "children": [{"$ref": "#/texts/0"}]},
}
payload = build_graph_payload(json.dumps(fixture), doc_id="doc-split")
on_page = [e for e in payload.edges if e["type"] == "ON_PAGE"]
assert len(on_page) == 1
def test_parent_of_edges_when_items_are_nested():
fixture = {
"pages": {"1": {"page_no": 1, "size": {"width": 595, "height": 842}}},
"body": {"self_ref": "#/body", "children": [{"$ref": "#/texts/0"}]},
"texts": [
{
"self_ref": "#/texts/0",
"parent": {"$ref": "#/body"},
"label": "section_header",
"text": "Chapter",
"level": 1,
"children": [{"$ref": "#/texts/1"}],
"prov": [{"page_no": 1, "bbox": {"l": 0, "t": 0, "r": 10, "b": 10}}],
},
{
"self_ref": "#/texts/1",
"parent": {"$ref": "#/texts/0"},
"label": "paragraph",
"text": "Body",
"prov": [{"page_no": 1, "bbox": {"l": 0, "t": 20, "r": 10, "b": 30}}],
},
],
"tables": [],
"pictures": [],
"groups": [],
}
payload = build_graph_payload(json.dumps(fixture), doc_id="doc-nested")
parents = [(e["source"], e["target"]) for e in payload.edges if e["type"] == "PARENT_OF"]
assert parents == [("elem::#/texts/0", "elem::#/texts/1")]
roots = [e for e in payload.edges if e["type"] == "HAS_ROOT"]
assert len(roots) == 1 # only the section is a direct child of body
# NEXT follows DFS order: #/texts/0 -> #/texts/1
nexts = [(e["source"], e["target"]) for e in payload.edges if e["type"] == "NEXT"]
assert nexts == [("elem::#/texts/0", "elem::#/texts/1")]
def test_truncated_when_page_count_exceeds_cap():
fixture = {
**FIXTURE,
"pages": {str(i): {"page_no": i, "size": {"width": 1, "height": 1}} for i in range(1, 12)},
}
payload = build_graph_payload(json.dumps(fixture), doc_id="doc-big", max_pages=10)
assert payload.truncated is True
assert payload.page_count == 11
assert payload.nodes == []
assert payload.edges == []
def test_title_is_surfaced_on_document_node():
payload = build_graph_payload(json.dumps(FIXTURE), doc_id="doc-fixture", title="My Doc.pdf")
doc_node = next(n for n in payload.nodes if n["group"] == "document")
assert doc_node["title"] == "My Doc.pdf"
def test_inline_group_collapses_into_single_paragraph():
"""Issue #197: an InlineGroup + N child `text` items must yield ONE
Paragraph node carrying the joined text and the union of children's provs."""
fixture = {
"pages": {"1": {"page_no": 1, "size": {"width": 595, "height": 842}}},
"body": {
"self_ref": "#/body",
"children": [{"$ref": "#/texts/0"}, {"$ref": "#/groups/0"}],
},
"texts": [
{
"self_ref": "#/texts/0",
"parent": {"$ref": "#/body"},
"label": "section_header",
"text": "Heading",
"level": 1,
"prov": [{"page_no": 1, "bbox": {"l": 10, "t": 10, "r": 100, "b": 30}}],
},
{
"self_ref": "#/texts/1",
"parent": {"$ref": "#/groups/0"},
"label": "text",
"text": "Hello",
"prov": [{"page_no": 1, "bbox": {"l": 10, "t": 40, "r": 50, "b": 60}}],
},
{
"self_ref": "#/texts/2",
"parent": {"$ref": "#/groups/0"},
"label": "text",
"text": "world",
"prov": [{"page_no": 1, "bbox": {"l": 55, "t": 40, "r": 100, "b": 60}}],
},
{
"self_ref": "#/texts/3",
"parent": {"$ref": "#/groups/0"},
"label": "text",
"text": "!",
"prov": [{"page_no": 1, "bbox": {"l": 105, "t": 40, "r": 110, "b": 60}}],
},
],
"tables": [],
"pictures": [],
"groups": [
{
"self_ref": "#/groups/0",
"parent": {"$ref": "#/body"},
"label": "inline",
"children": [
{"$ref": "#/texts/1"},
{"$ref": "#/texts/2"},
{"$ref": "#/texts/3"},
],
},
],
}
payload = build_graph_payload(json.dumps(fixture), doc_id="doc-inline")
# Section header + collapsed inline group only — NOT 5 (+3 style runs).
elements = [n for n in payload.nodes if n.get("group") == "element"]
assert {e["self_ref"] for e in elements} == {"#/texts/0", "#/groups/0"}
inline = next(n for n in elements if n["self_ref"] == "#/groups/0")
assert inline["label"] == "Paragraph"
assert inline["text"] == "Hello world !"
# Provs union: 3 children x 1 prov each = 3 provs on the collapsed node.
assert len(inline["provs"]) == 3
assert all(p.get("page_no") == 1 for p in inline["provs"])
# NEXT follows the post-collapse reading order — section_header → inline only.
nexts = [(e["source"], e["target"]) for e in payload.edges if e["type"] == "NEXT"]
assert nexts == [("elem::#/texts/0", "elem::#/groups/0")]
# ON_PAGE edges still wire the surviving elements to the page (one per
# element, deduped by `seen_pages`).
on_page = [e for e in payload.edges if e["type"] == "ON_PAGE"]
assert sorted(e["source"] for e in on_page) == ["elem::#/groups/0", "elem::#/texts/0"]
def test_picture_internal_labels_are_skipped():
"""Issue #197: a Picture's `children` (flowchart / diagram text labels
extracted by Docling's layout model) must not become standalone graph
nodes they drown the figure in dozens of tiny labels. The picture
itself stays, and a caption (separate `parent` chain on body) is
unaffected."""
fixture = {
"pages": {"1": {"page_no": 1, "size": {"width": 595, "height": 842}}},
"body": {
"self_ref": "#/body",
"children": [{"$ref": "#/texts/0"}, {"$ref": "#/pictures/0"}],
},
"texts": [
# Caption — separate item under body, referenced from the picture's
# `captions` field (not its `children`). Survives the collapse.
{
"self_ref": "#/texts/0",
"parent": {"$ref": "#/body"},
"label": "caption",
"text": "Figure 1: Sketch of Docling's pipelines.",
"prov": [{"page_no": 1, "bbox": {"l": 0, "t": 700, "r": 500, "b": 720}}],
},
# Internal labels of the figure (flowchart boxes). Live in
# `texts[]` with `parent` pointing at the picture.
*[
{
"self_ref": f"#/texts/{i}",
"parent": {"$ref": "#/pictures/0"},
"label": "text",
"text": label,
"prov": [
{
"page_no": 1,
"bbox": {"l": 100 + i * 30, "t": 200, "r": 130 + i * 30, "b": 220},
}
],
}
for i, label in enumerate(
["Parse", "Build", "Enrich", "Assemble", "Document"], start=1
)
],
],
"tables": [],
"pictures": [
{
"self_ref": "#/pictures/0",
"parent": {"$ref": "#/body"},
"label": "picture",
"children": [
{"$ref": "#/texts/1"},
{"$ref": "#/texts/2"},
{"$ref": "#/texts/3"},
{"$ref": "#/texts/4"},
{"$ref": "#/texts/5"},
],
"captions": [{"$ref": "#/texts/0"}],
"prov": [{"page_no": 1, "bbox": {"l": 90, "t": 100, "r": 510, "b": 600}}],
}
],
"groups": [],
}
payload = build_graph_payload(json.dumps(fixture), doc_id="doc-pic")
elements = [n for n in payload.nodes if n.get("group") == "element"]
refs = {e["self_ref"] for e in elements}
# Picture + caption only — the 5 internal labels must be dropped.
assert refs == {"#/pictures/0", "#/texts/0"}
# Picture keeps its own label / text / prov (no inline-style override).
pic = next(e for e in elements if e["self_ref"] == "#/pictures/0")
assert pic["label"] == "Figure"
assert pic["docling_label"] == "picture"
# No PARENT_OF edges from the picture to its (skipped) children.
parent_edges = [e for e in payload.edges if e["type"] == "PARENT_OF"]
assert all(e["source"] != "elem::#/pictures/0" for e in parent_edges)
# NEXT chain only includes surviving elements.
next_targets = [e["target"] for e in payload.edges if e["type"] == "NEXT"]
assert all(t in {"elem::#/texts/0", "elem::#/pictures/0"} for t in next_targets)
def test_element_text_is_capped_at_200_chars():
long = "x" * 500
fixture = {
"pages": {"1": {"page_no": 1, "size": {"width": 1, "height": 1}}},
"body": {"self_ref": "#/body", "children": [{"$ref": "#/texts/0"}]},
"texts": [
{
"self_ref": "#/texts/0",
"parent": {"$ref": "#/body"},
"label": "paragraph",
"text": long,
"prov": [{"page_no": 1}],
}
],
"tables": [],
"pictures": [],
"groups": [],
}
payload = build_graph_payload(json.dumps(fixture), doc_id="doc-long")
para = next(n for n in payload.nodes if n.get("self_ref") == "#/texts/0")
assert len(para["text"]) == 200

View file

@ -1,114 +0,0 @@
"""Tests for `api.graph` — the `/graph` (Neo4j) and `/reasoning-graph`
(SQLite) endpoints. Neo4j itself is not exercised here; `/graph` is covered
by the integration tests under `tests/neo4j/`. This file focuses on the
SQLite-backed reasoning endpoint and the error paths.
"""
from __future__ import annotations
import json
from unittest.mock import AsyncMock
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from api.graph import router
from domain.models import AnalysisJob
FIXTURE = {
"pages": {"1": {"page_no": 1, "size": {"width": 595, "height": 842}}},
"body": {"self_ref": "#/body", "children": [{"$ref": "#/texts/0"}]},
"texts": [
{
"self_ref": "#/texts/0",
"parent": {"$ref": "#/body"},
"label": "section_header",
"text": "Hello",
"level": 1,
"prov": [{"page_no": 1, "bbox": {"l": 0, "t": 0, "r": 10, "b": 10}}],
}
],
"tables": [],
"pictures": [],
"groups": [],
}
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="[]",
document_json=json.dumps(FIXTURE),
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 client(mock_analysis_repo: AsyncMock) -> TestClient:
app = FastAPI()
app.include_router(router)
app.state.analysis_repo = mock_analysis_repo
app.state.neo4j = None # /reasoning-graph must not need Neo4j
return TestClient(app)
class TestReasoningGraph:
def test_returns_payload_built_from_sqlite_json(self, client: TestClient) -> None:
resp = client.get("/api/documents/doc-1/reasoning-graph")
assert resp.status_code == 200
data = resp.json()
assert data["doc_id"] == "doc-1"
assert data["page_count"] == 1
assert data["truncated"] is False
groups = {n["group"] for n in data["nodes"]}
assert groups == {"document", "page", "element"}
edge_types = {e["type"] for e in data["edges"]}
# HAS_ROOT + ON_PAGE expected; NEXT absent (single element so no chain).
assert edge_types == {"HAS_ROOT", "ON_PAGE"}
def test_404_when_no_completed_analysis(
self, client: TestClient, mock_analysis_repo: AsyncMock
) -> None:
mock_analysis_repo.find_latest_completed_by_document.return_value = None
resp = client.get("/api/documents/doc-1/reasoning-graph")
assert resp.status_code == 404
def test_404_when_analysis_has_no_document_json(
self, client: TestClient, mock_analysis_repo: AsyncMock
) -> None:
job = AnalysisJob(document_id="doc-1")
job.mark_running()
job.mark_completed(
markdown="", html="", pages_json="[]", document_json=None, chunks_json="[]"
)
mock_analysis_repo.find_latest_completed_by_document.return_value = job
resp = client.get("/api/documents/doc-1/reasoning-graph")
assert resp.status_code == 404
def test_does_not_need_neo4j(self, client: TestClient) -> None:
# `app.state.neo4j = None` and the endpoint still serves — proves the
# reasoning graph is fully decoupled from the Neo4j provider.
resp = client.get("/api/documents/doc-1/reasoning-graph")
assert resp.status_code == 200
class TestPrimeEndpointRemoved:
def test_graph_prime_endpoint_is_gone(self, client: TestClient) -> None:
# Guardrail — if someone reintroduces /graph/prime we want a failing test.
resp = client.post("/api/documents/doc-1/graph/prime")
assert resp.status_code in (404, 405)

View file

@ -164,14 +164,16 @@ class TestPing:
async def test_ping_success(
self, service: IngestionService, mock_vector_store: AsyncMock
) -> None:
mock_vector_store.ping.return_value = True
mock_vector_store._client = AsyncMock()
mock_vector_store._client.info.return_value = {"cluster_name": "test"}
result = await service.ping()
assert result is True
async def test_ping_failure(
self, service: IngestionService, mock_vector_store: AsyncMock
) -> None:
mock_vector_store.ping.side_effect = ConnectionError("down")
mock_vector_store._client = AsyncMock()
mock_vector_store._client.info.side_effect = ConnectionError("down")
result = await service.ping()
assert result is False

View file

@ -58,7 +58,7 @@ class TestAnalysisJob:
job.mark_running()
assert job.status == AnalysisStatus.RUNNING
assert isinstance(job.started_at, datetime)
assert job.started_at is not None
def test_mark_completed(self):
job = AnalysisJob()
@ -74,8 +74,7 @@ class TestAnalysisJob:
assert job.content_markdown == "# Title"
assert job.content_html == "<h1>Title</h1>"
assert job.pages_json == '[{"page": 1}]'
assert isinstance(job.completed_at, datetime)
assert job.completed_at >= job.started_at
assert job.completed_at is not None
def test_mark_failed(self):
job = AnalysisJob()
@ -85,8 +84,7 @@ class TestAnalysisJob:
assert job.status == AnalysisStatus.FAILED
assert job.error_message == "Something went wrong"
assert isinstance(job.completed_at, datetime)
assert job.completed_at >= job.started_at
assert job.completed_at is not None
def test_status_transitions(self):
"""Test full lifecycle: PENDING -> RUNNING -> COMPLETED."""

View file

@ -1,82 +0,0 @@
"""Tests for `infra.llm.ollama_provider.OllamaProvider`.
Network is stubbed via `httpx` MockTransport so the tests don't depend on a
running Ollama instance.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
import httpx
from domain.ports import LLMProvider
from domain.value_objects import LLMProviderType
from infra.llm.ollama_provider import OllamaProvider
if TYPE_CHECKING:
import pytest
def test_provider_satisfies_llmprovider_protocol() -> None:
"""R13 — `OllamaProvider` is structurally a `LLMProvider`."""
p = OllamaProvider(host="http://localhost:11434", default_model_id="m")
assert isinstance(p, LLMProvider)
def test_provider_exposes_type_host_default_model_id() -> None:
p = OllamaProvider(host="http://ollama:11434", default_model_id="gpt-oss:20b")
assert p.type is LLMProviderType.OLLAMA
assert p.host == "http://ollama:11434"
assert p.default_model_id == "gpt-oss:20b"
def test_host_trailing_slash_is_stripped() -> None:
p = OllamaProvider(host="http://localhost:11434/", default_model_id="m")
assert p.host == "http://localhost:11434"
def test_health_check_returns_true_on_200(monkeypatch: pytest.MonkeyPatch) -> None:
def handler(request: httpx.Request) -> httpx.Response:
assert request.url.path == "/api/tags"
return httpx.Response(200, json={"models": []})
transport = httpx.MockTransport(handler)
real_get = httpx.get
def fake_get(url: str, **kwargs): # type: ignore[no-untyped-def]
with httpx.Client(transport=transport) as client:
return client.get(url, **kwargs)
monkeypatch.setattr(httpx, "get", fake_get)
p = OllamaProvider(host="http://localhost:11434", default_model_id="m")
assert p.health_check() is True
# Restore (defensive, MonkeyPatch handles it but explicit is fine)
monkeypatch.setattr(httpx, "get", real_get)
def test_health_check_returns_false_on_connection_error(
monkeypatch: pytest.MonkeyPatch,
) -> None:
def fake_get(url: str, **kwargs): # type: ignore[no-untyped-def]
raise httpx.ConnectError("connection refused")
monkeypatch.setattr(httpx, "get", fake_get)
p = OllamaProvider(host="http://nowhere:11434", default_model_id="m")
assert p.health_check() is False
def test_health_check_returns_false_on_non_200(monkeypatch: pytest.MonkeyPatch) -> None:
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(500)
transport = httpx.MockTransport(handler)
def fake_get(url: str, **kwargs): # type: ignore[no-untyped-def]
with httpx.Client(transport=transport) as client:
return client.get(url, **kwargs)
monkeypatch.setattr(httpx, "get", fake_get)
p = OllamaProvider(host="http://localhost:11434", default_model_id="m")
assert p.health_check() is False

View file

@ -53,8 +53,7 @@ class TestBuildConverter:
assert opts.generate_page_images is False
assert opts.generate_picture_images is False
assert opts.images_scale == 1.0
# default document timeout is 120s (cf. infra/settings.py)
assert opts.document_timeout == 120.0
assert opts.document_timeout is not None
def test_ocr_disabled(self):
conv = build_converter(ConversionOptions(do_ocr=False))

View file

@ -1,248 +0,0 @@
"""Tests for `api.reasoning` — the HTTP layer over a `ReasoningRunner` port.
The API layer is decoupled from docling-agent / mellea / docling-core. Tests
inject a fake `ReasoningRunner` on `app.state.reasoning_runner` and assert on
HTTP status / payload + on what the runner was called with.
Adapter behaviour (the actual docling-agent integration) is tested separately
in `tests/test_docling_agent_reasoning.py`.
"""
from __future__ import annotations
from unittest.mock import AsyncMock
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from api.reasoning import router
from domain.models import AnalysisJob
from domain.ports import ReasoningParseError
from domain.value_objects import ReasoningIteration, ReasoningResult
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="[]",
document_json='{"stub": true}',
chunks_json="[]",
)
return job
def _sample_result() -> ReasoningResult:
return ReasoningResult(
answer="stub answer",
converged=True,
iterations=[
ReasoningIteration(
iteration=1,
section_ref="#/texts/0",
reason="looks relevant",
section_text_length=42,
can_answer=True,
response="stub answer",
)
],
)
class _FakeRunner:
"""In-memory ReasoningRunner. Records the last call so tests can assert
on the args without touching docling-agent."""
def __init__(
self,
*,
result: ReasoningResult | None = None,
is_available: bool = True,
raises: Exception | None = None,
) -> None:
self._result = result or _sample_result()
self._is_available = is_available
self._raises = raises
self.last_call: dict | None = None
@property
def is_available(self) -> bool:
return self._is_available
async def run(
self,
*,
document_json: str,
query: str,
model_id: str | None = None,
) -> ReasoningResult:
self.last_call = {
"document_json": document_json,
"query": query,
"model_id": model_id,
}
if self._raises is not None:
raise self._raises
return self._result
@pytest.fixture
def mock_analysis_repo() -> AsyncMock:
repo = AsyncMock()
repo.find_latest_completed_by_document.return_value = _job_with_doc_json()
return repo
def _make_client(*, runner: _FakeRunner | None, repo: AsyncMock) -> TestClient:
app = FastAPI()
app.include_router(router)
app.state.analysis_repo = repo
app.state.reasoning_runner = runner
return TestClient(app)
class TestReasoningDisabled:
def test_503_when_runner_not_wired(self, mock_analysis_repo: AsyncMock) -> None:
client = _make_client(runner=None, repo=mock_analysis_repo)
resp = client.post("/api/documents/doc-1/reasoning", json={"query": "Q"})
assert resp.status_code == 503
assert "REASONING_ENABLED" in resp.json()["detail"]
def test_503_when_runner_unavailable(self, mock_analysis_repo: AsyncMock) -> None:
runner = _FakeRunner(is_available=False)
client = _make_client(runner=runner, repo=mock_analysis_repo)
resp = client.post("/api/documents/doc-1/reasoning", json={"query": "Q"})
assert resp.status_code == 503
class TestReasoningValidation:
def test_400_when_query_empty(self, mock_analysis_repo: AsyncMock) -> None:
client = _make_client(runner=_FakeRunner(), repo=mock_analysis_repo)
resp = client.post("/api/documents/doc-1/reasoning", json={"query": " "})
assert resp.status_code == 400
def test_404_when_no_completed_analysis(self, mock_analysis_repo: AsyncMock) -> None:
mock_analysis_repo.find_latest_completed_by_document.return_value = None
client = _make_client(runner=_FakeRunner(), repo=mock_analysis_repo)
resp = client.post("/api/documents/doc-1/reasoning", json={"query": "Q"})
assert resp.status_code == 404
class TestReasoningSuccess:
def test_returns_reasoning_result_shape(self, mock_analysis_repo: AsyncMock) -> None:
runner = _FakeRunner()
client = _make_client(runner=runner, repo=mock_analysis_repo)
resp = client.post("/api/documents/doc-1/reasoning", 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
assert it["section_text_length"] == 42
def test_passes_query_and_doc_json_to_runner(self, mock_analysis_repo: AsyncMock) -> None:
runner = _FakeRunner()
client = _make_client(runner=runner, repo=mock_analysis_repo)
client.post("/api/documents/doc-1/reasoning", json={"query": "Hello?"})
assert runner.last_call is not None
assert runner.last_call["query"] == "Hello?"
assert runner.last_call["document_json"] == '{"stub": true}'
def test_no_model_override_passes_none(self, mock_analysis_repo: AsyncMock) -> None:
runner = _FakeRunner()
client = _make_client(runner=runner, repo=mock_analysis_repo)
client.post("/api/documents/doc-1/reasoning", json={"query": "Q"})
assert runner.last_call is not None
assert runner.last_call["model_id"] is None
def test_per_request_model_id_override_wins(self, mock_analysis_repo: AsyncMock) -> None:
runner = _FakeRunner()
client = _make_client(runner=runner, repo=mock_analysis_repo)
client.post(
"/api/documents/doc-1/reasoning",
json={"query": "Q", "model_id": "override:13b"},
)
assert runner.last_call is not None
assert runner.last_call["model_id"] == "override:13b"
def test_iterations_with_multiple_steps_serialize_correctly(
self, mock_analysis_repo: AsyncMock
) -> None:
"""R6 — trace serializable on >=2 iterations, all fields preserved."""
result = ReasoningResult(
answer="final",
converged=False,
iterations=[
ReasoningIteration(
iteration=1,
section_ref="#/texts/0",
reason="r1",
section_text_length=10,
can_answer=False,
response="not yet",
),
ReasoningIteration(
iteration=2,
section_ref="#/texts/5",
reason="r2",
section_text_length=20,
can_answer=True,
response="final",
),
],
)
runner = _FakeRunner(result=result)
client = _make_client(runner=runner, repo=mock_analysis_repo)
resp = client.post("/api/documents/doc-1/reasoning", json={"query": "Q"})
assert resp.status_code == 200
data = resp.json()
assert data["converged"] is False
assert [it["iteration"] for it in data["iterations"]] == [1, 2]
assert [it["section_ref"] for it in data["iterations"]] == [
"#/texts/0",
"#/texts/5",
]
assert data["iterations"][0]["can_answer"] is False
assert data["iterations"][1]["can_answer"] is True
class TestReasoningUpstreamFailure:
def test_502_when_runner_raises_parse_error(self, mock_analysis_repo: AsyncMock) -> None:
"""The model couldn't produce a parseable JSON answer after retries."""
runner = _FakeRunner(
raises=ReasoningParseError(model_id="granite4:micro-h", reason="no parseable answer")
)
client = _make_client(runner=runner, repo=mock_analysis_repo)
resp = client.post(
"/api/documents/doc-1/reasoning",
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, mock_analysis_repo: AsyncMock) -> None:
runner = _FakeRunner(raises=RuntimeError("Ollama unreachable"))
client = _make_client(runner=runner, repo=mock_analysis_repo)
resp = client.post("/api/documents/doc-1/reasoning", json={"query": "Q"})
assert resp.status_code == 500
assert "Ollama unreachable" in resp.json()["detail"]

View file

@ -1,7 +1,5 @@
"""Tests for persistence repositories using a temporary SQLite database."""
from datetime import datetime
import pytest
from domain.models import AnalysisJob, AnalysisStatus, Document
@ -123,7 +121,7 @@ class TestAnalysisRepo:
found = await analysis_repo.find_by_id("job-1")
assert found.status == AnalysisStatus.RUNNING
assert isinstance(found.started_at, datetime)
assert found.started_at is not None
async def test_update_status_completed(self, document_repo, analysis_repo):
await self._insert_doc(document_repo)
@ -155,49 +153,6 @@ class TestAnalysisRepo:
deleted = await analysis_repo.delete("nonexistent")
assert deleted is False
async def test_find_latest_completed_by_document(self, document_repo, analysis_repo):
"""Reasoning tunnel helper: latest COMPLETED analysis with document_json."""
await self._insert_doc(document_repo)
# Each job must be insert()'d before update_status can touch it.
# Scenarios: pending (excluded — not COMPLETED), old completed without
# document_json (excluded — NULL json), recent completed with
# document_json (the one we want), running (excluded).
pending = AnalysisJob(id="job-pending", document_id="doc-1")
await analysis_repo.insert(pending)
old_completed = AnalysisJob(id="job-old", document_id="doc-1")
await analysis_repo.insert(old_completed)
old_completed.mark_running()
old_completed.mark_completed(markdown="", html="", pages_json="[]")
await analysis_repo.update_status(old_completed)
latest = AnalysisJob(id="job-latest", document_id="doc-1")
await analysis_repo.insert(latest)
latest.mark_running()
latest.mark_completed(
markdown="md",
html="<p/>",
pages_json="[]",
document_json='{"body":{"children":[]},"texts":[]}',
)
await analysis_repo.update_status(latest)
running = AnalysisJob(id="job-running", document_id="doc-1")
await analysis_repo.insert(running)
running.mark_running()
await analysis_repo.update_status(running)
found = await analysis_repo.find_latest_completed_by_document("doc-1")
assert found is not None
assert found.id == "job-latest"
assert found.document_json == '{"body":{"children":[]},"texts":[]}'
async def test_find_latest_completed_by_document_none(self, document_repo, analysis_repo):
await self._insert_doc(document_repo)
found = await analysis_repo.find_latest_completed_by_document("doc-1")
assert found is None
async def test_delete_by_document(self, document_repo, analysis_repo):
await self._insert_doc(document_repo)
for i in range(3):

View file

@ -94,10 +94,7 @@ class TestParseResponse:
assert result.content_html == "<h1>Hello</h1>"
assert result.page_count == 1
assert result.pages[0].width == 612.0
# document_json mirrors the json_content stub
assert result.document_json is not None
assert '"texts"' in result.document_json
assert '"pages"' in result.document_json
def test_response_with_elements(self):
data = {

View file

@ -18,8 +18,6 @@ class TestSettingsDefaults:
assert s.lock_timeout == 300
assert s.max_page_count == 0
assert s.max_file_size_mb == 50
assert s.max_paste_image_size_mb == 10
assert s.paste_allowed_image_types == ["image/png", "image/jpeg", "image/webp"]
assert s.batch_page_size == 0
assert s.opensearch_default_limit == 1000
assert s.upload_dir == "./uploads"
@ -78,18 +76,6 @@ class TestSettingsValidation:
with pytest.raises(ValueError, match="max_file_size_mb must be >= 0"):
Settings(max_file_size_mb=-1)
def test_negative_max_paste_image_size_mb_rejected(self):
import pytest
with pytest.raises(ValueError, match="max_paste_image_size_mb must be >= 0"):
Settings(max_paste_image_size_mb=-1)
def test_empty_paste_allowed_image_types_rejected(self):
import pytest
with pytest.raises(ValueError, match="paste_allowed_image_types must not be empty"):
Settings(paste_allowed_image_types=[])
def test_zero_max_file_size_mb_accepted(self):
s = Settings(max_file_size_mb=0)
assert s.max_file_size_mb == 0

View file

@ -1,34 +0,0 @@
@ui
Feature: UI — Reasoning feature flag
# The Reasoning sidebar entry must be hidden when the backend is not wired
# to expose the live runner. This test runs against the default CI backend
# (REASONING_ENABLED unset → false), so `reasoningAvailable` from
# /api/health is false and the sidebar entry must not render.
#
# When REASONING_ENABLED=true on the backend with docling-agent installed,
# this scenario will fail — flip the assertion or drop the @reasoning-off
# tag in CI accordingly.
@reasoning-off
Scenario: Sidebar does not show Reasoning when the runner isn't wired
* driver uiBaseUrl
* waitFor('[data-e2e=sidebar]')
# The other nav items must still render
* waitFor('[data-e2e=nav-studio]')
* waitFor('[data-e2e=nav-documents]')
# nav-reasoning must NOT be present (v-if="reasoningEnabled" gates it on
# `reasoningAvailable` in the health response)
* match karate.sizeOf(locateAll('[data-e2e=nav-reasoning]')) == 0
@reasoning-off
Scenario: /reasoning route is reachable but flag-driven UI gates the entry point
* driver uiBaseUrl + '/reasoning'
# The route is registered in the SPA router so a deep-link doesn't 404,
# but the sidebar nav-reasoning entry stays hidden — users can't reach
# the page through normal navigation when the runner is disabled.
* waitFor('[data-e2e=sidebar]')
* match karate.sizeOf(locateAll('[data-e2e=nav-reasoning]')) == 0

View file

@ -1 +0,0 @@
output/

View file

@ -1,139 +0,0 @@
# Reasoning Trace — R&D sandbox
Goal: run `docling-agent`'s RAG loop against a document already ingested in
Docling-Studio, capture the `RAGResult` (per-iteration reasoning trace), and
inspect what the agent does.
Fully **isolated** from the Studio backend: no deps added to
`document-parser/`, no services modified. Just a script + uv inline deps.
---
## What it does
1. Reads the pre-parsed `DoclingDocument` directly from Studio's SQLite
(`analysis_jobs.document_json`) — no PDF re-conversion.
2. Instantiates `DoclingRAGAgent` against a local Ollama model.
3. Calls `agent._rag_loop()` directly (the public `.run()` method discards the
`RAGResult`; we need the iterations to see the reasoning trace).
4. Dumps the full `RAGResult` as JSON to `output/`.
---
## Prerequisites
### 1. Ollama running
```sh
# If not already running as a service:
ollama serve # in another terminal
```
### 2. A model pulled
Recommended (Peter Staar's default, ~3B params, good JSON adherence):
```sh
ollama pull granite4:micro-h
```
Alternative already on your machine (2 GB, may struggle with strict JSON
rejection sampling):
```
llama3.2:3b
```
Bigger/more reliable but slower (20B):
```sh
ollama pull gpt-oss:20b
```
### 3. Pick an analysis job id
Any `COMPLETED` row from `analysis_jobs` with a non-null `document_json`:
```sh
sqlite3 document-parser/data/docling_studio.db \
"SELECT aj.id, d.filename, length(aj.document_json)
FROM analysis_jobs aj JOIN documents d ON d.id=aj.document_id
WHERE aj.document_json IS NOT NULL AND aj.status='COMPLETED'
ORDER BY length(aj.document_json) DESC LIMIT 5;"
```
On this machine, the biggest one right now is:
```
722d5631-0089-44a3-a64a-7ce5b99579d3 — CCI - Conférence IA - Offre Commerciale v1.0
```
---
## Run
```sh
uv run experiments/reasoning-trace/inspect_doc.py \
--job-id 722d5631-0089-44a3-a64a-7ce5b99579d3 \
--query "Quels sont les livrables principaux proposés ?" \
--model granite4:micro-h
```
Flags:
- `--job-id` — required, analysis_jobs.id
- `--query` — required, the question
- `--model` — either a mellea catalog constant (`IBM_GRANITE_4_HYBRID_MICRO`)
or a raw Ollama tag (`granite4:micro-h`, `llama3.2:3b`). Default:
`granite4:micro-h`.
- `--max-iters` — default 5 (agent's own default)
- `--quiet` — disable the rich panels during the loop
First run will take ~12 min: `uv` solves the `docling-agent` env (pulls
docling-core, mellea, pydantic, rich, …) into a cached virtualenv. Subsequent
runs are instant.
---
## Output
`experiments/reasoning-trace/output/<job-id-prefix>_<utc>.json`
Schema:
```json
{
"job_id": "…",
"filename": "…",
"query": "…",
"model": { "ollama_name": "…", "hf_model_name": "…" },
"max_iterations": 5,
"result": {
"answer": "…",
"converged": true,
"iterations": [
{ "iteration": 1, "section_ref": "#/texts/3",
"reason": "…", "section_text_length": 412,
"can_answer": false, "response": "…" },
]
}
}
```
This is the artifact the v1 Studio endpoint (`POST /api/rag/inspect`) will
import — so anything that works here should work there.
---
## Things to check on first run
- **Do we actually get a trace?** `iterations` list should have ≥ 1 entries
(empty means "no section headers found" fallback — bad sign for the viz idea).
- **Are `section_ref` values `#/texts/N` paths or `#/groups/N`?** Determines
how the resolver walks the tree.
- **Reasoning quality**: does `reason` actually explain the pick, or is it
LLM filler? That affects whether the trace is worth surfacing visually.
- **Convergence rate**: with `max_iters=5`, does a small model converge at all,
or hit the cap and return a partial answer?
- **Latency**: per-iteration wall-clock on your M-series machine with granite4.
---
## Next step (if the above looks promising)
Resolve each `iteration.section_ref``(page_no, bbox)` using the same
`DoclingDocument` that was loaded here. That's the `reasoning_service.py`
resolver described in `docs/design/reasoning-trace.md` §3.2 — implement it in
a second script here (`resolve_trace.py`) before touching Studio.

View file

@ -1,154 +0,0 @@
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.11"
# dependencies = [
# "docling-agent",
# "rich",
# ]
# ///
"""
Run a docling-agent RAG inspection on a Docling-Studio analysis job and dump the RAGResult.
Bypasses `DoclingRAGAgent.run()` (which discards the RAGResult) and calls the private
`_rag_loop()` directly so we can capture the per-iteration trace.
Loads the DoclingDocument from Studio's SQLite (`analysis_jobs.document_json`), so no
re-parsing of the PDF is needed same doc the UI is showing.
Usage:
uv run experiments/reasoning-trace/inspect_doc.py \\
--job-id 722d5631-0089-44a3-a64a-7ce5b99579d3 \\
--query "Quels sont les points clés de l'offre ?" \\
--model granite4:micro-h
Output:
experiments/reasoning-trace/output/<job-id-prefix>_<utc-timestamp>.json
"""
from __future__ import annotations
import argparse
import json
import sqlite3
import sys
from datetime import datetime, timezone
from pathlib import Path
from docling_agent.agent.rag import DoclingRAGAgent
from docling_core.types.doc.document import DoclingDocument
from mellea.backends import model_ids as M
from mellea.backends.model_ids import ModelIdentifier
HERE = Path(__file__).resolve().parent
REPO = HERE.parents[1]
DB_PATH = REPO / "document-parser" / "data" / "docling_studio.db"
OUT_DIR = HERE / "output"
def load_doc(job_id: str) -> tuple[DoclingDocument, str]:
if not DB_PATH.exists():
sys.exit(f"SQLite DB not found at {DB_PATH}")
con = sqlite3.connect(DB_PATH)
con.row_factory = sqlite3.Row
row = con.execute(
"""
SELECT aj.document_json, d.filename
FROM analysis_jobs aj
JOIN documents d ON d.id = aj.document_id
WHERE aj.id = ?
""",
(job_id,),
).fetchone()
con.close()
if row is None:
sys.exit(f"No analysis job with id {job_id}")
if not row["document_json"]:
sys.exit(f"Analysis job {job_id} has no document_json (not completed?)")
return DoclingDocument.model_validate_json(row["document_json"]), row["filename"]
def resolve_model(name: str) -> ModelIdentifier:
"""Accept either a mellea catalog constant name (e.g. 'IBM_GRANITE_4_HYBRID_MICRO')
or a raw Ollama tag (e.g. 'granite4:micro-h', 'llama3.2:3b')."""
const = getattr(M, name.upper(), None)
if isinstance(const, ModelIdentifier):
return const
return ModelIdentifier(ollama_name=name)
def summarize_structure(doc: DoclingDocument) -> str:
from docling_core.types.doc.document import SectionHeaderItem, TitleItem
headers = [
item for item, _ in doc.iterate_items()
if isinstance(item, (TitleItem, SectionHeaderItem))
]
return (
f"texts={len(doc.texts)} "
f"tables={len(doc.tables)} "
f"pictures={len(doc.pictures)} "
f"groups={len(doc.groups)} "
f"section_headers={len(headers)}"
)
def main() -> None:
p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
p.add_argument("--job-id", required=True, help="analysis_jobs.id from Studio SQLite")
p.add_argument("--query", required=True, help="Question to ask the document")
p.add_argument(
"--model",
default="granite4:micro-h",
help="Ollama tag or mellea catalog constant (default: granite4:micro-h)",
)
p.add_argument("--max-iters", type=int, default=5)
p.add_argument("--quiet", action="store_true", help="disable rich progress panels")
args = p.parse_args()
print(f"→ Loading DoclingDocument from analysis {args.job_id[:8]}")
doc, filename = load_doc(args.job_id)
print(f" {filename}")
print(f" {summarize_structure(doc)}")
model_id = resolve_model(args.model)
print(f"→ Model: ollama={model_id.ollama_name!r} hf={model_id.hf_model_name!r}")
agent = DoclingRAGAgent(
model_id=model_id,
tools=[],
max_iterations=args.max_iters,
verbose=not args.quiet,
)
print(f"→ Running RAG loop (query: {args.query!r})\n")
# Intentional: agent.run() discards the RAGResult. _rag_loop gives us the trace.
result = agent._rag_loop(query=args.query, doc=doc)
OUT_DIR.mkdir(exist_ok=True)
ts = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
out_path = OUT_DIR / f"{args.job_id[:8]}_{ts}.json"
payload = {
"job_id": args.job_id,
"filename": filename,
"query": args.query,
"model": {
"ollama_name": model_id.ollama_name,
"hf_model_name": model_id.hf_model_name,
},
"max_iterations": args.max_iters,
"result": json.loads(result.model_dump_json()),
}
out_path.write_text(json.dumps(payload, indent=2, ensure_ascii=False))
print()
print(f"✓ Wrote {out_path.relative_to(REPO)}")
print(
f" converged={result.converged} "
f"iterations={len(result.iterations)} "
f"answer_chars={len(result.answer)}"
)
if result.iterations:
print(" section_refs visited:", [it.section_ref for it in result.iterations])
if __name__ == "__main__":
main()

View file

@ -1,367 +0,0 @@
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.11"
# dependencies = [
# "neo4j>=5.20,<6",
# ]
# ///
"""
Compare the Neo4j graph for a given doc_id against the source DoclingDocument
stored in SQLite. Reports whether the graph is "well-formed" all elements
present, hierarchy intact, no orphans, reading order faithful.
Use when you want to sanity-check the Neo4j writer before building anything
on top of the graph (e.g. the live RAG overlay).
Usage:
uv run experiments/reasoning-trace/inspect_graph.py \\
--doc-id 307ad2ba-93d8-4dfd-8e38-c1ea06d23f0d
Env:
NEO4J_URI default bolt://localhost:7687
NEO4J_USER default neo4j
NEO4J_PASSWORD default changeme
"""
from __future__ import annotations
import argparse
import asyncio
import json
import os
import sqlite3
import sys
from collections import Counter
from pathlib import Path
HERE = Path(__file__).resolve().parent
REPO = HERE.parents[1]
DB_PATH = REPO / "document-parser" / "data" / "docling_studio.db"
def _load_doc_json(doc_id: str) -> tuple[str, dict]:
"""Return (filename, parsed DoclingDocument dict) for the latest completed
analysis of this doc."""
if not DB_PATH.exists():
sys.exit(f"SQLite DB not found at {DB_PATH}")
con = sqlite3.connect(DB_PATH)
con.row_factory = sqlite3.Row
row = con.execute(
"""
SELECT d.filename, aj.document_json
FROM analysis_jobs aj
JOIN documents d ON d.id = aj.document_id
WHERE aj.document_id = ?
AND aj.status = 'COMPLETED'
AND aj.document_json IS NOT NULL
ORDER BY aj.completed_at DESC LIMIT 1
""",
(doc_id,),
).fetchone()
con.close()
if not row:
sys.exit(f"No completed analysis with document_json for doc_id={doc_id}")
return row["filename"], json.loads(row["document_json"])
# ---------------------------------------------------------------------------
# Source-side summaries (DoclingDocument JSON)
# ---------------------------------------------------------------------------
_ITEM_LISTS = ("texts", "tables", "pictures", "groups")
def _iter_items(doc: dict):
for key in _ITEM_LISTS:
for it in doc.get(key) or []:
yield key, it
def _parent_ref(item: dict) -> str | None:
parent = item.get("parent")
if isinstance(parent, dict):
return parent.get("$ref") or parent.get("cref")
return None
def _dfs_reading_order(doc: dict) -> list[str]:
by_ref: dict[str, dict] = {}
for _, it in _iter_items(doc):
ref = it.get("self_ref")
if ref:
by_ref[ref] = it
body = doc.get("body") or {}
order: list[str] = []
def walk(children):
for ch in children or []:
ref = ch.get("$ref") or ch.get("cref")
if not ref:
continue
order.append(ref)
walk((by_ref.get(ref) or {}).get("children"))
walk(body.get("children"))
return order
def _summarize_source(doc: dict) -> dict:
items_by_kind = Counter()
labels = Counter()
roots_from_body = 0
with_prov = 0
no_text_no_caption = 0
section_headers: list[str] = []
for kind, it in _iter_items(doc):
items_by_kind[kind] += 1
label = (it.get("label") or "").lower()
labels[label] += 1
if _parent_ref(it) == "#/body":
roots_from_body += 1
if it.get("prov"):
with_prov += 1
if not (it.get("text") or it.get("caption")):
no_text_no_caption += 1
if label in ("section_header", "title"):
section_headers.append(it.get("self_ref") or "<?>")
pages = doc.get("pages") or {}
reading_order = _dfs_reading_order(doc)
return {
"filename_in_doc": doc.get("name"),
"items_by_kind": dict(items_by_kind),
"labels": dict(labels),
"total_items": sum(items_by_kind.values()),
"section_headers_count": len(section_headers),
"section_headers_sample": section_headers[:5],
"roots_from_body": roots_from_body,
"items_with_prov": with_prov,
"items_without_prov": sum(items_by_kind.values()) - with_prov,
"items_without_text_nor_caption": no_text_no_caption,
"pages_count": len(pages),
"reading_order_length": len(reading_order),
"reading_order_sample": reading_order[:5],
}
# ---------------------------------------------------------------------------
# Graph-side summaries (Neo4j)
# ---------------------------------------------------------------------------
async def _summarize_graph(neo, doc_id: str) -> dict:
async with neo.driver.session(database=neo.database) as s:
async def q(cypher: str, **params) -> list[dict]:
res = await s.run(cypher, doc_id=doc_id, **params)
return [dict(r) async for r in res]
doc = await q("MATCH (d:Document {id: $doc_id}) RETURN d.title AS title")
if not doc:
return {"exists": False}
elements = await q(
"MATCH (e:Element {doc_id: $doc_id}) "
"RETURN labels(e) AS labels, e.docling_label AS docling_label, "
" e.self_ref AS self_ref, e.prov_page AS page"
)
pages = await q(
"MATCH (p:Page {doc_id: $doc_id}) RETURN p.page_no AS page_no"
)
edges_by_type = await q(
"""
MATCH (n {doc_id: $doc_id})-[r]->(m)
WHERE (m:Document OR m:Element OR m:Page OR m:Chunk)
RETURN type(r) AS type, count(r) AS n
"""
)
# HAS_ROOT starts at :Document, so n.doc_id filter above miss the edge
# (doc has id= not doc_id=); fetch separately.
has_root = await q(
"MATCH (d:Document {id: $doc_id})-[:HAS_ROOT]->(c:Element) "
"RETURN count(c) AS n"
)
# Orphan detection: elements with no incoming PARENT_OF and not a root.
orphans = await q(
"""
MATCH (e:Element {doc_id: $doc_id})
WHERE NOT (()-[:PARENT_OF]->(e))
AND NOT ((:Document {id: $doc_id})-[:HAS_ROOT]->(e))
RETURN e.self_ref AS self_ref, e.docling_label AS label
"""
)
# Elements with prov_page but no ON_PAGE edge.
on_page_missing = await q(
"""
MATCH (e:Element {doc_id: $doc_id})
WHERE e.prov_page IS NOT NULL AND NOT (e)-[:ON_PAGE]->(:Page)
RETURN count(e) AS n
"""
)
# Pages with no element attached.
empty_pages = await q(
"""
MATCH (p:Page {doc_id: $doc_id})
WHERE NOT (:Element {doc_id: $doc_id})-[:ON_PAGE]->(p)
RETURN p.page_no AS page_no
"""
)
specific_label_counter: Counter = Counter()
docling_label_counter: Counter = Counter()
for e in elements:
specifics = [lbl for lbl in e["labels"] if lbl != "Element"]
specific_label_counter[specifics[0] if specifics else "<none>"] += 1
docling_label_counter[e["docling_label"] or "<none>"] += 1
edges = {row["type"]: row["n"] for row in edges_by_type}
edges["HAS_ROOT"] = has_root[0]["n"] if has_root else 0
return {
"exists": True,
"title": doc[0]["title"],
"element_count": len(elements),
"page_count": len(pages),
"specific_labels": dict(specific_label_counter),
"docling_labels": dict(docling_label_counter),
"edges": edges,
"orphan_elements": orphans,
"elements_with_prov_but_no_on_page": on_page_missing[0]["n"] if on_page_missing else 0,
"empty_pages": [r["page_no"] for r in empty_pages],
}
# ---------------------------------------------------------------------------
# Coherence checks
# ---------------------------------------------------------------------------
async def _coherence(neo, doc_id: str, doc: dict) -> dict:
source_refs = {it.get("self_ref") for _, it in _iter_items(doc) if it.get("self_ref")}
async with neo.driver.session(database=neo.database) as s:
res = await s.run(
"MATCH (e:Element {doc_id: $doc_id}) RETURN e.self_ref AS r",
doc_id=doc_id,
)
graph_refs = {row["r"] async for row in res}
return {
"source_refs_count": len(source_refs),
"graph_refs_count": len(graph_refs),
"missing_in_graph": sorted(source_refs - graph_refs)[:20],
"missing_in_graph_count": len(source_refs - graph_refs),
"extra_in_graph": sorted(graph_refs - source_refs)[:20],
"extra_in_graph_count": len(graph_refs - source_refs),
"reading_order_source_len": len(_dfs_reading_order(doc)),
}
# ---------------------------------------------------------------------------
# Pretty report
# ---------------------------------------------------------------------------
def _section(title: str, payload: dict) -> str:
lines = [f"\n── {title} " + "" * max(2, 70 - len(title))]
for k, v in payload.items():
if isinstance(v, (dict, list)):
lines.append(f" {k}: {json.dumps(v, ensure_ascii=False, default=str)[:220]}")
else:
lines.append(f" {k}: {v}")
return "\n".join(lines)
def _verdict(source: dict, graph: dict, coherence: dict) -> str:
issues: list[str] = []
if not graph.get("exists"):
return "❌ No graph in Neo4j for this doc."
if coherence["missing_in_graph_count"]:
issues.append(
f"MISSING — {coherence['missing_in_graph_count']} self_ref(s) in "
"the source DoclingDocument are not in Neo4j"
)
if coherence["extra_in_graph_count"]:
issues.append(
f"EXTRA — {coherence['extra_in_graph_count']} self_ref(s) in the "
"graph are not in the source doc"
)
# Edge integrity:
edges = graph.get("edges", {})
expected_has_root = source["roots_from_body"]
actual_has_root = edges.get("HAS_ROOT", 0)
if actual_has_root != expected_has_root:
issues.append(
f"HAS_ROOT mismatch — source has {expected_has_root} top-level "
f"items, graph has {actual_has_root}"
)
# Reading order: NEXT chain should be reading_order_length - 1.
expected_next = max(source["reading_order_length"] - 1, 0)
actual_next = edges.get("NEXT", 0)
if actual_next != expected_next:
issues.append(
f"NEXT chain mismatch — expected {expected_next}, got {actual_next}"
)
# ON_PAGE: every item with prov should have one ON_PAGE edge.
expected_on_page = source["items_with_prov"]
actual_on_page = edges.get("ON_PAGE", 0)
if actual_on_page != expected_on_page:
issues.append(
f"ON_PAGE mismatch — {expected_on_page} items have prov, "
f"{actual_on_page} ON_PAGE edges exist"
)
if graph.get("elements_with_prov_but_no_on_page"):
issues.append(
f"ON_PAGE broken — {graph['elements_with_prov_but_no_on_page']} "
"elements have prov_page but no ON_PAGE edge"
)
if graph.get("orphan_elements"):
orphans = graph["orphan_elements"]
issues.append(f"ORPHANS — {len(orphans)} disconnected Element node(s)")
if graph.get("empty_pages"):
issues.append(
f"EMPTY PAGES — pages with no element attached: {graph['empty_pages']}"
)
if not issues:
return "✅ Graph is well-formed — matches source doc 1:1."
return "⚠️ Findings:\n " + "\n ".join(f"{x}" for x in issues)
async def main_async() -> None:
p = argparse.ArgumentParser(
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
)
p.add_argument("--doc-id", required=True, help="documents.id (not analysis_jobs.id)")
args = p.parse_args()
filename, doc = _load_doc_json(args.doc_id)
# Imports deferred so we can patch sys.path from within the script runtime.
sys.path.insert(0, str(REPO / "document-parser"))
from infra.neo4j import close_driver, get_driver
neo = await get_driver(
os.environ.get("NEO4J_URI", "bolt://localhost:7687"),
os.environ.get("NEO4J_USER", "neo4j"),
os.environ.get("NEO4J_PASSWORD", "changeme"),
)
try:
source = _summarize_source(doc)
graph = await _summarize_graph(neo, args.doc_id)
coh = await _coherence(neo, args.doc_id, doc)
print(f"Document: {filename}")
print(f"doc_id: {args.doc_id}")
print(_section("SOURCE (DoclingDocument from SQLite)", source))
print(_section("GRAPH (Neo4j)", graph))
print(_section("COHERENCE", coh))
print()
print(_verdict(source, graph, coh))
finally:
await close_driver()
if __name__ == "__main__":
asyncio.run(main_async())

View file

@ -1,175 +0,0 @@
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.11"
# dependencies = ["neo4j>=5.20,<6"]
# ///
"""
Second-pass inspector: how well does our Neo4j graph model the *semantics*
of a DoclingDocument the parts Peter Staar would care about?
Goes beyond structural 1:1 coverage (inspect_graph.py) to answer:
- Are SectionHeader levels preserved (outline hierarchy intact)?
- Can we reconstruct "section scope" (section its content) from the graph?
- Are lists vs list-items distinguishable?
- Are figure captions linked to their figures?
- What's the depth of the actual hierarchy vs the flat body?
"""
from __future__ import annotations
import argparse
import asyncio
import os
import sys
from pathlib import Path
HERE = Path(__file__).resolve().parent
REPO = HERE.parents[1]
async def main_async(doc_id: str) -> None:
sys.path.insert(0, str(REPO / "document-parser"))
from infra.neo4j import close_driver, get_driver
neo = await get_driver(
os.environ.get("NEO4J_URI", "bolt://localhost:7687"),
os.environ.get("NEO4J_USER", "neo4j"),
os.environ.get("NEO4J_PASSWORD", "changeme"),
)
try:
async with neo.driver.session(database=neo.database) as s:
async def q(cypher: str, **params):
res = await s.run(cypher, doc_id=doc_id, **params)
return [dict(r) async for r in res]
print("=== 1. OUTLINE HIERARCHY (section header levels) ===")
rows = await q(
"""
MATCH (e:SectionHeader {doc_id: $doc_id})
RETURN e.self_ref AS ref, e.level AS level, substring(e.text, 0, 60) AS text
ORDER BY e.self_ref
"""
)
for r in rows:
print(f" level={r['level']} {r['ref']:<14} {r['text']}")
levels = [r["level"] for r in rows]
print(f" → distinct levels: {sorted(set(levels))}")
print("\n=== 2. DIRECT TREE DEPTH via PARENT_OF ===")
rows = await q(
"""
MATCH (root:Element {doc_id: $doc_id})
WHERE NOT (()-[:PARENT_OF]->(root))
OPTIONAL MATCH path = (root)-[:PARENT_OF*]->(leaf)
WITH root, max(length(path)) AS depth
RETURN
labels(root) AS labels,
root.docling_label AS docling_label,
coalesce(depth, 0) AS depth
ORDER BY depth DESC
LIMIT 10
"""
)
for r in rows:
specific = [l for l in r["labels"] if l != "Element"][0]
print(f" depth={r['depth']} {specific:<15} ({r['docling_label']})")
print("\n=== 3. SECTION SCOPE (can we infer section content from NEXT?) ===")
# For each section header, walk NEXT until the next section header —
# that's the section's content span as per docling-agent's logic.
rows = await q(
"""
MATCH (sh:SectionHeader {doc_id: $doc_id})
OPTIONAL MATCH p = (sh)-[:NEXT*]->(next:SectionHeader {doc_id: $doc_id})
WITH sh, min(length(p)) AS span
RETURN
sh.self_ref AS ref,
sh.level AS level,
coalesce(span - 1, -1) AS items_in_scope_if_span_works,
substring(sh.text, 0, 50) AS title
ORDER BY sh.self_ref
"""
)
for r in rows:
span = r["items_in_scope_if_span_works"]
label = f"~{span} items" if span >= 0 else "last (unknown span)"
print(f" level={r['level']} {r['ref']:<14} {label:<18} {r['title']}")
print("\n=== 4. LIST CONTAINER vs LIST ITEM distinction ===")
rows = await q(
"""
MATCH (e:ListItem {doc_id: $doc_id})
RETURN e.docling_label AS docling_label, count(*) AS n
ORDER BY docling_label
"""
)
for r in rows:
print(f" docling_label={r['docling_label']:<12} neo4j_label=:ListItem count={r['n']}")
print(" ⚠️ Both 'list' (container) and 'list_item' get :ListItem in Neo4j.")
print("\n=== 5. FIGURE ↔ CAPTION linkage ===")
captions = await q(
"MATCH (c:Caption {doc_id: $doc_id}) RETURN count(c) AS n"
)
linked = await q(
"""
MATCH (fig:Figure {doc_id: $doc_id})-[:PARENT_OF]-(c:Caption {doc_id: $doc_id})
RETURN count(DISTINCT fig) AS figs_with_caption, count(DISTINCT c) AS captions_linked
"""
)
print(
f" captions={captions[0]['n']} "
f"figures_with_caption={linked[0]['figs_with_caption']} "
f"captions_linked={linked[0]['captions_linked']}"
)
print("\n=== 6. TABLE CELL CONTENT — graph-addressable or opaque? ===")
rows = await q(
"""
MATCH (t:Table {doc_id: $doc_id})
RETURN t.self_ref AS ref,
CASE WHEN t.cells_json IS NOT NULL THEN 'JSON-blob' ELSE 'missing' END
AS cells_mode,
size(coalesce(t.cells_json, '')) AS cells_bytes
"""
)
for r in rows:
print(f" {r['ref']:<14} cells={r['cells_mode']} ({r['cells_bytes']} bytes)")
if rows:
print(" ⚠️ Cells are a JSON string on the Table node — not queryable as graph nodes.")
print("\n=== 7. WHAT AN AGENT VISIT LOOKS LIKE (section subgraph) ===")
# Pick the first section header and show what would be highlighted
# if we traversed its scope (via NEXT until next same-or-higher-level section).
rows = await q(
"""
MATCH (sh:SectionHeader {doc_id: $doc_id})
WITH sh ORDER BY sh.self_ref LIMIT 1
OPTIONAL MATCH chain = (sh)-[:NEXT*0..50]->(e:Element {doc_id: $doc_id})
WITH sh, e, length(chain) AS pos
OPTIONAL MATCH (e)<-[:NEXT*0..]-(_stop:SectionHeader)
WHERE _stop <> sh AND _stop.level <= sh.level
WITH sh, e, pos
ORDER BY pos
LIMIT 8
RETURN pos, labels(e) AS labels, e.docling_label AS kind,
substring(e.text, 0, 60) AS text
"""
)
for r in rows:
specific = [l for l in r["labels"] if l != "Element"][0]
print(f" pos={r['pos']:<3} {specific:<15} {r['text']}")
print(
" → Visiting a section on the graph shows ONE node. Its 'scope' "
"(the content) must be inferred via NEXT-walk — not materialized as edges."
)
finally:
await close_driver()
if __name__ == "__main__":
p = argparse.ArgumentParser(description=__doc__)
p.add_argument("--doc-id", required=True)
args = p.parse_args()
asyncio.run(main_async(args.doc_id))

View file

@ -1,148 +0,0 @@
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.11"
# dependencies = [
# "neo4j>=5.20,<6",
# "python-dotenv>=1.0",
# ]
# ///
"""
Populate Neo4j with the DoclingDocument tree + pages for one or more analysis
jobs, **without re-parsing the PDFs**. Reuses Studio's own TreeWriter so the
graph is byte-identical to what an in-UI analysis would produce.
Use when:
- Neo4j was brought up after some docs were already analyzed (orphan graphs).
- You want to prime a demo environment from existing SQLite state.
Usage:
# Single job
uv run experiments/reasoning-trace/prime_neo4j.py \\
--job-id 722d5631-0089-44a3-a64a-7ce5b99579d3
# All completed analyses that have a document_json but no graph yet
uv run experiments/reasoning-trace/prime_neo4j.py --all-missing
Env (defaults match docker-compose.dev.yml):
NEO4J_URI default bolt://localhost:7687
NEO4J_USER default neo4j
NEO4J_PASSWORD default changeme
"""
from __future__ import annotations
import argparse
import asyncio
import os
import sqlite3
import sys
from datetime import UTC, datetime
from pathlib import Path
HERE = Path(__file__).resolve().parent
REPO = HERE.parents[1]
DB_PATH = REPO / "document-parser" / "data" / "docling_studio.db"
# Studio's own TreeWriter lives in document-parser/infra/neo4j. Import it by
# adding document-parser to sys.path — this keeps us byte-identical with what
# the live backend writes, instead of re-implementing the walk.
sys.path.insert(0, str(REPO / "document-parser"))
def _fetch_row(job_id: str) -> tuple[str, str, str] | None:
con = sqlite3.connect(DB_PATH)
con.row_factory = sqlite3.Row
row = con.execute(
"""
SELECT aj.document_id, d.filename, aj.document_json
FROM analysis_jobs aj
JOIN documents d ON d.id = aj.document_id
WHERE aj.id = ? AND aj.document_json IS NOT NULL
""",
(job_id,),
).fetchone()
con.close()
return (row["document_id"], row["filename"], row["document_json"]) if row else None
def _fetch_all_completed() -> list[tuple[str, str, str, str]]:
"""Latest completed analysis per document that has a document_json."""
con = sqlite3.connect(DB_PATH)
con.row_factory = sqlite3.Row
rows = con.execute(
"""
SELECT aj.id, aj.document_id, d.filename, aj.document_json
FROM analysis_jobs aj
JOIN documents d ON d.id = aj.document_id
WHERE aj.document_json IS NOT NULL
AND aj.status = 'COMPLETED'
GROUP BY aj.document_id
HAVING MAX(aj.completed_at)
""",
).fetchall()
con.close()
return [(r["id"], r["document_id"], r["filename"], r["document_json"]) for r in rows]
async def prime(job_id: str, doc_id: str, filename: str, document_json: str) -> None:
# Imports deferred until after sys.path is patched.
from infra.neo4j import bootstrap_schema, close_driver, get_driver, write_document
uri = os.environ.get("NEO4J_URI", "bolt://localhost:7687")
user = os.environ.get("NEO4J_USER", "neo4j")
pwd = os.environ.get("NEO4J_PASSWORD", "changeme")
neo = await get_driver(uri, user, pwd)
try:
# Schema is idempotent; safe to run every time.
await bootstrap_schema(neo)
result = await write_document(
neo,
doc_id=doc_id,
filename=filename,
document_json=document_json,
)
print(
f"{doc_id[:8]} {filename[:40]:<40} "
f"elements={result.element_count} pages={result.page_count}"
)
finally:
await close_driver()
async def main_async() -> None:
p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
g = p.add_mutually_exclusive_group(required=True)
g.add_argument("--job-id", help="analysis_jobs.id to prime")
g.add_argument(
"--all-missing",
action="store_true",
help="prime every completed analysis with a document_json (latest per doc)",
)
args = p.parse_args()
if not DB_PATH.exists():
sys.exit(f"SQLite DB not found at {DB_PATH}")
started = datetime.now(tz=UTC)
if args.job_id:
row = _fetch_row(args.job_id)
if row is None:
sys.exit(f"No analysis with id {args.job_id} or no document_json")
doc_id, filename, document_json = row
print(f"→ Priming Neo4j for job {args.job_id[:8]} (doc {doc_id[:8]})")
await prime(args.job_id, doc_id, filename, document_json)
else:
rows = _fetch_all_completed()
print(f"→ Priming Neo4j for {len(rows)} document(s)")
for job_id, doc_id, filename, document_json in rows:
try:
await prime(job_id, doc_id, filename, document_json)
except Exception as e:
print(f"{doc_id[:8]} {filename[:40]:<40} FAILED: {e}")
elapsed = (datetime.now(tz=UTC) - started).total_seconds()
print(f"Done in {elapsed:.1f}s")
if __name__ == "__main__":
asyncio.run(main_async())

View file

@ -9,8 +9,6 @@ RUN npm run build
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf.template /etc/nginx/templates/default.conf.template
ENV NGINX_MAX_BODY_SIZE=200M
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80

7
frontend/env.d.ts vendored
View file

@ -7,10 +7,3 @@ declare module '*.vue' {
const component: DefineComponent<Record<string, unknown>, Record<string, unknown>, unknown>
export default component
}
// Cytoscape plugins we use in GraphView — they ship no types. Treated as
// opaque plugin objects; the runtime APIs they add to `cy` are called via
// `(cy as any)` at the call site and typed loosely.
declare module 'cytoscape-expand-collapse'
declare module 'cytoscape-navigator'
declare module 'cytoscape-navigator/cytoscape.js-navigator.css'

View file

@ -20,6 +20,6 @@ server {
proxy_set_header X-Real-IP $remote_addr;
proxy_read_timeout 900s;
proxy_send_timeout 900s;
client_max_body_size ${NGINX_MAX_BODY_SIZE};
client_max_body_size 5M;
}
}

View file

@ -10,7 +10,6 @@
"dependencies": {
"cytoscape": "^3.30.0",
"cytoscape-dagre": "^2.5.0",
"cytoscape-expand-collapse": "^4.1.1",
"dompurify": "^3.3.3",
"marked": "^17.0.4",
"pinia": "^2.3.0",
@ -1867,14 +1866,6 @@
"cytoscape": "^3.2.22"
}
},
"node_modules/cytoscape-expand-collapse": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/cytoscape-expand-collapse/-/cytoscape-expand-collapse-4.1.1.tgz",
"integrity": "sha512-MI4/GsA6Rf6RRzNR1aCitBLSnxiIKLxvZyCzF+oti/zn/ui1jmf769VcEFAEbjjsAtwteGsTmczI+niCMWJNvA==",
"peerDependencies": {
"cytoscape": "^3.3.0"
}
},
"node_modules/dagre": {
"version": "0.8.5",
"resolved": "https://registry.npmjs.org/dagre/-/dagre-0.8.5.tgz",

View file

@ -1,6 +1,6 @@
{
"name": "docling-studio",
"version": "0.5.0",
"version": "0.4.0",
"private": true,
"type": "module",
"scripts": {
@ -18,7 +18,6 @@
"dependencies": {
"cytoscape": "^3.30.0",
"cytoscape-dagre": "^2.5.0",
"cytoscape-expand-collapse": "^4.1.1",
"dompurify": "^3.3.3",
"marked": "^17.0.4",
"pinia": "^2.3.0",
@ -29,9 +28,9 @@
"@eslint/js": "^9.0.0",
"@types/cytoscape": "^3.21.4",
"@types/cytoscape-dagre": "^2.3.3",
"@vitest/mocker": "^4.1.2",
"@types/dompurify": "^3.2.0",
"@vitejs/plugin-vue": "^6.0.5",
"@vitest/mocker": "^4.1.2",
"eslint": "^9.0.0",
"eslint-plugin-vue": "^9.32.0",
"prettier": "^3.4.0",

View file

@ -27,22 +27,6 @@ const routes: RouteRecordRaw[] = [
name: 'search',
component: () => import('../../pages/SearchPage.vue'),
},
{
// Reasoning-trace tunnel. Route is always registered; the page shows
// an empty state when the `reasoning` feature flag is off (same pattern
// as /search does for ingestion).
path: '/reasoning',
name: 'reasoning',
component: () => import('../../pages/ReasoningPage.vue'),
},
{
// Deep-link into a specific document's reasoning workspace, e.g. shared
// by Peter to a teammate.
path: '/reasoning/:docId',
name: 'reasoning-doc',
component: () => import('../../pages/ReasoningPage.vue'),
props: true,
},
{
path: '/settings',
name: 'settings',

View file

@ -1,22 +1,5 @@
import { apiFetch } from '../../shared/api/http'
/**
* A single provenance entry for an element matches Docling's
* `ProvenanceItem`. One element may have multiple (e.g. a paragraph that
* spans a page break has two entries, one per page).
*/
export interface GraphProvenance {
order: number
page_no: number | null
bbox_l: number
bbox_t: number
bbox_r: number
bbox_b: number
coord_origin: string
charspan_start: number | null
charspan_end: number | null
}
export interface GraphNode {
id: string
group: 'document' | 'element' | 'page' | 'chunk'
@ -25,8 +8,6 @@ export interface GraphNode {
self_ref?: string
text?: string
prov_page?: number | null
/** Full list of provenances (page + bbox) — used by the bbox-highlight UI. */
provs?: GraphProvenance[]
level?: number | null
page_no?: number
chunk_index?: number

View file

@ -1,95 +0,0 @@
import { describe, expect, it } from 'vitest'
import type { GraphNode } from './graphApi'
import { LEGEND_CHIPS, findChip, partitionByHiddenChips } from './legendFilters'
function node(overrides: Partial<GraphNode>): GraphNode {
return {
id: overrides.id ?? 'n1',
group: overrides.group ?? 'element',
...overrides,
}
}
describe('LEGEND_CHIPS matching', () => {
it('section matches only SectionHeader elements', () => {
const chip = findChip('section')!
expect(chip.match(node({ group: 'element', label: 'SectionHeader' }))).toBe(true)
expect(chip.match(node({ group: 'element', label: 'Paragraph' }))).toBe(false)
// An element with no label should NOT match section.
expect(chip.match(node({ group: 'element' }))).toBe(false)
})
it('paragraph matches Paragraph and TextElement (fallback)', () => {
const chip = findChip('paragraph')!
expect(chip.match(node({ group: 'element', label: 'Paragraph' }))).toBe(true)
// TextElement is the generic fallback Docling-labels that don't have a
// specific mapping end up with. It's visually rendered in the paragraph
// color, so the chip covers both.
expect(chip.match(node({ group: 'element', label: 'TextElement' }))).toBe(true)
expect(chip.match(node({ group: 'element', label: 'Table' }))).toBe(false)
})
it('group-based chips use `group` not `label`', () => {
expect(findChip('document')!.match(node({ group: 'document' }))).toBe(true)
expect(findChip('page')!.match(node({ group: 'page', page_no: 1 }))).toBe(true)
expect(findChip('chunk')!.match(node({ group: 'chunk' }))).toBe(true)
// document chip must NOT match elements that happen to have label===undefined
expect(findChip('document')!.match(node({ group: 'element' }))).toBe(false)
})
it('exposes a stable list of chips', () => {
// The sidebar CSS targets `.legend-${key}` — renaming a key here would
// silently break the styles. Pin the expected order + keys.
expect(LEGEND_CHIPS.map((c) => c.key)).toEqual([
'document',
'section',
'paragraph',
'table',
'figure',
'page',
'chunk',
])
})
})
describe('partitionByHiddenChips', () => {
const nodes: GraphNode[] = [
node({ id: 'd', group: 'document' }),
node({ id: 's1', group: 'element', label: 'SectionHeader' }),
node({ id: 'p1', group: 'element', label: 'Paragraph' }),
node({ id: 'f1', group: 'element', label: 'Figure' }),
node({ id: 'pg1', group: 'page' }),
]
it('returns everything in `show` when the hidden set is empty', () => {
const { hide, show } = partitionByHiddenChips(nodes, new Set())
expect(hide).toHaveLength(0)
expect(show).toHaveLength(nodes.length)
})
it('hides the nodes matching the selected chips', () => {
const { hide, show } = partitionByHiddenChips(nodes, new Set(['figure', 'document']))
expect(hide.map((n) => n.id).sort()).toEqual(['d', 'f1'])
expect(show.map((n) => n.id).sort()).toEqual(['p1', 'pg1', 's1'])
})
it('ignores unknown chip keys (no silent breakage)', () => {
const { hide, show } = partitionByHiddenChips(nodes, new Set(['some-new-kind']))
expect(hide).toHaveLength(0)
expect(show).toHaveLength(nodes.length)
})
it('keeps un-classifiable nodes visible even if all chips are off', () => {
// A hypothetical element with an unmapped label (e.g. :Formula is in the
// writer but has no chip yet) must not be hidden when toggling unrelated
// chips — only explicit chip matches cause hiding.
const weird = node({ id: 'w', group: 'element', label: 'Formula' })
const { hide, show } = partitionByHiddenChips(
[weird, ...nodes],
new Set(['section', 'paragraph']),
)
expect(hide.map((n) => n.id)).toEqual(['s1', 'p1'])
expect(show.some((n) => n.id === 'w')).toBe(true)
})
})

View file

@ -1,108 +0,0 @@
/**
* Declarative definition of GraphView's legend chips.
*
* Each chip maps to (a) a swatch color shown in the toolbar, and (b) a
* predicate that decides whether a given node is "of that kind". Clicking a
* chip toggles the matching nodes' visibility via the `.hidden` Cytoscape
* class pure function kept here so it's unit-testable without mounting
* the component or spinning up Cytoscape.
*/
import type { GraphNode } from './graphApi'
export interface LegendChip {
/** Stable ID used as the key in the `hiddenChips` Set + as a CSS slug. */
key: string
/** Display label shown in the chip. Kept English for now matches
* existing `legend-*` class names that the design stylesheet targets. */
label: string
/** Swatch color (also used for the CSS class `legend-${key}`). */
color: string
/** Returns true when a node belongs to this chip's category. */
match: (node: GraphNode) => boolean
}
/**
* Legend order matches the chips currently rendered in GraphView's toolbar,
* so swapping to this source of truth is a drop-in replacement.
*
* `kindLabel` is the specific Neo4j label returned by `fetch_graph`
* (e.g. `SectionHeader`, `Paragraph`, `Table`, ). Group-based chips target
* the `group` attribute (document / page / chunk) only `element` nodes
* have a useful `kindLabel`.
*/
export const LEGEND_CHIPS: LegendChip[] = [
{
key: 'document',
label: 'Document',
color: '#1E293B',
match: (n) => n.group === 'document',
},
{
key: 'section',
label: 'Section',
color: '#F97316',
match: (n) => n.group === 'element' && n.label === 'SectionHeader',
},
{
key: 'paragraph',
label: 'Paragraph',
color: '#3B82F6',
match: (n) => n.group === 'element' && (n.label === 'Paragraph' || n.label === 'TextElement'),
},
{
key: 'table',
label: 'Table',
color: '#8B5CF6',
match: (n) => n.group === 'element' && n.label === 'Table',
},
{
key: 'figure',
label: 'Figure',
color: '#22C55E',
match: (n) => n.group === 'element' && n.label === 'Figure',
},
{
key: 'page',
label: 'Page',
color: '#94A3B8',
match: (n) => n.group === 'page',
},
{
key: 'chunk',
label: 'Chunk',
color: '#DC2626',
match: (n) => n.group === 'chunk',
},
]
/** Lookup by key — tiny helper to keep GraphView concise. */
export function findChip(key: string): LegendChip | undefined {
return LEGEND_CHIPS.find((c) => c.key === key)
}
/**
* Partition an iterable of nodes into `{ hide, show }` based on a set of
* chip keys to hide. A node is hidden if it matches any chip in the set.
* Nodes matching no chip at all (e.g. a future new kind we haven't added
* to the legend) are always shown no silent disappearance.
*/
export function partitionByHiddenChips(
nodes: Iterable<GraphNode>,
hiddenChipKeys: ReadonlySet<string>,
): { hide: GraphNode[]; show: GraphNode[] } {
const hide: GraphNode[] = []
const show: GraphNode[] = []
for (const n of nodes) {
let matchedHidden = false
for (const key of hiddenChipKeys) {
const chip = findChip(key)
if (chip && chip.match(n)) {
matchedHidden = true
break
}
}
;(matchedHidden ? hide : show).push(n)
}
return { hide, show }
}

View file

@ -1,136 +0,0 @@
import { describe, expect, it } from 'vitest'
import type { GraphEdge, GraphNode } from './graphApi'
import { computeSectionParents, explicitParentMap, mergeParentMaps } from './sectionParenting'
function el(selfRef: string, label: string): GraphNode {
return {
id: `elem::${selfRef}`,
group: 'element',
label,
self_ref: selfRef,
}
}
function nextEdge(from: string, to: string): GraphEdge {
return {
id: `NEXT::elem::${from}::elem::${to}`,
source: `elem::${from}`,
target: `elem::${to}`,
type: 'NEXT',
}
}
function parentEdge(parent: string, child: string): GraphEdge {
return {
id: `PARENT_OF::elem::${parent}::elem::${child}`,
source: `elem::${parent}`,
target: `elem::${child}`,
type: 'PARENT_OF',
}
}
describe('computeSectionParents', () => {
it('returns empty when no SectionHeader exists', () => {
const nodes = [el('#/texts/0', 'Paragraph'), el('#/texts/1', 'Paragraph')]
const edges = [nextEdge('#/texts/0', '#/texts/1')]
expect(computeSectionParents(nodes, edges)).toEqual(new Map())
})
it('parents every element to the preceding SectionHeader', () => {
// SH ─▶ P ─▶ P ─▶ SH ─▶ P
const nodes = [
el('#/texts/0', 'SectionHeader'),
el('#/texts/1', 'Paragraph'),
el('#/texts/2', 'Paragraph'),
el('#/texts/3', 'SectionHeader'),
el('#/texts/4', 'Paragraph'),
]
const edges = [
nextEdge('#/texts/0', '#/texts/1'),
nextEdge('#/texts/1', '#/texts/2'),
nextEdge('#/texts/2', '#/texts/3'),
nextEdge('#/texts/3', '#/texts/4'),
]
const parents = computeSectionParents(nodes, edges)
expect(parents.get('elem::#/texts/1')).toBe('elem::#/texts/0')
expect(parents.get('elem::#/texts/2')).toBe('elem::#/texts/0')
expect(parents.get('elem::#/texts/4')).toBe('elem::#/texts/3')
// Headers are never themselves synthetically parented.
expect(parents.has('elem::#/texts/0')).toBe(false)
expect(parents.has('elem::#/texts/3')).toBe(false)
})
it('does not override an explicit PARENT_OF', () => {
// SH ─▶ List ─▶ ListItem, with PARENT_OF(List, ListItem)
const nodes = [
el('#/texts/0', 'SectionHeader'),
el('#/groups/0', 'List'),
el('#/texts/1', 'ListItem'),
]
const edges: GraphEdge[] = [
nextEdge('#/texts/0', '#/groups/0'),
nextEdge('#/groups/0', '#/texts/1'),
parentEdge('#/groups/0', '#/texts/1'),
]
const parents = computeSectionParents(nodes, edges)
// The List falls under the section…
expect(parents.get('elem::#/groups/0')).toBe('elem::#/texts/0')
// …but the ListItem keeps its explicit List parent, not the section.
expect(parents.has('elem::#/texts/1')).toBe(false)
})
it('leaves elements preceding the first SectionHeader unparented', () => {
// Page-header stuff can come before any SectionHeader — don't force-parent it.
const nodes = [
el('#/texts/0', 'PageHeader'),
el('#/texts/1', 'SectionHeader'),
el('#/texts/2', 'Paragraph'),
]
const edges = [nextEdge('#/texts/0', '#/texts/1'), nextEdge('#/texts/1', '#/texts/2')]
const parents = computeSectionParents(nodes, edges)
expect(parents.has('elem::#/texts/0')).toBe(false)
expect(parents.get('elem::#/texts/2')).toBe('elem::#/texts/1')
})
it('handles multiple disconnected NEXT chains deterministically', () => {
// Two chains, each with its own section. Heads are sorted by id so the
// walk order is stable run-to-run.
const nodes = [
el('#/texts/10', 'SectionHeader'),
el('#/texts/11', 'Paragraph'),
el('#/texts/20', 'SectionHeader'),
el('#/texts/21', 'Paragraph'),
]
const edges = [nextEdge('#/texts/10', '#/texts/11'), nextEdge('#/texts/20', '#/texts/21')]
const parents = computeSectionParents(nodes, edges)
expect(parents.get('elem::#/texts/11')).toBe('elem::#/texts/10')
expect(parents.get('elem::#/texts/21')).toBe('elem::#/texts/20')
})
})
describe('explicitParentMap + mergeParentMaps', () => {
it('extracts explicit PARENT_OF into a child→parent map', () => {
const edges = [parentEdge('#/groups/0', '#/texts/1')]
const m = explicitParentMap(edges)
expect(m.get('elem::#/texts/1')).toBe('elem::#/groups/0')
})
it('explicit wins over synthetic when both map the same child', () => {
const synthetic = new Map([['c', 'synthetic-parent']])
const explicit = new Map([['c', 'real-parent']])
const merged = mergeParentMaps(explicit, synthetic)
expect(merged.get('c')).toBe('real-parent')
})
it('preserves synthetic entries that have no explicit counterpart', () => {
const synthetic = new Map([
['c1', 'section-1'],
['c2', 'section-1'],
])
const explicit = new Map([['c2', 'list-a']])
const merged = mergeParentMaps(explicit, synthetic)
expect(merged.get('c1')).toBe('section-1')
expect(merged.get('c2')).toBe('list-a')
})
})

View file

@ -1,111 +0,0 @@
/**
* Synthesize "section scope" compound-node parents for GraphView.
*
* The Docling graph is structurally flat most elements are direct children
* of `#/body` (HAS_ROOT) with no explicit parent relationship to the section
* header that "owns" them in the reader's mental model. To enable the
* expand-collapse UX per section, we derive that ownership here, at render
* time, from the NEXT reading-order chain:
*
* - Walk NEXT from the head of the chain.
* - Every time we hit a SectionHeader, it becomes the "current section".
* - Every non-SectionHeader node thereafter unless it has an explicit
* PARENT_OF edge (e.g. list_item list) gets that section as its
* compound parent.
*
* The function returns a `Map<childId, parentId>` where both ids are full
* Cytoscape node ids (`elem::<self_ref>`). The caller merges this with any
* existing PARENT_OF to produce the final `data.parent` on each node.
*
* Edge cases handled:
* - No SectionHeader in the doc returns an empty map (nothing to collapse).
* - Multiple disconnected NEXT chains each is walked independently.
* - Explicit PARENT_OF on a child never overridden.
* - SectionHeader that is itself a list_item or nested its descendants
* still use it as their section anchor; the SectionHeader keeps its own
* explicit parent.
*/
import type { GraphEdge, GraphNode } from './graphApi'
export function computeSectionParents(
nodes: readonly GraphNode[],
edges: readonly GraphEdge[],
): Map<string, string> {
const elementIds = new Set<string>()
const kindById = new Map<string, string | undefined>()
for (const n of nodes) {
if (n.group !== 'element') continue
elementIds.add(n.id)
kindById.set(n.id, n.label)
}
if (elementIds.size === 0) return new Map()
const explicitParentOf = new Set<string>()
const nextMap = new Map<string, string>()
const hasIncomingNext = new Set<string>()
for (const e of edges) {
if (!elementIds.has(e.source) || !elementIds.has(e.target)) continue
if (e.type === 'PARENT_OF') {
explicitParentOf.add(e.target)
} else if (e.type === 'NEXT') {
// Preserve first-seen NEXT edge per source (should be unique anyway).
if (!nextMap.has(e.source)) nextMap.set(e.source, e.target)
hasIncomingNext.add(e.target)
}
}
// Walk heads deterministically (sorted) so the same graph always produces
// the same parenting map — useful for tests and reproducible debugging.
const heads = [...elementIds].filter((id) => !hasIncomingNext.has(id)).sort()
const parents = new Map<string, string>()
const visited = new Set<string>()
for (const head of heads) {
let currentSection: string | null = null
let node: string | undefined = head
while (node && !visited.has(node)) {
visited.add(node)
const kind = kindById.get(node)
if (kind === 'SectionHeader') {
currentSection = node
// The SectionHeader itself does NOT get a synthetic parent — it is
// the anchor. If it has an explicit PARENT_OF, that one remains
// authoritative downstream (the merger respects it).
} else if (currentSection && !explicitParentOf.has(node)) {
parents.set(node, currentSection)
}
node = nextMap.get(node)
}
}
return parents
}
/**
* Merge an explicit PARENT_OF map with synthetic section parents. Explicit
* wins. Produces the final `childId → parentId` map that callers set as
* `data.parent` on Cytoscape nodes.
*/
export function mergeParentMaps(
explicit: ReadonlyMap<string, string>,
synthetic: ReadonlyMap<string, string>,
): Map<string, string> {
const out = new Map(synthetic)
for (const [child, parent] of explicit) out.set(child, parent)
return out
}
/** Extract the explicit PARENT_OF map from the edge list — convenience. */
export function explicitParentMap(edges: readonly GraphEdge[]): Map<string, string> {
const out = new Map<string, string>()
for (const e of edges) {
if (e.type === 'PARENT_OF') out.set(e.target, e.source)
}
return out
}

View file

@ -18,77 +18,26 @@
{{ payload?.page_count }} pages
</span>
<span class="graph-legend">
<button
v-for="chip in visibleChips"
:key="chip.key"
type="button"
class="legend-chip"
:class="[`legend-${chip.key}`, { 'legend-off': hiddenChips.has(chip.key) }]"
:data-e2e="`legend-${chip.key}`"
:title="
hiddenChips.has(chip.key) ? `Show ${chip.label} nodes` : `Hide ${chip.label} nodes`
"
:aria-pressed="!hiddenChips.has(chip.key)"
@click="toggleChip(chip.key)"
>
{{ chip.label }}
</button>
<span class="legend-chip legend-document">Document</span>
<span class="legend-chip legend-section">Section</span>
<span class="legend-chip legend-paragraph">Paragraph</span>
<span class="legend-chip legend-table">Table</span>
<span class="legend-chip legend-figure">Figure</span>
<span class="legend-chip legend-page">Page</span>
<span class="legend-chip legend-chunk">Chunk</span>
</span>
</div>
<div class="graph-body">
<div class="graph-canvas-wrap">
<div ref="containerRef" class="graph-canvas" data-e2e="graph-canvas" />
<!-- Hover tooltip, positioned inside the canvas wrap so its
coords match cy.renderedPosition() exactly. -->
<div
v-if="tooltip"
class="graph-tooltip"
data-e2e="graph-tooltip"
:style="{ left: tooltip.x + 'px', top: tooltip.y + 'px' }"
>
{{ tooltip.text }}
</div>
</div>
<NodeDetailsPanel
:node="selectedNode"
:contents="selectedNodeContents"
@close="closeDetails"
@navigate="navigateToNode"
/>
</div>
<div ref="containerRef" class="graph-canvas" data-e2e="graph-canvas" />
</template>
</div>
</template>
<script setup lang="ts">
import type { Core } from 'cytoscape'
import { computed, onMounted, onBeforeUnmount, ref, watch, nextTick } from 'vue'
import { 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'
import { LEGEND_CHIPS, findChip } from '../legendFilters'
import { computeSectionParents, explicitParentMap, mergeParentMaps } from '../sectionParenting'
import NodeDetailsPanel from './NodeDetailsPanel.vue'
import { fetchDocumentGraph, type GraphPayload } from '../graphApi'
// `fetcher` is optional so Maintain can keep using the Neo4j-backed endpoint
// (`fetchDocumentGraph`, the default) while the reasoning-trace page can
// inject a SQLite-backed fetcher that returns the same `GraphPayload` shape
// without requiring Neo4j. Keeping this at the component boundary means the
// rendering pipeline below doesn't care where the graph came from.
const props = withDefaults(
defineProps<{
docId: string | null
fetcher?: (docId: string) => Promise<GraphPayload>
}>(),
{ fetcher: fetchDocumentGraph },
)
const emit = defineEmits<{
/** Emitted on node tap with the element's `self_ref` (null when the tap
* cleared the selection, or when the tapped node has no self_ref
* Document / Page / Chunk). Consumers can mirror the selection elsewhere
* (e.g. the ReasoningWorkspace syncs it to the PDF viewer). */
nodeFocus: [selfRef: string | null]
}>()
const props = defineProps<{ docId: string | null }>()
const { t } = useI18n()
const containerRef = ref<HTMLDivElement | null>(null)
@ -96,58 +45,8 @@ const payload = ref<GraphPayload | null>(null)
const loading = ref(false)
const error = ref<string | null>(null)
const empty = ref(false)
// Exposed via defineExpose so parent components (e.g. the reasoning trace
// overlay) can read the live Cytoscape instance reactively. Null while the
// graph is loading / empty / unmounted.
const cy = ref<Core | null>(null)
// Legend-driven visibility: user clicks a chip its key lands here and the
// matching nodes get the `.hidden` Cytoscape class. Reset to empty every time
// the doc changes (see the `watch(() => props.docId)` below) kept as a Set
// because order doesn't matter and membership checks are the hot path.
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) ?? []
})
// Only surface chips that actually have matching nodes in the current
// payload. Keeps the legend in sync with the source (e.g. Reasoning view
// never emits Chunk nodes, so the Chunk chip would dangle) without
// hardcoding per-view chip lists.
const visibleChips = computed(() => {
const nodes = payload.value?.nodes ?? []
if (nodes.length === 0) return []
return LEGEND_CHIPS.filter((chip) => nodes.some((n) => chip.match(n)))
})
// Hover tooltip: position (px within .graph-canvas) + text. Null hides it.
const tooltip = ref<{ x: number; y: number; text: string } | null>(null)
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let cy: any | null = null
const NODE_COLORS: Record<string, string> = {
document: '#1E293B',
@ -188,7 +87,7 @@ async function load(): Promise<void> {
error.value = null
empty.value = false
try {
payload.value = await props.fetcher(props.docId)
payload.value = await fetchDocumentGraph(props.docId)
if (!payload.value.nodes.length) {
empty.value = true
return
@ -197,9 +96,6 @@ async function load(): Promise<void> {
loading.value = false
await nextTick()
await renderGraph()
// Re-apply any chips the user had toggled before this load (e.g. they
// hid Pages on doc A, then navigated to doc B keep Pages hidden).
applyHiddenChips()
} catch (e) {
error.value = (e as Error).message || 'Failed to load graph'
console.error('Failed to load graph', e)
@ -210,49 +106,26 @@ async function load(): Promise<void> {
async function renderGraph(): Promise<void> {
if (!containerRef.value || !payload.value) return
// Dynamic imports keep the heavy Cytoscape bundle out of the main chunk.
const [{ default: cytoscape }, { default: dagre }, ecMod] = await Promise.all([
// Dynamic import keeps cytoscape out of the main chunk.
const [{ default: cytoscape }, { default: dagre }] = await Promise.all([
import('cytoscape'),
import('cytoscape-dagre'),
import('cytoscape-expand-collapse'),
])
// eslint-disable-next-line @typescript-eslint/no-explicit-any
;(cytoscape as any).use(dagre)
const C = cytoscape as any
C.use(dagre)
// Plugin registration is idempotent calling use() twice is a no-op.
C.use((ecMod as any).default ?? ecMod)
if (cy.value) {
cy.value.destroy()
cy.value = null
if (cy) {
cy.destroy()
cy = null
}
// 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). 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) => ({
data: {
id: n.id,
label: nodeLabel(n),
// `kindLabel` is the specific Neo4j label (SectionHeader, Paragraph,
// Figure, ) kept as a data attribute so legend filters can match
// on it. `label` above is the human display string for Cytoscape.
kindLabel: n.label ?? null,
bg: nodeColor(n),
group: n.group,
// 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: computedParentMap.get(n.id),
raw: n,
},
})),
@ -266,7 +139,7 @@ async function renderGraph(): Promise<void> {
})),
]
cy.value = cytoscape({
cy = cytoscape({
container: containerRef.value,
elements,
style: [
@ -327,47 +200,8 @@ async function renderGraph(): Promise<void> {
selector: 'edge[type = "DERIVED_FROM"]',
style: { 'line-color': '#DC2626', 'target-arrow-color': '#DC2626' },
},
// Reasoning-trace overlay: visited-node class + synthetic REASONING_NEXT edges.
...reasoningOverlayStyles,
// Legend-driven visibility: chips toggle the `.hidden` class on
// matching nodes. `display: none` cascades to connected edges for free.
{ selector: 'node.hidden', style: { display: 'none' } },
// Compound nodes (section scope + explicit PARENT_OF containers). A
// node with :parent children is rendered as a bounding box here
// visually groups the section's content together. Minimal padding so
// the layout stays compact.
{
selector: ':parent',
style: {
'background-opacity': 0.08,
'background-color': '#F97316',
'border-color': '#FDBA74',
'border-width': 1,
'padding-top': '12px',
'padding-bottom': '12px',
'padding-left': '12px',
'padding-right': '12px',
'text-valign': 'top',
'text-halign': 'center',
'text-margin-y': -4,
shape: 'round-rectangle',
},
},
// Click-selected node: stronger border so the user sees which one
// populated the details panel.
{
selector: 'node.nd-selected',
style: {
'border-width': 4,
'border-color': '#0EA5E9',
'overlay-color': '#0EA5E9',
'overlay-opacity': 0.12,
'overlay-padding': 4,
'z-index': 60,
},
},
],
// eslint-disable-next-line @typescript-eslint/no-explicit-any
layout: {
name: 'dagre',
rankDir: 'TB',
@ -377,150 +211,13 @@ async function renderGraph(): Promise<void> {
} as any,
wheelSensitivity: 0.15,
})
// --- Plugin activation ---------------------------------------------------
// Expand/collapse on compound nodes. `layoutBy` re-runs dagre after each
// toggle so the graph stays tidy. Don't animate on big docs the per-node
// tween is choppy and the user just wants the end state.
;(cy.value as any).expandCollapse({
layoutBy: { name: 'dagre', rankDir: 'TB', nodeSep: 30, rankSep: 40 },
fisheye: false,
animate: false,
undoable: false,
cueEnabled: true, // shows the +/- cue on compound nodes
expandCollapseCuePosition: 'top-left',
expandCollapseCueSize: 12,
})
// --- Interactions --------------------------------------------------------
cy.value.on('tap', 'node', (evt) => {
const raw = evt.target.data('raw') as GraphNode | undefined
if (!raw) return
selectedNode.value = raw
// Visual feedback clear previous selection class first.
cy.value?.nodes('.nd-selected').removeClass('nd-selected')
evt.target.addClass('nd-selected')
// Let the outer workspace mirror the selection (e.g. into the PDF view).
// Nodes without a `self_ref` (Document / Page / Chunk) emit `null` so
// the consumer can reset its focus.
emit('nodeFocus', raw.self_ref ?? null)
})
// Click on background close details panel + clear cross-view focus.
cy.value.on('tap', (evt) => {
if (evt.target === cy.value) {
selectedNode.value = null
cy.value?.nodes('.nd-selected').removeClass('nd-selected')
emit('nodeFocus', null)
}
})
// Hover tooltip shows the full node text (backend truncates to 200 chars).
cy.value.on('mouseover', 'node', (evt) => {
const raw = evt.target.data('raw') as GraphNode | undefined
if (!raw) return
const text = tooltipTextFor(raw)
if (!text) return
const pos = evt.target.renderedPosition()
tooltip.value = { x: pos.x, y: pos.y, text }
})
cy.value.on('mouseout', 'node', () => {
tooltip.value = null
})
cy.value.on('pan zoom', () => {
// Tooltip coordinates are in rendered space; on pan/zoom they're stale.
tooltip.value = null
})
}
/**
* What to show on hover: prefer the node's full text, then title, then ref.
* Keeps the tooltip useful across node kinds (Document/Page/Chunk too).
*/
function tooltipTextFor(n: GraphNode): string {
if (n.group === 'document') return (n.title as string | undefined) ?? n.id
if (n.group === 'page') return `Page ${n.page_no ?? '?'}`
if (n.group === 'chunk') {
const head = ((n.text as string | undefined) ?? '').slice(0, 160)
return head ? `chunk #${n.chunk_index ?? '?'}\n${head}` : `chunk #${n.chunk_index ?? '?'}`
}
const text = (n.text as string | undefined) ?? ''
const ref = n.self_ref ?? ''
if (text) return text
return ref || n.label || ''
}
function closeDetails(): void {
selectedNode.value = null
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 })
}
}
/**
* Mirror an external selection (e.g. user clicked a bbox in the PDF view)
* onto the graph: select the matching node, scroll it into view, update
* the details panel. No-op if the element isn't in the current graph
* (common for a PDF-only element that the reasoning graph didn't emit).
*/
function selectBySelfRef(selfRef: string): void {
const node = payload.value?.nodes.find((n) => n.self_ref === selfRef) ?? null
if (!node) return
navigateToNode(node)
}
function disposeGraph(): void {
if (cy.value) {
cy.value.destroy()
cy.value = null
if (cy) {
cy.destroy()
cy = null
}
selectedNode.value = null
tooltip.value = null
}
/**
* Apply the current `hiddenChips` set to the Cytoscape instance marks
* every node whose chip is in the set with the `.hidden` class, and clears
* the class from nodes whose chip is no longer hidden.
*
* Called after every re-render (so chip state survives a doc reload) and
* whenever the user toggles a chip.
*/
function applyHiddenChips(): void {
const c = cy.value
if (!c) return
c.nodes().forEach((n: any) => {
const raw = n.data('raw')
if (!raw) return
const hiddenByChip = [...hiddenChips.value].some((key) => {
const chip = findChip(key)
return chip?.match(raw) ?? false
})
if (hiddenByChip) n.addClass('hidden')
else n.removeClass('hidden')
})
}
function toggleChip(key: string): void {
const next = new Set(hiddenChips.value)
if (next.has(key)) next.delete(key)
else next.add(key)
hiddenChips.value = next
applyHiddenChips()
}
onMounted(load)
@ -532,10 +229,6 @@ watch(
load()
},
)
// Let parent components observe the live Cytoscape instance (e.g. the
// reasoning-trace overlay reads it via `graphViewRef.value?.cy`).
defineExpose({ cy, load, selectBySelfRef })
</script>
<style scoped>
@ -574,21 +267,6 @@ defineExpose({ cy, load, selectBySelfRef })
padding: 2px 8px;
border-radius: 10px;
color: #f8fafc;
border: 0;
cursor: pointer;
font-family: inherit;
transition: opacity var(--transition);
}
.legend-chip:hover {
filter: brightness(1.12);
}
/* Inactive (user clicked the chip to hide that node kind). */
.legend-chip.legend-off {
opacity: 0.32;
text-decoration: line-through;
filter: saturate(0.4);
}
.legend-document {
@ -614,43 +292,12 @@ defineExpose({ cy, load, selectBySelfRef })
background: #dc2626;
}
.graph-body {
.graph-canvas {
flex: 1;
min-height: 0;
display: flex;
flex-direction: row;
overflow: hidden;
}
.graph-canvas-wrap {
flex: 1 1 auto;
min-width: 0;
position: relative;
overflow: hidden;
}
.graph-canvas {
position: absolute;
inset: 0;
background: var(--bg);
}
.graph-tooltip {
position: absolute;
transform: translate(-50%, calc(-100% - 14px));
max-width: 280px;
padding: 6px 10px;
background: rgba(15, 23, 42, 0.94);
color: #f8fafc;
font-size: 11px;
line-height: 1.4;
border-radius: var(--radius-sm);
pointer-events: none;
white-space: pre-wrap;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
z-index: 20;
}
.graph-placeholder {
display: flex;
flex-direction: column;

View file

@ -1,409 +0,0 @@
<template>
<aside
v-if="node"
class="nd-panel"
data-e2e="node-details-panel"
role="complementary"
:aria-label="t('graph.nodeDetails')"
>
<header class="nd-header">
<span class="nd-kind-chip" :style="{ background: kindColor }">{{ kindLabel }}</span>
<button class="nd-close" :aria-label="t('graph.close')" @click="$emit('close')"></button>
</header>
<dl class="nd-fields">
<template v-if="selfRef">
<dt>self_ref</dt>
<dd class="nd-mono">{{ selfRef }}</dd>
</template>
<template v-if="doclingLabel">
<dt>docling_label</dt>
<dd class="nd-mono">{{ doclingLabel }}</dd>
</template>
<template v-if="level != null">
<dt>level</dt>
<dd class="nd-mono">{{ level }}</dd>
</template>
<template v-if="pageNo != null">
<dt>{{ t('graph.page') }}</dt>
<dd class="nd-mono">p.{{ pageNo }}</dd>
</template>
<template v-if="chunkIndex != null">
<dt>chunk index</dt>
<dd class="nd-mono">#{{ chunkIndex }}</dd>
</template>
</dl>
<section v-if="text" class="nd-text-block">
<h4 class="nd-section-title">{{ t('graph.text') }}</h4>
<p class="nd-text">{{ text }}</p>
</section>
<section v-if="provs.length > 0" class="nd-provs-block">
<h4 class="nd-section-title">
{{ t('graph.provenances').replace('{n}', String(provs.length)) }}
</h4>
<ul class="nd-provs">
<li v-for="(p, i) in provs" :key="i" class="nd-prov">
<span class="nd-prov-page">p.{{ p.page_no ?? '?' }}</span>
<span class="nd-prov-bbox">
[{{ fmt(p.bbox_l) }}, {{ fmt(p.bbox_t) }}, {{ fmt(p.bbox_r) }}, {{ fmt(p.bbox_b) }}]
</span>
<span v-if="p.coord_origin" class="nd-prov-origin">{{ p.coord_origin }}</span>
</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>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from '../../../shared/i18n'
import type { GraphNode, GraphProvenance } from '../graphApi'
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()
// Centralised colour map, mirrors NODE_COLORS in GraphView keeping a copy
// here avoids coupling the detail panel to GraphView's internal state.
const KIND_COLORS: Record<string, string> = {
document: '#1E293B',
SectionHeader: '#F97316',
Paragraph: '#3B82F6',
TextElement: '#3B82F6',
Table: '#8B5CF6',
Figure: '#22C55E',
List: '#06B6D4',
ListItem: '#06B6D4',
Formula: '#EC4899',
Code: '#14B8A6',
Caption: '#EAB308',
PageHeader: '#64748B',
PageFooter: '#64748B',
KeyValueArea: '#D946EF',
FormArea: '#D946EF',
DocumentIndex: '#0EA5E9',
Page: '#94A3B8',
Chunk: '#DC2626',
}
const kindLabel = computed<string>(() => {
const n = props.node
if (!n) return ''
if (n.group === 'document') return 'Document'
if (n.group === 'page') return 'Page'
if (n.group === 'chunk') return 'Chunk'
return n.label ?? 'Element'
})
const kindColor = computed<string>(() => {
const n = props.node
if (!n) return '#64748B'
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
})
const selfRef = computed(() => props.node?.self_ref ?? null)
const doclingLabel = computed(() => (props.node?.docling_label as string | undefined) ?? null)
const level = computed<number | null>(() => {
const v = props.node?.level
return typeof v === 'number' ? v : null
})
const pageNo = computed<number | null>(() => {
const v = props.node?.page_no ?? props.node?.prov_page
return typeof v === 'number' ? v : null
})
const chunkIndex = computed<number | null>(() => {
const v = props.node?.chunk_index
return typeof v === 'number' ? v : null
})
const text = computed<string>(() => (props.node?.text as string | undefined) ?? '')
const provs = computed<GraphProvenance[]>(() => (props.node?.provs as GraphProvenance[]) ?? [])
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>
.nd-panel {
display: flex;
flex-direction: column;
gap: 14px;
width: 320px;
flex: 0 0 320px;
padding: 14px 16px;
background: var(--bg);
border-left: 1px solid var(--border);
overflow-y: auto;
height: 100%;
}
.nd-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.nd-kind-chip {
display: inline-block;
padding: 3px 10px;
border-radius: 10px;
color: #f8fafc;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.3px;
}
.nd-close {
background: transparent;
border: 0;
color: var(--text-muted);
font-size: 16px;
cursor: pointer;
padding: 2px 8px;
border-radius: var(--radius-sm);
}
.nd-close:hover {
background: var(--border-light);
color: var(--text);
}
.nd-fields {
display: grid;
grid-template-columns: 100px 1fr;
gap: 6px 10px;
margin: 0;
font-size: 12px;
}
.nd-fields dt {
color: var(--text-muted);
font-weight: 500;
text-transform: uppercase;
font-size: 10px;
letter-spacing: 0.4px;
align-self: center;
}
.nd-fields dd {
margin: 0;
color: var(--text);
}
.nd-mono {
font-family: 'IBM Plex Mono', monospace;
font-size: 11px;
}
.nd-section-title {
margin: 0 0 6px;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: var(--text-muted);
}
.nd-text-block {
display: flex;
flex-direction: column;
gap: 4px;
padding-top: 10px;
border-top: 1px solid var(--border-light);
}
.nd-text {
margin: 0;
font-size: 12px;
line-height: 1.5;
color: var(--text);
white-space: pre-wrap;
}
.nd-provs-block {
display: flex;
flex-direction: column;
gap: 4px;
padding-top: 10px;
border-top: 1px solid var(--border-light);
}
.nd-provs {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 4px;
}
.nd-prov {
display: grid;
grid-template-columns: 40px 1fr auto;
gap: 6px;
align-items: baseline;
font-family: 'IBM Plex Mono', monospace;
font-size: 10px;
padding: 4px 6px;
background: var(--border-light);
border-radius: var(--radius-sm);
}
.nd-prov-page {
font-weight: 700;
color: var(--text);
}
.nd-prov-bbox {
color: var(--text-secondary);
}
.nd-prov-origin {
color: var(--text-muted);
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>

View file

@ -40,10 +40,8 @@
<canvas
ref="canvasRef"
class="overlay-canvas"
:class="{ selectable }"
@mousemove="onMouseMove"
@mouseleave="hoveredElement = null"
@click="onCanvasClick"
/>
<!-- Tooltip -->
<div v-if="hoveredElement" class="tooltip" :style="tooltipStyle">
@ -78,39 +76,8 @@ const ELEMENT_COLORS: Record<string, string> = {
const props = defineProps({
pages: { type: Array as () => Page[], default: () => [] },
documentId: String,
/**
* Reasoning-trace integration hooks. Optional when unset, StructureViewer
* renders like before (Studio "Structure" tab). When set, enables overlays
* for the reasoning viewer without forking the component:
*
* - `visitedBySelfRef`: elements whose `self_ref` is in this map render in
* the reasoning accent color with a numbered badge (the visit order).
* - `focusedSelfRef`: when it changes, auto-scroll to the page of that
* element and pulse its bbox briefly.
* - `selectable`: when true, clicking a bbox emits `elementFocus` so a
* parent can sync the selection with the graph view.
*/
visitedBySelfRef: {
type: Object as () => Map<string, number> | null,
default: null,
},
focusedSelfRef: { type: String as () => string | null, default: null },
selectable: { type: Boolean, default: false },
/**
* When true AND `visitedBySelfRef` is set, non-visited elements are drawn
* with reduced alpha so the visited ones pop. Matches the reasoning
* panel's "Focus" toggle behavior on the graph.
*/
dimNonVisited: { type: Boolean, default: false },
})
const emit = defineEmits<{
/** Fired when the user clicks a bbox — only if `selectable` is true. */
elementFocus: [selfRef: string]
}>()
const REASONING_COLOR = '#EA580C'
const selectedPage = ref(1)
const hiddenTypes = reactive(new Set<string>())
const containerRef = ref<HTMLDivElement | null>(null)
@ -169,94 +136,39 @@ function drawOverlay() {
const scale = computeScale(img.clientWidth, img.clientHeight, page.width, page.height)
// Two-pass draw so reasoning overlays (highlight + pulse) sit on top of
// the base element strokes without being painted over by subsequent
// elements. First pass = base, second pass = accents.
for (const el of visibleElements.value) {
const rect = bboxToRect(el.bbox, scale)
const baseColor = ELEMENT_COLORS[el.type] || ELEMENT_COLORS.text
const isVisited =
props.visitedBySelfRef !== null && !!el.self_ref && props.visitedBySelfRef.has(el.self_ref)
const color = ELEMENT_COLORS[el.type] || ELEMENT_COLORS.text
if (isVisited) {
// Reasoning-visited element reasoning accent color, bolder stroke,
// more saturated fill than the base element. The visit-order badge
// is drawn in the second pass below.
ctx.strokeStyle = REASONING_COLOR
ctx.lineWidth = 3
ctx.strokeRect(rect.x, rect.y, rect.w, rect.h)
ctx.fillStyle = REASONING_COLOR + '33'
ctx.fillRect(rect.x, rect.y, rect.w, rect.h)
} else {
// Dim non-visited when focus mode is on and a visited set is present,
// so visited bboxes pop. Otherwise keep the regular styling.
const dim = props.dimNonVisited && props.visitedBySelfRef !== null
ctx.strokeStyle = baseColor + (dim ? '22' : '')
ctx.lineWidth = dim ? 1 : 2
ctx.strokeRect(rect.x, rect.y, rect.w, rect.h)
ctx.fillStyle = baseColor + (dim ? '08' : '20')
ctx.fillRect(rect.x, rect.y, rect.w, rect.h)
}
ctx.strokeStyle = color
ctx.lineWidth = 2
ctx.strokeRect(rect.x, rect.y, rect.w, rect.h)
ctx.fillStyle = color + '20'
ctx.fillRect(rect.x, rect.y, rect.w, rect.h)
}
// Second pass numbered badges on visited elements + focus pulse ring.
for (const el of visibleElements.value) {
const rect = bboxToRect(el.bbox, scale)
const order =
props.visitedBySelfRef !== null && el.self_ref
? props.visitedBySelfRef.get(el.self_ref)
: undefined
if (order !== undefined) {
drawVisitBadge(ctx, rect.x, rect.y, order)
}
if (props.focusedSelfRef && el.self_ref === props.focusedSelfRef) {
ctx.strokeStyle = REASONING_COLOR
ctx.lineWidth = 2
ctx.setLineDash([6, 4])
ctx.strokeRect(rect.x - 4, rect.y - 4, rect.w + 8, rect.h + 8)
ctx.setLineDash([])
}
}
}
function drawVisitBadge(ctx: CanvasRenderingContext2D, x: number, y: number, order: number): void {
const radius = 10
const cx = x
const cy = y
ctx.fillStyle = REASONING_COLOR
ctx.beginPath()
ctx.arc(cx, cy, radius, 0, Math.PI * 2)
ctx.fill()
ctx.fillStyle = '#ffffff'
ctx.font = 'bold 11px -apple-system, sans-serif'
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
ctx.fillText(String(order), cx, cy + 0.5)
}
function elementAt(e: MouseEvent): PageElement | null {
const canvas = canvasRef.value
const page = currentPageData.value
const img = imageRef.value
if (!canvas || !page || !img) return null
const canvasRect = canvas.getBoundingClientRect()
const mx = e.clientX - canvasRect.left
const my = e.clientY - canvasRect.top
const scale = computeScale(img.clientWidth, img.clientHeight, page.width, page.height)
for (const el of visibleElements.value) {
if (pointInRect(mx, my, bboxToRect(el.bbox, scale))) return el
}
return null
}
function onMouseMove(e: MouseEvent) {
const canvas = canvasRef.value
if (!canvas) return
const page = currentPageData.value
const img = imageRef.value
if (!canvas || !page || !img) return
const canvasRect = canvas.getBoundingClientRect()
const mx = e.clientX - canvasRect.left
const my = e.clientY - canvasRect.top
const found = elementAt(e)
const scale = computeScale(img.clientWidth, img.clientHeight, page.width, page.height)
let found: PageElement | null = null
for (const el of visibleElements.value) {
if (pointInRect(mx, my, bboxToRect(el.bbox, scale))) {
found = el
break
}
}
hoveredElement.value = found
if (found) {
tooltipStyle.value = {
@ -266,51 +178,9 @@ function onMouseMove(e: MouseEvent) {
}
}
function onCanvasClick(e: MouseEvent): void {
if (!props.selectable) return
const el = elementAt(e)
if (el?.self_ref) emit('elementFocus', el.self_ref)
}
watch([() => props.pages, selectedPage, hiddenTypes], () => {
nextTick(drawOverlay)
})
watch(
() => [props.visitedBySelfRef, props.dimNonVisited],
() => nextTick(drawOverlay),
)
// When the caller sets a focused self_ref (e.g. the user clicked a node in
// the graph), find which page that element lives on and jump to it. The
// overlay redraw will then show the dashed focus ring around its bbox.
function scrollToFocused(ref: string | null): void {
if (!ref) {
nextTick(drawOverlay)
return
}
for (const page of props.pages) {
if (page.elements.some((e) => e.self_ref === ref)) {
if (selectedPage.value !== page.page_number) {
selectedPage.value = page.page_number
// Let <img> reload before drawing drawOverlay runs on @load.
} else {
nextTick(drawOverlay)
}
return
}
}
// Ref not on any page (e.g. a #/body node) just redraw to clear the
// previous focus ring.
nextTick(drawOverlay)
}
watch(() => props.focusedSelfRef, scrollToFocused)
// Imperative entry point so callers can re-trigger a scroll on the same
// self_ref (the watch above only fires on value change). Used by the
// reasoning workspace when the user re-clicks the active iteration card.
defineExpose({ scrollToFocused })
</script>
<style scoped>
@ -412,10 +282,6 @@ defineExpose({ scrollToFocused })
pointer-events: auto;
}
.overlay-canvas.selectable {
cursor: pointer;
}
.tooltip {
position: absolute;
background: var(--bg-surface);

View file

@ -114,38 +114,6 @@ describe('useFeatureFlagStore', () => {
expect(store.isEnabled('ingestion')).toBe(false)
})
it('enables reasoning when reasoningAvailable is true', async () => {
mockApiFetch.mockResolvedValue({
status: 'ok',
engine: 'local',
reasoningAvailable: true,
})
const store = useFeatureFlagStore()
await store.load()
expect(store.reasoningAvailable).toBe(true)
expect(store.isEnabled('reasoning')).toBe(true)
})
it('disables reasoning when reasoningAvailable is false', async () => {
mockApiFetch.mockResolvedValue({
status: 'ok',
engine: 'local',
reasoningAvailable: false,
})
const store = useFeatureFlagStore()
await store.load()
expect(store.reasoningAvailable).toBe(false)
expect(store.isEnabled('reasoning')).toBe(false)
})
it('defaults reasoningAvailable to false when missing', async () => {
mockApiFetch.mockResolvedValue({ status: 'ok', engine: 'local' })
const store = useFeatureFlagStore()
await store.load()
expect(store.reasoningAvailable).toBe(false)
expect(store.isEnabled('reasoning')).toBe(false)
})
it('handles health endpoint failure gracefully', async () => {
mockApiFetch.mockRejectedValue(new Error('Network error'))
const store = useFeatureFlagStore()

View file

@ -14,10 +14,9 @@ interface HealthResponse {
maxPageCount?: number
maxFileSizeMb?: number
ingestionAvailable?: boolean
reasoningAvailable?: boolean
}
export type FeatureFlag = 'chunking' | 'disclaimer' | 'ingestion' | 'reasoning'
export type FeatureFlag = 'chunking' | 'disclaimer' | 'ingestion'
interface FeatureFlagDef {
description: string
@ -28,7 +27,6 @@ interface FeatureFlagContext {
engine: ConversionEngine | null
deploymentMode: DeploymentMode | null
ingestionAvailable: boolean
reasoningAvailable: boolean
}
const featureRegistry: Record<FeatureFlag, FeatureFlagDef> = {
@ -44,14 +42,6 @@ const featureRegistry: Record<FeatureFlag, FeatureFlagDef> = {
description: 'OpenSearch ingestion pipeline (embedding + vector indexing)',
isEnabled: (ctx) => ctx.ingestionAvailable,
},
reasoning: {
// Backend-gated: `reasoningAvailable` is true on `/api/health` only when
// `REASONING_ENABLED=true` AND docling-agent + mellea are importable.
// Hides the sidebar entry when the runner isn't wired, instead of
// letting the user click through to a 503.
description: 'Reasoning trace tunnel (docling-agent ReasoningResult viewer)',
isEnabled: (ctx) => ctx.reasoningAvailable,
},
}
export const useFeatureFlagStore = defineStore('feature-flags', () => {
@ -60,7 +50,6 @@ export const useFeatureFlagStore = defineStore('feature-flags', () => {
const maxPageCount = ref<number>(0)
const maxFileSizeMb = ref<number>(0)
const ingestionAvailable = ref(false)
const reasoningAvailable = ref(false)
const appVersion = ref<string>(__APP_VERSION__)
const loaded = ref(false)
const error = ref<string | null>(null)
@ -69,7 +58,6 @@ export const useFeatureFlagStore = defineStore('feature-flags', () => {
engine: engine.value,
deploymentMode: deploymentMode.value,
ingestionAvailable: ingestionAvailable.value,
reasoningAvailable: reasoningAvailable.value,
}))
function isEnabled(flag: FeatureFlag): boolean {
@ -86,7 +74,6 @@ export const useFeatureFlagStore = defineStore('feature-flags', () => {
maxPageCount.value = data.maxPageCount ?? 0
maxFileSizeMb.value = data.maxFileSizeMb ?? 0
ingestionAvailable.value = data.ingestionAvailable ?? false
reasoningAvailable.value = data.reasoningAvailable ?? false
appMaxFileSizeMb.value = maxFileSizeMb.value
appMaxPageCount.value = maxPageCount.value
if (data.version) appVersion.value = data.version
@ -104,7 +91,6 @@ export const useFeatureFlagStore = defineStore('feature-flags', () => {
maxPageCount,
maxFileSizeMb,
ingestionAvailable,
reasoningAvailable,
appVersion,
loaded,
error,

View file

@ -1,8 +1,3 @@
// Integration test — exercises the cross-feature wiring between history,
// analysis and document stores when restoring a History → Studio navigation.
// Lives under `src/__tests__/integration/` because importing real stores from
// three sibling features would be a feature-isolation violation in any
// single-feature test file. HTTP boundaries are still mocked.
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
@ -12,28 +7,28 @@ vi.mock('vue-router', () => ({
useRouter: () => ({ push: mockPush }),
}))
vi.mock('../../features/analysis/api', () => ({
vi.mock('../analysis/api', () => ({
fetchAnalyses: vi.fn(),
fetchAnalysis: vi.fn(),
createAnalysis: vi.fn(),
deleteAnalysis: vi.fn(),
}))
vi.mock('../../features/history/api', () => ({
vi.mock('./api', () => ({
fetchHistory: vi.fn(),
deleteHistoryEntry: vi.fn(),
}))
vi.mock('../../features/document/api', () => ({
vi.mock('../document/api', () => ({
fetchDocuments: vi.fn(),
uploadDocument: vi.fn(),
deleteDocument: vi.fn(),
getPreviewUrl: vi.fn(),
}))
import { useHistoryStore } from '../../features/history/store'
import { useAnalysisStore } from '../../features/analysis/store'
import { useDocumentStore } from '../../features/document/store'
import { useHistoryStore } from './store'
import { useAnalysisStore } from '../analysis/store'
import { useDocumentStore } from '../document/store'
describe('History → Studio navigation', () => {
beforeEach(() => {
@ -43,7 +38,7 @@ describe('History → Studio navigation', () => {
describe('History store provides data for navigation', () => {
it('analyses contain documentId for document selection', async () => {
const { fetchHistory } = await import('../../features/history/api')
const { fetchHistory } = await import('./api')
fetchHistory.mockResolvedValue([
{ id: 'a1', documentId: 'd1', documentFilename: 'test.pdf', status: 'COMPLETED' },
{ id: 'a2', documentId: 'd2', documentFilename: 'other.pdf', status: 'FAILED' },
@ -59,7 +54,7 @@ describe('History → Studio navigation', () => {
describe('Analysis store select() restores analysis state', () => {
it('select() sets currentAnalysis from fetched data', async () => {
const { fetchAnalysis } = await import('../../features/analysis/api')
const { fetchAnalysis } = await import('../analysis/api')
const analysis = {
id: 'a1',
documentId: 'd1',
@ -77,7 +72,7 @@ describe('History → Studio navigation', () => {
})
it('select() allows document store to select the associated document', async () => {
const { fetchAnalysis } = await import('../../features/analysis/api')
const { fetchAnalysis } = await import('../analysis/api')
fetchAnalysis.mockResolvedValue({
id: 'a1',
documentId: 'd1',
@ -136,7 +131,7 @@ describe('History → Studio navigation', () => {
describe('Full restore flow (store-level integration)', () => {
it('restores completed analysis: selects analysis + document + verify mode', async () => {
const { fetchAnalysis } = await import('../../features/analysis/api')
const { fetchAnalysis } = await import('../analysis/api')
fetchAnalysis.mockResolvedValue({
id: 'a1',
documentId: 'd1',
@ -163,7 +158,7 @@ describe('History → Studio navigation', () => {
})
it('restores failed analysis: selects analysis + document, stays in configure mode', async () => {
const { fetchAnalysis } = await import('../../features/analysis/api')
const { fetchAnalysis } = await import('../analysis/api')
fetchAnalysis.mockResolvedValue({
id: 'a2',
documentId: 'd2',
@ -186,7 +181,7 @@ describe('History → Studio navigation', () => {
})
it('handles missing analysis gracefully', async () => {
const { fetchAnalysis } = await import('../../features/analysis/api')
const { fetchAnalysis } = await import('../analysis/api')
fetchAnalysis.mockRejectedValue(new Error('Not found'))
vi.spyOn(console, 'error').mockImplementation(() => {})

View file

@ -1,45 +0,0 @@
import { apiFetch } from '../../shared/api/http'
import type { GraphPayload } from '../analysis/graphApi'
import type { ReasoningResult } from './types'
/**
* Fetch the reasoning-trace graph for a document built on the backend from
* the SQLite `document_json` blob, not Neo4j. This is intentionally decoupled
* from Maintain's richer Neo4j graph: reasoning only needs the structural
* view (sections, parent/child, reading order, pages) to overlay iterations
* onto, and should work even if Neo4j isn't configured.
*
* 404 if no completed analysis with `document_json` exists for the doc.
*/
export function fetchReasoningGraph(docId: string): Promise<GraphPayload> {
return apiFetch<GraphPayload>(`/api/documents/${encodeURIComponent(docId)}/reasoning-graph`)
}
/**
* Kick off a `docling-agent` reasoning run against a document and wait for
* the `ReasoningResult` (no streaming yet the backend blocks on `_rag_loop`
* and returns once the loop converges or hits `max_iterations`).
*
* Runs typically take 2040s depending on the model + Ollama latency. The
* caller should show a loading state.
*
* Errors:
* - 503 if `REASONING_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<ReasoningResult> {
return apiFetch<ReasoningResult>(`/api/documents/${encodeURIComponent(docId)}/reasoning`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
query,
// Backend accepts snake_case; don't camelCase here.
model_id: modelId || undefined,
}),
})
}

View file

@ -1,247 +0,0 @@
import cytoscape from 'cytoscape'
import type { Core } from 'cytoscape'
import { beforeEach, describe, expect, it } from 'vitest'
import {
REASONING_EDGE_TYPE,
applyReasoningOverlay,
buildDegradedOverlay,
clearReasoningOverlay,
focusIteration,
nodeIdForSectionRef,
} from './graphReasoningOverlay'
import type { ReasoningResult } from './types'
function seed(): Core {
// Headless mode — no DOM container needed.
return cytoscape({
headless: true,
elements: [
{ data: { id: 'elem::#/texts/0', group: 'element' } },
{ data: { id: 'elem::#/texts/3', group: 'element' } },
{ data: { id: 'elem::#/texts/7', group: 'element' } },
{ data: { id: 'elem::#/groups/1', group: 'element' } },
],
})
}
function result(
iterations: Array<Partial<ReasoningResult['iterations'][number]>>,
): ReasoningResult {
return {
answer: 'x',
converged: true,
iterations: iterations.map((it, i) => ({
iteration: i + 1,
section_ref: '',
reason: '',
section_text_length: 0,
can_answer: false,
response: '',
...it,
})),
}
}
describe('nodeIdForSectionRef', () => {
it('matches the backend _element_node id format', () => {
// Kept in sync with document-parser/infra/neo4j/queries.py::_element_node.
expect(nodeIdForSectionRef('#/texts/3')).toBe('elem::#/texts/3')
})
})
describe('applyReasoningOverlay', () => {
let cy: Core
beforeEach(() => {
cy = seed()
})
it('marks every resolvable section as visited with its iteration order', () => {
const res = applyReasoningOverlay(
cy,
result([{ section_ref: '#/texts/0' }, { section_ref: '#/texts/3' }]),
)
expect(res.presentCount).toBe(2)
expect(res.missingCount).toBe(0)
expect(cy.getElementById('elem::#/texts/0').hasClass('visited')).toBe(true)
expect(cy.getElementById('elem::#/texts/0').data('visitOrder')).toBe(1)
expect(cy.getElementById('elem::#/texts/3').data('visitOrder')).toBe(2)
})
it('adds a REASONING_NEXT edge between each consecutive visited pair', () => {
applyReasoningOverlay(
cy,
result([
{ section_ref: '#/texts/0' },
{ section_ref: '#/texts/3' },
{ section_ref: '#/texts/7' },
]),
)
const edges = cy.$(`edge[type = "${REASONING_EDGE_TYPE}"]`)
expect(edges.length).toBe(2)
const ids = edges.map((e) => e.id()).sort()
expect(ids).toEqual([
'REASONING_NEXT::elem::#/texts/0::elem::#/texts/3',
'REASONING_NEXT::elem::#/texts/3::elem::#/texts/7',
])
})
it('reports missing section refs and does not crash on them', () => {
const res = applyReasoningOverlay(
cy,
result([
{ section_ref: '#/texts/0' },
{ section_ref: '#/texts/999' }, // not in graph
{ section_ref: '#/texts/3' },
]),
)
expect(res.presentCount).toBe(2)
expect(res.missingCount).toBe(1)
expect(res.resolved[1].present).toBe(false)
expect(cy.getElementById('elem::#/texts/999').nonempty()).toBe(false)
})
it('breaks the arrow chain across a missing iteration (no ghost edges)', () => {
applyReasoningOverlay(
cy,
result([
{ section_ref: '#/texts/0' },
{ section_ref: '#/texts/999' }, // missing
{ section_ref: '#/texts/3' },
]),
)
// Only the chain between present-to-present pairs gets edges. With one
// missing in the middle, we still draw 0→3 because the filter keeps the
// present ones adjacent in the sequence used for edge drawing. Assert it:
// one edge between 0 and 3.
const edges = cy.$(`edge[type = "${REASONING_EDGE_TYPE}"]`)
expect(edges.length).toBe(1)
expect(edges[0].data('source')).toBe('elem::#/texts/0')
expect(edges[0].data('target')).toBe('elem::#/texts/3')
})
it('is idempotent — re-applying replaces the previous overlay', () => {
applyReasoningOverlay(cy, result([{ section_ref: '#/texts/0' }]))
applyReasoningOverlay(cy, result([{ section_ref: '#/texts/3' }, { section_ref: '#/texts/7' }]))
expect(cy.getElementById('elem::#/texts/0').hasClass('visited')).toBe(false)
expect(cy.getElementById('elem::#/texts/3').hasClass('visited')).toBe(true)
expect(cy.$(`edge[type = "${REASONING_EDGE_TYPE}"]`).length).toBe(1)
})
it('dims every non-visited node so the trace pops visually', () => {
applyReasoningOverlay(cy, result([{ section_ref: '#/texts/0' }, { section_ref: '#/texts/3' }]))
// Visited ones keep their full opacity (no dimmed class).
expect(cy.getElementById('elem::#/texts/0').hasClass('dimmed')).toBe(false)
expect(cy.getElementById('elem::#/texts/3').hasClass('dimmed')).toBe(false)
// Everything else gets dimmed.
expect(cy.getElementById('elem::#/texts/7').hasClass('dimmed')).toBe(true)
expect(cy.getElementById('elem::#/groups/1').hasClass('dimmed')).toBe(true)
})
it('does not dim anything when the trace has no resolvable iterations', () => {
// All missing → we don't want to wash out the whole graph for nothing.
applyReasoningOverlay(cy, result([{ section_ref: '#/texts/999' }]))
expect(cy.$('.dimmed').length).toBe(0)
})
it('skips dimming entirely when focusMode is off', () => {
applyReasoningOverlay(
cy,
result([{ section_ref: '#/texts/0' }, { section_ref: '#/texts/3' }]),
{ focusMode: false },
)
// Visited still highlighted...
expect(cy.getElementById('elem::#/texts/0').hasClass('visited')).toBe(true)
// ...but nothing else gets dimmed.
expect(cy.$('.dimmed').length).toBe(0)
})
it('does not dim the synthetic REASONING_NEXT edges', () => {
applyReasoningOverlay(
cy,
result([
{ section_ref: '#/texts/0' },
{ section_ref: '#/texts/3' },
{ section_ref: '#/texts/7' },
]),
)
const reasoningEdges = cy.$(`edge[type = "${REASONING_EDGE_TYPE}"]`)
expect(reasoningEdges.length).toBe(2)
reasoningEdges.forEach((e) => {
expect(e.hasClass('dimmed')).toBe(false)
})
})
it('preserves the original graph elements (no destructive mutations)', () => {
const beforeIds = cy
.elements()
.map((e) => e.id())
.sort()
applyReasoningOverlay(cy, result([{ section_ref: '#/texts/0' }]))
clearReasoningOverlay(cy)
const afterIds = cy
.elements()
.map((e) => e.id())
.sort()
expect(afterIds).toEqual(beforeIds)
})
})
describe('clearReasoningOverlay', () => {
it('removes class, data, dimming, and synthetic edges', () => {
const cy = seed()
applyReasoningOverlay(cy, result([{ section_ref: '#/texts/0' }, { section_ref: '#/texts/3' }]))
clearReasoningOverlay(cy)
expect(cy.$('.visited').length).toBe(0)
expect(cy.$('.dimmed').length).toBe(0)
expect(cy.getElementById('elem::#/texts/0').data('visitOrder')).toBeUndefined()
expect(cy.$(`edge[type = "${REASONING_EDGE_TYPE}"]`).length).toBe(0)
})
it('is a no-op when nothing is overlaid', () => {
const cy = seed()
expect(() => clearReasoningOverlay(cy)).not.toThrow()
})
})
describe('buildDegradedOverlay', () => {
// Used when the Neo4j graph isn't loaded — we still want to show the
// reasoning trace cards, just without graph positioning.
it('returns every iteration with present=false', () => {
const out = buildDegradedOverlay(
result([
{ section_ref: '#/texts/0', reason: 'r0', can_answer: false },
{ section_ref: '#/texts/3', reason: 'r1', can_answer: true, response: 'done' },
]),
)
expect(out.resolved).toHaveLength(2)
expect(out.presentCount).toBe(0)
expect(out.missingCount).toBe(2)
expect(out.resolved.every((r) => !r.present)).toBe(true)
expect(out.resolved[0].reason).toBe('r0')
expect(out.resolved[1].canAnswer).toBe(true)
expect(out.resolved[1].response).toBe('done')
expect(out.resolved[0].nodeId).toBe('elem::#/texts/0')
})
it('handles an empty iterations list', () => {
const out = buildDegradedOverlay(result([]))
expect(out.resolved).toEqual([])
expect(out.presentCount).toBe(0)
expect(out.missingCount).toBe(0)
})
})
describe('focusIteration', () => {
it('is a no-op for a missing node', () => {
const cy = seed()
expect(() => focusIteration(cy, 'elem::#/texts/does-not-exist')).not.toThrow()
})
})

View file

@ -1,278 +0,0 @@
/**
* Pure Cytoscape-manipulation helpers for the reasoning-trace overlay.
*
* Keeping this as a plain TS module (not a component) makes it trivially
* testable against `cytoscape({ headless: true })` and lets the same code
* drive both the v1 static-import UX and the v2 streaming-runner UX.
*
* The overlay is purely visual: it adds a class + a transient `visitOrder`
* data attribute to existing element nodes and injects synthetic
* `REASONING_NEXT` edges between successive visited nodes. None of this is
* persisted to Neo4j.
*/
import type { Core } from 'cytoscape'
import type { OverlayResult, ReasoningResult, ResolvedIteration } from './types'
export const REASONING_EDGE_TYPE = 'REASONING_NEXT'
const VISITED_CLASS = 'visited'
const DIMMED_CLASS = 'dimmed'
/**
* Build the Cytoscape node id for a given `section_ref`.
*
* Must stay in sync with `document-parser/infra/neo4j/queries.py::_element_node`
* which emits `f"elem::{self_ref}"`. The `#` that lives inside a Docling
* self_ref (e.g. `#/texts/3`) is fine inside an id string we just always
* look up via `cy.getElementById()` rather than the `#id` selector syntax,
* which would conflict with the leading `#`.
*/
export function nodeIdForSectionRef(sectionRef: string): string {
return `elem::${sectionRef}`
}
export interface OverlayOptions {
/**
* When true (default), dim every non-visited element and hide the Document
* root the user sees the trace pop against a muted background.
* When false, the graph keeps its full colors; only visited nodes get the
* orange ring + numbered badge and the REASONING_NEXT arrows are drawn.
* Toggled from the ReasoningPanel header.
*/
focusMode?: boolean
}
/**
* Apply the overlay for a freshly imported `ReasoningResult`.
*
* Idempotent: any previous overlay is cleared first. Returns a summary that
* the caller (store / panel) uses to drive the UI (list of iterations,
* missing count, etc).
*/
export function applyReasoningOverlay(
cy: Core,
result: ReasoningResult,
options: OverlayOptions = {},
): OverlayResult {
const focusMode = options.focusMode ?? true
clearReasoningOverlay(cy)
const resolved: ResolvedIteration[] = result.iterations.map((it) => {
const nodeId = nodeIdForSectionRef(it.section_ref)
const node = cy.getElementById(nodeId)
const present = node.nonempty()
if (present) {
node.addClass(VISITED_CLASS)
node.data('visitOrder', it.iteration)
}
return {
iteration: it.iteration,
sectionRef: it.section_ref,
nodeId,
present,
reason: it.reason,
canAnswer: it.can_answer,
response: it.response,
sectionTextLength: it.section_text_length,
}
})
// Dim everything that isn't part of the trace. We do this BEFORE injecting
// the synthetic REASONING_NEXT edges so those edges never receive `.dimmed`
// — they're the foreground of the viz. Takes the inspiration from the
// BboxOverlay approach (dim non-highlighted bboxes, keep the active ones
// fully opaque) and applies it to the graph.
//
// The Document root node is hidden outright (via a `display: none` rule on
// `node.dimmed[group = "document"]`) — it sits at the center of the layout
// and adds zero signal to a reasoning trace, only visual noise. Its
// connected `HAS_ROOT` edge is hidden automatically by Cytoscape.
const present = resolved.filter((r) => r.present)
if (focusMode && present.length > 0) {
cy.elements().not(`.${VISITED_CLASS}`).addClass(DIMMED_CLASS)
}
// Draw trace arrows only between successively-present iterations. Gaps
// (missing nodes) break the chain — we don't draw edges to ghosts.
for (let i = 0; i < present.length - 1; i++) {
const src = present[i]
const tgt = present[i + 1]
cy.add({
group: 'edges',
data: {
id: `${REASONING_EDGE_TYPE}::${src.nodeId}::${tgt.nodeId}`,
source: src.nodeId,
target: tgt.nodeId,
type: REASONING_EDGE_TYPE,
order: i,
},
})
}
const visitedEles = cy.$(`.${VISITED_CLASS}`)
if (visitedEles.nonempty()) {
// Padding keeps the arrows readable when the trace is one or two nodes.
cy.fit(visitedEles, 80)
}
return {
resolved,
presentCount: present.length,
missingCount: resolved.length - present.length,
}
}
/**
* Build a "degraded" overlay result when no Cytoscape instance is available
* (graph failed to load, or graph is empty for this document). Every iteration
* is marked `present: false`; the panel can still render the iteration cards
* so the user sees the reasoning they just don't get highlights on the graph.
*/
export function buildDegradedOverlay(result: ReasoningResult): OverlayResult {
const resolved: ResolvedIteration[] = result.iterations.map((it) => ({
iteration: it.iteration,
sectionRef: it.section_ref,
nodeId: nodeIdForSectionRef(it.section_ref),
present: false,
reason: it.reason,
canAnswer: it.can_answer,
response: it.response,
sectionTextLength: it.section_text_length,
}))
return {
resolved,
presentCount: 0,
missingCount: resolved.length,
}
}
/**
* Remove every overlay artifact from the graph. Safe to call when nothing
* is overlaid it becomes a no-op.
*/
export function clearReasoningOverlay(cy: Core): void {
cy.$(`.${VISITED_CLASS}`).forEach((n) => {
n.removeClass(VISITED_CLASS)
n.removeData('visitOrder')
})
cy.$(`.${DIMMED_CLASS}`).removeClass(DIMMED_CLASS)
cy.$(`edge[type = "${REASONING_EDGE_TYPE}"]`).remove()
}
/**
* Center the viewport on a single iteration's node and give it a quick
* visual pulse. No-op if the node isn't present (missing iteration).
*/
export function focusIteration(cy: Core, nodeId: string): void {
const node = cy.getElementById(nodeId)
if (!node.nonempty()) return
cy.animate(
{
center: { eles: node },
zoom: Math.max(cy.zoom(), 1.0),
},
{ duration: 300 },
)
// flashClass is a cytoscape-builtin: add the class then remove it after N ms.
node.flashClass('pulse', 800)
}
/**
* Shape of a single Cytoscape stylesheet block used for the overlay. We keep
* it intentionally loose (`Record<string, unknown>` for `style`) because the
* upstream cytoscape types fork selectors by node-vs-edge prefix and flag
* cross-type properties, while the runtime itself accepts everything we emit
* here.
*/
interface StyleBlock {
selector: string
style: Record<string, unknown>
}
/**
* Style rules appended to the existing GraphView stylesheet. Exported so the
* component can spread them into its Cytoscape config.
*/
export const reasoningOverlayStyles: StyleBlock[] = [
// Non-trace elements get dimmed — BboxOverlay-inspired: keep the colors,
// drop the opacity hard so the trace pops. Node opacity cascades to label
// + border; edges get a stronger fade because they add visual noise.
{
selector: `node.${DIMMED_CLASS}`,
style: {
opacity: 0.18,
'text-opacity': 0.25,
},
},
{
selector: `edge.${DIMMED_CLASS}`,
style: {
opacity: 0.08,
},
},
// Hide the Document root node entirely when a trace is active — it sits
// at the center of the dagre layout and is pure noise for reasoning-path
// inspection. Its `HAS_ROOT` edge is hidden automatically by Cytoscape
// because `display: none` cascades to connected edges.
{
selector: `node.${DIMMED_CLASS}[group = "document"]`,
style: {
display: 'none',
},
},
// Visited nodes: orange ring + label-less numbered badge above them.
// Explicit opacity: 1 prevents inheritance quirks when the user re-applies
// an overlay on top of an existing one.
{
selector: `node.${VISITED_CLASS}`,
style: {
'border-color': '#EA580C',
'border-width': 4,
'overlay-color': '#EA580C',
'overlay-opacity': 0.08,
'overlay-padding': 4,
opacity: 1,
'z-index': 50,
},
},
{
selector: `node.${VISITED_CLASS}[visitOrder]`,
style: {
label: 'data(visitOrder)',
'text-valign': 'top',
'text-margin-y': -6,
'text-background-color': '#EA580C',
'text-background-opacity': 1,
'text-background-padding': '3px',
'text-background-shape': 'roundrectangle',
'text-border-color': '#FFFFFF',
'text-border-width': 1.5,
'text-border-opacity': 1,
color: '#FFFFFF',
'font-weight': 700,
'font-size': 12,
'text-opacity': 1,
},
},
{
selector: `edge[type = "${REASONING_EDGE_TYPE}"]`,
style: {
'line-color': '#EA580C',
'target-arrow-color': '#EA580C',
'target-arrow-shape': 'triangle',
'arrow-scale': 1.4,
'curve-style': 'bezier',
'control-point-step-size': 40,
width: 3,
opacity: 0.95,
'z-index': 99,
},
},
{
selector: 'node.pulse',
style: {
'border-width': 7,
'border-color': '#F59E0B',
},
},
]

View file

@ -1,84 +0,0 @@
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it } from 'vitest'
import { parseImportedTrace, useReasoningStore } from './store'
describe('parseImportedTrace', () => {
const bare = {
answer: 'ok',
converged: true,
iterations: [],
}
it('accepts a bare ReasoningResult', () => {
const parsed = parseImportedTrace(bare)
expect(parsed?.result.answer).toBe('ok')
expect(parsed?.envelope).toBeNull()
})
it('accepts a sidecar envelope and extracts the result', () => {
const parsed = parseImportedTrace({
job_id: 'abc',
filename: 'x.pdf',
result: bare,
})
expect(parsed?.result.answer).toBe('ok')
expect(parsed?.envelope?.job_id).toBe('abc')
})
it('rejects shapes that are neither envelope nor bare ReasoningResult', () => {
expect(parseImportedTrace(null)).toBeNull()
expect(parseImportedTrace('string')).toBeNull()
expect(parseImportedTrace({ foo: 'bar' })).toBeNull()
expect(parseImportedTrace({ answer: 'x' })).toBeNull() // missing converged + iterations
})
})
describe('useReasoningStore', () => {
beforeEach(() => {
setActivePinia(createPinia())
})
it('starts empty', () => {
const s = useReasoningStore()
expect(s.hasTrace).toBe(false)
expect(s.iterations).toEqual([])
expect(s.presentCount).toBe(0)
})
it('setResult populates and resets active iteration', () => {
const s = useReasoningStore()
s.setActiveIteration(3)
s.setResult({ answer: 'a', converged: true, iterations: [] }, null)
expect(s.hasTrace).toBe(true)
expect(s.activeIteration).toBeNull()
})
it('toggleFocusMode flips the flag', () => {
const s = useReasoningStore()
expect(s.focusMode).toBe(true)
s.toggleFocusMode()
expect(s.focusMode).toBe(false)
s.toggleFocusMode()
expect(s.focusMode).toBe(true)
})
it('reset restores focusMode to the default on', () => {
const s = useReasoningStore()
s.toggleFocusMode()
expect(s.focusMode).toBe(false)
s.reset()
expect(s.focusMode).toBe(true)
})
it('reset clears everything including the dialog flag', () => {
const s = useReasoningStore()
s.openImportDialog()
s.setResult({ answer: 'a', converged: true, iterations: [] }, null)
s.setError('boom')
s.reset()
expect(s.hasTrace).toBe(false)
expect(s.error).toBeNull()
expect(s.importDialogOpen).toBe(false)
})
})

View file

@ -1,156 +0,0 @@
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import type { OverlayResult, ReasoningResult, ResolvedIteration, SidecarEnvelope } from './types'
/**
* Parse an arbitrary JSON payload as either:
* - a bare `ReasoningResult` (what `docling-agent` emits directly), or
* - the sidecar envelope (`{ job_id, filename, query, model, result }`)
*
* Returns `null` if the shape doesn't match either.
*/
export function parseImportedTrace(
raw: unknown,
): { result: ReasoningResult; envelope: SidecarEnvelope | null } | null {
if (!raw || typeof raw !== 'object') return null
const obj = raw as Record<string, unknown>
// Envelope shape
if (obj.result && typeof obj.result === 'object') {
const result = obj.result as ReasoningResult
if (isReasoningResult(result)) {
return { result, envelope: obj as unknown as SidecarEnvelope }
}
}
// Bare ReasoningResult
if (isReasoningResult(obj as unknown as ReasoningResult)) {
return { result: obj as unknown as ReasoningResult, envelope: null }
}
return null
}
function isReasoningResult(x: ReasoningResult | undefined): boolean {
if (!x || typeof x !== 'object') return false
return (
typeof x.answer === 'string' && typeof x.converged === 'boolean' && Array.isArray(x.iterations)
)
}
export const useReasoningStore = defineStore('reasoning', () => {
const importDialogOpen = ref(false)
// Separate modal for the live runner (POST /api/documents/:id/reasoning),
// 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<ReasoningResult | null>(null)
const envelope = ref<SidecarEnvelope | null>(null)
const overlay = ref<OverlayResult | null>(null)
const activeIteration = ref<number | null>(null)
const error = ref<string | null>(null)
// Focus mode: when true, non-visited elements are dimmed so the trace pops.
// Default ON because that's the primary value of the feature; user can
// switch it off from the panel to see the trace in the full graph context.
const focusMode = ref(true)
const hasTrace = computed(() => rawResult.value !== null)
const iterations = computed<ResolvedIteration[]>(() => overlay.value?.resolved ?? [])
const presentCount = computed(() => overlay.value?.presentCount ?? 0)
const missingCount = computed(() => overlay.value?.missingCount ?? 0)
function openImportDialog(): void {
importDialogOpen.value = true
}
function closeImportDialog(): void {
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
* reapplies the overlay via `graphReasoningOverlay.applyReasoningOverlay`.
*/
function setResult(result: ReasoningResult, env: SidecarEnvelope | null): void {
rawResult.value = result
envelope.value = env
error.value = null
activeIteration.value = null
}
function setOverlay(o: OverlayResult | null): void {
overlay.value = o
}
function setActiveIteration(n: number | null): void {
// Pure state — drives the active-card highlight in the iteration list.
// Side effects (graph pan, PDF scroll) are dispatched imperatively from
// ReasoningWorkspace.onIterationFocus so re-clicking the same iteration
// still re-focuses both views (a watch here would no-op on same value).
activeIteration.value = n
}
function setError(msg: string | null): void {
error.value = msg
}
function toggleFocusMode(): void {
focusMode.value = !focusMode.value
}
/** Full reset — e.g. when the user switches document. */
function reset(): void {
rawResult.value = null
envelope.value = null
overlay.value = null
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,
activeIteration,
error,
focusMode,
// computed
hasTrace,
iterations,
presentCount,
missingCount,
// actions
openImportDialog,
closeImportDialog,
openRunDialog,
closeRunDialog,
setRunning,
setResult,
setOverlay,
setActiveIteration,
setError,
toggleFocusMode,
reset,
}
})

View file

@ -1,66 +0,0 @@
/**
* Types mirroring the `docling-agent` reasoning-trace output (upstream type
* name: `RAGResult`).
*
* The JSON imported by the user is produced either by:
* - the R&D sidecar (`experiments/reasoning-trace/inspect_doc.py`), or
* - any external `docling-agent` run that was serialized to JSON.
*
* Since `docling-agent` uses plain pydantic (no alias generator), field names
* are **snake_case** here. This is one of the rare spots in the frontend where
* we don't normalize to camelCase keeping the shape 1:1 with upstream means
* a schema drift upstream gives us a clean type error rather than silent
* re-mapping.
*
* Source of truth: docling-project/docling-agent @ docling_agent/agent/rag_models.py
*/
export interface ReasoningIteration {
iteration: number
section_ref: string
reason: string
section_text_length: number
can_answer: boolean
response: string
}
export interface ReasoningResult {
answer: string
iterations: ReasoningIteration[]
converged: boolean
}
/**
* Envelope written by the R&D sidecar. The viewer also accepts a bare
* `ReasoningResult` (see `parseImportedTrace` in the store).
*/
export interface SidecarEnvelope {
job_id?: string
filename?: string
query?: string
model?: { ollama_name?: string | null; hf_model_name?: string | null }
max_iterations?: number
result: ReasoningResult
}
/**
* One iteration after matching its `section_ref` against the currently-loaded
* Cytoscape graph. `present=false` means the section ref has no corresponding
* node (doc not through Maintain, or a different version of the doc).
*/
export interface ResolvedIteration {
iteration: number
sectionRef: string
nodeId: string
present: boolean
reason: string
canAnswer: boolean
response: string
sectionTextLength: number
}
export interface OverlayResult {
resolved: ResolvedIteration[]
presentCount: number
missingCount: number
}

View file

@ -1,107 +0,0 @@
<template>
<div class="rdv-root" data-e2e="reasoning-document-view">
<div v-if="!pages || pages.length === 0" class="rdv-empty">
{{ t('reasoning.docNoContent') }}
</div>
<StructureViewer
v-else
ref="structureViewerRef"
:pages="pages"
:document-id="docId"
:visited-by-self-ref="visitedBySelfRef"
:focused-self-ref="focusedSelfRef"
:dim-non-visited="reasoningStore.focusMode"
selectable
class="rdv-viewer"
@element-focus="(ref) => emit('elementFocus', ref)"
/>
</div>
</template>
<script setup lang="ts">
/**
* PDF rendering of the document (per-page PNG via /api/documents/:id/preview),
* augmented with reasoning overlays:
* - elements visited by the RAG loop get a bold orange stroke + numbered
* badge showing the visit order
* - the current `focusedSelfRef` (usually driven by a graph-node click
* upstream) auto-jumps to the right page and pulses its bbox
* - clicking a bbox emits `elementFocus` so the graph can mirror the
* selection (bidirectional sync handled in ReasoningWorkspace).
*/
import { computed, ref } from 'vue'
import StructureViewer from '../../analysis/ui/StructureViewer.vue'
import { useAnalysisStore } from '../../analysis/store'
import { useI18n } from '../../../shared/i18n'
import type { Page } from '../../../shared/types'
import { useReasoningStore } from '../store'
const props = defineProps<{
docId: string
focusedSelfRef: string | null
}>()
const emit = defineEmits<{ elementFocus: [selfRef: string] }>()
const structureViewerRef = ref<InstanceType<typeof StructureViewer> | null>(null)
// Imperative passthrough so the reasoning workspace can re-trigger a scroll
// on the same self_ref (e.g. user re-clicks the active iteration card
// the prop hasn't changed so the watch wouldn't fire).
function scrollToFocused(selfRef: string | null): void {
structureViewerRef.value?.scrollToFocused(selfRef)
}
defineExpose({ scrollToFocused })
const analysisStore = useAnalysisStore()
const reasoningStore = useReasoningStore()
const { t } = useI18n()
const pages = computed<Page[]>(() => {
const hit = analysisStore.analyses.find(
(a) => a.documentId === props.docId && a.status === 'COMPLETED' && a.pagesJson,
)
if (!hit?.pagesJson) return []
try {
return JSON.parse(hit.pagesJson) as Page[]
} catch {
return []
}
})
const visitedBySelfRef = computed<Map<string, number>>(() => {
const out = new Map<string, number>()
for (const it of reasoningStore.iterations) {
if (!it.present || !it.sectionRef) continue
if (!out.has(it.sectionRef)) out.set(it.sectionRef, it.iteration)
}
return out
})
</script>
<style scoped>
.rdv-root {
flex: 1 1 auto;
min-width: 0;
min-height: 0;
overflow-y: auto;
background: var(--bg);
padding: 16px 20px;
}
.rdv-viewer {
max-width: 960px;
margin: 0 auto;
}
.rdv-empty {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
color: var(--text-muted);
font-size: 13px;
font-style: italic;
}
</style>

Some files were not shown because too many files have changed in this diff Show more