Two patterns in Docling's serialization were mirrored 1:1 by the graph
projection and produced node explosions on real documents:
- An InlineGroup (paragraph of mixed style runs) emits one `groups[]`
entry plus N `texts[]` runs. Naive iteration created one Paragraph
node per run.
- A Picture's `children` carry internal text labels extracted by the
layout model (flowchart boxes, chart axis labels, diagram callouts).
Each child became its own Paragraph node, drowning the figure.
`build_collapse_index` (in the shared `infra.docling_tree` helper) now
returns the `skip_refs` set + `inline_meta` overrides for both cases.
The Neo4j `tree_writer` and the in-memory `docling_graph` consume the
same index, so both projections stay in sync.
InlineGroups are projected as a single :Paragraph carrying the
concatenated text and the union of children's provs (re-indexed).
Pictures keep their :Figure node and prov; their descendants are
dropped. Captions live in the picture's separate `captions` field, not
in `children`, so they are unaffected.
* docs: rename Clean Architecture → Hexagonal Architecture (ports & adapters)
Le backend suit le pattern ports & adapters (ports dans domain/ports.py,
adaptateurs dans infra/), pas Clean Architecture au sens Uncle Bob.
Aligne la terminologie dans README, docs/architecture.md, ADR guide,
audit master, fiche audit 01, et la nav mkdocs.
Les noms de fichiers et la commande /audit:clean-architecture restent
stables pour preserver les liens croises et les skills existants.
* feat(settings): add paste-image size/type limits surfaced via /api/health
Introduces MAX_PASTE_IMAGE_SIZE_MB (default 10) and
PASTE_ALLOWED_IMAGE_TYPES (default image/png,image/jpeg,image/webp)
env vars so the upcoming Verify-mode clipboard-paste handler can
validate client-side against the same limits the backend enforces.
Follows the existing MAX_FILE_SIZE_MB pattern. Ships the accepted
design doc at docs/design/195-copy-paste-image-verify-mode.md.
Refs #195
Propagate Docling `self_ref` through PageElement so bboxes and graph nodes
share a stable identity. Add a Document/Graph mode switch to the reasoning
workspace; selecting a node highlights its bbox (numbered badge, focus ring,
optional dim of non-visited) and clicking a bbox re-centers the graph.
Backend — live runner
- New `POST /api/documents/:id/rag` endpoint. Loads `document_json` from
SQLite, reconstructs the DoclingDocument, wraps the model id in
`ModelIdentifier(ollama_name=...)`, and calls `agent._rag_loop`
off-thread (blocking sync call). Returns a `RAGResult` in the shape
the existing v1 import path already consumes, so the frontend overlay
is fully reused.
- `_rag_loop` is private upstream; we call it because `run()` wraps the
answer in a synthetic DoclingDocument and drops the iteration trace.
- Settings: `RAG_ENABLED`, `OLLAMA_HOST`, `RAG_MODEL_ID`. Router mounts
unconditionally; handler 503s when the flag is off or deps aren't
installed. `rag_available` surfaced in `/api/health`.
- Maps known docling-agent bugs to readable HTTP errors: 502 with
"the model couldn't produce a parseable answer" when `_rag_loop`
raises `IndexError` from `find_json_dicts([])[0]` after 3 + 3
rejection-sampling retries (model-dependent).
- Tests: 11 cases (flag off, query empty, no analysis, happy path,
model_id wrap, Ollama env, IndexError → 502, other errors → 500,
deps missing → 503).
Backend — bug fix
- Default `BATCH_PAGE_SIZE` flipped from `10` to `0` to match the
dataclass default. The old default silently dropped `document_json`
(see `domain/services.merge_results`) for any doc > 10 pages, which
broke the reasoning tunnel. Set `BATCH_PAGE_SIZE>0` explicitly on
memory-constrained deploys if batching is wanted.
Frontend — runner UX
- `features/reasoning/api.ts:runReasoning()` — POST wrapper.
- `RunReasoningDialog.vue` — query textarea + optional model_id
override. Blocks close while running, 20-40s loading state,
synthesises a sidecar-shaped envelope so the panel surfaces query +
model the same way an imported trace would.
- `ReasoningWorkspace.vue` — primary "Run reasoning" button; "Import
trace" relegated to ghost secondary.
- Store: `runDialogOpen`, `running`, `setRunning`.
Frontend — answer polish
- Answer rendered through `marked` + DOMPurify (models emit markdown
lists; `pre-wrap` rendered them as plain "1. …" strings).
- Dedicated answer block with orange border, "ANSWER" label, "Copy"
button (clipboard + "Copied ✓" feedback).
- IterationCard: drop the duplicate `response` block (the main answer
is authoritative); style reasons equal to `"fallback"` (docling-agent
`select_from_failure` placeholder) as italic muted "— no structured
rationale".
Frontend — node details contents
- Clicking a SectionHeader (or any node with compound children) lists
its contained elements in `NodeDetailsPanel` under a new "Contents"
block. Children come from the same `parentMap` used for Cytoscape
compound parenting (explicit PARENT_OF + synthetic section scope),
inverted once and cached as a computed.
- Click a child row → pan the viewport to it + swap the selection.
Housekeeping
- `cytoscape-navigator` removed from `package-lock.json` (follow-up
from the minimap removal in the previous commit).
Adds the `docling-agent` reasoning-trace viewer as a Studio tunnel, per
`docs/design/reasoning-trace.md`. Users pick an analyzed document, import
a RAGResult JSON, and the iterations are overlaid on the document graph.
Graph source is decoupled from Neo4j: a new pure builder
(`infra/docling_graph.build_graph_payload`) reads `document_json` from
SQLite and emits the same Cytoscape-shaped payload that `fetch_graph`
returns from Neo4j. Neo4j stays exclusive to the Maintain ingestion
pipeline. Shared DoclingDocument helpers live in `infra/docling_tree.py`
so TreeWriter and the builder can't drift on label taxonomy or tree walks.
Also removes the Cytoscape minimap (cytoscape-navigator) from GraphView:
second render instance hurt perf on large documents for no UX win.
Backend
- new `GET /api/documents/:id/reasoning-graph` (SQLite-only)
- new `infra/docling_tree.py`, `infra/docling_graph.py`
- `analysis_repo.find_latest_completed_by_document`
- tests: `test_docling_graph.py` (builder), `test_graph_api.py` (endpoint)
Frontend
- `features/reasoning/` — store, overlay, types, panel, import dialog,
workspace, doc picker
- new `ReasoningPage` + `/reasoning` and `/reasoning/:docId` routes
- `GraphView` gains a `fetcher` prop so reasoning can inject the
SQLite-backed fetcher while Maintain keeps using the Neo4j one
- drops minimap (nav container, dep, CSS)
- legend filters + section parenting extracted for reuse
- i18n base strings (FR + EN)
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/
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
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
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
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
- 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
Default value of 5 is now in the application code (settings.py) instead
of only in the Docker image ENV. Consistent across all deployment modes
(dev local, Docker, tests). Aligned docker-compose files and docs.
- local_converter.py: remove redundant `_default_converter = None` in
except block of `_ensure_default_converter` (variable was already None,
re-raised immediately — dead store)
- test_analysis_service.py: replace bare `await task` with
`await asyncio.gather(task)` to satisfy static analysis
- Replace French mode strings (configurer/verifier/preparer) with English
equivalents (configure/verify/prepare) in StudioPage.vue and tests
- Extract _build_conversion_options, _run_conversion, _finalize_analysis
from _run_analysis_inner to respect Single Responsibility Principle
- Rename _get_default_converter to _ensure_default_converter to reflect
its lazy-init side effect
Closes#136, closes#137, closes#138
Set up a full E2E test suite (39 scenarios) using Karate against
the real API stack. Hybrid architecture: domain-based features +
cross-domain workflows, with data-driven testing and callable helpers.
Structure:
- e2e/pom.xml: Maven + karate-core 1.5
- 3 helpers (upload, analyze+poll, cleanup)
- 3 JSON schemas (health, document, analysis)
- 12 feature files across health, documents, analyses, workflows
- Tags: @smoke (2), @regression (35), @e2e (2)
- generate-test-data.py: fpdf2-based PDF generation (no binaries)
Also adds:
- RATE_LIMIT_RPM env var to make rate limiter configurable (0=disabled)
- CI job e2e with needs: [backend, frontend]
- e2e/ in .dockerignore
Closes#119
Replace hardcoded 5 MB upload limit with a configurable setting.
Backend exposes the value via /api/health, frontend reads it
dynamically for validation and UI messages.
Closes#48
Use Docling's native page_range parameter to split large PDFs into
sequential batches, preventing memory exhaustion and timeouts.
Progress is reported via existing polling mechanism.
Closes#56
If the lazy-init of the default converter fails (e.g. model download
error), the singleton was left as None but subsequent calls would not
retry. Now the failed state is cleared so the next request retries.
Ref #57 (H5)
TableFormerMode.ACCURATE is very expensive on CPU (~3-5x slower).
The default can now be set to "fast" on resource-constrained
environments (HF Spaces) via the DEFAULT_TABLE_MODE env var.
User-specified table_mode in the request still takes precedence.
Ref #57 (H1)
Defense-in-depth: even if upload validation passes, Docling itself
now enforces page count and file size limits. Configurable via
MAX_PAGE_COUNT and MAX_FILE_SIZE env vars (0 = unlimited).
Ref #57 (C3)
A frozen conversion holding the lock indefinitely blocks all subsequent
jobs. Using lock.acquire(timeout=300) fails fast with a clear error
instead of waiting forever.
Ref #57 (C2)
Docling's native document_timeout is the only mechanism that can
interrupt processing inside a blocked thread (OCR, table extraction).
Without it, asyncio.wait_for cannot stop a frozen conversion.
Configurable via DOCUMENT_TIMEOUT env var (default: 120s).
Closes#57 (C1)
Prevents PyTorch/Docling pipeline crashes on HF Spaces CPU by:
- Reducing max file size from 50 MB to 5 MB
- Adding configurable MAX_PAGE_COUNT setting (env var, default unlimited)
- Increasing conversion timeout from 600s to 900s
- Adding frontend upload validation with explicit error messages
- Exposing maxPageCount via /api/health for dynamic UI hints
Lightweight sliding-window per-IP rate limiter (100 req/min default)
with no external dependency. Health endpoint is excluded. Returns 429
with Retry-After header when exceeded. Sufficient for single-process
SQLite deployments; document the Redis upgrade path for scale.
Unbounded asyncio.create_task calls could exhaust CPU and memory on
modest hardware. Add a configurable semaphore (MAX_CONCURRENT_ANALYSES,
default 3) so excess jobs queue instead of running all at once.
UPLOAD_DIR and DB_PATH were read directly from os.environ, bypassing
the Settings dataclass. This caused an inconsistency where overriding
Settings had no effect on these values. Now all modules import from
infra.settings.settings.
domain/ must be pure with no external dependencies. bbox.py imports
docling_core and belongs in infra/. Also refactor ServeConverter to
use the canonical to_topleft_list via BoundingBox instead of
duplicated manual coordinate conversion. Move docling-core to base
requirements since it is now needed in both modes.
Replace hardcoded version strings with build-time injection:
- Frontend: Vite __APP_VERSION__ from env or package.json
- Backend: APP_VERSION env var exposed via /health endpoint
- Docker: build arg propagated through both stages
- CI: release workflow extracts version from git tag
Document branching strategy and release process in CONTRIBUTING.md.
Catch up CHANGELOG with v0.2.0 and Unreleased sections.
Sync package.json version to 0.3.0.
Extract bounding boxes from chunk doc_items provenance in the chunker,
propagate through domain/service/API layers, and render highlighted
bboxes on canvas when hovering a chunk card. Reset highlights on
mode and page changes to prevent stale visual state.
LocalChunker implements DocumentChunker port using docling-core chunkers.
LocalConverter now serializes DoclingDocument to JSON for re-chunking support.
Docling Serve expects array fields (to_formats) as repeated multipart
keys (to_formats=md&to_formats=html&to_formats=json), not a JSON
string. Changed _build_form_data to return list[tuple] so httpx sends
repeated keys correctly. Fixes 422 Unprocessable Entity on convert.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Bug #1: _on_task_done now receives job_id via functools.partial and
calls _mark_failed when the background task raises or is cancelled,
preventing jobs from being stuck in RUNNING state forever.
Bug #5: _parse_response wraps json.loads in try/except JSONDecodeError
so malformed json_content strings fall back gracefully instead of crashing.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Delete domain/parsing.py (broke hexagonal layering by importing infra)
- Migrate all tests to import directly from domain.value_objects and
infra.local_converter
- Rewrite ServeConverter to match real Docling Serve v1 API contract:
options sent as individual form fields (not JSON blob), response
parsed from document.json_content (DoclingDocument), proper bbox
coord_origin handling (TOPLEFT/BOTTOMLEFT)
- Transmit all conversion options including generate_picture_images
- Replace fragile lazy import circular dep with FastAPI Depends() +
app.state for AnalysisService injection
- Add frontend file size validation (50MB) before upload
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Implement the HTTP client adapter that delegates document conversion
to a remote Docling Serve instance via its /v1/convert/file endpoint.
Switchable via CONVERSION_ENGINE=remote env var. Includes health check,
API key auth, response parsing, and 30 new tests covering parsing,
type mapping, HTTP calls, and DI wiring.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Extract domain value objects and ports from parsing.py, move Docling-specific
code to infra/local_converter.py, and convert analysis_service to a class
with injected DocumentConverter. This prepares the codebase for plugging in
alternative conversion backends (e.g. Docling Serve) via the Protocol pattern.
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>