Compare commits
23 commits
main
...
fix/197-co
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a95e1b628d | ||
|
|
d02731b622 | ||
|
|
679dbc975a | ||
|
|
636045e410 | ||
|
|
77fcb32e7f | ||
|
|
9ec64961fc | ||
|
|
bef7ec4686 | ||
|
|
1f02274ac4 | ||
|
|
df786a4ab4 | ||
|
|
e4c53f1809 | ||
|
|
5bc98ee483 | ||
|
|
aa60fbb768 | ||
|
|
5a2eaacd4d | ||
|
|
c9359f60e1 | ||
|
|
ee92e3c580 | ||
|
|
3474390688 | ||
|
|
dfbca40730 | ||
|
|
358e575f0f | ||
|
|
3bdc4cec50 | ||
|
|
0bffe6e7d4 | ||
|
|
987d43735d | ||
|
|
7a76d2efbd | ||
|
|
f2436290c5 |
97 changed files with 11422 additions and 94 deletions
|
|
@ -48,3 +48,8 @@
|
||||||
|
|
||||||
# Embedding vector dimension (default: 384 for Granite Embedding 30M / all-MiniLM-L6-v2)
|
# Embedding vector dimension (default: 384 for Granite Embedding 30M / all-MiniLM-L6-v2)
|
||||||
# EMBEDDING_DIMENSION=384
|
# EMBEDDING_DIMENSION=384
|
||||||
|
|
||||||
|
# Neo4j — graph-native document structure (used by docker-compose.dev.yml)
|
||||||
|
# NEO4J_URI=bolt://neo4j:7687
|
||||||
|
# NEO4J_USER=neo4j
|
||||||
|
# NEO4J_PASSWORD=changeme
|
||||||
|
|
|
||||||
6
.github/workflows/ci.yml
vendored
6
.github/workflows/ci.yml
vendored
|
|
@ -29,7 +29,7 @@ jobs:
|
||||||
with:
|
with:
|
||||||
python-version: "3.12"
|
python-version: "3.12"
|
||||||
cache: pip
|
cache: pip
|
||||||
cache-dependency-path: document-parser/requirements.txt
|
cache-dependency-path: document-parser/requirements-test.txt
|
||||||
|
|
||||||
- name: Install system dependencies
|
- name: Install system dependencies
|
||||||
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends poppler-utils
|
run: sudo apt-get update && sudo apt-get install -y --no-install-recommends poppler-utils
|
||||||
|
|
@ -37,8 +37,8 @@ jobs:
|
||||||
- name: Install Python dependencies
|
- name: Install Python dependencies
|
||||||
run: |
|
run: |
|
||||||
pip install --upgrade pip
|
pip install --upgrade pip
|
||||||
pip install -r requirements.txt
|
pip install -r requirements-test.txt
|
||||||
pip install pytest pytest-asyncio httpx ruff
|
pip install httpx ruff
|
||||||
|
|
||||||
- name: Lint
|
- name: Lint
|
||||||
run: ruff check .
|
run: ruff check .
|
||||||
|
|
|
||||||
12
.trivyignore.yaml
Normal file
12
.trivyignore.yaml
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
vulnerabilities:
|
||||||
|
- id: CVE-2026-40393
|
||||||
|
paths:
|
||||||
|
- "usr/lib/x86_64-linux-gnu/libgbm.so.*"
|
||||||
|
- "usr/lib/x86_64-linux-gnu/dri/*"
|
||||||
|
- "usr/lib/x86_64-linux-gnu/libGLX_mesa.so.*"
|
||||||
|
- "usr/lib/x86_64-linux-gnu/libgallium-*.so"
|
||||||
|
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
|
||||||
66
README.md
66
README.md
|
|
@ -33,6 +33,7 @@ Upload a PDF, configure the extraction pipeline, and visualize the results — t
|
||||||
- **Per-page results** — right panel syncs with the current PDF page
|
- **Per-page results** — right panel syncs with the current PDF page
|
||||||
- **Chunking** — split extracted content into semantic chunks (hierarchical, hybrid, or page-based) with configurable token limits and inline editing
|
- **Chunking** — split extracted content into semantic chunks (hierarchical, hybrid, or page-based) with configurable token limits and inline editing
|
||||||
- **Ingestion pipeline** — Docling → chunking → embedding → OpenSearch vector indexing (one-click from Studio)
|
- **Ingestion pipeline** — Docling → chunking → embedding → OpenSearch vector indexing (one-click from Studio)
|
||||||
|
- **Graph storage (Neo4j)** — full DoclingDocument tree (sections, paragraphs, tables, pages, chunks) mirrored as a graph with `PARENT_OF`, `NEXT`, `ON_PAGE`, `HAS_CHUNK`, `DERIVED_FROM` relations, with an in-app graph view powered by Cytoscape.js
|
||||||
- **Markdown & HTML export** of extracted content
|
- **Markdown & HTML export** of extracted content
|
||||||
- **Document management** — upload, list, delete, search, filter by indexing status
|
- **Document management** — upload, list, delete, search, filter by indexing status
|
||||||
- **Analysis history** — re-visit and open past analyses
|
- **Analysis history** — re-visit and open past analyses
|
||||||
|
|
@ -58,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 |
|
| **frontend** | Vue 3, TypeScript, Vite, Pinia | UI, PDF viewer, results display |
|
||||||
| **document-parser** | FastAPI, Docling, SQLite, pdf2image | REST API, document parsing, storage |
|
| **document-parser** | FastAPI, Docling, SQLite, pdf2image | REST API, document parsing, storage |
|
||||||
|
|
||||||
### Backend structure (clean architecture)
|
### Backend structure (hexagonal architecture — ports & adapters)
|
||||||
|
|
||||||
```
|
```
|
||||||
document-parser/
|
document-parser/
|
||||||
|
|
@ -244,6 +245,69 @@ When ingestion is enabled, the UI shows:
|
||||||
| `EMBEDDING_URL` | — | Embedding service endpoint (empty = ingestion disabled) |
|
| `EMBEDDING_URL` | — | Embedding service endpoint (empty = ingestion disabled) |
|
||||||
| `EMBEDDING_DIMENSION` | `384` | Vector dimension (must match embedding model) |
|
| `EMBEDDING_DIMENSION` | `384` | Vector dimension (must match embedding model) |
|
||||||
|
|
||||||
|
## Graph storage with Neo4j (opt-in)
|
||||||
|
|
||||||
|
Docling Studio can mirror the full **DoclingDocument tree** into a [Neo4j](https://neo4j.com/) graph: sections, paragraphs, tables, figures, pages, and chunks all become first-class nodes connected by `HAS_ROOT`, `PARENT_OF`, `NEXT`, `ON_PAGE`, `HAS_CHUNK`, and `DERIVED_FROM` edges. This enables queries that are impossible with a flat chunk store — navigating a document's outline, finding all tables under a given section, or tracing a chunk back to its source elements.
|
||||||
|
|
||||||
|
Enable Neo4j with the ingestion profile (it ships alongside OpenSearch):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose --profile ingestion \
|
||||||
|
-f docker-compose.yml -f docker-compose.ingestion.yml \
|
||||||
|
up --build
|
||||||
|
```
|
||||||
|
|
||||||
|
The Neo4j Browser is available at <http://localhost:7474> (user `neo4j`, password `changeme` by default).
|
||||||
|
|
||||||
|
### Schema at a glance
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
graph TD
|
||||||
|
D[Document] -->|HAS_ROOT| SH[SectionHeader]
|
||||||
|
D -->|HAS_CHUNK| C[Chunk]
|
||||||
|
SH -->|PARENT_OF| P[Paragraph]
|
||||||
|
SH -->|PARENT_OF| T[Table]
|
||||||
|
P -->|NEXT| T
|
||||||
|
P -->|ON_PAGE| PG[Page]
|
||||||
|
T -->|ON_PAGE| PG
|
||||||
|
C -->|DERIVED_FROM| P
|
||||||
|
C -->|DERIVED_FROM| T
|
||||||
|
```
|
||||||
|
|
||||||
|
### Example Cypher queries
|
||||||
|
|
||||||
|
Find all "Methods" sections across documents (impossible in vector-only stores):
|
||||||
|
|
||||||
|
```cypher
|
||||||
|
MATCH (d:Document)-[:HAS_ROOT]->(:Element)-[:PARENT_OF*]->(s:SectionHeader)
|
||||||
|
WHERE toLower(s.text) CONTAINS 'method'
|
||||||
|
RETURN d.title, s.text, s.level
|
||||||
|
```
|
||||||
|
|
||||||
|
Get the parent section and sibling elements of a chunk (context for RAG):
|
||||||
|
|
||||||
|
```cypher
|
||||||
|
MATCH (c:Chunk {id: $chunk_id})-[:DERIVED_FROM]->(e:Element)
|
||||||
|
MATCH (e)<-[:PARENT_OF]-(parent:Element)-[:PARENT_OF]->(sibling:Element)
|
||||||
|
RETURN parent, collect(sibling) AS siblings
|
||||||
|
```
|
||||||
|
|
||||||
|
List all tables from documents ingested from an `invoices/` path:
|
||||||
|
|
||||||
|
```cypher
|
||||||
|
MATCH (d:Document)-[:HAS_ROOT]->(:Element)-[:PARENT_OF*]->(t:Table)
|
||||||
|
WHERE d.source_uri CONTAINS 'invoices/'
|
||||||
|
RETURN d.title, t.caption, t.cells_json
|
||||||
|
```
|
||||||
|
|
||||||
|
| Variable | Default | Description |
|
||||||
|
|----------|---------|-------------|
|
||||||
|
| `NEO4J_URI` | — | Neo4j Bolt endpoint (empty = graph storage disabled) |
|
||||||
|
| `NEO4J_USER` | `neo4j` | Neo4j username |
|
||||||
|
| `NEO4J_PASSWORD` | `changeme` | Neo4j password |
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
## CI / Release
|
## CI / Release
|
||||||
|
|
||||||
GitHub Actions pipelines (see [`.github/workflows/`](.github/workflows/)):
|
GitHub Actions pipelines (see [`.github/workflows/`](.github/workflows/)):
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,26 @@
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
services:
|
services:
|
||||||
|
# --- Neo4j (graph-native document structure) ---
|
||||||
|
neo4j:
|
||||||
|
image: neo4j:5.15-community
|
||||||
|
environment:
|
||||||
|
NEO4J_AUTH: ${NEO4J_USER:-neo4j}/${NEO4J_PASSWORD:-changeme}
|
||||||
|
NEO4J_server_memory_heap_initial__size: 512m
|
||||||
|
NEO4J_server_memory_heap_max__size: 1g
|
||||||
|
ports:
|
||||||
|
- "7474:7474"
|
||||||
|
- "7687:7687"
|
||||||
|
volumes:
|
||||||
|
- neo4j_data:/data
|
||||||
|
- neo4j_logs:/logs
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "cypher-shell -u $${NEO4J_USER:-neo4j} -p $${NEO4J_PASSWORD:-changeme} 'RETURN 1' || exit 1"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
start_period: 30s
|
||||||
|
|
||||||
# --- OpenSearch (single-node, security disabled for local dev) ---
|
# --- OpenSearch (single-node, security disabled for local dev) ---
|
||||||
opensearch:
|
opensearch:
|
||||||
image: opensearchproject/opensearch:2
|
image: opensearchproject/opensearch:2
|
||||||
|
|
@ -77,12 +97,17 @@ services:
|
||||||
BATCH_PAGE_SIZE: ${BATCH_PAGE_SIZE:-10}
|
BATCH_PAGE_SIZE: ${BATCH_PAGE_SIZE:-10}
|
||||||
OPENSEARCH_URL: http://opensearch:9200
|
OPENSEARCH_URL: http://opensearch:9200
|
||||||
EMBEDDING_URL: http://embedding:8001
|
EMBEDDING_URL: http://embedding:8001
|
||||||
|
NEO4J_URI: bolt://neo4j:7687
|
||||||
|
NEO4J_USER: ${NEO4J_USER:-neo4j}
|
||||||
|
NEO4J_PASSWORD: ${NEO4J_PASSWORD:-changeme}
|
||||||
command: ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
|
command: ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--reload"]
|
||||||
depends_on:
|
depends_on:
|
||||||
opensearch:
|
opensearch:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
embedding:
|
embedding:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
neo4j:
|
||||||
|
condition: service_healthy
|
||||||
deploy:
|
deploy:
|
||||||
resources:
|
resources:
|
||||||
limits:
|
limits:
|
||||||
|
|
@ -105,6 +130,8 @@ services:
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
opensearch_data:
|
opensearch_data:
|
||||||
|
neo4j_data:
|
||||||
|
neo4j_logs:
|
||||||
uploads_data:
|
uploads_data:
|
||||||
db_data:
|
db_data:
|
||||||
frontend_node_modules:
|
frontend_node_modules:
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,22 @@
|
||||||
services:
|
services:
|
||||||
|
# --- Neo4j (graph-native document structure) ---
|
||||||
|
neo4j:
|
||||||
|
profiles: ["ingestion"]
|
||||||
|
image: neo4j:5.15-community
|
||||||
|
environment:
|
||||||
|
NEO4J_AUTH: ${NEO4J_USER:-neo4j}/${NEO4J_PASSWORD:-changeme}
|
||||||
|
NEO4J_server_memory_heap_initial__size: 512m
|
||||||
|
NEO4J_server_memory_heap_max__size: 1g
|
||||||
|
volumes:
|
||||||
|
- neo4j_data:/data
|
||||||
|
- neo4j_logs:/logs
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "cypher-shell -u $${NEO4J_USER:-neo4j} -p $${NEO4J_PASSWORD:-changeme} 'RETURN 1' || exit 1"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
start_period: 30s
|
||||||
|
|
||||||
# --- OpenSearch (single-node, security disabled) ---
|
# --- OpenSearch (single-node, security disabled) ---
|
||||||
opensearch:
|
opensearch:
|
||||||
profiles: ["ingestion"]
|
profiles: ["ingestion"]
|
||||||
|
|
@ -53,6 +71,9 @@ services:
|
||||||
BATCH_PAGE_SIZE: ${BATCH_PAGE_SIZE:-10}
|
BATCH_PAGE_SIZE: ${BATCH_PAGE_SIZE:-10}
|
||||||
OPENSEARCH_URL: ${OPENSEARCH_URL:-}
|
OPENSEARCH_URL: ${OPENSEARCH_URL:-}
|
||||||
EMBEDDING_URL: ${EMBEDDING_URL:-}
|
EMBEDDING_URL: ${EMBEDDING_URL:-}
|
||||||
|
NEO4J_URI: ${NEO4J_URI:-}
|
||||||
|
NEO4J_USER: ${NEO4J_USER:-neo4j}
|
||||||
|
NEO4J_PASSWORD: ${NEO4J_PASSWORD:-changeme}
|
||||||
deploy:
|
deploy:
|
||||||
resources:
|
resources:
|
||||||
limits:
|
limits:
|
||||||
|
|
@ -69,5 +90,7 @@ services:
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
opensearch_data:
|
opensearch_data:
|
||||||
|
neo4j_data:
|
||||||
|
neo4j_logs:
|
||||||
uploads_data:
|
uploads_data:
|
||||||
db_data:
|
db_data:
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ Two services communicating via REST. The frontend is a Vue 3 SPA served by Nginx
|
||||||
|
|
||||||
### Zooming into the backend
|
### Zooming into the backend
|
||||||
|
|
||||||
The schema above shows the macro view. Inside the backend, the code follows a **Clean Architecture** with strict layer boundaries:
|
The schema above shows the macro view. Inside the backend, the code follows a **Hexagonal Architecture** (ports & adapters) 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.
|
Dependencies flow **inward**: `api → services → domain`. The domain layer has zero knowledge of HTTP or database.
|
||||||
|
|
||||||
## Backend — Clean Architecture
|
## Backend — Hexagonal Architecture (ports & adapters)
|
||||||
|
|
||||||
The backend follows a strict layered architecture. Dependencies flow inward: API → Services → Domain. The domain layer has zero knowledge of HTTP or database.
|
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.
|
||||||
|
|
||||||
```
|
```
|
||||||
document-parser/
|
document-parser/
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,7 @@ These decisions were made before the ADR process was introduced. They are docume
|
||||||
|
|
||||||
| Decision | Rationale | Date |
|
| Decision | Rationale | Date |
|
||||||
|----------|-----------|------|
|
|----------|-----------|------|
|
||||||
| Clean Architecture (hexagonal) for backend | Decouple domain from framework — enable converter swapping (local/remote) | 2025-01 |
|
| Hexagonal Architecture (ports & adapters) for backend | Decouple domain from framework — enable converter swapping (local/remote) via ports | 2025-01 |
|
||||||
| FastAPI over Django | Async-first, lightweight, Pydantic-native — better fit for a single-purpose API | 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 |
|
| 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 |
|
| SQLite over PostgreSQL | Single-file DB, zero ops — appropriate for a document processing tool | 2025-01 |
|
||||||
|
|
|
||||||
142
docs/architecture/adrs/ADR-001-graph-visualization-library.md
Normal file
142
docs/architecture/adrs/ADR-001-graph-visualization-library.md
Normal file
|
|
@ -0,0 +1,142 @@
|
||||||
|
# ADR-001: Graph visualization library for the Neo4j graph view
|
||||||
|
|
||||||
|
**Date**: 2026-04-17
|
||||||
|
**Status**: Proposed
|
||||||
|
**Deciders**: Pier-Jean Malandrino
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
v0.5.0 introduces Neo4j as a graph-native storage layer for parsed documents
|
||||||
|
(see [docs/design/neo4j-integration.md](../../design/neo4j-integration.md)
|
||||||
|
and [#186](https://github.com/scub-france/Docling-Studio/issues/186)). We need
|
||||||
|
an in-app visualization of that graph: the `DoclingDocument` tree as rendered
|
||||||
|
in Neo4j, with nodes colored by element type (`SectionHeader`, `Paragraph`,
|
||||||
|
`Table`, `Figure`, `ListItem`, `Formula`) and edges (`PARENT_OF`, `NEXT`,
|
||||||
|
`ON_PAGE`, `HAS_CHUNK`, `DERIVED_FROM`).
|
||||||
|
|
||||||
|
The view lives in the existing Vue 3 debug panel. It is the **primary demo
|
||||||
|
artifact** for the Hackernoon hackathon (Neo4j partner), so polish matters as
|
||||||
|
much as correctness.
|
||||||
|
|
||||||
|
### Constraints
|
||||||
|
|
||||||
|
- Vue 3 + Vite frontend, no framework change
|
||||||
|
- Must render the full tree of a 200-page document (worst case ≈ a few
|
||||||
|
thousand nodes; see graph endpoint cap in the design doc §8.4)
|
||||||
|
- Needs a **clean hierarchical layout** — documents are trees, not arbitrary
|
||||||
|
graphs; a good tree layout is the single biggest UX lever
|
||||||
|
- Needs per-node styling (shape + color by label), click, hover, zoom, pan
|
||||||
|
- Must be installable without Java/Python-side changes
|
||||||
|
- License compatible with the repo (MIT-ish preferred)
|
||||||
|
|
||||||
|
### Non-goals for v0.5.0
|
||||||
|
|
||||||
|
- 3D rendering
|
||||||
|
- Force-directed simulation as the primary layout (we have a tree)
|
||||||
|
- Editing nodes in place (read-only view)
|
||||||
|
- Rendering millions of nodes
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Use **Cytoscape.js** via a thin Vue wrapper (`vue-cytoscape` or a bespoke
|
||||||
|
`GraphView.vue` that imports `cytoscape` directly and uses the
|
||||||
|
`dagre`/`breadthfirst` layouts).
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
### Positive
|
||||||
|
|
||||||
|
- Battle-tested library (13k+ GitHub stars, maintained since 2013, used by
|
||||||
|
Neo4j's own "Bloom"-style visualizations in the community)
|
||||||
|
- First-class support for hierarchical layouts via `cytoscape-dagre` (hub-and-
|
||||||
|
spoke / tree) and built-in `breadthfirst` — both map naturally to our
|
||||||
|
`PARENT_OF` structure
|
||||||
|
- CSS-like selector syntax for styling (`node[label = "Table"] { ... }`),
|
||||||
|
which is pleasant to evolve as we add node types
|
||||||
|
- Permissive licensing (MIT)
|
||||||
|
- Headless mode available, so it can be tested outside a DOM (Jest + jsdom
|
||||||
|
works cleanly)
|
||||||
|
- Active ecosystem: `cytoscape-cola`, `cytoscape-klay`, `cytoscape-popper` for
|
||||||
|
tooltips, all maintained
|
||||||
|
- Bundle size is reasonable for a demo: ~300 KB min+gz for core + dagre, well
|
||||||
|
below our current frontend budget
|
||||||
|
|
||||||
|
### Negative
|
||||||
|
|
||||||
|
- Styling DSL is powerful but has its own syntax to learn; not plain CSS
|
||||||
|
- Large graphs (>10k nodes) benefit from canvas+WebGL libraries
|
||||||
|
(sigma.js, reagraph) — we are explicitly not in that regime for v0.5, but
|
||||||
|
we would need to swap if we later visualize the cross-document graph
|
||||||
|
- No Vue 3 component library that is both maintained and popular — we wrap it
|
||||||
|
ourselves in `GraphView.vue` (the wrapper is ~50 LOC, so this is minor)
|
||||||
|
|
||||||
|
### Neutral
|
||||||
|
|
||||||
|
- Not "Neo4j-branded": we do not use Neovis.js, which is a thin Cytoscape
|
||||||
|
wrapper around the Bolt protocol. Our graph API already returns shaped
|
||||||
|
JSON, so the Neovis convenience is not worth the lock-in
|
||||||
|
- We take on one runtime dependency (`cytoscape` + `cytoscape-dagre`)
|
||||||
|
|
||||||
|
## Alternatives Considered
|
||||||
|
|
||||||
|
### Alternative 1: vis-network (vis.js)
|
||||||
|
|
||||||
|
- **Pros**: Very easy to get started, built-in physics, shipped by Neo4j
|
||||||
|
Browser historically
|
||||||
|
- **Cons**: Maintenance has been rocky (original vis.js split into several
|
||||||
|
forks; `vis-network` is the maintained branch but releases are sparse);
|
||||||
|
hierarchical layout is OK but less configurable than dagre; styling API is
|
||||||
|
less expressive; TypeScript types lag behind the JS API
|
||||||
|
- **Why rejected**: Hierarchical layout quality is the single most important
|
||||||
|
criterion for a document tree, and vis-network is clearly a notch below
|
||||||
|
Cytoscape + dagre here. Maintenance trajectory is also a concern for a
|
||||||
|
release we want to keep shipping on
|
||||||
|
|
||||||
|
### Alternative 2: Neovis.js
|
||||||
|
|
||||||
|
- **Pros**: Built by Neo4j Labs, connects directly to a Bolt endpoint, nice
|
||||||
|
out-of-the-box "Neo4j look"
|
||||||
|
- **Cons**: Wraps Cytoscape anyway, so everything it can do we can do with
|
||||||
|
Cytoscape directly; expects the browser to talk Bolt, which forces us to
|
||||||
|
expose Neo4j creds in the frontend OR to proxy Bolt through the backend
|
||||||
|
(both worse than our current "backend returns JSON" design); limited
|
||||||
|
customization compared to raw Cytoscape
|
||||||
|
- **Why rejected**: The auth story is a non-starter for a hackathon demo we
|
||||||
|
want to show publicly, and we lose nothing vs. Cytoscape by going one
|
||||||
|
layer lower
|
||||||
|
|
||||||
|
### Alternative 3: D3 (d3-hierarchy + d3-force)
|
||||||
|
|
||||||
|
- **Pros**: Maximum flexibility; beautiful, publication-grade output; full
|
||||||
|
SVG control
|
||||||
|
- **Cons**: Much more code for the same result — layout, zoom, pan, hover,
|
||||||
|
selection all hand-rolled; steeper learning curve for future contributors
|
||||||
|
to the project; no built-in graph data model
|
||||||
|
- **Why rejected**: We're building a product feature, not a data-viz
|
||||||
|
artefact. The time budget (1 day of Day 3) doesn't fit a D3 build-your-own
|
||||||
|
|
||||||
|
### Alternative 4: Reagraph / react-force-graph / sigma.js (WebGL)
|
||||||
|
|
||||||
|
- **Pros**: Scales to tens of thousands of nodes at 60 FPS; good for future
|
||||||
|
cross-document visualization
|
||||||
|
- **Cons**: Optimized for force-directed layouts, weaker hierarchical
|
||||||
|
support; Reagraph is React-only (requires a React island inside Vue);
|
||||||
|
sigma.js's tree layout is immature
|
||||||
|
- **Why rejected**: Wrong regime for a single-document tree. Worth
|
||||||
|
reconsidering if/when we visualize the full corpus graph in a later release
|
||||||
|
|
||||||
|
### Alternative 5: Mermaid
|
||||||
|
|
||||||
|
- **Pros**: Trivial to embed, already used in docs
|
||||||
|
- **Cons**: Static rendering, no interactivity, not designed for thousands of
|
||||||
|
nodes, no per-node click/hover
|
||||||
|
- **Why rejected**: A viewer, not a visualizer. We need interactivity
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [Neo4j integration design doc](../../design/neo4j-integration.md) §8.3
|
||||||
|
- [Issue #186 — Neo4j integration](https://github.com/scub-france/Docling-Studio/issues/186)
|
||||||
|
- [Cytoscape.js](https://js.cytoscape.org/)
|
||||||
|
- [cytoscape-dagre](https://github.com/cytoscape/cytoscape.js-dagre)
|
||||||
|
- [vis-network](https://visjs.github.io/vis-network/docs/network/)
|
||||||
|
- [Neovis.js](https://github.com/neo4j-contrib/neovis.js)
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
# Audit 01 — Clean Architecture
|
# Audit 01 — Hexagonal Architecture (ports & adapters)
|
||||||
|
|
||||||
**Objectif** : verifier que le backend respecte le flux de dependances strict `api -> services -> domain` et que chaque couche a une responsabilite claire.
|
**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.
|
||||||
|
|
||||||
**Cible** : `document-parser/` (hors `.venv/`, `__pycache__/`, `tests/`)
|
**Cible** : `document-parser/` (hors `.venv/`, `__pycache__/`, `tests/`)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -74,7 +74,7 @@ Les audits sont executes dans l'ordre ci-dessous. Chacun est une fiche autonome
|
||||||
|
|
||||||
| # | Audit | Fichier | Focus |
|
| # | Audit | Fichier | Focus |
|
||||||
|---|-------|---------|-------|
|
|---|-------|---------|-------|
|
||||||
| 01 | Clean Architecture | [01-clean-architecture.md](audits/01-clean-architecture.md) | Respect des couches, flux de dependances |
|
| 01 | Hexagonal Architecture | [01-clean-architecture.md](audits/01-clean-architecture.md) | Ports & adapters, respect des couches, flux de dependances |
|
||||||
| 02 | DDD | [02-ddd.md](audits/02-ddd.md) | Bounded contexts, entites, value objects, ubiquitous language |
|
| 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 |
|
| 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 |
|
| 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 |
|
| # | Audit | Score | CRIT | MAJ | MIN | INFO | Verdict |
|
||||||
|---|-------|-------|------|-----|-----|------|---------|
|
|---|-------|-------|------|-----|-----|------|---------|
|
||||||
| 01 | Clean Architecture | XX | N | N | N | N | GO |
|
| 01 | Hexagonal Architecture | XX | N | N | N | N | GO |
|
||||||
| 02 | DDD | XX | N | N | N | N | GO |
|
| 02 | DDD | XX | N | N | N | N | GO |
|
||||||
| ... | ... | ... | ... | ... | ... | ... | ... |
|
| ... | ... | ... | ... | ... | ... | ... | ... |
|
||||||
|
|
||||||
|
|
|
||||||
404
docs/design/195-copy-paste-image-verify-mode.md
Normal file
404
docs/design/195-copy-paste-image-verify-mode.md
Normal file
|
|
@ -0,0 +1,404 @@
|
||||||
|
# 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>
|
||||||
435
docs/design/neo4j-integration.md
Normal file
435
docs/design/neo4j-integration.md
Normal file
|
|
@ -0,0 +1,435 @@
|
||||||
|
# Neo4j integration — Docling-Studio v0.5.0
|
||||||
|
|
||||||
|
Design doc for Neo4j integration targeting release 0.5.0.
|
||||||
|
Target: Hackernoon hackathon demo (Neo4j partner).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. Context and goals
|
||||||
|
|
||||||
|
### Already in Docling-Studio
|
||||||
|
- Ingestion pipeline: Docling parser → chunking (HybridChunker) → embedding → OpenSearch (vector index)
|
||||||
|
- Vue 3 + FastAPI UI
|
||||||
|
- Debug view to inspect/edit chunks before retrieval
|
||||||
|
- Docker compose with existing services
|
||||||
|
|
||||||
|
### What we add in v0.5.0
|
||||||
|
- Neo4j as **graph-native storage** of the document structure
|
||||||
|
- A new ingestion layer that stores the DoclingDocument tree faithfully as a graph
|
||||||
|
- Minimal UI to visualize the graph (demo value to the judges)
|
||||||
|
- Compose pipeline with Neo4j
|
||||||
|
|
||||||
|
### Why graph-native (hackathon positioning)
|
||||||
|
> Most document AI tools store parsed content as flat chunks in a vector DB.
|
||||||
|
> Docling-Studio v0.5 introduces a graph-native storage layer on top of Neo4j,
|
||||||
|
> preserving the full hierarchical structure of documents as first-class citizens.
|
||||||
|
> This unlocks hybrid retrieval, agentic navigation, and structural debugging —
|
||||||
|
> impossible with chunk-only stores.
|
||||||
|
|
||||||
|
### Out of scope for v0.5.0 (roadmap mention only)
|
||||||
|
- EnrichmentWriter (entities / summaries / keywords via docling-agent) — v0.6.0
|
||||||
|
- Agent reasoning trace viewer — v0.6.0
|
||||||
|
- RAG hybrid (graph traversal + vector) — v0.7.0
|
||||||
|
- Document versioning — v0.7.0+
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. Architectural principles
|
||||||
|
|
||||||
|
### Port & adapter, with nuance
|
||||||
|
|
||||||
|
**Write side**: one `Writer` port, **composable stages** (not alternative adapters).
|
||||||
|
Pipelines A and B are additive, not exclusive.
|
||||||
|
|
||||||
|
```
|
||||||
|
CORE (always) Pipeline A (RAG) Pipeline B (agent-ready, v0.6+)
|
||||||
|
┌─────────────┐ ┌────────────────┐ ┌───────────────────┐
|
||||||
|
│ TreeWriter │ ─────▶ │ ChunkWriter │ │ EnrichmentWriter │
|
||||||
|
│ │ │ (existing │ │ (via docling- │
|
||||||
|
│ │ │ OpenSearch + │ │ agent, v0.6+) │
|
||||||
|
│ │ │ adds chunks │ │ │
|
||||||
|
│ │ │ to Neo4j) │ │ │
|
||||||
|
└─────────────┘ └────────────────┘ └───────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
```python
|
||||||
|
# docling_studio/ingestion/pipeline.py
|
||||||
|
class Writer(Protocol):
|
||||||
|
def write(self, doc: DoclingDocument, ctx: IngestionContext) -> None: ...
|
||||||
|
|
||||||
|
# Explicit composition per use case
|
||||||
|
def build_pipeline(config: PipelineConfig) -> list[Writer]:
|
||||||
|
writers = [TreeWriter(neo4j_driver)]
|
||||||
|
if config.rag_enabled:
|
||||||
|
writers.append(ChunkWriter(neo4j_driver, chunker, embedder, opensearch))
|
||||||
|
if config.enrichment_enabled: # v0.6.0+
|
||||||
|
writers.append(EnrichmentWriter(neo4j_driver, docling_agent))
|
||||||
|
return writers
|
||||||
|
```
|
||||||
|
|
||||||
|
**Read side**: two distinct ports (same Neo4j backend, different queries).
|
||||||
|
|
||||||
|
```python
|
||||||
|
class RAGRetrievalPort(Protocol):
|
||||||
|
def search(self, query: str, k: int) -> list[Chunk]: ...
|
||||||
|
def similar(self, chunk_id: str, k: int) -> list[Chunk]: ...
|
||||||
|
|
||||||
|
class TreeNavigationPort(Protocol): # v0.6.0+
|
||||||
|
def get_outline(self, doc_id: str) -> Tree: ...
|
||||||
|
def read_node(self, ref: str) -> Element: ...
|
||||||
|
def list_children(self, ref: str) -> list[Element]: ...
|
||||||
|
def walk(self, ref: str, depth: int) -> SubTree: ...
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. Neo4j schema
|
||||||
|
|
||||||
|
### Constraints & indexes (created at boot)
|
||||||
|
|
||||||
|
```cypher
|
||||||
|
// Uniqueness
|
||||||
|
CREATE CONSTRAINT document_id IF NOT EXISTS
|
||||||
|
FOR (d:Document) REQUIRE d.id IS UNIQUE;
|
||||||
|
|
||||||
|
CREATE CONSTRAINT element_composite IF NOT EXISTS
|
||||||
|
FOR (e:Element) REQUIRE (e.doc_id, e.self_ref) IS UNIQUE;
|
||||||
|
|
||||||
|
CREATE CONSTRAINT page_composite IF NOT EXISTS
|
||||||
|
FOR (p:Page) REQUIRE (p.doc_id, p.page_no) IS UNIQUE;
|
||||||
|
|
||||||
|
CREATE CONSTRAINT chunk_id IF NOT EXISTS
|
||||||
|
FOR (c:Chunk) REQUIRE c.id IS UNIQUE;
|
||||||
|
|
||||||
|
// Full-text index (element text search)
|
||||||
|
CREATE FULLTEXT INDEX element_text IF NOT EXISTS
|
||||||
|
FOR (e:Element) ON EACH [e.text];
|
||||||
|
|
||||||
|
// Simple indexes for per-doc queries
|
||||||
|
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);
|
||||||
|
```
|
||||||
|
|
||||||
|
### Data model
|
||||||
|
|
||||||
|
```cypher
|
||||||
|
// Root document
|
||||||
|
(:Document {
|
||||||
|
id: string, // UUID or PDF hash
|
||||||
|
title: string,
|
||||||
|
source_uri: string, // path or S3
|
||||||
|
ingested_at: datetime,
|
||||||
|
docling_version: string,
|
||||||
|
stages_applied: list<string>, // ["tree", "chunks"] etc.
|
||||||
|
last_tree_write: datetime,
|
||||||
|
last_chunk_write: datetime,
|
||||||
|
tenant_id: string // simple multi-tenancy
|
||||||
|
})
|
||||||
|
|
||||||
|
// All tree elements (shared :Element label + specific label)
|
||||||
|
(:Element:SectionHeader {doc_id, self_ref, text, level, prov_page, prov_bbox})
|
||||||
|
(:Element:Paragraph {doc_id, self_ref, text, prov_page, prov_bbox})
|
||||||
|
(:Element:Table {doc_id, self_ref, caption, cells_json, prov_page, prov_bbox})
|
||||||
|
(:Element:Figure {doc_id, self_ref, caption, image_uri, prov_page, prov_bbox})
|
||||||
|
(:Element:ListItem {doc_id, self_ref, text, marker, prov_page, prov_bbox})
|
||||||
|
(:Element:Formula {doc_id, self_ref, latex, text, prov_page, prov_bbox})
|
||||||
|
|
||||||
|
// Page for layout provenance
|
||||||
|
(:Page {doc_id, page_no, width, height})
|
||||||
|
|
||||||
|
// Chunks (Pipeline A)
|
||||||
|
(:Chunk {
|
||||||
|
id, doc_id,
|
||||||
|
text,
|
||||||
|
chunk_index,
|
||||||
|
embedding_ref, // id in OpenSearch (no inline duplication)
|
||||||
|
token_count
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Relations
|
||||||
|
|
||||||
|
```cypher
|
||||||
|
// Hierarchical structure
|
||||||
|
(:Document)-[:HAS_ROOT]->(:Element)
|
||||||
|
(:Element)-[:PARENT_OF {order: int}]->(:Element) // order preserves sequence
|
||||||
|
(:Element)-[:NEXT]->(:Element) // DFS pre-order reading
|
||||||
|
|
||||||
|
// Layout
|
||||||
|
(:Element)-[:ON_PAGE]->(:Page)
|
||||||
|
|
||||||
|
// Pipeline A (chunking)
|
||||||
|
(:Document)-[:HAS_CHUNK]->(:Chunk)
|
||||||
|
(:Chunk)-[:DERIVED_FROM]->(:Element) // back-reference; a chunk can span multiple elements
|
||||||
|
```
|
||||||
|
|
||||||
|
### Decisions
|
||||||
|
|
||||||
|
| Decision | Choice | Rationale |
|
||||||
|
|----------|-------|---------------|
|
||||||
|
| Element composite key | `(doc_id, self_ref)` | self_ref not unique across docs |
|
||||||
|
| Multi-tenancy | `tenant_id` property on Document | Simple, filterable, migrable to multi-db later |
|
||||||
|
| Table cells | `cells_json` property | v0.5 KISS. May model `(Table)-[:HAS_CELL]->(Cell)` in v0.6+ |
|
||||||
|
| Reading order | `[:NEXT]` chain + `{order}` on `PARENT_OF` | Both views useful |
|
||||||
|
| Versioning | None (replace strategy on re-upload) | v0.5 KISS |
|
||||||
|
| APOC | Not required | Pure Cypher is sufficient for v0.5 |
|
||||||
|
|
||||||
|
### Re-ingestion strategy
|
||||||
|
|
||||||
|
```cypher
|
||||||
|
// Before ingesting, wipe existing
|
||||||
|
MATCH (d:Document {id: $doc_id})
|
||||||
|
OPTIONAL MATCH (d)-[:HAS_ROOT|HAS_CHUNK]->()
|
||||||
|
DETACH DELETE d
|
||||||
|
// Then re-walk cleanly
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Implementation plan (3 days)
|
||||||
|
|
||||||
|
### Day 1 — Infra + schema
|
||||||
|
|
||||||
|
- [ ] Add `neo4j` service to `docker-compose.yml` (`neo4j:5.15-community`, persistent volume, healthcheck)
|
||||||
|
- [ ] Add env vars (`NEO4J_URI`, `NEO4J_USER`, `NEO4J_PASSWORD`) to `.env.example`
|
||||||
|
- [ ] Create module `docling_studio/storage/neo4j/`:
|
||||||
|
- `driver.py` — neo4j-python driver wrapper (connection pool, context manager)
|
||||||
|
- `schema.py` — idempotent `bootstrap_schema()` (CREATE CONSTRAINT / INDEX at startup)
|
||||||
|
- `__init__.py` with exports
|
||||||
|
- [ ] Hook `bootstrap_schema()` in FastAPI startup
|
||||||
|
- [ ] Basic integration tests:
|
||||||
|
- Driver connection
|
||||||
|
- Schema bootstrap (idempotence verified)
|
||||||
|
- Simple round-trip: write Document, read Document, delete Document
|
||||||
|
|
||||||
|
**Deliverable:** docker compose boots with healthy Neo4j, schema in place at init.
|
||||||
|
|
||||||
|
### Day 2 — TreeWriter (write + read)
|
||||||
|
|
||||||
|
- [ ] `storage/neo4j/tree_writer.py` — `DoclingDocument → Neo4j` walker
|
||||||
|
- `write_document(doc, tenant_id, driver)` in a transaction
|
||||||
|
- DFS pre-order, batched `MERGE` for perf
|
||||||
|
- Pages first, then Elements, then `PARENT_OF` / `NEXT` / `ON_PAGE` relations
|
||||||
|
- Dynamic labels based on `node.label` (`SectionHeader`, `Paragraph`, …)
|
||||||
|
- [ ] `storage/neo4j/tree_reader.py` — inverse walker `Neo4j → DoclingDocument`
|
||||||
|
- `read_document(doc_id, driver) -> DoclingDocument`
|
||||||
|
- Loads all Elements + Pages, rebuilds the Pydantic structure
|
||||||
|
- Prerequisite for v0.6 (feeding docling-agent from Neo4j)
|
||||||
|
- [ ] Integrate into existing ingestion pipeline:
|
||||||
|
- Add TreeWriter as first stage of `IngestionPipeline`
|
||||||
|
- `neo4j_enabled: bool` config toggle
|
||||||
|
- [ ] Round-trip tests:
|
||||||
|
- 3–4 varied PDFs (academic, invoice, report)
|
||||||
|
- Assertion: `doc_original == read_document(write_document(doc_original))`
|
||||||
|
- Beware dates, bbox floats (tolerance)
|
||||||
|
|
||||||
|
**Deliverable:** A PDF uploaded to Docling-Studio is fully present in Neo4j and rebuildable.
|
||||||
|
|
||||||
|
### Day 3 — UI + ChunkWriter + packaging
|
||||||
|
|
||||||
|
- [ ] `storage/neo4j/chunk_writer.py`:
|
||||||
|
- After existing chunking, push each Chunk to Neo4j
|
||||||
|
- Create `(:Chunk)-[:DERIVED_FROM]->(:Element)` via source element `self_ref`
|
||||||
|
- Do NOT duplicate embeddings (stay in OpenSearch, keep `embedding_ref`)
|
||||||
|
- [ ] Frontend: new "Graph view" tab in debug panel
|
||||||
|
- Vue component with `cytoscape` (lighter, better layout API — see [ADR-001](../architecture/adrs/ADR-001-graph-visualization-library.md))
|
||||||
|
- FastAPI endpoint `/api/documents/{doc_id}/graph` returns full nodes + edges for the document, **capped at 200 pages** (HTTP 413 beyond; pagination deferred to v0.6). The endpoint must include a `truncated: bool` flag and `node_count` / `edge_count` in the response envelope so the UI can warn the user cleanly.
|
||||||
|
- View: vertical tree, colors per node type, click-to-zoom, hover details
|
||||||
|
- [ ] Per-document "Graph-ready" / "RAG-ready" badge in list
|
||||||
|
- [ ] README update:
|
||||||
|
- "Graph storage with Neo4j" section
|
||||||
|
- Schema diagram (Mermaid or image)
|
||||||
|
- 2–3 Cypher examples like "find all paragraphs under section X that mention Y"
|
||||||
|
- Neo4j badge in features list
|
||||||
|
- [ ] (bonus if time) "Query explorer" dev tab for live demo: Cypher editor + results
|
||||||
|
|
||||||
|
**Deliverable:** release 0.5.0 with Neo4j visible, functional, documented.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. Proposed code structure
|
||||||
|
|
||||||
|
```
|
||||||
|
docling_studio/
|
||||||
|
├── storage/
|
||||||
|
│ ├── neo4j/
|
||||||
|
│ │ ├── __init__.py
|
||||||
|
│ │ ├── driver.py # connection management
|
||||||
|
│ │ ├── schema.py # bootstrap_schema()
|
||||||
|
│ │ ├── tree_writer.py # DoclingDocument -> Neo4j
|
||||||
|
│ │ ├── tree_reader.py # Neo4j -> DoclingDocument
|
||||||
|
│ │ ├── chunk_writer.py # Chunks -> Neo4j
|
||||||
|
│ │ └── queries.py # shared Cypher queries
|
||||||
|
│ ├── opensearch/ # (existing)
|
||||||
|
│ └── ports.py # Writer, RAGRetrievalPort protocols
|
||||||
|
├── ingestion/
|
||||||
|
│ └── pipeline.py # IngestionPipeline composing Writers
|
||||||
|
├── api/
|
||||||
|
│ └── graph.py # /api/documents/{id}/graph
|
||||||
|
└── frontend/
|
||||||
|
└── components/
|
||||||
|
└── GraphView.vue # cytoscape + graph API fetch
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. Docker compose (added excerpt)
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
services:
|
||||||
|
neo4j:
|
||||||
|
image: neo4j:5.15-community
|
||||||
|
environment:
|
||||||
|
NEO4J_AUTH: ${NEO4J_USER:-neo4j}/${NEO4J_PASSWORD:-changeme}
|
||||||
|
NEO4J_PLUGINS: '["apoc"]'
|
||||||
|
NEO4J_server_memory_heap_initial__size: 512m
|
||||||
|
NEO4J_server_memory_heap_max__size: 1g
|
||||||
|
ports:
|
||||||
|
- "7474:7474" # Browser UI (demo)
|
||||||
|
- "7687:7687" # Bolt protocol
|
||||||
|
volumes:
|
||||||
|
- neo4j_data:/data
|
||||||
|
- neo4j_logs:/logs
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "cypher-shell -u neo4j -p $${NEO4J_PASSWORD:-changeme} 'RETURN 1' || exit 1"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 10
|
||||||
|
|
||||||
|
docling-studio-backend:
|
||||||
|
depends_on:
|
||||||
|
neo4j:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
NEO4J_URI: bolt://neo4j:7687
|
||||||
|
NEO4J_USER: neo4j
|
||||||
|
NEO4J_PASSWORD: ${NEO4J_PASSWORD:-changeme}
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
neo4j_data:
|
||||||
|
neo4j_logs:
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. Tests
|
||||||
|
|
||||||
|
### Unit tests
|
||||||
|
- `tests/storage/neo4j/test_schema.py` — bootstrap is idempotent
|
||||||
|
- `tests/storage/neo4j/test_tree_writer.py` — round-trip on synthetic DoclingDocument
|
||||||
|
- `tests/storage/neo4j/test_chunk_writer.py` — chunks written with correct `DERIVED_FROM`
|
||||||
|
|
||||||
|
### Integration tests
|
||||||
|
- `tests/integration/test_ingestion_pipeline.py` — full pipeline on a real PDF
|
||||||
|
- PDF fixtures: 1 academic (complex heading hierarchy), 1 invoice (tables), 1 report (lists)
|
||||||
|
|
||||||
|
### E2E (bonus)
|
||||||
|
- Upload PDF via UI → check structure in Neo4j Browser
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 8. Open decisions to settle before coding
|
||||||
|
|
||||||
|
1. **Neo4j edition**: Community (free) or AuraDB (managed) ?
|
||||||
|
- Rec: Community in Docker for v0.5.0 dev/demo. AuraDB mentioned as prod option.
|
||||||
|
|
||||||
|
2. **Chunks: duplicate embeddings in Neo4j or OpenSearch ref ?**
|
||||||
|
- Rec: OpenSearch ref (avoid duplication; OpenSearch remains source of truth for vectors). In v0.6+, consider native Neo4j vector index.
|
||||||
|
|
||||||
|
3. **Graph view UI: cytoscape or vis-network ?**
|
||||||
|
- Decided: **Cytoscape.js** — see [ADR-001](../architecture/adrs/ADR-001-graph-visualization-library.md) for the full analysis.
|
||||||
|
|
||||||
|
4. **Graph endpoint: return full doc or paginate ?**
|
||||||
|
- Decided: full doc for v0.5, **hard cap at 200 pages**. Beyond the cap, the endpoint returns HTTP 413 with a `truncated: true` flag; the UI shows "Graph too large to render — reduce scope". Pagination ships in v0.6.
|
||||||
|
|
||||||
|
5. **Error strategy**: if Neo4j is down at ingestion, fail or degrade gracefully ?
|
||||||
|
- Rec: **fail fast** for v0.5 (avoid silent inconsistencies). `neo4j_required: bool` config option.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 9. Hooks for later (v0.6.0+ — don't implement but prepare)
|
||||||
|
|
||||||
|
**EnrichmentWriter (v0.6)** — will need:
|
||||||
|
- The reader (Neo4j → DoclingDocument) to re-materialize the doc, feed docling-agent, re-patch enrichments
|
||||||
|
- A stage addable to `IngestionPipeline` without touching other stages
|
||||||
|
- An `:Entity` label (not created in v0.5 but schema-compatible)
|
||||||
|
|
||||||
|
**Agent reasoning trace viewer (v0.6)** — will need:
|
||||||
|
- An event stream (WebSocket) that v0.5 already prepares via the reactive UI
|
||||||
|
- A node_ref ↔ Element correlation in Neo4j (our composite `self_ref` key is enough)
|
||||||
|
|
||||||
|
**TreeNavigationPort (v0.7)** — will need:
|
||||||
|
- Optimized Cypher queries for descendant/ancestor walk (indexes already provisioned)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 10. v0.5.0 success criteria
|
||||||
|
|
||||||
|
**Must have:**
|
||||||
|
- [ ] A PDF uploaded to Docling-Studio is in Neo4j with structure preserved
|
||||||
|
- [ ] Neo4j Browser shows the graph and is manually explorable
|
||||||
|
- [ ] A graph visual in the Docling-Studio UI works
|
||||||
|
- [ ] `docker compose up` works zero-config
|
||||||
|
- [ ] README mentions Neo4j and describes the schema
|
||||||
|
|
||||||
|
**Nice to have (decreasing priority):**
|
||||||
|
- [ ] Graph-ready / RAG-ready badge per doc
|
||||||
|
- [ ] Live query explorer in the UI
|
||||||
|
- [ ] 2–3 example queries in README that do something impossible with vector-only
|
||||||
|
|
||||||
|
**For the hackathon (post-release):**
|
||||||
|
- [ ] 60s video: upload PDF → structure in Neo4j → cross-doc query impossible in vector-only
|
||||||
|
- [ ] HackerNoon post explaining "graph-native documents" positioning
|
||||||
|
- [ ] Explicit Neo4j partnership mention
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 11. Fundamental architectural decisions recap
|
||||||
|
|
||||||
|
| Question | Answer |
|
||||||
|
|----------|---------|
|
||||||
|
| Is Neo4j source of truth or cache ? | **Source of truth** for structure. OpenSearch remains source of truth for embeddings. |
|
||||||
|
| Does chunking go away ? | No, v0.5.0 keeps existing chunking. "Chunkless" is Pipeline B, v0.6+. |
|
||||||
|
| Can it be toggled per doc ? | Yes — `stages_applied` on Document + pipeline config |
|
||||||
|
| What about OpenSearch ? | Stays, stores vectors. Neo4j tracks `(:Chunk)-[:DERIVED_FROM]->(:Element)` links. |
|
||||||
|
| Multi-tenancy ? | `tenant_id` property on Document, Cypher filter |
|
||||||
|
| Versioning ? | None for v0.5.0 — replace strategy on re-upload |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Appendix — Demo queries
|
||||||
|
|
||||||
|
### Query 1 — All "Methods" sections across documents
|
||||||
|
```cypher
|
||||||
|
MATCH (d:Document)-[:HAS_ROOT]->(:Element)-[:PARENT_OF*]->(s:SectionHeader)
|
||||||
|
WHERE toLower(s.text) CONTAINS 'method'
|
||||||
|
RETURN d.title, s.text, s.level
|
||||||
|
```
|
||||||
|
|
||||||
|
### Query 2 — Context of a chunk (parent + siblings)
|
||||||
|
```cypher
|
||||||
|
MATCH (c:Chunk {id: $chunk_id})-[:DERIVED_FROM]->(e:Element)
|
||||||
|
MATCH (e)<-[:PARENT_OF]-(parent:Element)
|
||||||
|
MATCH (parent)-[:PARENT_OF]->(sibling:Element)
|
||||||
|
RETURN parent, collect(sibling) AS siblings
|
||||||
|
```
|
||||||
|
|
||||||
|
### Query 3 — All tables from a document type
|
||||||
|
```cypher
|
||||||
|
MATCH (d:Document)-[:HAS_ROOT]->(:Element)-[:PARENT_OF*]->(t:Table)
|
||||||
|
WHERE d.source_uri CONTAINS 'invoices/'
|
||||||
|
RETURN d.title, t.caption, t.cells_json
|
||||||
|
```
|
||||||
|
|
||||||
|
### Query 4 — Direct children of a section (ordered)
|
||||||
|
```cypher
|
||||||
|
MATCH (s:Element {doc_id: $doc_id, self_ref: $section_ref})
|
||||||
|
MATCH (s)-[pof:PARENT_OF]->(child)
|
||||||
|
RETURN child
|
||||||
|
ORDER BY pof.order
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Single reference doc for Neo4j v0.5.0 implementation.
|
||||||
|
Read this first in the implementation thread.*
|
||||||
253
docs/design/reasoning-trace.md
Normal file
253
docs/design/reasoning-trace.md
Normal file
|
|
@ -0,0 +1,253 @@
|
||||||
|
# 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 1–2 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.
|
||||||
|
|
@ -155,7 +155,7 @@ async def delete_chunk(job_id: str, chunk_index: int, service: ServiceDep) -> li
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{job_id}", status_code=204)
|
@router.delete("/{job_id}", status_code=204, response_model=None)
|
||||||
async def delete_analysis(job_id: str, service: ServiceDep) -> None:
|
async def delete_analysis(job_id: str, service: ServiceDep) -> None:
|
||||||
"""Delete an analysis job."""
|
"""Delete an analysis job."""
|
||||||
deleted = await service.delete(job_id)
|
deleted = await service.delete(job_id)
|
||||||
|
|
|
||||||
|
|
@ -85,7 +85,7 @@ async def get_document(doc_id: str, service: ServiceDep) -> DocumentResponse:
|
||||||
return _to_response(doc)
|
return _to_response(doc)
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{doc_id}", status_code=204)
|
@router.delete("/{doc_id}", status_code=204, response_model=None)
|
||||||
async def delete_document(doc_id: str, service: ServiceDep) -> None:
|
async def delete_document(doc_id: str, service: ServiceDep) -> None:
|
||||||
"""Delete a document and its file."""
|
"""Delete a document and its file."""
|
||||||
deleted = await service.delete(doc_id)
|
deleted = await service.delete(doc_id)
|
||||||
|
|
|
||||||
123
document-parser/api/graph.py
Normal file
123
document-parser/api/graph.py
Normal file
|
|
@ -0,0 +1,123 @@
|
||||||
|
"""Graph API — returns a cytoscape-shaped view of the document structure.
|
||||||
|
|
||||||
|
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`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
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__)
|
||||||
|
router = APIRouter(prefix="/api/documents", tags=["graph"])
|
||||||
|
|
||||||
|
MAX_PAGES = 200
|
||||||
|
|
||||||
|
|
||||||
|
class GraphNode(BaseModel):
|
||||||
|
id: str
|
||||||
|
group: str
|
||||||
|
label: str | None = None
|
||||||
|
|
||||||
|
model_config = {"extra": "allow"}
|
||||||
|
|
||||||
|
|
||||||
|
class GraphEdge(BaseModel):
|
||||||
|
id: str
|
||||||
|
source: str
|
||||||
|
target: str
|
||||||
|
type: str
|
||||||
|
order: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class GraphResponse(BaseModel):
|
||||||
|
doc_id: str
|
||||||
|
nodes: list[GraphNode]
|
||||||
|
edges: list[GraphEdge]
|
||||||
|
node_count: int
|
||||||
|
edge_count: int
|
||||||
|
truncated: bool
|
||||||
|
page_count: int
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{doc_id}/graph", response_model=GraphResponse)
|
||||||
|
async def get_document_graph(doc_id: str, request: Request) -> GraphResponse:
|
||||||
|
neo = getattr(request.app.state, "neo4j", None)
|
||||||
|
if neo is None:
|
||||||
|
raise HTTPException(status_code=503, detail="Neo4j is not configured")
|
||||||
|
|
||||||
|
payload = await fetch_graph(neo, doc_id, max_pages=MAX_PAGES)
|
||||||
|
if payload is None:
|
||||||
|
raise HTTPException(status_code=404, detail=f"No graph for document {doc_id}")
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@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,
|
||||||
|
)
|
||||||
|
|
@ -74,7 +74,7 @@ async def ingest_analysis(
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/{doc_id}", status_code=204)
|
@router.delete("/{doc_id}", status_code=204, response_model=None)
|
||||||
async def delete_ingested_document(doc_id: str, ingestion: IngestionDep) -> None:
|
async def delete_ingested_document(doc_id: str, ingestion: IngestionDep) -> None:
|
||||||
"""Delete all indexed chunks for a document."""
|
"""Delete all indexed chunks for a document."""
|
||||||
await ingestion.delete_document(doc_id)
|
await ingestion.delete_document(doc_id)
|
||||||
|
|
|
||||||
148
document-parser/api/reasoning.py
Normal file
148
document-parser/api/reasoning.py
Normal file
|
|
@ -0,0 +1,148 @@
|
||||||
|
"""Reasoning API — live `docling-agent` runner (R&D).
|
||||||
|
|
||||||
|
`POST /api/documents/:id/rag` invokes `docling-agent`'s Chunkless RAG loop
|
||||||
|
against the stored `DoclingDocument` and returns a `RAGResult` in the same
|
||||||
|
shape the v1 import dialog already consumes — so the frontend overlay code
|
||||||
|
is fully reused.
|
||||||
|
|
||||||
|
Constraints (docling-agent v0.1.0):
|
||||||
|
- Backend is hard-wired to Ollama (`setup_local_session` in
|
||||||
|
`docling_agent/agent_models.py`). Set `OLLAMA_HOST` + `RAG_MODEL_ID` in the
|
||||||
|
environment. No OpenAI/WatsonX path without forking upstream.
|
||||||
|
- We call the private `_rag_loop` because `DoclingRAGAgent.run()` wraps the
|
||||||
|
answer in a synthetic `DoclingDocument` and never returns the iteration
|
||||||
|
trace. This is brittle — track upstream for a public hook.
|
||||||
|
- Sync blocking call offloaded to a thread so we don't stall the event loop.
|
||||||
|
No streaming at this step (see design doc §7 for v2 SSE plan).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, Request
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from infra.settings import settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
router = APIRouter(prefix="/api/documents", tags=["reasoning"])
|
||||||
|
|
||||||
|
|
||||||
|
class RagRunRequest(BaseModel):
|
||||||
|
query: str
|
||||||
|
# Optional per-run override; falls back to settings.rag_model_id.
|
||||||
|
model_id: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class RagIterationResponse(BaseModel):
|
||||||
|
iteration: int
|
||||||
|
section_ref: str
|
||||||
|
reason: str
|
||||||
|
section_text_length: int
|
||||||
|
can_answer: bool
|
||||||
|
response: str
|
||||||
|
|
||||||
|
|
||||||
|
class RagResultResponse(BaseModel):
|
||||||
|
answer: str
|
||||||
|
iterations: list[RagIterationResponse]
|
||||||
|
converged: bool
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/{doc_id}/rag", response_model=RagResultResponse)
|
||||||
|
async def run_rag(doc_id: str, body: RagRunRequest, request: Request) -> RagResultResponse:
|
||||||
|
if not settings.rag_enabled:
|
||||||
|
raise HTTPException(status_code=503, detail="Live reasoning disabled (RAG_ENABLED=false)")
|
||||||
|
|
||||||
|
if not body.query.strip():
|
||||||
|
raise HTTPException(status_code=400, detail="Query must not be empty")
|
||||||
|
|
||||||
|
analysis_repo = getattr(request.app.state, "analysis_repo", None)
|
||||||
|
if analysis_repo is None:
|
||||||
|
raise HTTPException(status_code=500, detail="AnalysisRepository not wired")
|
||||||
|
|
||||||
|
latest = await analysis_repo.find_latest_completed_by_document(doc_id)
|
||||||
|
if latest is None or not latest.document_json:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=404,
|
||||||
|
detail=f"No completed analysis with document_json for {doc_id}",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Lazy-import docling-agent so the backend boots even if the dep isn't
|
||||||
|
# installed (R&D group). If missing, return 503 with a clear install hint.
|
||||||
|
try:
|
||||||
|
from docling_agent.agents import DoclingRAGAgent
|
||||||
|
from docling_core.types.doc.document import DoclingDocument
|
||||||
|
from mellea.backends.model_ids import ModelIdentifier
|
||||||
|
except ImportError as e:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=503,
|
||||||
|
detail=f"docling-agent not installed: {e}. `pip install docling-agent mellea`.",
|
||||||
|
) from e
|
||||||
|
|
||||||
|
# Ollama client reads OLLAMA_HOST at request time; set it per-call so the
|
||||||
|
# configured host takes effect without needing to restart the server.
|
||||||
|
os.environ["OLLAMA_HOST"] = settings.ollama_host
|
||||||
|
raw_model_id = body.model_id or settings.rag_model_id
|
||||||
|
# `DoclingRAGAgent` (pydantic) validates `model_id` strictly against the
|
||||||
|
# `ModelIdentifier` dataclass from Mellea. A raw string like "gpt-oss:20b"
|
||||||
|
# is rejected even though the Ollama backend itself would accept one.
|
||||||
|
# Wrap on the Ollama axis; add other axes here if we ever fork upstream to
|
||||||
|
# support non-Ollama backends.
|
||||||
|
model_id = ModelIdentifier(ollama_name=raw_model_id)
|
||||||
|
|
||||||
|
try:
|
||||||
|
doc = DoclingDocument.model_validate_json(latest.document_json)
|
||||||
|
except Exception as e:
|
||||||
|
raise HTTPException(status_code=500, detail=f"Failed to parse document_json: {e}") from e
|
||||||
|
|
||||||
|
agent = DoclingRAGAgent(model_id=model_id, tools=[])
|
||||||
|
logger.info(
|
||||||
|
"RAG run: doc_id=%s model_id=%s ollama_host=%s query=%r",
|
||||||
|
doc_id,
|
||||||
|
model_id,
|
||||||
|
settings.ollama_host,
|
||||||
|
body.query[:120],
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# `_rag_loop` is a synchronous LLM-heavy call (N * model latency). Run
|
||||||
|
# it in a worker thread so concurrent requests don't block the loop.
|
||||||
|
result = await asyncio.to_thread(agent._rag_loop, query=body.query, doc=doc)
|
||||||
|
except IndexError as e:
|
||||||
|
# Known docling-agent bug: `_attempt_answer` / `_select_section` call
|
||||||
|
# `find_json_dicts(answer.value)[0]` without checking for an empty
|
||||||
|
# list. When the model can't produce a parseable JSON after 3
|
||||||
|
# rejection-sampling retries + 3 `select_from_failure` retries, the
|
||||||
|
# list is empty and the `[0]` crashes. It's model-dependent (some
|
||||||
|
# questions + some models trip it, others don't).
|
||||||
|
#
|
||||||
|
# Report as 502 Bad Gateway — the upstream LLM couldn't produce a
|
||||||
|
# usable response, not our fault — with a message the UI can show
|
||||||
|
# to the user so they pick another model or rephrase.
|
||||||
|
logger.warning(
|
||||||
|
"docling-agent produced no parseable JSON for doc=%s model=%s query=%r",
|
||||||
|
doc_id,
|
||||||
|
raw_model_id,
|
||||||
|
body.query[:120],
|
||||||
|
)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=502,
|
||||||
|
detail=(
|
||||||
|
f"The model '{raw_model_id}' couldn't produce a parseable "
|
||||||
|
"answer after retries. Try a different model (e.g. mistral-small3.2) "
|
||||||
|
"or rephrase the question."
|
||||||
|
),
|
||||||
|
) from e
|
||||||
|
except Exception as e:
|
||||||
|
logger.exception("RAG loop failed for doc %s", doc_id)
|
||||||
|
raise HTTPException(status_code=500, detail=f"RAG loop failed: {e}") from e
|
||||||
|
|
||||||
|
return RagResultResponse(
|
||||||
|
answer=result.answer,
|
||||||
|
iterations=[RagIterationResponse(**it.model_dump()) for it in result.iterations],
|
||||||
|
converged=result.converged,
|
||||||
|
)
|
||||||
|
|
@ -34,7 +34,13 @@ class HealthResponse(_CamelModel):
|
||||||
database: str
|
database: str
|
||||||
max_page_count: int | None = None
|
max_page_count: int | None = None
|
||||||
max_file_size_mb: int | None = None
|
max_file_size_mb: int | None = None
|
||||||
|
max_paste_image_size_mb: int | None = None
|
||||||
|
paste_allowed_image_types: list[str] = Field(default_factory=list)
|
||||||
ingestion_available: bool = False
|
ingestion_available: bool = False
|
||||||
|
# True when the live-reasoning runner (docling-agent + Ollama) is
|
||||||
|
# available: RAG_ENABLED=true AND deps importable. Doesn't imply Ollama
|
||||||
|
# itself is reachable — that's checked per-call.
|
||||||
|
rag_available: bool = False
|
||||||
|
|
||||||
|
|
||||||
class DocumentResponse(_CamelModel):
|
class DocumentResponse(_CamelModel):
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,10 @@ from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
|
|
||||||
|
# US Letter page dimensions (points) — fallback when page size is unknown
|
||||||
|
DEFAULT_PAGE_WIDTH: float = 612.0
|
||||||
|
DEFAULT_PAGE_HEIGHT: float = 792.0
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class PageElement:
|
class PageElement:
|
||||||
|
|
@ -15,6 +19,11 @@ class PageElement:
|
||||||
bbox: list[float]
|
bbox: list[float]
|
||||||
content: str
|
content: str
|
||||||
level: int = 0
|
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)
|
@dataclass(frozen=True)
|
||||||
|
|
@ -71,6 +80,14 @@ class ChunkBbox:
|
||||||
bbox: list[float] # [left, top, right, bottom] in TOPLEFT origin
|
bbox: list[float] # [left, top, right, bottom] in TOPLEFT origin
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class ChunkDocItem:
|
||||||
|
"""Source element referenced by a chunk. Enables Neo4j DERIVED_FROM edges."""
|
||||||
|
|
||||||
|
self_ref: str
|
||||||
|
label: str
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@dataclass(frozen=True)
|
||||||
class ChunkResult:
|
class ChunkResult:
|
||||||
text: str
|
text: str
|
||||||
|
|
@ -78,3 +95,4 @@ class ChunkResult:
|
||||||
source_page: int | None = None
|
source_page: int | None = None
|
||||||
token_count: int = 0
|
token_count: int = 0
|
||||||
bboxes: list[ChunkBbox] = field(default_factory=list)
|
bboxes: list[ChunkBbox] = field(default_factory=list)
|
||||||
|
doc_items: list[ChunkDocItem] = field(default_factory=list)
|
||||||
|
|
|
||||||
178
document-parser/infra/docling_graph.py
Normal file
178
document-parser/infra/docling_graph.py
Normal file
|
|
@ -0,0 +1,178 @@
|
||||||
|
"""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,
|
||||||
|
)
|
||||||
276
document-parser/infra/docling_tree.py
Normal file
276
document-parser/infra/docling_tree.py
Normal file
|
|
@ -0,0 +1,276 @@
|
||||||
|
"""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"),
|
||||||
|
}
|
||||||
|
|
@ -15,7 +15,7 @@ from docling_core.transforms.chunker import HierarchicalChunker
|
||||||
from docling_core.transforms.chunker.hybrid_chunker import HybridChunker
|
from docling_core.transforms.chunker.hybrid_chunker import HybridChunker
|
||||||
from docling_core.types.doc.document import DoclingDocument
|
from docling_core.types.doc.document import DoclingDocument
|
||||||
|
|
||||||
from domain.value_objects import ChunkBbox, ChunkingOptions, ChunkResult
|
from domain.value_objects import ChunkBbox, ChunkDocItem, ChunkingOptions, ChunkResult
|
||||||
from infra.bbox import EMPTY_BBOX, to_topleft_list
|
from infra.bbox import EMPTY_BBOX, to_topleft_list
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
@ -39,9 +39,18 @@ def _chunk_sync(document_json: str, options: ChunkingOptions) -> list[ChunkResul
|
||||||
source_page = None
|
source_page = None
|
||||||
token_count = 0
|
token_count = 0
|
||||||
bboxes: list[ChunkBbox] = []
|
bboxes: list[ChunkBbox] = []
|
||||||
|
doc_items: list[ChunkDocItem] = []
|
||||||
|
|
||||||
if hasattr(chunk, "meta") and chunk.meta and chunk.meta.doc_items:
|
if hasattr(chunk, "meta") and chunk.meta and chunk.meta.doc_items:
|
||||||
for doc_item in chunk.meta.doc_items:
|
for doc_item in chunk.meta.doc_items:
|
||||||
|
ref = getattr(doc_item, "self_ref", None)
|
||||||
|
if ref:
|
||||||
|
doc_items.append(
|
||||||
|
ChunkDocItem(
|
||||||
|
self_ref=ref,
|
||||||
|
label=str(getattr(doc_item, "label", "") or ""),
|
||||||
|
)
|
||||||
|
)
|
||||||
if not hasattr(doc_item, "prov") or not doc_item.prov:
|
if not hasattr(doc_item, "prov") or not doc_item.prov:
|
||||||
continue
|
continue
|
||||||
for prov in doc_item.prov:
|
for prov in doc_item.prov:
|
||||||
|
|
@ -67,6 +76,7 @@ def _chunk_sync(document_json: str, options: ChunkingOptions) -> list[ChunkResul
|
||||||
source_page=source_page,
|
source_page=source_page,
|
||||||
token_count=token_count,
|
token_count=token_count,
|
||||||
bboxes=bboxes,
|
bboxes=bboxes,
|
||||||
|
doc_items=doc_items,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -36,6 +36,8 @@ from docling_core.types.doc import (
|
||||||
)
|
)
|
||||||
|
|
||||||
from domain.value_objects import (
|
from domain.value_objects import (
|
||||||
|
DEFAULT_PAGE_HEIGHT,
|
||||||
|
DEFAULT_PAGE_WIDTH,
|
||||||
ConversionOptions,
|
ConversionOptions,
|
||||||
ConversionResult,
|
ConversionResult,
|
||||||
PageDetail,
|
PageDetail,
|
||||||
|
|
@ -50,10 +52,6 @@ logger = logging.getLogger(__name__)
|
||||||
# Uses a timeout to prevent a frozen conversion from blocking all others.
|
# Uses a timeout to prevent a frozen conversion from blocking all others.
|
||||||
_converter_lock = threading.Lock()
|
_converter_lock = threading.Lock()
|
||||||
|
|
||||||
# US Letter page dimensions (points) — fallback when page size is unknown
|
|
||||||
_DEFAULT_PAGE_WIDTH = 612.0
|
|
||||||
_DEFAULT_PAGE_HEIGHT = 792.0
|
|
||||||
|
|
||||||
# Default converter (lazy-init on first request)
|
# Default converter (lazy-init on first request)
|
||||||
_default_converter: DoclingConverter | None = None
|
_default_converter: DoclingConverter | None = None
|
||||||
|
|
||||||
|
|
@ -175,11 +173,11 @@ def _process_content_item(
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Page %d not found in document metadata — using US Letter fallback (%sx%s pt)",
|
"Page %d not found in document metadata — using US Letter fallback (%sx%s pt)",
|
||||||
page_no,
|
page_no,
|
||||||
_DEFAULT_PAGE_WIDTH,
|
DEFAULT_PAGE_WIDTH,
|
||||||
_DEFAULT_PAGE_HEIGHT,
|
DEFAULT_PAGE_HEIGHT,
|
||||||
)
|
)
|
||||||
pages[page_no] = PageDetail(
|
pages[page_no] = PageDetail(
|
||||||
page_number=page_no, width=_DEFAULT_PAGE_WIDTH, height=_DEFAULT_PAGE_HEIGHT
|
page_number=page_no, width=DEFAULT_PAGE_WIDTH, height=DEFAULT_PAGE_HEIGHT
|
||||||
)
|
)
|
||||||
|
|
||||||
page_height = pages[page_no].height
|
page_height = pages[page_no].height
|
||||||
|
|
@ -196,7 +194,13 @@ def _process_content_item(
|
||||||
content = item.export_to_markdown()
|
content = item.export_to_markdown()
|
||||||
|
|
||||||
pages[page_no].elements.append(
|
pages[page_no].elements.append(
|
||||||
PageElement(type=element_type, bbox=bbox, content=content, level=level)
|
PageElement(
|
||||||
|
type=element_type,
|
||||||
|
bbox=bbox,
|
||||||
|
content=content,
|
||||||
|
level=level,
|
||||||
|
self_ref=getattr(item, "self_ref", "") or "",
|
||||||
|
)
|
||||||
)
|
)
|
||||||
except (AttributeError, KeyError, TypeError, ValueError):
|
except (AttributeError, KeyError, TypeError, ValueError):
|
||||||
logger.warning(
|
logger.warning(
|
||||||
|
|
@ -248,10 +252,10 @@ def _convert_sync(
|
||||||
pages_detail = [
|
pages_detail = [
|
||||||
PageDetail(
|
PageDetail(
|
||||||
page_number=i + 1,
|
page_number=i + 1,
|
||||||
width=doc.pages[i + 1].size.width if (i + 1) in doc.pages else _DEFAULT_PAGE_WIDTH,
|
width=doc.pages[i + 1].size.width if (i + 1) in doc.pages else DEFAULT_PAGE_WIDTH,
|
||||||
height=doc.pages[i + 1].size.height
|
height=doc.pages[i + 1].size.height
|
||||||
if (i + 1) in doc.pages
|
if (i + 1) in doc.pages
|
||||||
else _DEFAULT_PAGE_HEIGHT,
|
else DEFAULT_PAGE_HEIGHT,
|
||||||
)
|
)
|
||||||
for i in range(page_count)
|
for i in range(page_count)
|
||||||
]
|
]
|
||||||
|
|
|
||||||
31
document-parser/infra/neo4j/__init__.py
Normal file
31
document-parser/infra/neo4j/__init__.py
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
"""Neo4j storage adapter — graph-native document structure.
|
||||||
|
|
||||||
|
Provides a thin driver wrapper, idempotent schema bootstrap, and
|
||||||
|
walkers between DoclingDocument and the graph model.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from infra.neo4j.chunk_writer import ChunkWriteResult, write_chunks
|
||||||
|
from infra.neo4j.driver import Neo4jDriver, close_driver, get_driver
|
||||||
|
from infra.neo4j.queries import fetch_graph
|
||||||
|
from infra.neo4j.schema import bootstrap_schema
|
||||||
|
from infra.neo4j.tree_reader import (
|
||||||
|
delete_document,
|
||||||
|
document_exists,
|
||||||
|
read_document_json,
|
||||||
|
)
|
||||||
|
from infra.neo4j.tree_writer import TreeWriteResult, write_document
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"ChunkWriteResult",
|
||||||
|
"Neo4jDriver",
|
||||||
|
"TreeWriteResult",
|
||||||
|
"bootstrap_schema",
|
||||||
|
"close_driver",
|
||||||
|
"delete_document",
|
||||||
|
"document_exists",
|
||||||
|
"fetch_graph",
|
||||||
|
"get_driver",
|
||||||
|
"read_document_json",
|
||||||
|
"write_chunks",
|
||||||
|
"write_document",
|
||||||
|
]
|
||||||
134
document-parser/infra/neo4j/chunk_writer.py
Normal file
134
document-parser/infra/neo4j/chunk_writer.py
Normal file
|
|
@ -0,0 +1,134 @@
|
||||||
|
"""ChunkWriter — push chunk nodes and DERIVED_FROM edges to Neo4j.
|
||||||
|
|
||||||
|
Embeddings stay in OpenSearch. Each :Chunk node carries a chunk_index so the
|
||||||
|
OpenSearch entry can be retrieved via (doc_id, chunk_index). The
|
||||||
|
`embedding_ref` property is reserved for a future vector-store id (not used
|
||||||
|
in v0.5 — OpenSearch indexes by doc_id+chunk_index already).
|
||||||
|
|
||||||
|
When chunks carry `doc_items` provenance (list of `self_ref` strings), we
|
||||||
|
create `(:Chunk)-[:DERIVED_FROM]->(:Element)` links so that queries can go
|
||||||
|
from a chunk back to its source elements. Chunks without doc_items get no
|
||||||
|
back-links but are still persisted.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from infra.neo4j.driver import Neo4jDriver
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ChunkWriteResult:
|
||||||
|
doc_id: str
|
||||||
|
chunks_written: int
|
||||||
|
derived_from_edges: int
|
||||||
|
|
||||||
|
|
||||||
|
def _chunk_id(doc_id: str, index: int) -> str:
|
||||||
|
return f"{doc_id}::chunk::{index}"
|
||||||
|
|
||||||
|
|
||||||
|
async def write_chunks(
|
||||||
|
neo: Neo4jDriver,
|
||||||
|
*,
|
||||||
|
doc_id: str,
|
||||||
|
chunks_json: str,
|
||||||
|
) -> ChunkWriteResult:
|
||||||
|
"""Persist chunks for `doc_id`. Wipes prior chunks first (idempotent)."""
|
||||||
|
chunks: list[dict[str, Any]] = json.loads(chunks_json)
|
||||||
|
active = [c for c in chunks if not c.get("deleted")]
|
||||||
|
|
||||||
|
chunk_rows: list[dict[str, Any]] = []
|
||||||
|
derived_rows: list[dict[str, Any]] = []
|
||||||
|
for idx, c in enumerate(active):
|
||||||
|
cid = _chunk_id(doc_id, idx)
|
||||||
|
chunk_rows.append(
|
||||||
|
{
|
||||||
|
"id": cid,
|
||||||
|
"doc_id": doc_id,
|
||||||
|
"text": c.get("text") or "",
|
||||||
|
"chunk_index": idx,
|
||||||
|
"token_count": c.get("tokenCount") or 0,
|
||||||
|
"embedding_ref": "",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
for item in c.get("docItems") or []:
|
||||||
|
ref = item.get("selfRef") if isinstance(item, dict) else None
|
||||||
|
if ref:
|
||||||
|
derived_rows.append({"chunk_id": cid, "doc_id": doc_id, "self_ref": ref})
|
||||||
|
|
||||||
|
async with (
|
||||||
|
neo.driver.session(database=neo.database) as session,
|
||||||
|
await session.begin_transaction() as tx,
|
||||||
|
):
|
||||||
|
# Replace existing chunks.
|
||||||
|
await tx.run(
|
||||||
|
"""
|
||||||
|
MATCH (d:Document {id: $doc_id})-[:HAS_CHUNK]->(c:Chunk)
|
||||||
|
DETACH DELETE c
|
||||||
|
""",
|
||||||
|
doc_id=doc_id,
|
||||||
|
)
|
||||||
|
await tx.run("MATCH (c:Chunk {doc_id: $doc_id}) DETACH DELETE c", doc_id=doc_id)
|
||||||
|
|
||||||
|
if chunk_rows:
|
||||||
|
await tx.run(
|
||||||
|
"""
|
||||||
|
MATCH (d:Document {id: $doc_id})
|
||||||
|
UNWIND $rows AS r
|
||||||
|
CREATE (c:Chunk {
|
||||||
|
id: r.id,
|
||||||
|
doc_id: r.doc_id,
|
||||||
|
text: r.text,
|
||||||
|
chunk_index: r.chunk_index,
|
||||||
|
token_count: r.token_count,
|
||||||
|
embedding_ref: r.embedding_ref
|
||||||
|
})
|
||||||
|
MERGE (d)-[:HAS_CHUNK]->(c)
|
||||||
|
""",
|
||||||
|
doc_id=doc_id,
|
||||||
|
rows=chunk_rows,
|
||||||
|
)
|
||||||
|
|
||||||
|
if derived_rows:
|
||||||
|
await tx.run(
|
||||||
|
"""
|
||||||
|
UNWIND $rows AS r
|
||||||
|
MATCH (c:Chunk {id: r.chunk_id})
|
||||||
|
MATCH (e:Element {doc_id: r.doc_id, self_ref: r.self_ref})
|
||||||
|
MERGE (c)-[:DERIVED_FROM]->(e)
|
||||||
|
""",
|
||||||
|
rows=derived_rows,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Flag the Document with the new stage.
|
||||||
|
await tx.run(
|
||||||
|
"""
|
||||||
|
MATCH (d:Document {id: $doc_id})
|
||||||
|
SET d.stages_applied = [s IN coalesce(d.stages_applied, []) WHERE s <> 'chunks']
|
||||||
|
+ ['chunks'],
|
||||||
|
d.last_chunk_write = datetime()
|
||||||
|
""",
|
||||||
|
doc_id=doc_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
await tx.commit()
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Neo4j: wrote %d chunks (%d DERIVED_FROM) for doc %s",
|
||||||
|
len(chunk_rows),
|
||||||
|
len(derived_rows),
|
||||||
|
doc_id,
|
||||||
|
)
|
||||||
|
return ChunkWriteResult(
|
||||||
|
doc_id=doc_id,
|
||||||
|
chunks_written=len(chunk_rows),
|
||||||
|
derived_from_edges=len(derived_rows),
|
||||||
|
)
|
||||||
48
document-parser/infra/neo4j/driver.py
Normal file
48
document-parser/infra/neo4j/driver.py
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
"""Async Neo4j driver wrapper.
|
||||||
|
|
||||||
|
Owns a single `AsyncDriver` per process. Callers acquire it via
|
||||||
|
`get_driver()` and must call `close_driver()` at shutdown.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from dataclasses import dataclass
|
||||||
|
|
||||||
|
from neo4j import AsyncDriver, AsyncGraphDatabase
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class Neo4jDriver:
|
||||||
|
driver: AsyncDriver
|
||||||
|
database: str = "neo4j"
|
||||||
|
|
||||||
|
|
||||||
|
_instance: Neo4jDriver | None = None
|
||||||
|
|
||||||
|
|
||||||
|
async def get_driver(uri: str, user: str, password: str, database: str = "neo4j") -> Neo4jDriver:
|
||||||
|
"""Return the process-wide driver, creating it on first call.
|
||||||
|
|
||||||
|
Verifies connectivity once at creation — raises if the server is unreachable.
|
||||||
|
"""
|
||||||
|
global _instance
|
||||||
|
if _instance is not None:
|
||||||
|
return _instance
|
||||||
|
|
||||||
|
driver = AsyncGraphDatabase.driver(uri, auth=(user, password))
|
||||||
|
await driver.verify_connectivity()
|
||||||
|
logger.info("Neo4j driver connected to %s (db=%s)", uri, database)
|
||||||
|
_instance = Neo4jDriver(driver=driver, database=database)
|
||||||
|
return _instance
|
||||||
|
|
||||||
|
|
||||||
|
async def close_driver() -> None:
|
||||||
|
global _instance
|
||||||
|
if _instance is None:
|
||||||
|
return
|
||||||
|
await _instance.driver.close()
|
||||||
|
_instance = None
|
||||||
|
logger.info("Neo4j driver closed")
|
||||||
269
document-parser/infra/neo4j/queries.py
Normal file
269
document-parser/infra/neo4j/queries.py
Normal file
|
|
@ -0,0 +1,269 @@
|
||||||
|
"""Reusable Cypher queries — kept out of the API layer for reuse + testing."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from infra.neo4j.driver import Neo4jDriver
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class GraphPayload:
|
||||||
|
doc_id: str
|
||||||
|
nodes: list[dict[str, Any]]
|
||||||
|
edges: list[dict[str, Any]]
|
||||||
|
node_count: int
|
||||||
|
edge_count: int
|
||||||
|
truncated: bool
|
||||||
|
page_count: int
|
||||||
|
|
||||||
|
|
||||||
|
# Full graph for one doc: Document + Elements + Pages + Chunks and their edges.
|
||||||
|
# Each node/edge type is collected inside its own CALL {} subquery so every
|
||||||
|
# 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 (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 {
|
||||||
|
WITH d
|
||||||
|
MATCH (pe:Element {doc_id: d.id})-[r:PARENT_OF]->(ce:Element)
|
||||||
|
RETURN collect({from: pe.self_ref, to: ce.self_ref, order: r.order, type: 'PARENT_OF'}) AS parent_edges
|
||||||
|
}
|
||||||
|
CALL {
|
||||||
|
WITH d
|
||||||
|
MATCH (a:Element {doc_id: d.id})-[:NEXT]->(b:Element)
|
||||||
|
RETURN collect({from: a.self_ref, to: b.self_ref, type: 'NEXT'}) AS next_edges
|
||||||
|
}
|
||||||
|
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
|
||||||
|
RETURN collect({from: er.self_ref, to: pr.page_no, type: 'ON_PAGE'}) AS on_page_edges
|
||||||
|
}
|
||||||
|
CALL {
|
||||||
|
WITH d
|
||||||
|
MATCH (d)-[:HAS_ROOT]->(rr:Element)
|
||||||
|
RETURN collect({from: d.id, to: rr.self_ref, type: 'HAS_ROOT'}) AS has_root_edges
|
||||||
|
}
|
||||||
|
CALL {
|
||||||
|
WITH d
|
||||||
|
MATCH (d)-[:HAS_CHUNK]->(rc:Chunk)
|
||||||
|
RETURN collect({from: d.id, to: rc.id, type: 'HAS_CHUNK'}) AS has_chunk_edges
|
||||||
|
}
|
||||||
|
CALL {
|
||||||
|
WITH d
|
||||||
|
MATCH (cc:Chunk {doc_id: d.id})-[:DERIVED_FROM]->(ee:Element)
|
||||||
|
RETURN collect({from: cc.id, to: ee.self_ref, type: 'DERIVED_FROM'}) AS derived_from_edges
|
||||||
|
}
|
||||||
|
RETURN d AS document, elements, pages, chunks,
|
||||||
|
parent_edges, next_edges, on_page_edges,
|
||||||
|
has_root_edges, has_chunk_edges, derived_from_edges
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def _element_node(
|
||||||
|
doc_id: str, e: dict[str, Any], provs: list[dict[str, Any]] | None = None
|
||||||
|
) -> 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 [],
|
||||||
|
"level": e.get("level"),
|
||||||
|
"doc_id": doc_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _page_node(doc_id: str, p: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"id": f"page::{p.get('page_no')}",
|
||||||
|
"group": "page",
|
||||||
|
"page_no": p.get("page_no"),
|
||||||
|
"width": p.get("width"),
|
||||||
|
"height": p.get("height"),
|
||||||
|
"doc_id": doc_id,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _chunk_node(p: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"id": f"chunk::{p.get('id')}",
|
||||||
|
"group": "chunk",
|
||||||
|
"chunk_index": p.get("chunk_index"),
|
||||||
|
"text": (p.get("text") or "")[:200],
|
||||||
|
"token_count": p.get("token_count"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _edge_id(from_id: str, to_id: str, edge_type: str) -> str:
|
||||||
|
return f"{edge_type}::{from_id}::{to_id}"
|
||||||
|
|
||||||
|
|
||||||
|
async def fetch_graph(
|
||||||
|
neo: Neo4jDriver,
|
||||||
|
doc_id: str,
|
||||||
|
*,
|
||||||
|
max_pages: int = 200,
|
||||||
|
) -> GraphPayload | None:
|
||||||
|
"""Return the full graph for a document, or None if the document is unknown.
|
||||||
|
|
||||||
|
Enforces the page cap from design §8.4: beyond `max_pages`, returns a
|
||||||
|
`truncated=True` payload with empty node/edge lists so the caller can
|
||||||
|
surface a clean error (HTTP 413) to the UI.
|
||||||
|
"""
|
||||||
|
async with neo.driver.session(database=neo.database) as session:
|
||||||
|
page_count_result = await session.run(
|
||||||
|
"MATCH (p:Page {doc_id: $doc_id}) RETURN count(p) AS n",
|
||||||
|
doc_id=doc_id,
|
||||||
|
)
|
||||||
|
pc_record = await page_count_result.single()
|
||||||
|
if pc_record is None:
|
||||||
|
return None
|
||||||
|
page_count = int(pc_record["n"])
|
||||||
|
|
||||||
|
exists_result = await session.run(
|
||||||
|
"MATCH (d:Document {id: $doc_id}) RETURN count(d) AS n",
|
||||||
|
doc_id=doc_id,
|
||||||
|
)
|
||||||
|
exists_record = await exists_result.single()
|
||||||
|
if not exists_record or exists_record["n"] == 0:
|
||||||
|
return None
|
||||||
|
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await session.run(_FETCH_GRAPH, doc_id=doc_id)
|
||||||
|
record = await result.single()
|
||||||
|
|
||||||
|
nodes: list[dict[str, Any]] = []
|
||||||
|
edges: list[dict[str, Any]] = []
|
||||||
|
|
||||||
|
if record is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Document node.
|
||||||
|
doc_node = record["document"]
|
||||||
|
if doc_node is not None:
|
||||||
|
nodes.append(
|
||||||
|
{
|
||||||
|
"id": f"doc::{doc_id}",
|
||||||
|
"group": "document",
|
||||||
|
"doc_id": doc_id,
|
||||||
|
"title": doc_node.get("title"),
|
||||||
|
"stages_applied": doc_node.get("stages_applied"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
# 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
|
||||||
|
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["label"] = labels[0] if labels else "TextElement"
|
||||||
|
nodes.append(node)
|
||||||
|
|
||||||
|
# Pages.
|
||||||
|
for p in record["pages"] or []:
|
||||||
|
if p is None:
|
||||||
|
continue
|
||||||
|
nodes.append(_page_node(doc_id, dict(p)))
|
||||||
|
|
||||||
|
# Chunks.
|
||||||
|
for c in record["chunks"] or []:
|
||||||
|
if c is None:
|
||||||
|
continue
|
||||||
|
nodes.append(_chunk_node(dict(c)))
|
||||||
|
|
||||||
|
# Edges — filter out rows whose from/to is null (OPTIONAL MATCH can yield them).
|
||||||
|
def _push_element_edge(e: dict[str, Any], from_prefix: str, to_prefix: str) -> None:
|
||||||
|
frm, to = e.get("from"), e.get("to")
|
||||||
|
if frm is None or to is None:
|
||||||
|
return
|
||||||
|
edges.append(
|
||||||
|
{
|
||||||
|
"id": _edge_id(f"{from_prefix}{frm}", f"{to_prefix}{to}", e["type"]),
|
||||||
|
"source": f"{from_prefix}{frm}",
|
||||||
|
"target": f"{to_prefix}{to}",
|
||||||
|
"type": e["type"],
|
||||||
|
"order": e.get("order"),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
for e in record["parent_edges"] or []:
|
||||||
|
_push_element_edge(e, "elem::", "elem::")
|
||||||
|
for e in record["next_edges"] or []:
|
||||||
|
_push_element_edge(e, "elem::", "elem::")
|
||||||
|
for e in record["on_page_edges"] or []:
|
||||||
|
_push_element_edge(e, "elem::", "page::")
|
||||||
|
for e in record["has_root_edges"] or []:
|
||||||
|
_push_element_edge(e, "doc::", "elem::")
|
||||||
|
for e in record["has_chunk_edges"] or []:
|
||||||
|
_push_element_edge(e, "doc::", "chunk::")
|
||||||
|
for e in record["derived_from_edges"] or []:
|
||||||
|
_push_element_edge(e, "chunk::", "elem::")
|
||||||
|
|
||||||
|
return GraphPayload(
|
||||||
|
doc_id=doc_id,
|
||||||
|
nodes=nodes,
|
||||||
|
edges=edges,
|
||||||
|
node_count=len(nodes),
|
||||||
|
edge_count=len(edges),
|
||||||
|
truncated=False,
|
||||||
|
page_count=page_count,
|
||||||
|
)
|
||||||
56
document-parser/infra/neo4j/schema.py
Normal file
56
document-parser/infra/neo4j/schema.py
Normal file
|
|
@ -0,0 +1,56 @@
|
||||||
|
"""Idempotent Neo4j schema bootstrap.
|
||||||
|
|
||||||
|
Runs at backend startup. All statements use `IF NOT EXISTS`, so calling
|
||||||
|
this multiple times is safe — it's the contract integration tests rely on.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from infra.neo4j.driver import Neo4jDriver
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
CONSTRAINTS: tuple[str, ...] = (
|
||||||
|
"CREATE CONSTRAINT document_id IF NOT EXISTS FOR (d:Document) REQUIRE d.id IS UNIQUE",
|
||||||
|
"CREATE CONSTRAINT element_composite IF NOT EXISTS "
|
||||||
|
"FOR (e:Element) REQUIRE (e.doc_id, e.self_ref) IS UNIQUE",
|
||||||
|
"CREATE CONSTRAINT page_composite IF NOT EXISTS "
|
||||||
|
"FOR (p:Page) REQUIRE (p.doc_id, p.page_no) IS UNIQUE",
|
||||||
|
"CREATE CONSTRAINT chunk_id IF NOT EXISTS FOR (c:Chunk) REQUIRE c.id IS UNIQUE",
|
||||||
|
)
|
||||||
|
|
||||||
|
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, ...] = (
|
||||||
|
"CREATE FULLTEXT INDEX element_text IF NOT EXISTS FOR (e:Element) ON EACH [e.text]",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def bootstrap_schema(neo: Neo4jDriver) -> None:
|
||||||
|
"""Create constraints and indexes required by the graph model.
|
||||||
|
|
||||||
|
Idempotent: safe to call on every startup.
|
||||||
|
"""
|
||||||
|
async with neo.driver.session(database=neo.database) as session:
|
||||||
|
for stmt in (*CONSTRAINTS, *INDEXES, *FULLTEXT_INDEXES):
|
||||||
|
await session.run(stmt)
|
||||||
|
logger.info(
|
||||||
|
"Neo4j schema bootstrapped (%d constraints, %d indexes, %d fulltext)",
|
||||||
|
len(CONSTRAINTS),
|
||||||
|
len(INDEXES),
|
||||||
|
len(FULLTEXT_INDEXES),
|
||||||
|
)
|
||||||
64
document-parser/infra/neo4j/tree_reader.py
Normal file
64
document-parser/infra/neo4j/tree_reader.py
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
"""TreeReader — fetch a DoclingDocument back from Neo4j.
|
||||||
|
|
||||||
|
v0.5.0 implementation relies on the verbatim `document_json` property stored
|
||||||
|
on the Document node by TreeWriter. Reconstruction by walking Element nodes
|
||||||
|
is deferred to v0.6 (EnrichmentWriter prerequisite), where we may need to
|
||||||
|
rebuild the DoclingDocument after enrichments have been patched on graph
|
||||||
|
nodes directly.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from infra.neo4j.driver import Neo4jDriver
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def read_document_json(neo: Neo4jDriver, doc_id: str) -> str | None:
|
||||||
|
"""Return the stored DoclingDocument JSON for `doc_id`, or None if absent."""
|
||||||
|
async with neo.driver.session(database=neo.database) as session:
|
||||||
|
result = await session.run(
|
||||||
|
"MATCH (d:Document {id: $doc_id}) RETURN d.document_json AS json",
|
||||||
|
doc_id=doc_id,
|
||||||
|
)
|
||||||
|
record = await result.single()
|
||||||
|
if record is None:
|
||||||
|
return None
|
||||||
|
return record["json"]
|
||||||
|
|
||||||
|
|
||||||
|
async def document_exists(neo: Neo4jDriver, doc_id: str) -> bool:
|
||||||
|
async with neo.driver.session(database=neo.database) as session:
|
||||||
|
result = await session.run(
|
||||||
|
"MATCH (d:Document {id: $doc_id}) RETURN count(d) AS n",
|
||||||
|
doc_id=doc_id,
|
||||||
|
)
|
||||||
|
record = await result.single()
|
||||||
|
return bool(record and record["n"] > 0)
|
||||||
|
|
||||||
|
|
||||||
|
async def delete_document(neo: Neo4jDriver, doc_id: str) -> int:
|
||||||
|
"""Wipe everything related to a doc_id. Returns nodes removed."""
|
||||||
|
async with neo.driver.session(database=neo.database) as session:
|
||||||
|
result = await session.run(
|
||||||
|
"""
|
||||||
|
MATCH (d:Document {id: $doc_id})
|
||||||
|
OPTIONAL MATCH (d)-[:HAS_ROOT|HAS_CHUNK*0..]->(n)
|
||||||
|
WITH d, collect(DISTINCT n) AS children
|
||||||
|
DETACH DELETE d
|
||||||
|
WITH children
|
||||||
|
UNWIND children AS c
|
||||||
|
DETACH DELETE c
|
||||||
|
RETURN size(children) + 1 AS removed
|
||||||
|
""",
|
||||||
|
doc_id=doc_id,
|
||||||
|
)
|
||||||
|
record = await result.single()
|
||||||
|
# Also clean up orphan elements and pages tagged with this doc_id.
|
||||||
|
await session.run("MATCH (e:Element {doc_id: $doc_id}) DETACH DELETE e", doc_id=doc_id)
|
||||||
|
await session.run("MATCH (p:Page {doc_id: $doc_id}) DETACH DELETE p", doc_id=doc_id)
|
||||||
|
return int(record["removed"]) if record else 0
|
||||||
310
document-parser/infra/neo4j/tree_writer.py
Normal file
310
document-parser/infra/neo4j/tree_writer.py
Normal file
|
|
@ -0,0 +1,310 @@
|
||||||
|
"""TreeWriter — persist a DoclingDocument as a graph in Neo4j.
|
||||||
|
|
||||||
|
v0.5.0 strategy: replace-on-write. For a given doc_id, all existing
|
||||||
|
Document/Element/Page/Chunk nodes are wiped before re-ingestion. The full
|
||||||
|
serialized `DoclingDocument` JSON is stored as a property on the Document
|
||||||
|
node so that `TreeReader` can round-trip it verbatim — reconstruction from
|
||||||
|
graph nodes is deferred to v0.6 (see docs/design/neo4j-integration.md §2).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import contextlib
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
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__)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class TreeWriteResult:
|
||||||
|
doc_id: str
|
||||||
|
elements_written: int
|
||||||
|
pages_written: int
|
||||||
|
provenances_written: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
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).
|
||||||
|
"""
|
||||||
|
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 "",
|
||||||
|
}
|
||||||
|
# Type-specific extras.
|
||||||
|
if "level" in item:
|
||||||
|
props["level"] = item.get("level")
|
||||||
|
if "caption" in item and isinstance(item.get("caption"), str):
|
||||||
|
props["caption"] = item.get("caption")
|
||||||
|
if item.get("data") and isinstance(item["data"], dict):
|
||||||
|
# Tables carry cell layout under data; stringify to keep the schema flat.
|
||||||
|
with contextlib.suppress(TypeError, ValueError):
|
||||||
|
props["cells_json"] = json.dumps(item["data"])
|
||||||
|
return props
|
||||||
|
|
||||||
|
|
||||||
|
async def write_document(
|
||||||
|
neo: Neo4jDriver,
|
||||||
|
*,
|
||||||
|
doc_id: str,
|
||||||
|
filename: str,
|
||||||
|
document_json: str,
|
||||||
|
tenant_id: str = "default",
|
||||||
|
source_uri: str | None = None,
|
||||||
|
docling_version: str | None = None,
|
||||||
|
) -> TreeWriteResult:
|
||||||
|
"""Persist the full DoclingDocument tree to Neo4j.
|
||||||
|
|
||||||
|
Idempotent: wipes any existing graph for doc_id before writing.
|
||||||
|
Fails fast (exception propagates) if Neo4j is unavailable — per design §8.5.
|
||||||
|
"""
|
||||||
|
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):
|
||||||
|
ref = item.get("self_ref")
|
||||||
|
if not ref or ref in skip_refs:
|
||||||
|
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)
|
||||||
|
elements.append(
|
||||||
|
{
|
||||||
|
"specific_label": specific,
|
||||||
|
"parent_ref": parent_ref(item),
|
||||||
|
**props,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
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)]
|
||||||
|
|
||||||
|
reading_order = dfs_order(doc_data, skip_refs)
|
||||||
|
|
||||||
|
async with (
|
||||||
|
neo.driver.session(database=neo.database) as session,
|
||||||
|
await session.begin_transaction() as tx,
|
||||||
|
):
|
||||||
|
# 1. Wipe existing graph for this doc_id (replace strategy).
|
||||||
|
await tx.run(
|
||||||
|
"MATCH (d:Document {id: $doc_id}) "
|
||||||
|
"OPTIONAL MATCH (d)-[:HAS_ROOT|HAS_CHUNK*0..]->(n) "
|
||||||
|
"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)
|
||||||
|
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)
|
||||||
|
|
||||||
|
# 2. Document node (carries the verbatim JSON for TreeReader).
|
||||||
|
await tx.run(
|
||||||
|
"""
|
||||||
|
CREATE (d:Document {
|
||||||
|
id: $doc_id,
|
||||||
|
title: $title,
|
||||||
|
source_uri: $source_uri,
|
||||||
|
ingested_at: datetime($ingested_at),
|
||||||
|
docling_version: $docling_version,
|
||||||
|
stages_applied: ['tree'],
|
||||||
|
last_tree_write: datetime($ingested_at),
|
||||||
|
tenant_id: $tenant_id,
|
||||||
|
document_json: $document_json
|
||||||
|
})
|
||||||
|
""",
|
||||||
|
doc_id=doc_id,
|
||||||
|
title=filename,
|
||||||
|
source_uri=source_uri or "",
|
||||||
|
ingested_at=ingested_at,
|
||||||
|
docling_version=docling_version or "",
|
||||||
|
tenant_id=tenant_id,
|
||||||
|
document_json=document_json,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 3. Page nodes.
|
||||||
|
if pages:
|
||||||
|
await tx.run(
|
||||||
|
"UNWIND $pages AS p "
|
||||||
|
"CREATE (:Page {doc_id: p.doc_id, page_no: p.page_no, "
|
||||||
|
"width: p.width, height: p.height})",
|
||||||
|
pages=pages,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 4. Element nodes — use dynamic :Element:<specific> labels via APOC-free trick.
|
||||||
|
# We split by specific label so the CREATE statement is static (no APOC).
|
||||||
|
by_specific: dict[str, list[dict[str, Any]]] = {}
|
||||||
|
for e in elements:
|
||||||
|
by_specific.setdefault(e["specific_label"], []).append(e)
|
||||||
|
for specific, batch in by_specific.items():
|
||||||
|
await tx.run(
|
||||||
|
f"""
|
||||||
|
UNWIND $batch AS e
|
||||||
|
CREATE (n:Element:{specific} {{
|
||||||
|
doc_id: e.doc_id,
|
||||||
|
self_ref: e.self_ref,
|
||||||
|
docling_label: e.docling_label,
|
||||||
|
text: e.text,
|
||||||
|
level: e.level,
|
||||||
|
caption: e.caption,
|
||||||
|
cells_json: e.cells_json
|
||||||
|
}})
|
||||||
|
""",
|
||||||
|
batch=batch,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 5. PARENT_OF relations (tree structure). Order tracked inline.
|
||||||
|
parent_rows = [
|
||||||
|
{
|
||||||
|
"doc_id": doc_id,
|
||||||
|
"parent_ref": e["parent_ref"],
|
||||||
|
"child_ref": e["self_ref"],
|
||||||
|
"order": idx,
|
||||||
|
}
|
||||||
|
for idx, e in enumerate(elements)
|
||||||
|
if e["parent_ref"] and e["parent_ref"] != "#/body"
|
||||||
|
]
|
||||||
|
if parent_rows:
|
||||||
|
await tx.run(
|
||||||
|
"""
|
||||||
|
UNWIND $rows AS r
|
||||||
|
MATCH (p:Element {doc_id: r.doc_id, self_ref: r.parent_ref})
|
||||||
|
MATCH (c:Element {doc_id: r.doc_id, self_ref: r.child_ref})
|
||||||
|
MERGE (p)-[rel:PARENT_OF]->(c)
|
||||||
|
SET rel.order = r.order
|
||||||
|
""",
|
||||||
|
rows=parent_rows,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 6. HAS_ROOT for top-level children of the document body.
|
||||||
|
root_rows = [
|
||||||
|
{"doc_id": doc_id, "child_ref": e["self_ref"]}
|
||||||
|
for e in elements
|
||||||
|
if e["parent_ref"] == "#/body"
|
||||||
|
]
|
||||||
|
if root_rows:
|
||||||
|
await tx.run(
|
||||||
|
"""
|
||||||
|
UNWIND $rows AS r
|
||||||
|
MATCH (d:Document {id: r.doc_id})
|
||||||
|
MATCH (c:Element {doc_id: r.doc_id, self_ref: r.child_ref})
|
||||||
|
MERGE (d)-[:HAS_ROOT]->(c)
|
||||||
|
""",
|
||||||
|
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:
|
||||||
|
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)
|
||||||
|
""",
|
||||||
|
rows=provenances,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 8. NEXT chain in DFS pre-order.
|
||||||
|
if len(reading_order) > 1:
|
||||||
|
pairs = [
|
||||||
|
{"doc_id": doc_id, "a": reading_order[i], "b": reading_order[i + 1]}
|
||||||
|
for i in range(len(reading_order) - 1)
|
||||||
|
]
|
||||||
|
await tx.run(
|
||||||
|
"""
|
||||||
|
UNWIND $pairs AS p
|
||||||
|
MATCH (a:Element {doc_id: p.doc_id, self_ref: p.a})
|
||||||
|
MATCH (b:Element {doc_id: p.doc_id, self_ref: p.b})
|
||||||
|
MERGE (a)-[:NEXT]->(b)
|
||||||
|
""",
|
||||||
|
pairs=pairs,
|
||||||
|
)
|
||||||
|
|
||||||
|
await tx.commit()
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"Neo4j: wrote doc %s (%d elements, %d pages, %d provenances)",
|
||||||
|
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),
|
||||||
|
)
|
||||||
|
|
@ -69,13 +69,14 @@ class OpenSearchStore:
|
||||||
verify_certs: Whether to verify TLS certificates.
|
verify_certs: Whether to verify TLS certificates.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, url: str, *, verify_certs: bool = False) -> None:
|
def __init__(self, url: str, *, verify_certs: bool = False, default_limit: int = 1000) -> None:
|
||||||
self._client = AsyncOpenSearch(
|
self._client = AsyncOpenSearch(
|
||||||
hosts=[url],
|
hosts=[url],
|
||||||
use_ssl=url.startswith("https"),
|
use_ssl=url.startswith("https"),
|
||||||
verify_certs=verify_certs,
|
verify_certs=verify_certs,
|
||||||
ssl_show_warn=False,
|
ssl_show_warn=False,
|
||||||
)
|
)
|
||||||
|
self._default_limit = default_limit
|
||||||
|
|
||||||
# -- lifecycle -------------------------------------------------------------
|
# -- lifecycle -------------------------------------------------------------
|
||||||
|
|
||||||
|
|
@ -147,9 +148,11 @@ class OpenSearchStore:
|
||||||
index_name: str,
|
index_name: str,
|
||||||
doc_id: str,
|
doc_id: str,
|
||||||
*,
|
*,
|
||||||
limit: int = 1000,
|
limit: int | None = None,
|
||||||
) -> list[SearchResult]:
|
) -> list[SearchResult]:
|
||||||
"""Retrieve all indexed chunks for a document, ordered by chunk_index."""
|
"""Retrieve all indexed chunks for a document, ordered by chunk_index."""
|
||||||
|
if limit is None:
|
||||||
|
limit = self._default_limit
|
||||||
resp = await self._client.search(
|
resp = await self._client.search(
|
||||||
index=index_name,
|
index=index_name,
|
||||||
body={
|
body={
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,8 @@ import httpx
|
||||||
from docling_core.types.doc.base import BoundingBox, CoordOrigin
|
from docling_core.types.doc.base import BoundingBox, CoordOrigin
|
||||||
|
|
||||||
from domain.value_objects import (
|
from domain.value_objects import (
|
||||||
|
DEFAULT_PAGE_HEIGHT,
|
||||||
|
DEFAULT_PAGE_WIDTH,
|
||||||
ConversionOptions,
|
ConversionOptions,
|
||||||
ConversionResult,
|
ConversionResult,
|
||||||
PageDetail,
|
PageDetail,
|
||||||
|
|
@ -31,7 +33,6 @@ from infra.bbox import to_topleft_list
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
_API_PREFIX = "/v1"
|
_API_PREFIX = "/v1"
|
||||||
_DEFAULT_TIMEOUT = 600.0
|
|
||||||
|
|
||||||
# Docling Serve label → our element type
|
# Docling Serve label → our element type
|
||||||
_LABEL_MAP = {
|
_LABEL_MAP = {
|
||||||
|
|
@ -60,7 +61,7 @@ class ServeConverter:
|
||||||
self,
|
self,
|
||||||
base_url: str,
|
base_url: str,
|
||||||
api_key: str | None = None,
|
api_key: str | None = None,
|
||||||
timeout: float = _DEFAULT_TIMEOUT,
|
timeout: float = 600.0,
|
||||||
):
|
):
|
||||||
self._base_url = base_url.rstrip("/")
|
self._base_url = base_url.rstrip("/")
|
||||||
self._api_key = api_key
|
self._api_key = api_key
|
||||||
|
|
@ -95,6 +96,13 @@ class ServeConverter:
|
||||||
headers=self._headers(),
|
headers=self._headers(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if response.status_code >= 400:
|
||||||
|
logger.error(
|
||||||
|
"Docling Serve error %d: %s (form_data=%s)",
|
||||||
|
response.status_code,
|
||||||
|
response.text[:500],
|
||||||
|
{k: v for k, v in form_data.items()},
|
||||||
|
)
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
result_data = response.json()
|
result_data = response.json()
|
||||||
|
|
||||||
|
|
@ -121,8 +129,12 @@ def _build_form_data(
|
||||||
) -> dict[str, str | list[str]]:
|
) -> dict[str, str | list[str]]:
|
||||||
"""Build form fields matching Docling Serve's multipart form contract.
|
"""Build form fields matching Docling Serve's multipart form contract.
|
||||||
|
|
||||||
Array fields (to_formats) are sent as lists — httpx encodes them as
|
Serve uses FastAPI's ``Form()`` parsing — list/tuple fields are sent
|
||||||
repeated form keys (to_formats=md&to_formats=html&to_formats=json).
|
as **repeated form keys** (httpx encodes Python lists this way
|
||||||
|
automatically: ``to_formats=md&to_formats=html&to_formats=json``).
|
||||||
|
|
||||||
|
Note: ``generate_page_images`` is a PdfPipelineOptions field, NOT a
|
||||||
|
ConvertDocumentsOptions field — sending it causes a 422.
|
||||||
"""
|
"""
|
||||||
data: dict[str, str | list[str]] = {
|
data: dict[str, str | list[str]] = {
|
||||||
"to_formats": ["md", "html", "json"],
|
"to_formats": ["md", "html", "json"],
|
||||||
|
|
@ -134,11 +146,12 @@ def _build_form_data(
|
||||||
"do_picture_classification": str(options.do_picture_classification).lower(),
|
"do_picture_classification": str(options.do_picture_classification).lower(),
|
||||||
"do_picture_description": str(options.do_picture_description).lower(),
|
"do_picture_description": str(options.do_picture_description).lower(),
|
||||||
"include_images": str(options.generate_picture_images).lower(),
|
"include_images": str(options.generate_picture_images).lower(),
|
||||||
"generate_page_images": str(options.generate_page_images).lower(),
|
|
||||||
"images_scale": str(options.images_scale),
|
"images_scale": str(options.images_scale),
|
||||||
}
|
}
|
||||||
if page_range is not None:
|
if page_range is not None:
|
||||||
data["page_range"] = f"{page_range[0]}-{page_range[1]}"
|
# Serve expects page_range as two repeated form fields:
|
||||||
|
# page_range=1&page_range=10
|
||||||
|
data["page_range"] = [str(page_range[0]), str(page_range[1])]
|
||||||
return data
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -192,8 +205,8 @@ def _extract_pages_from_docling_document(doc: dict) -> list[PageDetail]:
|
||||||
size = page_data.get("size", {})
|
size = page_data.get("size", {})
|
||||||
pages_dict[page_no] = PageDetail(
|
pages_dict[page_no] = PageDetail(
|
||||||
page_number=page_no,
|
page_number=page_no,
|
||||||
width=size.get("width", 612.0),
|
width=size.get("width", DEFAULT_PAGE_WIDTH),
|
||||||
height=size.get("height", 792.0),
|
height=size.get("height", DEFAULT_PAGE_HEIGHT),
|
||||||
)
|
)
|
||||||
|
|
||||||
# Process all element arrays
|
# Process all element arrays
|
||||||
|
|
@ -220,8 +233,8 @@ def _add_element(item: dict, pages: dict[int, PageDetail]) -> None:
|
||||||
if page_no not in pages:
|
if page_no not in pages:
|
||||||
pages[page_no] = PageDetail(
|
pages[page_no] = PageDetail(
|
||||||
page_number=page_no,
|
page_number=page_no,
|
||||||
width=612.0,
|
width=DEFAULT_PAGE_WIDTH,
|
||||||
height=792.0,
|
height=DEFAULT_PAGE_HEIGHT,
|
||||||
)
|
)
|
||||||
|
|
||||||
bbox_data = prov.get("bbox", {})
|
bbox_data = prov.get("bbox", {})
|
||||||
|
|
|
||||||
|
|
@ -25,9 +25,23 @@ class Settings:
|
||||||
batch_page_size: int = 0 # 0 = disabled, > 0 = pages per batch
|
batch_page_size: int = 0 # 0 = disabled, > 0 = pages per batch
|
||||||
opensearch_url: str = "" # empty = disabled
|
opensearch_url: str = "" # empty = disabled
|
||||||
embedding_url: str = "" # empty = disabled (e.g. http://localhost:8001)
|
embedding_url: str = "" # empty = disabled (e.g. http://localhost:8001)
|
||||||
|
neo4j_uri: str = "" # empty = disabled (e.g. bolt://neo4j:7687)
|
||||||
|
neo4j_user: str = "neo4j"
|
||||||
|
neo4j_password: str = "changeme"
|
||||||
|
# Live reasoning via docling-agent — off by default (heavy deps, needs an
|
||||||
|
# Ollama host reachable from the backend). Toggle RAG_ENABLED=true + point
|
||||||
|
# OLLAMA_HOST at a running instance (default http://localhost:11434).
|
||||||
|
rag_enabled: bool = False
|
||||||
|
ollama_host: str = "http://localhost:11434"
|
||||||
|
rag_model_id: str = "gpt-oss:20b" # matches docling-agent's example_05
|
||||||
|
opensearch_default_limit: int = 1000 # max chunks returned by get_chunks
|
||||||
embedding_dimension: int = 384 # Granite Embedding 30M / all-MiniLM-L6-v2
|
embedding_dimension: int = 384 # Granite Embedding 30M / all-MiniLM-L6-v2
|
||||||
upload_dir: str = "./uploads"
|
upload_dir: str = "./uploads"
|
||||||
db_path: str = "./data/docling_studio.db"
|
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(
|
cors_origins: list[str] = field(
|
||||||
default_factory=lambda: ["http://localhost:3000", "http://localhost:5173"]
|
default_factory=lambda: ["http://localhost:3000", "http://localhost:5173"]
|
||||||
)
|
)
|
||||||
|
|
@ -50,10 +64,20 @@ class Settings:
|
||||||
errors.append(f"max_file_size must be >= 0 (got {self.max_file_size})")
|
errors.append(f"max_file_size must be >= 0 (got {self.max_file_size})")
|
||||||
if self.max_file_size_mb < 0:
|
if self.max_file_size_mb < 0:
|
||||||
errors.append(f"max_file_size_mb must be >= 0 (got {self.max_file_size_mb})")
|
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:
|
if self.rate_limit_rpm < 0:
|
||||||
errors.append(f"rate_limit_rpm must be >= 0 (got {self.rate_limit_rpm})")
|
errors.append(f"rate_limit_rpm must be >= 0 (got {self.rate_limit_rpm})")
|
||||||
if self.batch_page_size < 0:
|
if self.batch_page_size < 0:
|
||||||
errors.append(f"batch_page_size must be >= 0 (got {self.batch_page_size})")
|
errors.append(f"batch_page_size must be >= 0 (got {self.batch_page_size})")
|
||||||
|
if self.opensearch_default_limit < 1:
|
||||||
|
errors.append(
|
||||||
|
f"opensearch_default_limit must be >= 1 (got {self.opensearch_default_limit})"
|
||||||
|
)
|
||||||
if self.embedding_dimension < 1:
|
if self.embedding_dimension < 1:
|
||||||
errors.append(f"embedding_dimension must be >= 1 (got {self.embedding_dimension})")
|
errors.append(f"embedding_dimension must be >= 1 (got {self.embedding_dimension})")
|
||||||
if self.default_table_mode not in ("accurate", "fast"):
|
if self.default_table_mode not in ("accurate", "fast"):
|
||||||
|
|
@ -79,6 +103,9 @@ class Settings:
|
||||||
def from_env(cls) -> Settings:
|
def from_env(cls) -> Settings:
|
||||||
"""Build a Settings instance from environment variables."""
|
"""Build a Settings instance from environment variables."""
|
||||||
cors_raw = os.environ.get("CORS_ORIGINS", "http://localhost:3000,http://localhost:5173")
|
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(
|
return cls(
|
||||||
app_version=os.environ.get("APP_VERSION", "dev"),
|
app_version=os.environ.get("APP_VERSION", "dev"),
|
||||||
conversion_engine=os.environ.get("CONVERSION_ENGINE", "local"),
|
conversion_engine=os.environ.get("CONVERSION_ENGINE", "local"),
|
||||||
|
|
@ -94,12 +121,26 @@ class Settings:
|
||||||
max_file_size=int(os.environ.get("MAX_FILE_SIZE", "0")),
|
max_file_size=int(os.environ.get("MAX_FILE_SIZE", "0")),
|
||||||
max_file_size_mb=int(os.environ.get("MAX_FILE_SIZE_MB", "50")),
|
max_file_size_mb=int(os.environ.get("MAX_FILE_SIZE_MB", "50")),
|
||||||
rate_limit_rpm=int(os.environ.get("RATE_LIMIT_RPM", "100")),
|
rate_limit_rpm=int(os.environ.get("RATE_LIMIT_RPM", "100")),
|
||||||
batch_page_size=int(os.environ.get("BATCH_PAGE_SIZE", "10")),
|
# 0 = batching disabled (matches dataclass default). Batching
|
||||||
|
# preserves memory on very large docs but `merge_results` drops
|
||||||
|
# `document_json`, which breaks the reasoning tunnel. Enable
|
||||||
|
# explicitly (e.g. 50+) for memory-bound deploys.
|
||||||
|
batch_page_size=int(os.environ.get("BATCH_PAGE_SIZE", "0")),
|
||||||
opensearch_url=os.environ.get("OPENSEARCH_URL", ""),
|
opensearch_url=os.environ.get("OPENSEARCH_URL", ""),
|
||||||
embedding_url=os.environ.get("EMBEDDING_URL", ""),
|
embedding_url=os.environ.get("EMBEDDING_URL", ""),
|
||||||
|
neo4j_uri=os.environ.get("NEO4J_URI", ""),
|
||||||
|
neo4j_user=os.environ.get("NEO4J_USER", "neo4j"),
|
||||||
|
neo4j_password=os.environ.get("NEO4J_PASSWORD", "changeme"),
|
||||||
|
rag_enabled=os.environ.get("RAG_ENABLED", "false").lower()
|
||||||
|
in ("1", "true", "yes", "on"),
|
||||||
|
ollama_host=os.environ.get("OLLAMA_HOST", "http://localhost:11434"),
|
||||||
|
rag_model_id=os.environ.get("RAG_MODEL_ID", "gpt-oss:20b"),
|
||||||
|
opensearch_default_limit=int(os.environ.get("OPENSEARCH_DEFAULT_LIMIT", "1000")),
|
||||||
embedding_dimension=int(os.environ.get("EMBEDDING_DIMENSION", "384")),
|
embedding_dimension=int(os.environ.get("EMBEDDING_DIMENSION", "384")),
|
||||||
upload_dir=os.environ.get("UPLOAD_DIR", "./uploads"),
|
upload_dir=os.environ.get("UPLOAD_DIR", "./uploads"),
|
||||||
db_path=os.environ.get("DB_PATH", "./data/docling_studio.db"),
|
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(",")],
|
cors_origins=[o.strip() for o in cors_raw.split(",")],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,7 @@ def _build_converter():
|
||||||
return ServeConverter(
|
return ServeConverter(
|
||||||
base_url=settings.docling_serve_url,
|
base_url=settings.docling_serve_url,
|
||||||
api_key=settings.docling_serve_api_key,
|
api_key=settings.docling_serve_api_key,
|
||||||
|
timeout=settings.conversion_timeout,
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
from infra.local_converter import LocalConverter
|
from infra.local_converter import LocalConverter
|
||||||
|
|
@ -56,12 +57,15 @@ def _build_converter():
|
||||||
|
|
||||||
|
|
||||||
def _build_chunker():
|
def _build_chunker():
|
||||||
"""Build the chunker adapter — only available in local mode."""
|
"""Build the chunker adapter.
|
||||||
if settings.conversion_engine == "local":
|
|
||||||
from infra.local_chunker import LocalChunker
|
|
||||||
|
|
||||||
return LocalChunker()
|
Uses LocalChunker in all modes — in remote mode it chunks the
|
||||||
return None
|
DoclingDocument JSON returned by Docling Serve, so docling-core
|
||||||
|
(lightweight) is the only local dependency needed.
|
||||||
|
"""
|
||||||
|
from infra.local_chunker import LocalChunker
|
||||||
|
|
||||||
|
return LocalChunker()
|
||||||
|
|
||||||
|
|
||||||
def _build_repos() -> tuple[SqliteDocumentRepository, SqliteAnalysisRepository]:
|
def _build_repos() -> tuple[SqliteDocumentRepository, SqliteAnalysisRepository]:
|
||||||
|
|
@ -71,6 +75,7 @@ def _build_repos() -> tuple[SqliteDocumentRepository, SqliteAnalysisRepository]:
|
||||||
def _build_analysis_service(
|
def _build_analysis_service(
|
||||||
document_repo: SqliteDocumentRepository,
|
document_repo: SqliteDocumentRepository,
|
||||||
analysis_repo: SqliteAnalysisRepository,
|
analysis_repo: SqliteAnalysisRepository,
|
||||||
|
neo4j_driver=None,
|
||||||
) -> AnalysisService:
|
) -> AnalysisService:
|
||||||
converter = _build_converter()
|
converter = _build_converter()
|
||||||
chunker = _build_chunker()
|
chunker = _build_chunker()
|
||||||
|
|
@ -86,10 +91,33 @@ def _build_analysis_service(
|
||||||
conversion_timeout=settings.conversion_timeout,
|
conversion_timeout=settings.conversion_timeout,
|
||||||
max_concurrent=settings.max_concurrent_analyses,
|
max_concurrent=settings.max_concurrent_analyses,
|
||||||
config=config,
|
config=config,
|
||||||
|
neo4j_driver=neo4j_driver,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _build_ingestion_service() -> IngestionService | None:
|
async def _init_neo4j():
|
||||||
|
"""Initialize the Neo4j driver and bootstrap schema — skip if not configured."""
|
||||||
|
if not settings.neo4j_uri:
|
||||||
|
logger.info("Neo4j disabled (NEO4J_URI not set)")
|
||||||
|
return None
|
||||||
|
|
||||||
|
from infra.neo4j import bootstrap_schema, get_driver
|
||||||
|
|
||||||
|
try:
|
||||||
|
neo = await get_driver(
|
||||||
|
settings.neo4j_uri,
|
||||||
|
settings.neo4j_user,
|
||||||
|
settings.neo4j_password,
|
||||||
|
)
|
||||||
|
await bootstrap_schema(neo)
|
||||||
|
logger.info("Neo4j ready (uri=%s)", settings.neo4j_uri)
|
||||||
|
return neo
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Neo4j init failed — continuing without graph storage")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _build_ingestion_service(neo4j_driver=None) -> IngestionService | None:
|
||||||
"""Build the ingestion service — only if embedding + opensearch are configured."""
|
"""Build the ingestion service — only if embedding + opensearch are configured."""
|
||||||
if not settings.embedding_url or not settings.opensearch_url:
|
if not settings.embedding_url or not settings.opensearch_url:
|
||||||
logger.info("Ingestion disabled (EMBEDDING_URL or OPENSEARCH_URL not set)")
|
logger.info("Ingestion disabled (EMBEDDING_URL or OPENSEARCH_URL not set)")
|
||||||
|
|
@ -99,7 +127,10 @@ def _build_ingestion_service() -> IngestionService | None:
|
||||||
from infra.opensearch_store import OpenSearchStore
|
from infra.opensearch_store import OpenSearchStore
|
||||||
|
|
||||||
embedding = EmbeddingClient(settings.embedding_url)
|
embedding = EmbeddingClient(settings.embedding_url)
|
||||||
vector_store = OpenSearchStore(settings.opensearch_url)
|
vector_store = OpenSearchStore(
|
||||||
|
settings.opensearch_url,
|
||||||
|
default_limit=settings.opensearch_default_limit,
|
||||||
|
)
|
||||||
config = IngestionConfig(
|
config = IngestionConfig(
|
||||||
embedding_dimension=settings.embedding_dimension,
|
embedding_dimension=settings.embedding_dimension,
|
||||||
)
|
)
|
||||||
|
|
@ -108,7 +139,7 @@ def _build_ingestion_service() -> IngestionService | None:
|
||||||
settings.embedding_url,
|
settings.embedding_url,
|
||||||
settings.opensearch_url,
|
settings.opensearch_url,
|
||||||
)
|
)
|
||||||
return IngestionService(embedding, vector_store, config)
|
return IngestionService(embedding, vector_store, config, neo4j_driver=neo4j_driver)
|
||||||
|
|
||||||
|
|
||||||
def _build_document_service(
|
def _build_document_service(
|
||||||
|
|
@ -136,15 +167,30 @@ def _build_document_service(
|
||||||
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
async def lifespan(app: FastAPI) -> AsyncIterator[None]:
|
||||||
await init_db()
|
await init_db()
|
||||||
document_repo, analysis_repo = _build_repos()
|
document_repo, analysis_repo = _build_repos()
|
||||||
app.state.analysis_service = _build_analysis_service(document_repo, analysis_repo)
|
# 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
|
||||||
|
)
|
||||||
app.state.document_service = _build_document_service(document_repo, analysis_repo)
|
app.state.document_service = _build_document_service(document_repo, analysis_repo)
|
||||||
ingestion_service = _build_ingestion_service()
|
ingestion_service = _build_ingestion_service(neo4j_driver=app.state.neo4j)
|
||||||
app.state.ingestion_service = ingestion_service
|
app.state.ingestion_service = ingestion_service
|
||||||
if ingestion_service is not None:
|
if ingestion_service is not None:
|
||||||
app.include_router(ingestion_router)
|
app.include_router(ingestion_router)
|
||||||
logger.info("Ingestion router mounted")
|
logger.info("Ingestion router mounted")
|
||||||
logger.info("Docling Studio backend ready (engine=%s)", settings.conversion_engine)
|
logger.info("Docling Studio backend ready (engine=%s)", settings.conversion_engine)
|
||||||
yield
|
try:
|
||||||
|
yield
|
||||||
|
finally:
|
||||||
|
if app.state.neo4j is not None:
|
||||||
|
from infra.neo4j import close_driver
|
||||||
|
|
||||||
|
await close_driver()
|
||||||
|
|
||||||
|
|
||||||
app = FastAPI(
|
app = FastAPI(
|
||||||
|
|
@ -170,6 +216,18 @@ if settings.rate_limit_rpm > 0:
|
||||||
app.include_router(documents_router)
|
app.include_router(documents_router)
|
||||||
app.include_router(analyses_router)
|
app.include_router(analyses_router)
|
||||||
|
|
||||||
|
# Graph view — mounted regardless; individual requests 503 if Neo4j is absent.
|
||||||
|
from api.graph import router as graph_router # noqa: E402
|
||||||
|
|
||||||
|
app.include_router(graph_router)
|
||||||
|
|
||||||
|
# Live reasoning (docling-agent runner). Router is mounted unconditionally so
|
||||||
|
# the route is introspectable in OpenAPI; the handler itself 503s when
|
||||||
|
# `RAG_ENABLED` is off or the deps aren't installed.
|
||||||
|
from api.reasoning import router as reasoning_router # noqa: E402
|
||||||
|
|
||||||
|
app.include_router(reasoning_router)
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/health", response_model=HealthResponse)
|
@app.get("/api/health", response_model=HealthResponse)
|
||||||
async def health() -> HealthResponse:
|
async def health() -> HealthResponse:
|
||||||
|
|
@ -191,5 +249,23 @@ async def health() -> HealthResponse:
|
||||||
database=db_status,
|
database=db_status,
|
||||||
max_page_count=settings.max_page_count if settings.max_page_count > 0 else None,
|
max_page_count=settings.max_page_count if settings.max_page_count > 0 else None,
|
||||||
max_file_size_mb=settings.max_file_size_mb if settings.max_file_size_mb > 0 else None,
|
max_file_size_mb=settings.max_file_size_mb if settings.max_file_size_mb > 0 else None,
|
||||||
|
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,
|
ingestion_available=getattr(app.state, "ingestion_service", None) is not None,
|
||||||
|
# True when the live-reasoning runner is wired (flag on + deps present).
|
||||||
|
# The actual Ollama reachability is checked lazily at call-time to avoid
|
||||||
|
# blocking health checks on the LLM host.
|
||||||
|
rag_available=settings.rag_enabled and _rag_deps_present(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _rag_deps_present() -> bool:
|
||||||
|
"""Import-check only — does not hit Ollama."""
|
||||||
|
try:
|
||||||
|
import docling_agent.agents # noqa: F401
|
||||||
|
import mellea # noqa: F401
|
||||||
|
except ImportError:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
|
||||||
|
|
@ -73,6 +73,23 @@ class SqliteAnalysisRepository:
|
||||||
row = await cursor.fetchone()
|
row = await cursor.fetchone()
|
||||||
return _row_to_job(row) if row else None
|
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:
|
async def update_status(self, job: AnalysisJob) -> None:
|
||||||
"""Persist all mutable fields of an analysis job (status, results, timestamps)."""
|
"""Persist all mutable fields of an analysis job (status, results, timestamps)."""
|
||||||
async with get_connection() as db:
|
async with get_connection() as db:
|
||||||
|
|
|
||||||
|
|
@ -9,11 +9,9 @@ from contextlib import asynccontextmanager
|
||||||
|
|
||||||
import aiosqlite
|
import aiosqlite
|
||||||
|
|
||||||
from infra.settings import settings
|
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
DB_PATH = settings.db_path
|
DB_PATH = os.environ.get("DB_PATH", "./data/docling_studio.db")
|
||||||
|
|
||||||
_SCHEMA = """
|
_SCHEMA = """
|
||||||
CREATE TABLE IF NOT EXISTS documents (
|
CREATE TABLE IF NOT EXISTS documents (
|
||||||
|
|
|
||||||
4
document-parser/requirements-test.txt
Normal file
4
document-parser/requirements-test.txt
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
-r requirements.txt
|
||||||
|
pytest>=8.0.0,<9.0.0
|
||||||
|
pytest-asyncio>=0.23.0,<1.0.0
|
||||||
|
pytestarch>=2.0.0,<3.0.0
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
docling-core>=2.0.0,<3.0.0
|
docling-core[chunking]>=2.0.0,<3.0.0
|
||||||
fastapi>=0.115.0,<1.0.0
|
fastapi>=0.115.0,<1.0.0
|
||||||
uvicorn[standard]>=0.32.0,<1.0.0
|
uvicorn[standard]>=0.32.0,<1.0.0
|
||||||
python-multipart>=0.0.12
|
python-multipart>=0.0.12
|
||||||
|
|
@ -8,3 +8,8 @@ aiosqlite>=0.20.0,<1.0.0
|
||||||
httpx>=0.27.0,<1.0.0
|
httpx>=0.27.0,<1.0.0
|
||||||
pypdfium2>=4.0.0,<5.0.0
|
pypdfium2>=4.0.0,<5.0.0
|
||||||
opensearch-py[async]>=2.6.0,<3.0.0
|
opensearch-py[async]>=2.6.0,<3.0.0
|
||||||
|
neo4j>=5.15.0,<6.0.0
|
||||||
|
# R&D reasoning-trace live runner — calls docling-agent's `_rag_loop` over
|
||||||
|
# an Ollama backend. Gated server-side by `RAG_ENABLED`; pulls ~60MB of deps.
|
||||||
|
docling-agent==0.1.0
|
||||||
|
mellea==0.4.2
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,7 @@ def _chunk_to_dict(c: ChunkResult) -> dict:
|
||||||
"sourcePage": c.source_page,
|
"sourcePage": c.source_page,
|
||||||
"tokenCount": c.token_count,
|
"tokenCount": c.token_count,
|
||||||
"bboxes": [{"page": b.page, "bbox": b.bbox} for b in c.bboxes],
|
"bboxes": [{"page": b.page, "bbox": b.bbox} for b in c.bboxes],
|
||||||
|
"docItems": [{"selfRef": d.self_ref, "label": d.label} for d in c.doc_items],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -69,6 +70,7 @@ class AnalysisConfig:
|
||||||
|
|
||||||
default_table_mode: str = "accurate"
|
default_table_mode: str = "accurate"
|
||||||
batch_page_size: int = 0
|
batch_page_size: int = 0
|
||||||
|
neo4j_required: bool = False # if True, ingestion fails when Neo4j write fails
|
||||||
|
|
||||||
|
|
||||||
class AnalysisService:
|
class AnalysisService:
|
||||||
|
|
@ -83,6 +85,7 @@ class AnalysisService:
|
||||||
conversion_timeout: int = 600,
|
conversion_timeout: int = 600,
|
||||||
max_concurrent: int = _DEFAULT_MAX_CONCURRENT,
|
max_concurrent: int = _DEFAULT_MAX_CONCURRENT,
|
||||||
config: AnalysisConfig | None = None,
|
config: AnalysisConfig | None = None,
|
||||||
|
neo4j_driver=None,
|
||||||
):
|
):
|
||||||
self._converter = converter
|
self._converter = converter
|
||||||
self._chunker = chunker
|
self._chunker = chunker
|
||||||
|
|
@ -93,6 +96,7 @@ class AnalysisService:
|
||||||
self._running_tasks: dict[str, asyncio.Task] = {}
|
self._running_tasks: dict[str, asyncio.Task] = {}
|
||||||
self._background_tasks: set[asyncio.Task] = set()
|
self._background_tasks: set[asyncio.Task] = set()
|
||||||
self._config = config or AnalysisConfig()
|
self._config = config or AnalysisConfig()
|
||||||
|
self._neo4j = neo4j_driver
|
||||||
|
|
||||||
async def create(
|
async def create(
|
||||||
self,
|
self,
|
||||||
|
|
@ -324,11 +328,18 @@ class AnalysisService:
|
||||||
file_path: str,
|
file_path: str,
|
||||||
options: ConversionOptions,
|
options: ConversionOptions,
|
||||||
) -> ConversionResult | None:
|
) -> ConversionResult | None:
|
||||||
"""Run batched or single conversion. Returns None if the job was deleted mid-batch."""
|
"""Run batched or single conversion. Returns None if the job was deleted mid-batch.
|
||||||
|
|
||||||
|
Batching is only used for local mode — it limits memory usage when
|
||||||
|
Docling runs in-process. In remote mode the Serve instance manages
|
||||||
|
its own resources, and batching would discard document_json (needed
|
||||||
|
for chunking).
|
||||||
|
"""
|
||||||
total_pages = _count_pdf_pages(file_path)
|
total_pages = _count_pdf_pages(file_path)
|
||||||
batch_size = self._config.batch_page_size
|
batch_size = self._config.batch_page_size
|
||||||
|
is_remote = self._is_remote_converter()
|
||||||
|
|
||||||
if batch_size > 0 and total_pages > batch_size:
|
if batch_size > 0 and total_pages > batch_size and not is_remote:
|
||||||
return await self._run_batched_conversion(
|
return await self._run_batched_conversion(
|
||||||
job_id, file_path, options, total_pages, batch_size
|
job_id, file_path, options, total_pages, batch_size
|
||||||
)
|
)
|
||||||
|
|
@ -337,6 +348,15 @@ class AnalysisService:
|
||||||
timeout=self._conversion_timeout,
|
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(
|
async def _finalize_analysis(
|
||||||
self,
|
self,
|
||||||
job_id: str,
|
job_id: str,
|
||||||
|
|
@ -370,8 +390,32 @@ class AnalysisService:
|
||||||
if result.page_count:
|
if result.page_count:
|
||||||
await self._document_repo.update_page_count(job.document_id, result.page_count)
|
await self._document_repo.update_page_count(job.document_id, result.page_count)
|
||||||
|
|
||||||
|
await self._write_tree_to_neo4j(job, result.document_json)
|
||||||
|
|
||||||
logger.info("Analysis completed: %s (%d pages)", job_id, result.page_count)
|
logger.info("Analysis completed: %s (%d pages)", job_id, result.page_count)
|
||||||
|
|
||||||
|
async def _write_tree_to_neo4j(self, job, document_json: str | None) -> None:
|
||||||
|
"""Mirror the DoclingDocument tree into Neo4j if configured.
|
||||||
|
|
||||||
|
Silent no-op when Neo4j isn't wired in. Logs but does not fail the
|
||||||
|
analysis when the write fails, unless `config.neo4j_required` is set.
|
||||||
|
"""
|
||||||
|
if self._neo4j is None or not document_json:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
from infra.neo4j import write_document
|
||||||
|
|
||||||
|
await write_document(
|
||||||
|
self._neo4j,
|
||||||
|
doc_id=job.document_id,
|
||||||
|
filename=job.document_filename or job.document_id,
|
||||||
|
document_json=document_json,
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Neo4j TreeWriter failed for doc %s", job.document_id)
|
||||||
|
if self._config.neo4j_required:
|
||||||
|
raise
|
||||||
|
|
||||||
async def _run_analysis_inner(
|
async def _run_analysis_inner(
|
||||||
self,
|
self,
|
||||||
job_id: str,
|
job_id: str,
|
||||||
|
|
|
||||||
|
|
@ -54,10 +54,12 @@ class IngestionService:
|
||||||
embedding_service: EmbeddingService,
|
embedding_service: EmbeddingService,
|
||||||
vector_store: VectorStore,
|
vector_store: VectorStore,
|
||||||
config: IngestionConfig | None = None,
|
config: IngestionConfig | None = None,
|
||||||
|
neo4j_driver=None,
|
||||||
) -> None:
|
) -> None:
|
||||||
self._embedding = embedding_service
|
self._embedding = embedding_service
|
||||||
self._vector_store = vector_store
|
self._vector_store = vector_store
|
||||||
self._config = config or IngestionConfig()
|
self._config = config or IngestionConfig()
|
||||||
|
self._neo4j = neo4j_driver
|
||||||
|
|
||||||
async def ensure_index(self) -> None:
|
async def ensure_index(self) -> None:
|
||||||
"""Ensure the vector index exists with the correct mapping."""
|
"""Ensure the vector index exists with the correct mapping."""
|
||||||
|
|
@ -139,6 +141,15 @@ class IngestionService:
|
||||||
indexed = await self._vector_store.index_chunks(self._config.index_name, indexed_chunks)
|
indexed = await self._vector_store.index_chunks(self._config.index_name, indexed_chunks)
|
||||||
logger.info("Indexed %d/%d chunks for doc %s", indexed, len(indexed_chunks), doc_id)
|
logger.info("Indexed %d/%d chunks for doc %s", indexed, len(indexed_chunks), doc_id)
|
||||||
|
|
||||||
|
# 5. Mirror chunks in Neo4j if configured (with DERIVED_FROM edges).
|
||||||
|
if self._neo4j is not None:
|
||||||
|
try:
|
||||||
|
from infra.neo4j import write_chunks
|
||||||
|
|
||||||
|
await write_chunks(self._neo4j, doc_id=doc_id, chunks_json=chunks_json)
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Neo4j ChunkWriter failed for doc %s", doc_id)
|
||||||
|
|
||||||
return IngestionResult(
|
return IngestionResult(
|
||||||
doc_id=doc_id,
|
doc_id=doc_id,
|
||||||
chunks_indexed=indexed,
|
chunks_indexed=indexed,
|
||||||
|
|
|
||||||
0
document-parser/tests/neo4j/__init__.py
Normal file
0
document-parser/tests/neo4j/__init__.py
Normal file
40
document-parser/tests/neo4j/conftest.py
Normal file
40
document-parser/tests/neo4j/conftest.py
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
"""Shared fixtures for Neo4j integration tests.
|
||||||
|
|
||||||
|
These tests are skipped unless a live Neo4j is reachable via NEO4J_TEST_URI
|
||||||
|
(defaulting to bolt://localhost:7687). CI spins up `neo4j:5.15-community`
|
||||||
|
alongside the job; locally run `docker compose -f docker-compose.dev.yml up neo4j`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
# Skip the entire module cleanly when the neo4j driver package is absent
|
||||||
|
# (e.g. local dev without the dependency installed).
|
||||||
|
pytest.importorskip("neo4j")
|
||||||
|
|
||||||
|
from infra.neo4j import close_driver, get_driver
|
||||||
|
|
||||||
|
|
||||||
|
def _cfg() -> tuple[str, str, str]:
|
||||||
|
return (
|
||||||
|
os.environ.get("NEO4J_TEST_URI", "bolt://localhost:7687"),
|
||||||
|
os.environ.get("NEO4J_TEST_USER", "neo4j"),
|
||||||
|
os.environ.get("NEO4J_TEST_PASSWORD", "changeme"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
async def neo4j_driver():
|
||||||
|
uri, user, password = _cfg()
|
||||||
|
try:
|
||||||
|
neo = await get_driver(uri, user, password)
|
||||||
|
except Exception as exc:
|
||||||
|
pytest.skip(f"Neo4j not reachable at {uri}: {exc}")
|
||||||
|
# Wipe DB before each test — integration tests assume an empty graph.
|
||||||
|
async with neo.driver.session(database=neo.database) as session:
|
||||||
|
await session.run("MATCH (n) DETACH DELETE n")
|
||||||
|
yield neo
|
||||||
|
await close_driver()
|
||||||
113
document-parser/tests/neo4j/test_chunk_writer.py
Normal file
113
document-parser/tests/neo4j/test_chunk_writer.py
Normal file
|
|
@ -0,0 +1,113 @@
|
||||||
|
"""ChunkWriter creates Chunk nodes + DERIVED_FROM links.
|
||||||
|
|
||||||
|
Builds on the tree_writer fixture — writes the tree first so that DERIVED_FROM
|
||||||
|
has Elements to link against.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
from infra.neo4j import fetch_graph, write_chunks, write_document
|
||||||
|
from infra.neo4j.schema import bootstrap_schema
|
||||||
|
from tests.neo4j.test_tree_writer import FIXTURE
|
||||||
|
|
||||||
|
CHUNKS = [
|
||||||
|
{
|
||||||
|
"text": "Introduction. First paragraph on page 1.",
|
||||||
|
"sourcePage": 1,
|
||||||
|
"tokenCount": 8,
|
||||||
|
"docItems": [
|
||||||
|
{"selfRef": "#/texts/0", "label": "section_header"},
|
||||||
|
{"selfRef": "#/texts/1", "label": "paragraph"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"text": "Continued on page 2.",
|
||||||
|
"sourcePage": 2,
|
||||||
|
"tokenCount": 4,
|
||||||
|
"docItems": [{"selfRef": "#/texts/2", "label": "paragraph"}],
|
||||||
|
"deleted": False,
|
||||||
|
},
|
||||||
|
# soft-deleted chunk: must be ignored
|
||||||
|
{"text": "gone", "deleted": True, "docItems": []},
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_write_chunks_and_derived_from(neo4j_driver):
|
||||||
|
await bootstrap_schema(neo4j_driver)
|
||||||
|
await write_document(
|
||||||
|
neo4j_driver,
|
||||||
|
doc_id="doc-fixture",
|
||||||
|
filename="fixture.pdf",
|
||||||
|
document_json=json.dumps(FIXTURE),
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await write_chunks(
|
||||||
|
neo4j_driver,
|
||||||
|
doc_id="doc-fixture",
|
||||||
|
chunks_json=json.dumps(CHUNKS),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.chunks_written == 2
|
||||||
|
assert result.derived_from_edges == 3
|
||||||
|
|
||||||
|
async with neo4j_driver.driver.session(database=neo4j_driver.database) as s:
|
||||||
|
count = await (
|
||||||
|
await s.run(
|
||||||
|
"MATCH (:Document {id: $id})-[:HAS_CHUNK]->(c:Chunk) RETURN count(c) AS n",
|
||||||
|
id="doc-fixture",
|
||||||
|
)
|
||||||
|
).single()
|
||||||
|
assert count["n"] == 2
|
||||||
|
|
||||||
|
# First chunk derives from 2 elements, second from 1.
|
||||||
|
for idx, expected in [(0, 2), (1, 1)]:
|
||||||
|
cnt = await (
|
||||||
|
await s.run(
|
||||||
|
"MATCH (c:Chunk {id: $cid})-[:DERIVED_FROM]->(e:Element) RETURN count(e) AS n",
|
||||||
|
cid=f"doc-fixture::chunk::{idx}",
|
||||||
|
)
|
||||||
|
).single()
|
||||||
|
assert cnt["n"] == expected
|
||||||
|
|
||||||
|
stages = await (
|
||||||
|
await s.run(
|
||||||
|
"MATCH (d:Document {id: $id}) RETURN d.stages_applied AS s", id="doc-fixture"
|
||||||
|
)
|
||||||
|
).single()
|
||||||
|
assert "chunks" in stages["s"]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_fetch_graph_returns_full_payload(neo4j_driver):
|
||||||
|
await bootstrap_schema(neo4j_driver)
|
||||||
|
await write_document(
|
||||||
|
neo4j_driver,
|
||||||
|
doc_id="doc-fixture",
|
||||||
|
filename="fixture.pdf",
|
||||||
|
document_json=json.dumps(FIXTURE),
|
||||||
|
)
|
||||||
|
await write_chunks(
|
||||||
|
neo4j_driver,
|
||||||
|
doc_id="doc-fixture",
|
||||||
|
chunks_json=json.dumps(CHUNKS),
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = await fetch_graph(neo4j_driver, "doc-fixture")
|
||||||
|
assert payload is not None
|
||||||
|
assert payload.truncated is False
|
||||||
|
assert payload.page_count == 2
|
||||||
|
|
||||||
|
groups = {n["group"] for n in payload.nodes}
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
async def test_fetch_graph_missing_doc_returns_none(neo4j_driver):
|
||||||
|
await bootstrap_schema(neo4j_driver)
|
||||||
|
assert await fetch_graph(neo4j_driver, "no-such-doc") is None
|
||||||
32
document-parser/tests/neo4j/test_document_roundtrip.py
Normal file
32
document-parser/tests/neo4j/test_document_roundtrip.py
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
"""Minimal Document node round-trip — validates the driver + schema end-to-end."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from infra.neo4j.schema import bootstrap_schema
|
||||||
|
|
||||||
|
|
||||||
|
async def test_document_write_read_delete(neo4j_driver):
|
||||||
|
await bootstrap_schema(neo4j_driver)
|
||||||
|
|
||||||
|
async with neo4j_driver.driver.session(database=neo4j_driver.database) as session:
|
||||||
|
await session.run(
|
||||||
|
"CREATE (d:Document {id: $id, title: $title, tenant_id: $tenant})",
|
||||||
|
id="doc-42",
|
||||||
|
title="Round-trip fixture",
|
||||||
|
tenant="default",
|
||||||
|
)
|
||||||
|
|
||||||
|
result = await session.run(
|
||||||
|
"MATCH (d:Document {id: $id}) RETURN d.title AS title, d.tenant_id AS tenant",
|
||||||
|
id="doc-42",
|
||||||
|
)
|
||||||
|
record = await result.single()
|
||||||
|
assert record is not None
|
||||||
|
assert record["title"] == "Round-trip fixture"
|
||||||
|
assert record["tenant"] == "default"
|
||||||
|
|
||||||
|
await session.run("MATCH (d:Document {id: $id}) DETACH DELETE d", id="doc-42")
|
||||||
|
gone = await (
|
||||||
|
await session.run("MATCH (d:Document {id: $id}) RETURN d", id="doc-42")
|
||||||
|
).single()
|
||||||
|
assert gone is None
|
||||||
10
document-parser/tests/neo4j/test_driver.py
Normal file
10
document-parser/tests/neo4j/test_driver.py
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
"""Neo4j driver connectivity smoke test."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|
||||||
|
async def test_driver_connects_and_runs_cypher(neo4j_driver):
|
||||||
|
async with neo4j_driver.driver.session(database=neo4j_driver.database) as session:
|
||||||
|
result = await session.run("RETURN 1 AS x")
|
||||||
|
record = await result.single()
|
||||||
|
assert record["x"] == 1
|
||||||
38
document-parser/tests/neo4j/test_schema.py
Normal file
38
document-parser/tests/neo4j/test_schema.py
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
"""Schema bootstrap is idempotent and produces the expected constraints/indexes."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from infra.neo4j.schema import CONSTRAINTS, FULLTEXT_INDEXES, INDEXES, bootstrap_schema
|
||||||
|
|
||||||
|
|
||||||
|
async def _count_schema(neo4j_driver) -> tuple[int, int]:
|
||||||
|
async with neo4j_driver.driver.session(database=neo4j_driver.database) as session:
|
||||||
|
constraints = await (await session.run("SHOW CONSTRAINTS")).data()
|
||||||
|
indexes = await (await session.run("SHOW INDEXES")).data()
|
||||||
|
return len(constraints), len(indexes)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_bootstrap_is_idempotent(neo4j_driver):
|
||||||
|
await bootstrap_schema(neo4j_driver)
|
||||||
|
first = await _count_schema(neo4j_driver)
|
||||||
|
|
||||||
|
# Running a second time must not duplicate anything.
|
||||||
|
await bootstrap_schema(neo4j_driver)
|
||||||
|
second = await _count_schema(neo4j_driver)
|
||||||
|
|
||||||
|
assert first == second
|
||||||
|
# Sanity: we created at least what we declared.
|
||||||
|
assert first[0] >= len(CONSTRAINTS)
|
||||||
|
assert first[1] >= len(INDEXES) + len(FULLTEXT_INDEXES)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_document_id_is_unique(neo4j_driver):
|
||||||
|
await bootstrap_schema(neo4j_driver)
|
||||||
|
async with neo4j_driver.driver.session(database=neo4j_driver.database) as session:
|
||||||
|
await session.run("CREATE (d:Document {id: 'doc-1', title: 'first'})")
|
||||||
|
with_err: Exception | None = None
|
||||||
|
try:
|
||||||
|
await session.run("CREATE (d:Document {id: 'doc-1', title: 'dup'})")
|
||||||
|
except Exception as exc:
|
||||||
|
with_err = exc
|
||||||
|
assert with_err is not None, "unique constraint on Document.id must reject duplicates"
|
||||||
469
document-parser/tests/neo4j/test_tree_writer.py
Normal file
469
document-parser/tests/neo4j/test_tree_writer.py
Normal file
|
|
@ -0,0 +1,469 @@
|
||||||
|
"""TreeWriter round-trip + structural sanity checks.
|
||||||
|
|
||||||
|
Fixture is a hand-crafted DoclingDocument JSON with: one section containing
|
||||||
|
two paragraphs and a table, spanning two pages. Tests verify that the graph
|
||||||
|
mirrors the structure (HAS_ROOT, PARENT_OF, ON_PAGE, NEXT) and that
|
||||||
|
re-writing the same doc is an idempotent replace.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
from infra.neo4j import read_document_json, write_document
|
||||||
|
from infra.neo4j.schema import bootstrap_schema
|
||||||
|
|
||||||
|
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, "grid": [[1, 2], [3, 4]]},
|
||||||
|
"prov": [{"page_no": 2, "bbox": {"l": 10, "t": 90, "r": 500, "b": 200}}],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"pictures": [],
|
||||||
|
"groups": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _count(session, cypher: str, **params) -> int:
|
||||||
|
r = await session.run(cypher, **params)
|
||||||
|
rec = await r.single()
|
||||||
|
return int(rec["n"]) if rec else 0
|
||||||
|
|
||||||
|
|
||||||
|
async def test_write_creates_expected_structure(neo4j_driver):
|
||||||
|
await bootstrap_schema(neo4j_driver)
|
||||||
|
doc_json = json.dumps(FIXTURE)
|
||||||
|
|
||||||
|
result = await write_document(
|
||||||
|
neo4j_driver,
|
||||||
|
doc_id="doc-fixture",
|
||||||
|
filename="fixture.pdf",
|
||||||
|
document_json=doc_json,
|
||||||
|
)
|
||||||
|
|
||||||
|
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 (
|
||||||
|
await _count(
|
||||||
|
s,
|
||||||
|
"MATCH (d:Document {id: $id}) RETURN count(d) AS n",
|
||||||
|
id="doc-fixture",
|
||||||
|
)
|
||||||
|
== 1
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
await _count(
|
||||||
|
s,
|
||||||
|
"MATCH (:Document {id: $id})-[:HAS_ROOT]->(e:Element) RETURN count(e) AS n",
|
||||||
|
id="doc-fixture",
|
||||||
|
)
|
||||||
|
== 4
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
await _count(
|
||||||
|
s,
|
||||||
|
"MATCH (e:Element:SectionHeader {doc_id: $id, self_ref: '#/texts/0'}) "
|
||||||
|
"RETURN count(e) AS n",
|
||||||
|
id="doc-fixture",
|
||||||
|
)
|
||||||
|
== 1
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
await _count(
|
||||||
|
s,
|
||||||
|
"MATCH (e:Element:Table {doc_id: $id}) RETURN count(e) AS n",
|
||||||
|
id="doc-fixture",
|
||||||
|
)
|
||||||
|
== 1
|
||||||
|
)
|
||||||
|
# Reading-order chain: 3 NEXT edges for 4 elements.
|
||||||
|
assert (
|
||||||
|
await _count(
|
||||||
|
s,
|
||||||
|
"MATCH (a:Element {doc_id: $id})-[:NEXT]->(b:Element {doc_id: $id}) "
|
||||||
|
"RETURN count(*) AS n",
|
||||||
|
id="doc-fixture",
|
||||||
|
)
|
||||||
|
== 3
|
||||||
|
)
|
||||||
|
# Post-v0.6: ON_PAGE attaches Provenance to Page, not Element directly.
|
||||||
|
# Traverse through the Provenance node.
|
||||||
|
assert (
|
||||||
|
await _count(
|
||||||
|
s,
|
||||||
|
"MATCH (:Element {doc_id: $id})-[:HAS_PROV]->"
|
||||||
|
"(:Provenance)-[: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):
|
||||||
|
await bootstrap_schema(neo4j_driver)
|
||||||
|
doc_json = json.dumps(FIXTURE)
|
||||||
|
|
||||||
|
await write_document(
|
||||||
|
neo4j_driver,
|
||||||
|
doc_id="doc-fixture",
|
||||||
|
filename="fixture.pdf",
|
||||||
|
document_json=doc_json,
|
||||||
|
)
|
||||||
|
# Second write with the same id must not duplicate anything.
|
||||||
|
await write_document(
|
||||||
|
neo4j_driver,
|
||||||
|
doc_id="doc-fixture",
|
||||||
|
filename="fixture.pdf",
|
||||||
|
document_json=doc_json,
|
||||||
|
)
|
||||||
|
|
||||||
|
async with neo4j_driver.driver.session(database=neo4j_driver.database) as s:
|
||||||
|
assert (
|
||||||
|
await _count(s, "MATCH (d:Document {id: $id}) RETURN count(d) AS n", id="doc-fixture")
|
||||||
|
== 1
|
||||||
|
)
|
||||||
|
assert (
|
||||||
|
await _count(
|
||||||
|
s,
|
||||||
|
"MATCH (e:Element {doc_id: $id}) RETURN count(e) AS n",
|
||||||
|
id="doc-fixture",
|
||||||
|
)
|
||||||
|
== 4
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_reader_returns_verbatim_json(neo4j_driver):
|
||||||
|
await bootstrap_schema(neo4j_driver)
|
||||||
|
doc_json = json.dumps(FIXTURE, sort_keys=True)
|
||||||
|
await write_document(
|
||||||
|
neo4j_driver,
|
||||||
|
doc_id="doc-fixture",
|
||||||
|
filename="fixture.pdf",
|
||||||
|
document_json=doc_json,
|
||||||
|
)
|
||||||
|
|
||||||
|
read_back = await read_document_json(neo4j_driver, "doc-fixture")
|
||||||
|
assert read_back is not None
|
||||||
|
assert json.loads(read_back) == json.loads(doc_json)
|
||||||
|
|
||||||
|
|
||||||
|
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
|
||||||
|
)
|
||||||
208
document-parser/tests/test_architecture.py
Normal file
208
document-parser/tests/test_architecture.py
Normal file
|
|
@ -0,0 +1,208 @@
|
||||||
|
"""Hexagonal architecture tests — enforce layer dependency rules.
|
||||||
|
|
||||||
|
Uses pytestarch for inter-layer dependency rules and ast-based import
|
||||||
|
scanning for external (third-party) dependency constraints.
|
||||||
|
|
||||||
|
Rules enforced:
|
||||||
|
- domain -> no import from api, services, infra, persistence
|
||||||
|
- services -> no import from api, infra, persistence
|
||||||
|
- api -> no import from infra, persistence
|
||||||
|
- infra -> no import from api, services
|
||||||
|
- persistence -> no import from api, services, infra
|
||||||
|
- domain -> no import of fastapi, sqlalchemy, httpx, opensearchpy
|
||||||
|
- services -> no import of fastapi
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import ast
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from pytestarch import Rule, get_evaluable_architecture
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# pytestarch evaluable (project root = document-parser/)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||||
|
|
||||||
|
# pytestarch uses the directory name as module prefix when given absolute paths.
|
||||||
|
# We use the directory name to build qualified module references.
|
||||||
|
_PREFIX = _PROJECT_ROOT.name # "document-parser"
|
||||||
|
|
||||||
|
_evaluable = get_evaluable_architecture(str(_PROJECT_ROOT), str(_PROJECT_ROOT))
|
||||||
|
|
||||||
|
|
||||||
|
def _mod(layer: str) -> str:
|
||||||
|
"""Return the fully-qualified pytestarch module name for a layer."""
|
||||||
|
return f"{_PREFIX}.{layer}"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Helper: collect top-level imports from all .py files in a package
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _collect_imports(package: str) -> set[str]:
|
||||||
|
"""Return the set of top-level module names imported by *package*."""
|
||||||
|
pkg_path = Path(_PROJECT_ROOT) / package
|
||||||
|
imports: set[str] = set()
|
||||||
|
for py_file in pkg_path.rglob("*.py"):
|
||||||
|
tree = ast.parse(py_file.read_text(), filename=str(py_file))
|
||||||
|
for node in ast.walk(tree):
|
||||||
|
if isinstance(node, ast.Import):
|
||||||
|
for alias in node.names:
|
||||||
|
imports.add(alias.name.split(".")[0])
|
||||||
|
elif isinstance(node, ast.ImportFrom) and node.module:
|
||||||
|
imports.add(node.module.split(".")[0])
|
||||||
|
return imports
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Inter-layer dependency rules (pytestarch)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestDomainLayerIsolation:
|
||||||
|
"""domain must not depend on any other layer."""
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("forbidden", ["api", "services", "infra", "persistence"])
|
||||||
|
def test_domain_does_not_import(self, forbidden: str):
|
||||||
|
rule = (
|
||||||
|
Rule()
|
||||||
|
.modules_that()
|
||||||
|
.are_sub_modules_of(_mod("domain"))
|
||||||
|
.should_not()
|
||||||
|
.import_modules_that()
|
||||||
|
.are_sub_modules_of(_mod(forbidden))
|
||||||
|
)
|
||||||
|
rule.assert_applies(_evaluable)
|
||||||
|
|
||||||
|
|
||||||
|
class TestServicesLayerIsolation:
|
||||||
|
"""services may import domain only."""
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("forbidden", ["api", "infra", "persistence"])
|
||||||
|
def test_services_does_not_import(self, forbidden: str):
|
||||||
|
rule = (
|
||||||
|
Rule()
|
||||||
|
.modules_that()
|
||||||
|
.are_sub_modules_of(_mod("services"))
|
||||||
|
.should_not()
|
||||||
|
.import_modules_that()
|
||||||
|
.are_sub_modules_of(_mod(forbidden))
|
||||||
|
)
|
||||||
|
rule.assert_applies(_evaluable)
|
||||||
|
|
||||||
|
|
||||||
|
class TestApiLayerIsolation:
|
||||||
|
"""api may import services and domain, but not infra or persistence."""
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("forbidden", ["infra", "persistence"])
|
||||||
|
def test_api_does_not_import(self, forbidden: str):
|
||||||
|
rule = (
|
||||||
|
Rule()
|
||||||
|
.modules_that()
|
||||||
|
.are_sub_modules_of(_mod("api"))
|
||||||
|
.should_not()
|
||||||
|
.import_modules_that()
|
||||||
|
.are_sub_modules_of(_mod(forbidden))
|
||||||
|
)
|
||||||
|
rule.assert_applies(_evaluable)
|
||||||
|
|
||||||
|
|
||||||
|
class TestInfraLayerIsolation:
|
||||||
|
"""infra may import domain (ports), but not api or services."""
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("forbidden", ["api", "services"])
|
||||||
|
def test_infra_does_not_import(self, forbidden: str):
|
||||||
|
rule = (
|
||||||
|
Rule()
|
||||||
|
.modules_that()
|
||||||
|
.are_sub_modules_of(_mod("infra"))
|
||||||
|
.should_not()
|
||||||
|
.import_modules_that()
|
||||||
|
.are_sub_modules_of(_mod(forbidden))
|
||||||
|
)
|
||||||
|
rule.assert_applies(_evaluable)
|
||||||
|
|
||||||
|
|
||||||
|
class TestPersistenceLayerIsolation:
|
||||||
|
"""persistence may import domain, but not api, services, or infra."""
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("forbidden", ["api", "services", "infra"])
|
||||||
|
def test_persistence_does_not_import(self, forbidden: str):
|
||||||
|
rule = (
|
||||||
|
Rule()
|
||||||
|
.modules_that()
|
||||||
|
.are_sub_modules_of(_mod("persistence"))
|
||||||
|
.should_not()
|
||||||
|
.import_modules_that()
|
||||||
|
.are_sub_modules_of(_mod(forbidden))
|
||||||
|
)
|
||||||
|
rule.assert_applies(_evaluable)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# External dependency rules (ast-based)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
_DOMAIN_FORBIDDEN_EXTERNALS = {"fastapi", "sqlalchemy", "httpx", "opensearchpy"}
|
||||||
|
_SERVICES_FORBIDDEN_EXTERNALS = {"fastapi"}
|
||||||
|
|
||||||
|
|
||||||
|
class TestDomainExternalDependencies:
|
||||||
|
"""domain must not import infrastructure-specific third-party libraries."""
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("lib", sorted(_DOMAIN_FORBIDDEN_EXTERNALS))
|
||||||
|
def test_domain_does_not_import_external(self, lib: str):
|
||||||
|
imports = _collect_imports("domain")
|
||||||
|
assert lib not in imports, f"domain imports forbidden external library '{lib}'"
|
||||||
|
|
||||||
|
|
||||||
|
class TestServicesExternalDependencies:
|
||||||
|
"""services must not import web-framework libraries."""
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("lib", sorted(_SERVICES_FORBIDDEN_EXTERNALS))
|
||||||
|
def test_services_does_not_import_external(self, lib: str):
|
||||||
|
imports = _collect_imports("services")
|
||||||
|
assert lib not in imports, f"services imports forbidden external library '{lib}'"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Convention: ports live exclusively in domain.ports
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestPortConvention:
|
||||||
|
"""Protocol definitions (ports) must live in domain.ports only."""
|
||||||
|
|
||||||
|
def test_no_protocol_outside_domain_ports(self):
|
||||||
|
"""No Protocol subclass should be defined outside domain/ports.py."""
|
||||||
|
ports_file = Path(_PROJECT_ROOT) / "domain" / "ports.py"
|
||||||
|
for py_file in Path(_PROJECT_ROOT).rglob("*.py"):
|
||||||
|
if py_file == ports_file:
|
||||||
|
continue
|
||||||
|
# Skip test files and __pycache__
|
||||||
|
if "tests" in py_file.parts or "__pycache__" in py_file.parts:
|
||||||
|
continue
|
||||||
|
tree = ast.parse(py_file.read_text(), filename=str(py_file))
|
||||||
|
for node in ast.walk(tree):
|
||||||
|
if isinstance(node, ast.ClassDef):
|
||||||
|
for base in node.bases:
|
||||||
|
base_name = _get_name(base)
|
||||||
|
if base_name == "Protocol":
|
||||||
|
pytest.fail(
|
||||||
|
f"Protocol '{node.name}' defined in {py_file.relative_to(_PROJECT_ROOT)}"
|
||||||
|
f" — ports must live in domain/ports.py"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _get_name(node: ast.expr) -> str:
|
||||||
|
"""Extract a simple name from an AST expression node."""
|
||||||
|
if isinstance(node, ast.Name):
|
||||||
|
return node.id
|
||||||
|
if isinstance(node, ast.Attribute):
|
||||||
|
return node.attr
|
||||||
|
return ""
|
||||||
|
|
@ -66,6 +66,7 @@ class TestChunkResult:
|
||||||
"source_page": 1,
|
"source_page": 1,
|
||||||
"token_count": 10,
|
"token_count": 10,
|
||||||
"bboxes": [],
|
"bboxes": [],
|
||||||
|
"doc_items": [],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -465,3 +466,74 @@ class TestRechunkEndpoint:
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
assert resp.status_code == 422
|
assert resp.status_code == 422
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Remote chunking path — hybrid local chunking from Serve's document_json
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
class TestRemoteChunkingPath:
|
||||||
|
"""Verify that chunking works on document_json produced by Serve (remote mode)."""
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_rechunk_with_serve_document_json(self):
|
||||||
|
"""AnalysisService.rechunk() works with a LocalChunker even in remote mode."""
|
||||||
|
from infra.local_chunker import LocalChunker
|
||||||
|
from services.analysis_service import AnalysisService
|
||||||
|
|
||||||
|
chunker = LocalChunker()
|
||||||
|
analysis_repo = AsyncMock()
|
||||||
|
document_repo = AsyncMock()
|
||||||
|
converter = AsyncMock() # ServeConverter mock — not used for rechunking
|
||||||
|
|
||||||
|
service = AnalysisService(
|
||||||
|
converter=converter,
|
||||||
|
analysis_repo=analysis_repo,
|
||||||
|
document_repo=document_repo,
|
||||||
|
chunker=chunker,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Simulate a completed job with document_json from Serve
|
||||||
|
job = AnalysisJob(id="j-remote", document_id="d1")
|
||||||
|
job.mark_running()
|
||||||
|
job.mark_completed(
|
||||||
|
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": {},
|
||||||
|
}
|
||||||
|
),
|
||||||
|
)
|
||||||
|
analysis_repo.find_by_id = AsyncMock(return_value=job)
|
||||||
|
analysis_repo.update_chunks = AsyncMock(return_value=True)
|
||||||
|
|
||||||
|
chunks = await service.rechunk(
|
||||||
|
"j-remote",
|
||||||
|
{"chunker_type": "hybrid", "max_tokens": 512},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert isinstance(chunks, list)
|
||||||
|
analysis_repo.update_chunks.assert_called_once()
|
||||||
|
|
|
||||||
398
document-parser/tests/test_docling_graph.py
Normal file
398
document-parser/tests/test_docling_graph.py
Normal file
|
|
@ -0,0 +1,398 @@
|
||||||
|
"""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
|
||||||
114
document-parser/tests/test_graph_api.py
Normal file
114
document-parser/tests/test_graph_api.py
Normal file
|
|
@ -0,0 +1,114 @@
|
||||||
|
"""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)
|
||||||
261
document-parser/tests/test_reasoning_api.py
Normal file
261
document-parser/tests/test_reasoning_api.py
Normal file
|
|
@ -0,0 +1,261 @@
|
||||||
|
"""Tests for `api.reasoning` — the live `docling-agent` RAG runner endpoint.
|
||||||
|
|
||||||
|
docling-agent + mellea are NOT installed in the CI test env (heavy deps).
|
||||||
|
The endpoint does a lazy import inside the handler; we stub the modules via
|
||||||
|
`sys.modules` injection so the tests cover the real code path without
|
||||||
|
bringing in Ollama, mellea, or LLM clients.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import types
|
||||||
|
from dataclasses import replace
|
||||||
|
from unittest.mock import AsyncMock, MagicMock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from api import reasoning as reasoning_module
|
||||||
|
from api.reasoning import router
|
||||||
|
from domain.models import AnalysisJob
|
||||||
|
|
||||||
|
|
||||||
|
def _patched_settings(monkeypatch, **overrides):
|
||||||
|
"""Replace `api.reasoning.settings` with a frozen dataclass copy carrying
|
||||||
|
the given overrides. `Settings` is frozen, so attribute-level monkeypatch
|
||||||
|
doesn't work — we swap the whole instance on the module.
|
||||||
|
"""
|
||||||
|
new_settings = replace(reasoning_module.settings, **overrides)
|
||||||
|
monkeypatch.setattr(reasoning_module, "settings", new_settings)
|
||||||
|
return new_settings
|
||||||
|
|
||||||
|
|
||||||
|
def _job_with_doc_json() -> AnalysisJob:
|
||||||
|
job = AnalysisJob(document_id="doc-1")
|
||||||
|
job.document_filename = "hello.pdf"
|
||||||
|
job.mark_running()
|
||||||
|
job.mark_completed(
|
||||||
|
markdown="# Hello",
|
||||||
|
html="<h1>Hello</h1>",
|
||||||
|
pages_json="[]",
|
||||||
|
# Minimal placeholder — the test stubs `DoclingDocument.model_validate_json`
|
||||||
|
# so the content doesn't need to be a real DoclingDocument.
|
||||||
|
document_json='{"stub": true}',
|
||||||
|
chunks_json="[]",
|
||||||
|
)
|
||||||
|
return job
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mock_analysis_repo() -> AsyncMock:
|
||||||
|
repo = AsyncMock()
|
||||||
|
repo.find_latest_completed_by_document.return_value = _job_with_doc_json()
|
||||||
|
return repo
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def stub_docling_agent(monkeypatch):
|
||||||
|
"""Inject fake `docling_agent.agents` + `docling_core.types.doc.document`
|
||||||
|
modules so the endpoint's lazy imports resolve to our stubs.
|
||||||
|
|
||||||
|
Returns the `DoclingRAGAgent` stub class so tests can assert on its calls
|
||||||
|
/ configure its `_rag_loop` return value.
|
||||||
|
"""
|
||||||
|
fake_result = MagicMock()
|
||||||
|
fake_result.answer = "stub answer"
|
||||||
|
fake_result.converged = True
|
||||||
|
fake_result.iterations = [
|
||||||
|
MagicMock(
|
||||||
|
model_dump=lambda: {
|
||||||
|
"iteration": 1,
|
||||||
|
"section_ref": "#/texts/0",
|
||||||
|
"reason": "looks relevant",
|
||||||
|
"section_text_length": 42,
|
||||||
|
"can_answer": True,
|
||||||
|
"response": "stub answer",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
]
|
||||||
|
|
||||||
|
agent_instance = MagicMock()
|
||||||
|
agent_instance._rag_loop.return_value = fake_result
|
||||||
|
agent_class = MagicMock(return_value=agent_instance)
|
||||||
|
|
||||||
|
fake_agents_mod = types.ModuleType("docling_agent.agents")
|
||||||
|
fake_agents_mod.DoclingRAGAgent = agent_class
|
||||||
|
fake_root_mod = types.ModuleType("docling_agent")
|
||||||
|
fake_root_mod.agents = fake_agents_mod
|
||||||
|
|
||||||
|
fake_doc_class = MagicMock()
|
||||||
|
fake_doc_class.model_validate_json = MagicMock(return_value="fake-doc-instance")
|
||||||
|
fake_doc_mod = types.ModuleType("docling_core.types.doc.document")
|
||||||
|
fake_doc_mod.DoclingDocument = fake_doc_class
|
||||||
|
|
||||||
|
# Stub `mellea.backends.model_ids.ModelIdentifier` — the endpoint wraps
|
||||||
|
# the string model_id in this dataclass before handing to DoclingRAGAgent.
|
||||||
|
# Identity-like: stores the kwargs so tests can assert on `ollama_name`.
|
||||||
|
def fake_model_identifier(**kwargs):
|
||||||
|
m = MagicMock()
|
||||||
|
m.ollama_name = kwargs.get("ollama_name")
|
||||||
|
m.openai_name = kwargs.get("openai_name")
|
||||||
|
return m
|
||||||
|
|
||||||
|
fake_model_ids_mod = types.ModuleType("mellea.backends.model_ids")
|
||||||
|
fake_model_ids_mod.ModelIdentifier = fake_model_identifier
|
||||||
|
fake_backends_mod = types.ModuleType("mellea.backends")
|
||||||
|
fake_backends_mod.model_ids = fake_model_ids_mod
|
||||||
|
fake_mellea_mod = types.ModuleType("mellea")
|
||||||
|
fake_mellea_mod.backends = fake_backends_mod
|
||||||
|
|
||||||
|
monkeypatch.setitem(sys.modules, "docling_agent", fake_root_mod)
|
||||||
|
monkeypatch.setitem(sys.modules, "docling_agent.agents", fake_agents_mod)
|
||||||
|
monkeypatch.setitem(sys.modules, "docling_core.types.doc.document", fake_doc_mod)
|
||||||
|
monkeypatch.setitem(sys.modules, "mellea", fake_mellea_mod)
|
||||||
|
monkeypatch.setitem(sys.modules, "mellea.backends", fake_backends_mod)
|
||||||
|
monkeypatch.setitem(sys.modules, "mellea.backends.model_ids", fake_model_ids_mod)
|
||||||
|
|
||||||
|
return agent_class, agent_instance, fake_result
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client(mock_analysis_repo: AsyncMock) -> TestClient:
|
||||||
|
app = FastAPI()
|
||||||
|
app.include_router(router)
|
||||||
|
app.state.analysis_repo = mock_analysis_repo
|
||||||
|
return TestClient(app)
|
||||||
|
|
||||||
|
|
||||||
|
class TestRagDisabled:
|
||||||
|
def test_503_when_flag_off(self, client: TestClient, monkeypatch) -> None:
|
||||||
|
_patched_settings(monkeypatch, rag_enabled=False)
|
||||||
|
resp = client.post("/api/documents/doc-1/rag", json={"query": "Q"})
|
||||||
|
assert resp.status_code == 503
|
||||||
|
assert "RAG_ENABLED" in resp.json()["detail"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestRagValidation:
|
||||||
|
def test_400_when_query_empty(self, client: TestClient, monkeypatch) -> None:
|
||||||
|
_patched_settings(monkeypatch, rag_enabled=True)
|
||||||
|
resp = client.post("/api/documents/doc-1/rag", json={"query": " "})
|
||||||
|
assert resp.status_code == 400
|
||||||
|
|
||||||
|
def test_404_when_no_completed_analysis(
|
||||||
|
self, client: TestClient, mock_analysis_repo: AsyncMock, monkeypatch
|
||||||
|
) -> None:
|
||||||
|
_patched_settings(monkeypatch, rag_enabled=True)
|
||||||
|
mock_analysis_repo.find_latest_completed_by_document.return_value = None
|
||||||
|
resp = client.post("/api/documents/doc-1/rag", json={"query": "Q"})
|
||||||
|
assert resp.status_code == 404
|
||||||
|
|
||||||
|
|
||||||
|
class TestRagSuccess:
|
||||||
|
def test_returns_rag_result_shape(
|
||||||
|
self, client: TestClient, stub_docling_agent, monkeypatch
|
||||||
|
) -> None:
|
||||||
|
_patched_settings(monkeypatch, rag_enabled=True)
|
||||||
|
_agent_class, _agent_instance, _fake_result = stub_docling_agent
|
||||||
|
|
||||||
|
resp = client.post("/api/documents/doc-1/rag", json={"query": "What is this?"})
|
||||||
|
assert resp.status_code == 200
|
||||||
|
data = resp.json()
|
||||||
|
assert data["answer"] == "stub answer"
|
||||||
|
assert data["converged"] is True
|
||||||
|
assert len(data["iterations"]) == 1
|
||||||
|
it = data["iterations"][0]
|
||||||
|
assert it["iteration"] == 1
|
||||||
|
assert it["section_ref"] == "#/texts/0"
|
||||||
|
assert it["can_answer"] is True
|
||||||
|
|
||||||
|
def test_calls_rag_loop_with_query_and_doc(
|
||||||
|
self, client: TestClient, stub_docling_agent, monkeypatch
|
||||||
|
) -> None:
|
||||||
|
_patched_settings(monkeypatch, rag_enabled=True)
|
||||||
|
_agent_class, agent_instance, _ = stub_docling_agent
|
||||||
|
|
||||||
|
client.post("/api/documents/doc-1/rag", json={"query": "Hello?"})
|
||||||
|
|
||||||
|
agent_instance._rag_loop.assert_called_once()
|
||||||
|
kwargs = agent_instance._rag_loop.call_args.kwargs
|
||||||
|
assert kwargs["query"] == "Hello?"
|
||||||
|
# The stub returns the string "fake-doc-instance" from model_validate_json
|
||||||
|
# and we pass it straight through to `doc=`.
|
||||||
|
assert kwargs["doc"] == "fake-doc-instance"
|
||||||
|
|
||||||
|
def test_uses_default_model_id_when_not_overridden(
|
||||||
|
self, client: TestClient, stub_docling_agent, monkeypatch
|
||||||
|
) -> None:
|
||||||
|
_patched_settings(monkeypatch, rag_enabled=True, rag_model_id="custom-model:7b")
|
||||||
|
agent_class, _, _ = stub_docling_agent
|
||||||
|
|
||||||
|
client.post("/api/documents/doc-1/rag", json={"query": "Q"})
|
||||||
|
|
||||||
|
agent_class.assert_called_once()
|
||||||
|
# model_id is wrapped in a ModelIdentifier(ollama_name=...) dataclass
|
||||||
|
# before reaching the agent — the stub exposes the field for assertion.
|
||||||
|
passed = agent_class.call_args.kwargs["model_id"]
|
||||||
|
assert passed.ollama_name == "custom-model:7b"
|
||||||
|
|
||||||
|
def test_per_request_model_id_override_wins(
|
||||||
|
self, client: TestClient, stub_docling_agent, monkeypatch
|
||||||
|
) -> None:
|
||||||
|
_patched_settings(monkeypatch, rag_enabled=True, rag_model_id="default:7b")
|
||||||
|
agent_class, _, _ = stub_docling_agent
|
||||||
|
|
||||||
|
client.post("/api/documents/doc-1/rag", json={"query": "Q", "model_id": "override:13b"})
|
||||||
|
|
||||||
|
passed = agent_class.call_args.kwargs["model_id"]
|
||||||
|
assert passed.ollama_name == "override:13b"
|
||||||
|
|
||||||
|
def test_sets_ollama_host_env_from_settings(
|
||||||
|
self, client: TestClient, stub_docling_agent, monkeypatch
|
||||||
|
) -> None:
|
||||||
|
import os
|
||||||
|
|
||||||
|
_patched_settings(monkeypatch, rag_enabled=True, ollama_host="http://ollama:11434")
|
||||||
|
|
||||||
|
client.post("/api/documents/doc-1/rag", json={"query": "Q"})
|
||||||
|
assert os.environ["OLLAMA_HOST"] == "http://ollama:11434"
|
||||||
|
|
||||||
|
|
||||||
|
class TestRagDepsMissing:
|
||||||
|
def test_503_when_docling_agent_not_installed(self, client: TestClient, monkeypatch) -> None:
|
||||||
|
_patched_settings(monkeypatch, rag_enabled=True)
|
||||||
|
# Simulate the import failing: remove any stub and ensure the name
|
||||||
|
# resolves to a module that raises on attribute access.
|
||||||
|
monkeypatch.setitem(sys.modules, "docling_agent.agents", None)
|
||||||
|
|
||||||
|
resp = client.post("/api/documents/doc-1/rag", json={"query": "Q"})
|
||||||
|
assert resp.status_code == 503
|
||||||
|
assert "docling-agent" in resp.json()["detail"]
|
||||||
|
|
||||||
|
|
||||||
|
class TestRagUpstreamFailure:
|
||||||
|
def test_502_when_docling_agent_raises_indexerror(
|
||||||
|
self, client: TestClient, stub_docling_agent, monkeypatch
|
||||||
|
) -> None:
|
||||||
|
"""Known docling-agent bug: `find_json_dicts(answer.value)[0]` raises
|
||||||
|
`IndexError` when the model fails to produce parseable JSON after
|
||||||
|
retries. Our endpoint must surface a 502 with a human-readable
|
||||||
|
message, not a 500 stack trace."""
|
||||||
|
_patched_settings(monkeypatch, rag_enabled=True, rag_model_id="granite4:micro-h")
|
||||||
|
_agent_class, agent_instance, _ = stub_docling_agent
|
||||||
|
agent_instance._rag_loop.side_effect = IndexError("list index out of range")
|
||||||
|
|
||||||
|
resp = client.post("/api/documents/doc-1/rag", json={"query": "Quelle tarification ?"})
|
||||||
|
assert resp.status_code == 502
|
||||||
|
detail = resp.json()["detail"]
|
||||||
|
assert "granite4:micro-h" in detail
|
||||||
|
assert "parseable" in detail or "rephrase" in detail
|
||||||
|
|
||||||
|
def test_500_for_other_unexpected_errors(
|
||||||
|
self, client: TestClient, stub_docling_agent, monkeypatch
|
||||||
|
) -> None:
|
||||||
|
_patched_settings(monkeypatch, rag_enabled=True)
|
||||||
|
_agent_class, agent_instance, _ = stub_docling_agent
|
||||||
|
agent_instance._rag_loop.side_effect = RuntimeError("Ollama unreachable")
|
||||||
|
|
||||||
|
resp = client.post("/api/documents/doc-1/rag", json={"query": "Q"})
|
||||||
|
assert resp.status_code == 500
|
||||||
|
assert "Ollama unreachable" in resp.json()["detail"]
|
||||||
|
|
@ -153,6 +153,49 @@ class TestAnalysisRepo:
|
||||||
deleted = await analysis_repo.delete("nonexistent")
|
deleted = await analysis_repo.delete("nonexistent")
|
||||||
assert deleted is False
|
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 is not None
|
||||||
|
|
||||||
|
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):
|
async def test_delete_by_document(self, document_repo, analysis_repo):
|
||||||
await self._insert_doc(document_repo)
|
await self._insert_doc(document_repo)
|
||||||
for i in range(3):
|
for i in range(3):
|
||||||
|
|
|
||||||
|
|
@ -39,7 +39,6 @@ class TestBuildFormData:
|
||||||
assert data["do_picture_classification"] == "false"
|
assert data["do_picture_classification"] == "false"
|
||||||
assert data["do_picture_description"] == "false"
|
assert data["do_picture_description"] == "false"
|
||||||
assert data["include_images"] == "false"
|
assert data["include_images"] == "false"
|
||||||
assert data["generate_page_images"] == "false"
|
|
||||||
assert data["images_scale"] == "1.0"
|
assert data["images_scale"] == "1.0"
|
||||||
assert set(data["to_formats"]) == {"md", "html", "json"}
|
assert set(data["to_formats"]) == {"md", "html", "json"}
|
||||||
|
|
||||||
|
|
@ -56,9 +55,14 @@ class TestBuildFormData:
|
||||||
assert data["images_scale"] == "2.0"
|
assert data["images_scale"] == "2.0"
|
||||||
assert data["include_images"] == "true"
|
assert data["include_images"] == "true"
|
||||||
|
|
||||||
def test_page_range_included_when_set(self):
|
def test_no_generate_page_images_field(self):
|
||||||
|
"""generate_page_images is a PdfPipelineOptions field, not a Serve field."""
|
||||||
|
data = _build_form_data(ConversionOptions())
|
||||||
|
assert "generate_page_images" not in data
|
||||||
|
|
||||||
|
def test_page_range_as_repeated_fields(self):
|
||||||
data = _build_form_data(ConversionOptions(), page_range=(11, 20))
|
data = _build_form_data(ConversionOptions(), page_range=(11, 20))
|
||||||
assert data["page_range"] == "11-20"
|
assert data["page_range"] == ["11", "20"]
|
||||||
|
|
||||||
def test_page_range_absent_when_none(self):
|
def test_page_range_absent_when_none(self):
|
||||||
data = _build_form_data(ConversionOptions())
|
data = _build_form_data(ConversionOptions())
|
||||||
|
|
@ -402,11 +406,12 @@ class TestServeConverterConvert:
|
||||||
assert len(result.pages[0].elements) == 1
|
assert len(result.pages[0].elements) == 1
|
||||||
assert result.pages[0].elements[0].type == "title"
|
assert result.pages[0].elements[0].type == "title"
|
||||||
|
|
||||||
# Verify form fields sent as dict with list for repeated keys
|
# Verify form fields sent correctly
|
||||||
call_kwargs = mock_client.post.call_args
|
call_kwargs = mock_client.post.call_args
|
||||||
sent_data = call_kwargs.kwargs.get("data", {})
|
sent_data = call_kwargs.kwargs.get("data", {})
|
||||||
assert sent_data["do_ocr"] == "true"
|
assert sent_data["do_ocr"] == "true"
|
||||||
assert set(sent_data["to_formats"]) == {"md", "html", "json"}
|
assert set(sent_data["to_formats"]) == {"md", "html", "json"}
|
||||||
|
assert "generate_page_images" not in sent_data
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_http_error_raises(self, tmp_path):
|
async def test_http_error_raises(self, tmp_path):
|
||||||
|
|
@ -414,6 +419,8 @@ class TestServeConverterConvert:
|
||||||
test_file.write_bytes(b"%PDF-1.4 fake content")
|
test_file.write_bytes(b"%PDF-1.4 fake content")
|
||||||
|
|
||||||
mock_response = MagicMock()
|
mock_response = MagicMock()
|
||||||
|
mock_response.status_code = 500
|
||||||
|
mock_response.text = "Internal Server Error"
|
||||||
mock_response.raise_for_status.side_effect = httpx.HTTPStatusError(
|
mock_response.raise_for_status.side_effect = httpx.HTTPStatusError(
|
||||||
"Server Error",
|
"Server Error",
|
||||||
request=MagicMock(),
|
request=MagicMock(),
|
||||||
|
|
@ -510,3 +517,17 @@ class TestConverterWiring:
|
||||||
converter = _build_converter()
|
converter = _build_converter()
|
||||||
assert isinstance(converter, ServeConverter)
|
assert isinstance(converter, ServeConverter)
|
||||||
assert converter._api_key == "my-key"
|
assert converter._api_key == "my-key"
|
||||||
|
|
||||||
|
def test_remote_engine_builds_chunker(self):
|
||||||
|
"""Chunker must be available in remote mode (hybrid local chunking)."""
|
||||||
|
from infra.local_chunker import LocalChunker
|
||||||
|
from infra.settings import Settings
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"main.settings",
|
||||||
|
Settings(conversion_engine="remote", docling_serve_url="http://serve:5001"),
|
||||||
|
):
|
||||||
|
from main import _build_chunker
|
||||||
|
|
||||||
|
chunker = _build_chunker()
|
||||||
|
assert isinstance(chunker, LocalChunker)
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,10 @@ class TestSettingsDefaults:
|
||||||
assert s.lock_timeout == 300
|
assert s.lock_timeout == 300
|
||||||
assert s.max_page_count == 0
|
assert s.max_page_count == 0
|
||||||
assert s.max_file_size_mb == 50
|
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.batch_page_size == 0
|
||||||
|
assert s.opensearch_default_limit == 1000
|
||||||
assert s.upload_dir == "./uploads"
|
assert s.upload_dir == "./uploads"
|
||||||
assert s.db_path == "./data/docling_studio.db"
|
assert s.db_path == "./data/docling_studio.db"
|
||||||
assert "http://localhost:3000" in s.cors_origins
|
assert "http://localhost:3000" in s.cors_origins
|
||||||
|
|
@ -75,6 +78,18 @@ class TestSettingsValidation:
|
||||||
with pytest.raises(ValueError, match="max_file_size_mb must be >= 0"):
|
with pytest.raises(ValueError, match="max_file_size_mb must be >= 0"):
|
||||||
Settings(max_file_size_mb=-1)
|
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):
|
def test_zero_max_file_size_mb_accepted(self):
|
||||||
s = Settings(max_file_size_mb=0)
|
s = Settings(max_file_size_mb=0)
|
||||||
assert s.max_file_size_mb == 0
|
assert s.max_file_size_mb == 0
|
||||||
|
|
@ -103,6 +118,12 @@ class TestSettingsValidation:
|
||||||
with pytest.raises(ValueError, match="lock_timeout must be > 0"):
|
with pytest.raises(ValueError, match="lock_timeout must be > 0"):
|
||||||
Settings(lock_timeout=0)
|
Settings(lock_timeout=0)
|
||||||
|
|
||||||
|
def test_zero_opensearch_default_limit_rejected(self):
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
with pytest.raises(ValueError, match="opensearch_default_limit must be >= 1"):
|
||||||
|
Settings(opensearch_default_limit=0)
|
||||||
|
|
||||||
def test_invalid_table_mode_rejected(self):
|
def test_invalid_table_mode_rejected(self):
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
|
|
@ -146,6 +167,7 @@ class TestSettingsFromEnv:
|
||||||
monkeypatch.setenv("MAX_PAGE_COUNT", "20")
|
monkeypatch.setenv("MAX_PAGE_COUNT", "20")
|
||||||
monkeypatch.setenv("MAX_FILE_SIZE_MB", "100")
|
monkeypatch.setenv("MAX_FILE_SIZE_MB", "100")
|
||||||
monkeypatch.setenv("BATCH_PAGE_SIZE", "15")
|
monkeypatch.setenv("BATCH_PAGE_SIZE", "15")
|
||||||
|
monkeypatch.setenv("OPENSEARCH_DEFAULT_LIMIT", "500")
|
||||||
monkeypatch.setenv("UPLOAD_DIR", "/data/uploads")
|
monkeypatch.setenv("UPLOAD_DIR", "/data/uploads")
|
||||||
monkeypatch.setenv("DB_PATH", "/data/test.db")
|
monkeypatch.setenv("DB_PATH", "/data/test.db")
|
||||||
monkeypatch.setenv("CORS_ORIGINS", "http://a.com, http://b.com")
|
monkeypatch.setenv("CORS_ORIGINS", "http://a.com, http://b.com")
|
||||||
|
|
@ -163,6 +185,7 @@ class TestSettingsFromEnv:
|
||||||
assert s.max_page_count == 20
|
assert s.max_page_count == 20
|
||||||
assert s.max_file_size_mb == 100
|
assert s.max_file_size_mb == 100
|
||||||
assert s.batch_page_size == 15
|
assert s.batch_page_size == 15
|
||||||
|
assert s.opensearch_default_limit == 500
|
||||||
assert s.upload_dir == "/data/uploads"
|
assert s.upload_dir == "/data/uploads"
|
||||||
assert s.db_path == "/data/test.db"
|
assert s.db_path == "/data/test.db"
|
||||||
assert s.cors_origins == ["http://a.com", "http://b.com"]
|
assert s.cors_origins == ["http://a.com", "http://b.com"]
|
||||||
|
|
|
||||||
1
experiments/reasoning-trace/.gitignore
vendored
Normal file
1
experiments/reasoning-trace/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
output/
|
||||||
139
experiments/reasoning-trace/README.md
Normal file
139
experiments/reasoning-trace/README.md
Normal file
|
|
@ -0,0 +1,139 @@
|
||||||
|
# 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 ~1–2 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.
|
||||||
154
experiments/reasoning-trace/inspect_doc.py
Normal file
154
experiments/reasoning-trace/inspect_doc.py
Normal file
|
|
@ -0,0 +1,154 @@
|
||||||
|
#!/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()
|
||||||
367
experiments/reasoning-trace/inspect_graph.py
Normal file
367
experiments/reasoning-trace/inspect_graph.py
Normal file
|
|
@ -0,0 +1,367 @@
|
||||||
|
#!/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())
|
||||||
175
experiments/reasoning-trace/inspect_semantics.py
Normal file
175
experiments/reasoning-trace/inspect_semantics.py
Normal file
|
|
@ -0,0 +1,175 @@
|
||||||
|
#!/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))
|
||||||
148
experiments/reasoning-trace/prime_neo4j.py
Normal file
148
experiments/reasoning-trace/prime_neo4j.py
Normal file
|
|
@ -0,0 +1,148 @@
|
||||||
|
#!/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())
|
||||||
7
frontend/env.d.ts
vendored
7
frontend/env.d.ts
vendored
|
|
@ -7,3 +7,10 @@ declare module '*.vue' {
|
||||||
const component: DefineComponent<Record<string, unknown>, Record<string, unknown>, unknown>
|
const component: DefineComponent<Record<string, unknown>, Record<string, unknown>, unknown>
|
||||||
export default component
|
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'
|
||||||
|
|
|
||||||
77
frontend/package-lock.json
generated
77
frontend/package-lock.json
generated
|
|
@ -1,13 +1,16 @@
|
||||||
{
|
{
|
||||||
"name": "docling-studio",
|
"name": "docling-studio",
|
||||||
"version": "0.3.1",
|
"version": "0.4.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "docling-studio",
|
"name": "docling-studio",
|
||||||
"version": "0.3.1",
|
"version": "0.4.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"cytoscape": "^3.30.0",
|
||||||
|
"cytoscape-dagre": "^2.5.0",
|
||||||
|
"cytoscape-expand-collapse": "^4.1.1",
|
||||||
"dompurify": "^3.3.3",
|
"dompurify": "^3.3.3",
|
||||||
"marked": "^17.0.4",
|
"marked": "^17.0.4",
|
||||||
"pinia": "^2.3.0",
|
"pinia": "^2.3.0",
|
||||||
|
|
@ -16,6 +19,8 @@
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.0.0",
|
"@eslint/js": "^9.0.0",
|
||||||
|
"@types/cytoscape": "^3.21.4",
|
||||||
|
"@types/cytoscape-dagre": "^2.3.3",
|
||||||
"@types/dompurify": "^3.2.0",
|
"@types/dompurify": "^3.2.0",
|
||||||
"@vitejs/plugin-vue": "^6.0.5",
|
"@vitejs/plugin-vue": "^6.0.5",
|
||||||
"@vitest/mocker": "^4.1.2",
|
"@vitest/mocker": "^4.1.2",
|
||||||
|
|
@ -1021,6 +1026,23 @@
|
||||||
"assertion-error": "^2.0.1"
|
"assertion-error": "^2.0.1"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/cytoscape": {
|
||||||
|
"version": "3.21.9",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/cytoscape/-/cytoscape-3.21.9.tgz",
|
||||||
|
"integrity": "sha512-JyrG4tllI6jvuISPjHK9j2Xv/LTbnLekLke5otGStjFluIyA9JjgnvgZrSBsp8cEDpiTjwgZUZwpPv8TSBcoLw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/cytoscape-dagre": {
|
||||||
|
"version": "2.3.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/cytoscape-dagre/-/cytoscape-dagre-2.3.4.tgz",
|
||||||
|
"integrity": "sha512-uOGXuPfPLFoKZaegjHl9oj4tqONNJuhUl180FiJgRZ35rVijBs6J4UP1Ah6mA6S46h+7pv4ICqpgfdC3EADZlw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"cytoscape": "^3.31"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/deep-eql": {
|
"node_modules/@types/deep-eql": {
|
||||||
"version": "4.0.2",
|
"version": "4.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
|
||||||
|
|
@ -1824,6 +1846,45 @@
|
||||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
|
||||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="
|
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="
|
||||||
},
|
},
|
||||||
|
"node_modules/cytoscape": {
|
||||||
|
"version": "3.33.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/cytoscape/-/cytoscape-3.33.2.tgz",
|
||||||
|
"integrity": "sha512-sj4HXd3DokGhzZAdjDejGvTPLqlt84vNFN8m7bGsOzDY5DyVcxIb2ejIXat2Iy7HxWhdT/N1oKyheJ5YdpsGuw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/cytoscape-dagre": {
|
||||||
|
"version": "2.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/cytoscape-dagre/-/cytoscape-dagre-2.5.0.tgz",
|
||||||
|
"integrity": "sha512-VG2Knemmshop4kh5fpLO27rYcyUaaDkRw+6PiX4bstpB+QFt0p2oauMrsjVbUamGWQ6YNavh7x2em2uZlzV44g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"dagre": "^0.8.5"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"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",
|
||||||
|
"integrity": "sha512-/aTqmnRta7x7MCCpExk7HQL2O4owCT2h8NT//9I1OQ9vt29Pa0BzSAkR5lwFUcQ7491yVi/3CXU9jQ5o0Mn2Sw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"graphlib": "^2.1.8",
|
||||||
|
"lodash": "^4.17.15"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/de-indent": {
|
"node_modules/de-indent": {
|
||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/de-indent/-/de-indent-1.0.2.tgz",
|
||||||
|
|
@ -2252,6 +2313,15 @@
|
||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/graphlib": {
|
||||||
|
"version": "2.1.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/graphlib/-/graphlib-2.1.8.tgz",
|
||||||
|
"integrity": "sha512-jcLLfkpoVGmH7/InMC/1hIvOPSUh38oJtGhvrOFGzioE1DZ+0YW16RgmOJhHiuWTvGiJQ9Z1Ik43JvkRPRvE+A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"lodash": "^4.17.15"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/has-flag": {
|
"node_modules/has-flag": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz",
|
||||||
|
|
@ -2401,8 +2471,7 @@
|
||||||
"node_modules/lodash": {
|
"node_modules/lodash": {
|
||||||
"version": "4.17.23",
|
"version": "4.17.23",
|
||||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
|
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz",
|
||||||
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==",
|
"integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w=="
|
||||||
"dev": true
|
|
||||||
},
|
},
|
||||||
"node_modules/lodash.merge": {
|
"node_modules/lodash.merge": {
|
||||||
"version": "4.6.2",
|
"version": "4.6.2",
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,9 @@
|
||||||
"format:check": "prettier --check src/"
|
"format:check": "prettier --check src/"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"cytoscape": "^3.30.0",
|
||||||
|
"cytoscape-dagre": "^2.5.0",
|
||||||
|
"cytoscape-expand-collapse": "^4.1.1",
|
||||||
"dompurify": "^3.3.3",
|
"dompurify": "^3.3.3",
|
||||||
"marked": "^17.0.4",
|
"marked": "^17.0.4",
|
||||||
"pinia": "^2.3.0",
|
"pinia": "^2.3.0",
|
||||||
|
|
@ -24,9 +27,11 @@
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^9.0.0",
|
"@eslint/js": "^9.0.0",
|
||||||
"@vitest/mocker": "^4.1.2",
|
"@types/cytoscape": "^3.21.4",
|
||||||
|
"@types/cytoscape-dagre": "^2.3.3",
|
||||||
"@types/dompurify": "^3.2.0",
|
"@types/dompurify": "^3.2.0",
|
||||||
"@vitejs/plugin-vue": "^6.0.5",
|
"@vitejs/plugin-vue": "^6.0.5",
|
||||||
|
"@vitest/mocker": "^4.1.2",
|
||||||
"eslint": "^9.0.0",
|
"eslint": "^9.0.0",
|
||||||
"eslint-plugin-vue": "^9.32.0",
|
"eslint-plugin-vue": "^9.32.0",
|
||||||
"prettier": "^3.4.0",
|
"prettier": "^3.4.0",
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,22 @@ const routes: RouteRecordRaw[] = [
|
||||||
name: 'search',
|
name: 'search',
|
||||||
component: () => import('../../pages/SearchPage.vue'),
|
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',
|
path: '/settings',
|
||||||
name: 'settings',
|
name: 'settings',
|
||||||
|
|
|
||||||
59
frontend/src/features/analysis/graphApi.ts
Normal file
59
frontend/src/features/analysis/graphApi.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
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'
|
||||||
|
label?: string
|
||||||
|
docling_label?: string
|
||||||
|
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
|
||||||
|
title?: string
|
||||||
|
doc_id?: string
|
||||||
|
token_count?: number
|
||||||
|
[key: string]: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GraphEdge {
|
||||||
|
id: string
|
||||||
|
source: string
|
||||||
|
target: string
|
||||||
|
type: 'HAS_ROOT' | 'PARENT_OF' | 'NEXT' | 'ON_PAGE' | 'HAS_CHUNK' | 'DERIVED_FROM'
|
||||||
|
order?: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GraphPayload {
|
||||||
|
doc_id: string
|
||||||
|
nodes: GraphNode[]
|
||||||
|
edges: GraphEdge[]
|
||||||
|
node_count: number
|
||||||
|
edge_count: number
|
||||||
|
truncated: boolean
|
||||||
|
page_count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function fetchDocumentGraph(docId: string): Promise<GraphPayload> {
|
||||||
|
return apiFetch<GraphPayload>(`/api/documents/${encodeURIComponent(docId)}/graph`)
|
||||||
|
}
|
||||||
95
frontend/src/features/analysis/legendFilters.test.ts
Normal file
95
frontend/src/features/analysis/legendFilters.test.ts
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
})
|
||||||
108
frontend/src/features/analysis/legendFilters.ts
Normal file
108
frontend/src/features/analysis/legendFilters.ts
Normal file
|
|
@ -0,0 +1,108 @@
|
||||||
|
/**
|
||||||
|
* 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 }
|
||||||
|
}
|
||||||
136
frontend/src/features/analysis/sectionParenting.test.ts
Normal file
136
frontend/src/features/analysis/sectionParenting.test.ts
Normal file
|
|
@ -0,0 +1,136 @@
|
||||||
|
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')
|
||||||
|
})
|
||||||
|
})
|
||||||
111
frontend/src/features/analysis/sectionParenting.ts
Normal file
111
frontend/src/features/analysis/sectionParenting.ts
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
}
|
||||||
695
frontend/src/features/analysis/ui/GraphView.vue
Normal file
695
frontend/src/features/analysis/ui/GraphView.vue
Normal file
|
|
@ -0,0 +1,695 @@
|
||||||
|
<template>
|
||||||
|
<div class="graph-view" data-e2e="graph-view">
|
||||||
|
<div v-if="loading" class="graph-placeholder">
|
||||||
|
<div class="spinner-large" />
|
||||||
|
<span>{{ t('results.graphLoading') }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="error" class="graph-placeholder error" data-e2e="graph-error">
|
||||||
|
<span>{{ error }}</span>
|
||||||
|
<button class="retry-btn" @click="load">{{ t('results.retry') }}</button>
|
||||||
|
</div>
|
||||||
|
<div v-else-if="empty" class="graph-placeholder">
|
||||||
|
<span>{{ t('results.graphEmpty') }}</span>
|
||||||
|
</div>
|
||||||
|
<template v-else>
|
||||||
|
<div class="graph-toolbar">
|
||||||
|
<span class="graph-stats">
|
||||||
|
{{ payload?.node_count }} nodes · {{ payload?.edge_count }} edges ·
|
||||||
|
{{ 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>
|
||||||
|
</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>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { Core } from 'cytoscape'
|
||||||
|
import { computed, onMounted, onBeforeUnmount, ref, watch, nextTick } from 'vue'
|
||||||
|
import { useI18n } from '../../../shared/i18n'
|
||||||
|
import { reasoningOverlayStyles } from '../../reasoning/graphReasoningOverlay'
|
||||||
|
import { fetchDocumentGraph, type GraphNode, type GraphPayload } from '../graphApi'
|
||||||
|
import { LEGEND_CHIPS, findChip } from '../legendFilters'
|
||||||
|
import { computeSectionParents, explicitParentMap, mergeParentMaps } from '../sectionParenting'
|
||||||
|
import NodeDetailsPanel from './NodeDetailsPanel.vue'
|
||||||
|
|
||||||
|
// `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 { t } = useI18n()
|
||||||
|
|
||||||
|
const containerRef = ref<HTMLDivElement | null>(null)
|
||||||
|
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)
|
||||||
|
|
||||||
|
const NODE_COLORS: Record<string, string> = {
|
||||||
|
document: '#1E293B',
|
||||||
|
SectionHeader: '#F97316',
|
||||||
|
Paragraph: '#3B82F6',
|
||||||
|
TextElement: '#3B82F6',
|
||||||
|
Table: '#8B5CF6',
|
||||||
|
Figure: '#22C55E',
|
||||||
|
ListItem: '#06B6D4',
|
||||||
|
Formula: '#EC4899',
|
||||||
|
Code: '#14B8A6',
|
||||||
|
Caption: '#EAB308',
|
||||||
|
Page: '#94A3B8',
|
||||||
|
Chunk: '#DC2626',
|
||||||
|
}
|
||||||
|
|
||||||
|
function nodeColor(n: GraphPayload['nodes'][number]): string {
|
||||||
|
if (n.group === 'document') return NODE_COLORS.document
|
||||||
|
if (n.group === 'page') return NODE_COLORS.Page
|
||||||
|
if (n.group === 'chunk') return NODE_COLORS.Chunk
|
||||||
|
return NODE_COLORS[n.label || 'TextElement'] || NODE_COLORS.TextElement
|
||||||
|
}
|
||||||
|
|
||||||
|
function nodeLabel(n: GraphPayload['nodes'][number]): string {
|
||||||
|
if (n.group === 'document') return n.title || n.id
|
||||||
|
if (n.group === 'page') return `p.${n.page_no}`
|
||||||
|
if (n.group === 'chunk') return `chunk #${n.chunk_index}`
|
||||||
|
const txt = (n.text || '').slice(0, 40)
|
||||||
|
return txt || n.label || n.docling_label || n.self_ref || n.id
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load(): Promise<void> {
|
||||||
|
if (!props.docId) {
|
||||||
|
empty.value = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
empty.value = false
|
||||||
|
try {
|
||||||
|
payload.value = await props.fetcher(props.docId)
|
||||||
|
if (!payload.value.nodes.length) {
|
||||||
|
empty.value = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// Flip loading off so the canvas <div> mounts, then wait a tick before init.
|
||||||
|
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)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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([
|
||||||
|
import('cytoscape'),
|
||||||
|
import('cytoscape-dagre'),
|
||||||
|
import('cytoscape-expand-collapse'),
|
||||||
|
])
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
...payload.value.edges.map((e) => ({
|
||||||
|
data: {
|
||||||
|
id: e.id,
|
||||||
|
source: e.source,
|
||||||
|
target: e.target,
|
||||||
|
type: e.type,
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
]
|
||||||
|
|
||||||
|
cy.value = cytoscape({
|
||||||
|
container: containerRef.value,
|
||||||
|
elements,
|
||||||
|
style: [
|
||||||
|
{
|
||||||
|
selector: 'node',
|
||||||
|
style: {
|
||||||
|
'background-color': 'data(bg)',
|
||||||
|
label: 'data(label)',
|
||||||
|
color: '#0F172A',
|
||||||
|
'font-size': 10,
|
||||||
|
'text-wrap': 'ellipsis',
|
||||||
|
'text-max-width': '140px',
|
||||||
|
'text-valign': 'center',
|
||||||
|
'text-halign': 'center',
|
||||||
|
width: 28,
|
||||||
|
height: 28,
|
||||||
|
'border-width': 1,
|
||||||
|
'border-color': '#0F172A',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
selector: 'node[group = "document"]',
|
||||||
|
style: { shape: 'round-rectangle', width: 60, height: 36, color: '#F8FAFC' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
selector: 'node[group = "page"]',
|
||||||
|
style: { shape: 'round-rectangle', width: 40, height: 24 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
selector: 'node[group = "chunk"]',
|
||||||
|
style: { shape: 'diamond', color: '#F8FAFC' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
selector: 'edge',
|
||||||
|
style: {
|
||||||
|
width: 1,
|
||||||
|
'line-color': '#94A3B8',
|
||||||
|
'target-arrow-color': '#94A3B8',
|
||||||
|
'target-arrow-shape': 'triangle',
|
||||||
|
'curve-style': 'bezier',
|
||||||
|
'font-size': 8,
|
||||||
|
color: '#64748B',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
selector: 'edge[type = "PARENT_OF"]',
|
||||||
|
style: { 'line-color': '#1E293B', 'target-arrow-color': '#1E293B', width: 1.5 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
selector: 'edge[type = "NEXT"]',
|
||||||
|
style: { 'line-style': 'dashed', 'line-color': '#64748B' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
selector: 'edge[type = "ON_PAGE"]',
|
||||||
|
style: { 'line-color': '#CBD5E1', width: 1 },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
layout: {
|
||||||
|
name: 'dagre',
|
||||||
|
rankDir: 'TB',
|
||||||
|
nodeSep: 30,
|
||||||
|
edgeSep: 10,
|
||||||
|
rankSep: 40,
|
||||||
|
} 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
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
onBeforeUnmount(disposeGraph)
|
||||||
|
watch(
|
||||||
|
() => props.docId,
|
||||||
|
() => {
|
||||||
|
disposeGraph()
|
||||||
|
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>
|
||||||
|
.graph-view {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.graph-toolbar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.graph-stats {
|
||||||
|
font-family: 'IBM Plex Mono', monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.graph-legend {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend-chip {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
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 {
|
||||||
|
background: #1e293b;
|
||||||
|
}
|
||||||
|
.legend-section {
|
||||||
|
background: #f97316;
|
||||||
|
}
|
||||||
|
.legend-paragraph {
|
||||||
|
background: #3b82f6;
|
||||||
|
}
|
||||||
|
.legend-table {
|
||||||
|
background: #8b5cf6;
|
||||||
|
}
|
||||||
|
.legend-figure {
|
||||||
|
background: #22c55e;
|
||||||
|
}
|
||||||
|
.legend-page {
|
||||||
|
background: #94a3b8;
|
||||||
|
color: #0f172a;
|
||||||
|
}
|
||||||
|
.legend-chunk {
|
||||||
|
background: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.graph-body {
|
||||||
|
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;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
gap: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 14px;
|
||||||
|
padding: 32px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.graph-placeholder.error {
|
||||||
|
color: var(--error);
|
||||||
|
}
|
||||||
|
|
||||||
|
.retry-btn {
|
||||||
|
background: var(--accent);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 6px 16px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner-large {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border: 3px solid var(--border-light);
|
||||||
|
border-top-color: var(--accent);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
409
frontend/src/features/analysis/ui/NodeDetailsPanel.vue
Normal file
409
frontend/src/features/analysis/ui/NodeDetailsPanel.vue
Normal file
|
|
@ -0,0 +1,409 @@
|
||||||
|
<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>
|
||||||
|
|
@ -40,8 +40,10 @@
|
||||||
<canvas
|
<canvas
|
||||||
ref="canvasRef"
|
ref="canvasRef"
|
||||||
class="overlay-canvas"
|
class="overlay-canvas"
|
||||||
|
:class="{ selectable }"
|
||||||
@mousemove="onMouseMove"
|
@mousemove="onMouseMove"
|
||||||
@mouseleave="hoveredElement = null"
|
@mouseleave="hoveredElement = null"
|
||||||
|
@click="onCanvasClick"
|
||||||
/>
|
/>
|
||||||
<!-- Tooltip -->
|
<!-- Tooltip -->
|
||||||
<div v-if="hoveredElement" class="tooltip" :style="tooltipStyle">
|
<div v-if="hoveredElement" class="tooltip" :style="tooltipStyle">
|
||||||
|
|
@ -76,8 +78,39 @@ const ELEMENT_COLORS: Record<string, string> = {
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
pages: { type: Array as () => Page[], default: () => [] },
|
pages: { type: Array as () => Page[], default: () => [] },
|
||||||
documentId: String,
|
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 selectedPage = ref(1)
|
||||||
const hiddenTypes = reactive(new Set<string>())
|
const hiddenTypes = reactive(new Set<string>())
|
||||||
const containerRef = ref<HTMLDivElement | null>(null)
|
const containerRef = ref<HTMLDivElement | null>(null)
|
||||||
|
|
@ -136,39 +169,94 @@ function drawOverlay() {
|
||||||
|
|
||||||
const scale = computeScale(img.clientWidth, img.clientHeight, page.width, page.height)
|
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) {
|
for (const el of visibleElements.value) {
|
||||||
const rect = bboxToRect(el.bbox, scale)
|
const rect = bboxToRect(el.bbox, scale)
|
||||||
const color = ELEMENT_COLORS[el.type] || ELEMENT_COLORS.text
|
const baseColor = ELEMENT_COLORS[el.type] || ELEMENT_COLORS.text
|
||||||
|
const isVisited =
|
||||||
|
props.visitedBySelfRef !== null && !!el.self_ref && props.visitedBySelfRef.has(el.self_ref)
|
||||||
|
|
||||||
ctx.strokeStyle = color
|
if (isVisited) {
|
||||||
ctx.lineWidth = 2
|
// Reasoning-visited element — reasoning accent color, bolder stroke,
|
||||||
ctx.strokeRect(rect.x, rect.y, rect.w, rect.h)
|
// more saturated fill than the base element. The visit-order badge
|
||||||
|
// is drawn in the second pass below.
|
||||||
ctx.fillStyle = color + '20'
|
ctx.strokeStyle = REASONING_COLOR
|
||||||
ctx.fillRect(rect.x, rect.y, rect.w, rect.h)
|
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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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) {
|
function onMouseMove(e: MouseEvent) {
|
||||||
const canvas = canvasRef.value
|
const canvas = canvasRef.value
|
||||||
const page = currentPageData.value
|
if (!canvas) return
|
||||||
const img = imageRef.value
|
|
||||||
if (!canvas || !page || !img) return
|
|
||||||
|
|
||||||
const canvasRect = canvas.getBoundingClientRect()
|
const canvasRect = canvas.getBoundingClientRect()
|
||||||
const mx = e.clientX - canvasRect.left
|
const mx = e.clientX - canvasRect.left
|
||||||
const my = e.clientY - canvasRect.top
|
const my = e.clientY - canvasRect.top
|
||||||
|
|
||||||
const scale = computeScale(img.clientWidth, img.clientHeight, page.width, page.height)
|
const found = elementAt(e)
|
||||||
|
|
||||||
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
|
hoveredElement.value = found
|
||||||
if (found) {
|
if (found) {
|
||||||
tooltipStyle.value = {
|
tooltipStyle.value = {
|
||||||
|
|
@ -178,9 +266,51 @@ 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], () => {
|
watch([() => props.pages, selectedPage, hiddenTypes], () => {
|
||||||
nextTick(drawOverlay)
|
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>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
@ -282,6 +412,10 @@ watch([() => props.pages, selectedPage, hiddenTypes], () => {
|
||||||
pointer-events: auto;
|
pointer-events: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.overlay-canvas.selectable {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
.tooltip {
|
.tooltip {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
background: var(--bg-surface);
|
background: var(--bg-surface);
|
||||||
|
|
|
||||||
|
|
@ -29,12 +29,12 @@ describe('useFeatureFlagStore', () => {
|
||||||
expect(store.isEnabled('chunking')).toBe(true)
|
expect(store.isEnabled('chunking')).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('disables chunking when engine is remote', async () => {
|
it('enables chunking when engine is remote', async () => {
|
||||||
mockApiFetch.mockResolvedValue({ status: 'ok', engine: 'remote' })
|
mockApiFetch.mockResolvedValue({ status: 'ok', engine: 'remote' })
|
||||||
const store = useFeatureFlagStore()
|
const store = useFeatureFlagStore()
|
||||||
await store.load()
|
await store.load()
|
||||||
expect(store.engine).toBe('remote')
|
expect(store.engine).toBe('remote')
|
||||||
expect(store.isEnabled('chunking')).toBe(false)
|
expect(store.isEnabled('chunking')).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('enables disclaimer when deploymentMode is huggingface', async () => {
|
it('enables disclaimer when deploymentMode is huggingface', async () => {
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ interface HealthResponse {
|
||||||
ingestionAvailable?: boolean
|
ingestionAvailable?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export type FeatureFlag = 'chunking' | 'disclaimer' | 'ingestion'
|
export type FeatureFlag = 'chunking' | 'disclaimer' | 'ingestion' | 'reasoning'
|
||||||
|
|
||||||
interface FeatureFlagDef {
|
interface FeatureFlagDef {
|
||||||
description: string
|
description: string
|
||||||
|
|
@ -32,7 +32,7 @@ interface FeatureFlagContext {
|
||||||
const featureRegistry: Record<FeatureFlag, FeatureFlagDef> = {
|
const featureRegistry: Record<FeatureFlag, FeatureFlagDef> = {
|
||||||
chunking: {
|
chunking: {
|
||||||
description: 'Document chunking for RAG preparation',
|
description: 'Document chunking for RAG preparation',
|
||||||
isEnabled: (ctx) => ctx.engine === 'local',
|
isEnabled: (ctx) => ctx.engine !== null,
|
||||||
},
|
},
|
||||||
disclaimer: {
|
disclaimer: {
|
||||||
description: 'Show shared-instance disclaimer banner',
|
description: 'Show shared-instance disclaimer banner',
|
||||||
|
|
@ -42,6 +42,13 @@ const featureRegistry: Record<FeatureFlag, FeatureFlagDef> = {
|
||||||
description: 'OpenSearch ingestion pipeline (embedding + vector indexing)',
|
description: 'OpenSearch ingestion pipeline (embedding + vector indexing)',
|
||||||
isEnabled: (ctx) => ctx.ingestionAvailable,
|
isEnabled: (ctx) => ctx.ingestionAvailable,
|
||||||
},
|
},
|
||||||
|
reasoning: {
|
||||||
|
// SQLite-backed (builds the graph from `document_json` on the fly), so no
|
||||||
|
// server-side gating needed. Kept as a flag so a future deployment can
|
||||||
|
// still kill-switch the UI if it wants to.
|
||||||
|
description: 'Reasoning trace tunnel (docling-agent RAGResult viewer)',
|
||||||
|
isEnabled: () => true,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useFeatureFlagStore = defineStore('feature-flags', () => {
|
export const useFeatureFlagStore = defineStore('feature-flags', () => {
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,6 @@ describe('useFeatureFlag', () => {
|
||||||
expect(flag.value).toBe(true)
|
expect(flag.value).toBe(true)
|
||||||
|
|
||||||
store.$patch({ engine: 'remote' })
|
store.$patch({ engine: 'remote' })
|
||||||
expect(flag.value).toBe(false)
|
expect(flag.value).toBe(true)
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
|
||||||
41
frontend/src/features/reasoning/api.ts
Normal file
41
frontend/src/features/reasoning/api.ts
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
import { apiFetch } from '../../shared/api/http'
|
||||||
|
import type { GraphPayload } from '../analysis/graphApi'
|
||||||
|
import type { RAGResult } from './types'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch the reasoning-trace graph for a document — built on the backend from
|
||||||
|
* 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` RAG run against a document and wait for the
|
||||||
|
* `RAGResult` (no streaming yet — the backend blocks on `_rag_loop` and
|
||||||
|
* returns once the loop converges or hits `max_iterations`).
|
||||||
|
*
|
||||||
|
* Runs typically take 20–40s depending on the model + Ollama latency. The
|
||||||
|
* caller should show a loading state.
|
||||||
|
*
|
||||||
|
* Errors:
|
||||||
|
* - 503 if `RAG_ENABLED=false` server-side or docling-agent isn't installed
|
||||||
|
* - 404 if no completed analysis exists for the doc
|
||||||
|
* - 500 if the loop itself raises (Ollama unreachable, model missing, …)
|
||||||
|
*/
|
||||||
|
export function runReasoning(docId: string, query: string, modelId?: string): Promise<RAGResult> {
|
||||||
|
return apiFetch<RAGResult>(`/api/documents/${encodeURIComponent(docId)}/rag`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
query,
|
||||||
|
// Backend accepts snake_case; don't camelCase here.
|
||||||
|
model_id: modelId || undefined,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}
|
||||||
245
frontend/src/features/reasoning/graphReasoningOverlay.test.ts
Normal file
245
frontend/src/features/reasoning/graphReasoningOverlay.test.ts
Normal file
|
|
@ -0,0 +1,245 @@
|
||||||
|
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 { RAGResult } 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<RAGResult['iterations'][number]>>): RAGResult {
|
||||||
|
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()
|
||||||
|
})
|
||||||
|
})
|
||||||
278
frontend/src/features/reasoning/graphReasoningOverlay.ts
Normal file
278
frontend/src/features/reasoning/graphReasoningOverlay.ts
Normal file
|
|
@ -0,0 +1,278 @@
|
||||||
|
/**
|
||||||
|
* 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, RAGResult, 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 `RAGResult`.
|
||||||
|
*
|
||||||
|
* 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: RAGResult,
|
||||||
|
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: RAGResult): 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',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
84
frontend/src/features/reasoning/store.test.ts
Normal file
84
frontend/src/features/reasoning/store.test.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
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 RAGResult', () => {
|
||||||
|
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 RAGResult', () => {
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
})
|
||||||
156
frontend/src/features/reasoning/store.ts
Normal file
156
frontend/src/features/reasoning/store.ts
Normal file
|
|
@ -0,0 +1,156 @@
|
||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
|
||||||
|
import type { OverlayResult, RAGResult, ResolvedIteration, SidecarEnvelope } from './types'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse an arbitrary JSON payload as either:
|
||||||
|
* - a bare `RAGResult` (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: RAGResult; 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 RAGResult
|
||||||
|
if (isRAGResult(result)) {
|
||||||
|
return { result, envelope: obj as unknown as SidecarEnvelope }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Bare RAGResult
|
||||||
|
if (isRAGResult(obj as unknown as RAGResult)) {
|
||||||
|
return { result: obj as unknown as RAGResult, envelope: null }
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRAGResult(x: RAGResult | 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/rag), so it
|
||||||
|
// can coexist with the import dialog conceptually even if only one is ever
|
||||||
|
// open at a time.
|
||||||
|
const runDialogOpen = ref(false)
|
||||||
|
const running = ref(false)
|
||||||
|
const rawResult = ref<RAGResult | null>(null)
|
||||||
|
const envelope = ref<SidecarEnvelope | null>(null)
|
||||||
|
const overlay = ref<OverlayResult | null>(null)
|
||||||
|
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: RAGResult, 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,
|
||||||
|
}
|
||||||
|
})
|
||||||
65
frontend/src/features/reasoning/types.ts
Normal file
65
frontend/src/features/reasoning/types.ts
Normal file
|
|
@ -0,0 +1,65 @@
|
||||||
|
/**
|
||||||
|
* Types mirroring the `docling-agent` RAG output.
|
||||||
|
*
|
||||||
|
* 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 RAGIteration {
|
||||||
|
iteration: number
|
||||||
|
section_ref: string
|
||||||
|
reason: string
|
||||||
|
section_text_length: number
|
||||||
|
can_answer: boolean
|
||||||
|
response: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RAGResult {
|
||||||
|
answer: string
|
||||||
|
iterations: RAGIteration[]
|
||||||
|
converged: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Envelope written by the R&D sidecar. The viewer also accepts a bare
|
||||||
|
* `RAGResult` (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: RAGResult
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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
|
||||||
|
}
|
||||||
107
frontend/src/features/reasoning/ui/DocumentView.vue
Normal file
107
frontend/src/features/reasoning/ui/DocumentView.vue
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
<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>
|
||||||
340
frontend/src/features/reasoning/ui/ImportTraceDialog.vue
Normal file
340
frontend/src/features/reasoning/ui/ImportTraceDialog.vue
Normal file
|
|
@ -0,0 +1,340 @@
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
v-if="store.importDialogOpen"
|
||||||
|
class="trace-modal-backdrop"
|
||||||
|
data-e2e="reasoning-import-modal"
|
||||||
|
@click.self="close"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="trace-modal"
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
:aria-label="t('reasoning.importTitle')"
|
||||||
|
>
|
||||||
|
<div class="trace-modal-header">
|
||||||
|
<h3>{{ t('reasoning.importTitle') }}</h3>
|
||||||
|
<button class="trace-modal-close" :aria-label="t('reasoning.close')" @click="close">
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="trace-modal-hint">{{ t('reasoning.importHint') }}</p>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="trace-drop"
|
||||||
|
:class="{ dragging, parsing }"
|
||||||
|
data-e2e="reasoning-drop-zone"
|
||||||
|
@dragover.prevent="dragging = true"
|
||||||
|
@dragleave.prevent="dragging = false"
|
||||||
|
@drop.prevent="onDrop"
|
||||||
|
@click="openFilePicker"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
ref="fileInput"
|
||||||
|
type="file"
|
||||||
|
accept=".json,application/json"
|
||||||
|
hidden
|
||||||
|
@change="onFileSelect"
|
||||||
|
/>
|
||||||
|
<div v-if="parsing" class="trace-drop-state">
|
||||||
|
<div class="spinner" />
|
||||||
|
<span>{{ t('reasoning.parsing') }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-else class="trace-drop-state">
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.5"
|
||||||
|
class="trace-icon"
|
||||||
|
>
|
||||||
|
<path d="M12 16V4m0 0L8 8m4-4l4 4M4 17v2a1 1 0 001 1h14a1 1 0 001-1v-2" />
|
||||||
|
</svg>
|
||||||
|
<span class="trace-drop-title">{{ t('reasoning.drop') }}</span>
|
||||||
|
<span class="trace-drop-sub">{{ t('reasoning.dropSub') }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<details class="trace-paste">
|
||||||
|
<summary>{{ t('reasoning.pasteToggle') }}</summary>
|
||||||
|
<textarea
|
||||||
|
v-model="pastedJson"
|
||||||
|
class="trace-paste-area"
|
||||||
|
:placeholder="t('reasoning.pastePlaceholder')"
|
||||||
|
rows="6"
|
||||||
|
data-e2e="reasoning-paste-area"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
class="trace-paste-btn"
|
||||||
|
:disabled="!pastedJson.trim() || parsing"
|
||||||
|
@click="submitPasted"
|
||||||
|
>
|
||||||
|
{{ t('reasoning.pasteSubmit') }}
|
||||||
|
</button>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<div v-if="errorMsg" class="trace-modal-error" data-e2e="reasoning-import-error">
|
||||||
|
{{ errorMsg }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
import { useI18n } from '../../../shared/i18n'
|
||||||
|
import { useReasoningStore } from '../store'
|
||||||
|
import { parseImportedTrace } from '../store'
|
||||||
|
|
||||||
|
const store = useReasoningStore()
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const fileInput = ref<HTMLInputElement | null>(null)
|
||||||
|
const dragging = ref(false)
|
||||||
|
const parsing = ref(false)
|
||||||
|
const pastedJson = ref('')
|
||||||
|
const errorMsg = ref<string | null>(null)
|
||||||
|
|
||||||
|
function close(): void {
|
||||||
|
store.closeImportDialog()
|
||||||
|
errorMsg.value = null
|
||||||
|
pastedJson.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function openFilePicker(): void {
|
||||||
|
fileInput.value?.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readFileAsText(file: File): Promise<string> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = () => resolve(String(reader.result ?? ''))
|
||||||
|
reader.onerror = () => reject(reader.error ?? new Error('read failed'))
|
||||||
|
reader.readAsText(file)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function ingest(rawText: string): boolean {
|
||||||
|
let raw: unknown
|
||||||
|
try {
|
||||||
|
raw = JSON.parse(rawText)
|
||||||
|
} catch (e) {
|
||||||
|
errorMsg.value = t('reasoning.errJson').replace('{msg}', (e as Error).message)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const parsed = parseImportedTrace(raw)
|
||||||
|
if (!parsed) {
|
||||||
|
errorMsg.value = t('reasoning.errShape')
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
errorMsg.value = null
|
||||||
|
store.setResult(parsed.result, parsed.envelope)
|
||||||
|
close()
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleFile(file: File): Promise<void> {
|
||||||
|
errorMsg.value = null
|
||||||
|
parsing.value = true
|
||||||
|
try {
|
||||||
|
const text = await readFileAsText(file)
|
||||||
|
ingest(text)
|
||||||
|
} catch (e) {
|
||||||
|
errorMsg.value = (e as Error).message
|
||||||
|
} finally {
|
||||||
|
parsing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onFileSelect(e: Event): Promise<void> {
|
||||||
|
const target = e.target as HTMLInputElement
|
||||||
|
const file = target.files?.[0]
|
||||||
|
if (file) await handleFile(file)
|
||||||
|
target.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onDrop(e: DragEvent): Promise<void> {
|
||||||
|
dragging.value = false
|
||||||
|
const file = e.dataTransfer?.files?.[0]
|
||||||
|
if (file) await handleFile(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
function submitPasted(): void {
|
||||||
|
if (!pastedJson.value.trim()) return
|
||||||
|
parsing.value = true
|
||||||
|
try {
|
||||||
|
ingest(pastedJson.value)
|
||||||
|
} finally {
|
||||||
|
parsing.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.trace-modal-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(15, 23, 42, 0.55);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trace-modal {
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 20px;
|
||||||
|
width: min(560px, 100%);
|
||||||
|
max-height: 90vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
box-shadow: 0 12px 48px rgba(15, 23, 42, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.trace-modal-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trace-modal-header h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.trace-modal-close {
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.trace-modal-close:hover {
|
||||||
|
background: var(--border-light);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.trace-modal-hint {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin: 4px 0 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trace-drop {
|
||||||
|
border: 2px dashed var(--border-light);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 28px 16px;
|
||||||
|
text-align: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.trace-drop:hover,
|
||||||
|
.trace-drop.dragging {
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: var(--accent-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.trace-drop.parsing {
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trace-drop-state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trace-icon {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.trace-drop-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.trace-drop-sub {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.trace-paste {
|
||||||
|
margin-top: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trace-paste summary {
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
padding: 4px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trace-paste-area {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
margin-top: 8px;
|
||||||
|
padding: 8px;
|
||||||
|
font-family: 'IBM Plex Mono', monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trace-paste-btn {
|
||||||
|
margin-top: 8px;
|
||||||
|
background: var(--accent);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 6px 16px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trace-paste-btn:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trace-modal-error {
|
||||||
|
margin-top: 12px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: rgba(220, 38, 38, 0.08);
|
||||||
|
color: var(--error, #dc2626);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border: 2px solid var(--border-light);
|
||||||
|
border-top-color: var(--accent);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.6s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
173
frontend/src/features/reasoning/ui/IterationCard.vue
Normal file
173
frontend/src/features/reasoning/ui/IterationCard.vue
Normal file
|
|
@ -0,0 +1,173 @@
|
||||||
|
<template>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="it-card"
|
||||||
|
:class="{ active, missing: !iteration.present, converged: iteration.canAnswer }"
|
||||||
|
:data-e2e="`reasoning-iteration-${iteration.iteration}`"
|
||||||
|
@click="$emit('focus', iteration.iteration)"
|
||||||
|
>
|
||||||
|
<div class="it-row">
|
||||||
|
<span class="it-badge">{{ iteration.iteration }}</span>
|
||||||
|
<span class="it-ref" :title="iteration.sectionRef">{{ iteration.sectionRef }}</span>
|
||||||
|
<span
|
||||||
|
class="it-status"
|
||||||
|
:class="{
|
||||||
|
ok: iteration.canAnswer,
|
||||||
|
more: !iteration.canAnswer && iteration.present,
|
||||||
|
missing: !iteration.present,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
{{ statusLabel }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p v-if="iteration.reason" class="it-reason" :class="{ placeholder: isPlaceholderReason }">
|
||||||
|
{{ isPlaceholderReason ? t('reasoning.reasonPlaceholder') : iteration.reason }}
|
||||||
|
</p>
|
||||||
|
<div class="it-meta">
|
||||||
|
<span v-if="iteration.sectionTextLength">
|
||||||
|
{{ t('reasoning.charsLabel').replace('{n}', String(iteration.sectionTextLength)) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
import { useI18n } from '../../../shared/i18n'
|
||||||
|
import type { ResolvedIteration } from '../types'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
iteration: ResolvedIteration
|
||||||
|
active: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
defineEmits<{ focus: [iteration: number] }>()
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const statusLabel = computed(() => {
|
||||||
|
if (!props.iteration.present) return t('reasoning.statusMissing')
|
||||||
|
if (props.iteration.canAnswer) return t('reasoning.statusAnswered')
|
||||||
|
return t('reasoning.statusMore')
|
||||||
|
})
|
||||||
|
|
||||||
|
// docling-agent emits the literal string "fallback" for `reason` when its
|
||||||
|
// `select_from_failure` branch runs (the model's structured output didn't
|
||||||
|
// parse N times in a row). Don't show that noise — render a dash-style
|
||||||
|
// placeholder the user can visually skip.
|
||||||
|
const isPlaceholderReason = computed(() => {
|
||||||
|
const r = (props.iteration.reason || '').trim().toLowerCase()
|
||||||
|
return r === '' || r === 'fallback'
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.it-card {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
padding: 10px 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition);
|
||||||
|
font-family: inherit;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.it-card:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: var(--accent-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.it-card.active {
|
||||||
|
border-color: #ea580c;
|
||||||
|
box-shadow: 0 0 0 2px rgba(234, 88, 12, 0.2);
|
||||||
|
background: rgba(234, 88, 12, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.it-card.missing {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.it-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.it-badge {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #ea580c;
|
||||||
|
color: #ffffff;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.it-card.missing .it-badge {
|
||||||
|
background: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.it-ref {
|
||||||
|
font-family: 'IBM Plex Mono', monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.it-status {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 10px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.it-status.ok {
|
||||||
|
background: rgba(22, 163, 74, 0.15);
|
||||||
|
color: #15803d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.it-status.more {
|
||||||
|
background: rgba(234, 179, 8, 0.15);
|
||||||
|
color: #a16207;
|
||||||
|
}
|
||||||
|
|
||||||
|
.it-status.missing {
|
||||||
|
background: var(--border-light);
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.it-reason {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.45;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.it-reason.placeholder {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.it-meta {
|
||||||
|
margin-top: 6px;
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-family: 'IBM Plex Mono', monospace;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
355
frontend/src/features/reasoning/ui/ReasoningDocPicker.vue
Normal file
355
frontend/src/features/reasoning/ui/ReasoningDocPicker.vue
Normal file
|
|
@ -0,0 +1,355 @@
|
||||||
|
<template>
|
||||||
|
<div class="rp-picker">
|
||||||
|
<header class="rp-picker-header">
|
||||||
|
<h1 class="rp-picker-title">{{ t('reasoning.pageTitle') }}</h1>
|
||||||
|
<p class="rp-picker-subtitle">{{ t('reasoning.pageSubtitle') }}</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="rp-picker-upload" data-e2e="reasoning-upload">
|
||||||
|
<div
|
||||||
|
class="rp-drop"
|
||||||
|
:class="{ dragging, uploading }"
|
||||||
|
@dragover.prevent="dragging = true"
|
||||||
|
@dragleave.prevent="dragging = false"
|
||||||
|
@drop.prevent="onDrop"
|
||||||
|
@click="openFilePicker"
|
||||||
|
>
|
||||||
|
<input ref="fileInput" type="file" accept=".pdf" hidden @change="onFileSelect" />
|
||||||
|
<div v-if="uploading" class="rp-drop-state">
|
||||||
|
<div class="spinner" />
|
||||||
|
<span>{{ t('reasoning.uploading') }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-else class="rp-drop-state">
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.5"
|
||||||
|
class="rp-upload-icon"
|
||||||
|
>
|
||||||
|
<path d="M12 16V4m0 0L8 8m4-4l4 4M4 17v2a1 1 0 001 1h14a1 1 0 001-1v-2" />
|
||||||
|
</svg>
|
||||||
|
<span class="rp-drop-title">{{ t('reasoning.dropPdf') }}</span>
|
||||||
|
<span class="rp-drop-sub">{{ t('reasoning.dropPdfHint') }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p v-if="uploadError" class="rp-upload-error" data-e2e="reasoning-upload-error">
|
||||||
|
{{ uploadError }}
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-if="docsWithAnalysis.length > 0" class="rp-picker-list">
|
||||||
|
<h2 class="rp-list-title">{{ t('reasoning.existingDocs') }}</h2>
|
||||||
|
<div class="rp-doc-grid">
|
||||||
|
<button
|
||||||
|
v-for="doc in docsWithAnalysis"
|
||||||
|
:key="doc.id"
|
||||||
|
class="rp-doc-card"
|
||||||
|
:data-e2e="`reasoning-doc-${doc.id}`"
|
||||||
|
@click="emit('select', doc.id)"
|
||||||
|
>
|
||||||
|
<div class="rp-doc-card-icon">
|
||||||
|
<svg viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M4 4a2 2 0 012-2h4.586A2 2 0 0112 2.586L15.414 6A2 2 0 0116 7.414V16a2 2 0 01-2 2H6a2 2 0 01-2-2V4z"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="rp-doc-card-body">
|
||||||
|
<div class="rp-doc-name" :title="doc.filename">{{ doc.filename }}</div>
|
||||||
|
<div class="rp-doc-meta">
|
||||||
|
<span v-if="doc.pageCount">
|
||||||
|
{{ t('reasoning.pagesCount').replace('{n}', String(doc.pageCount)) }}
|
||||||
|
</span>
|
||||||
|
<span class="rp-doc-meta-dot">·</span>
|
||||||
|
<span>{{ formatDate(doc.createdAt) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
<p v-else-if="documentStore.documents.length > 0" class="rp-empty-hint">
|
||||||
|
{{ t('reasoning.noAnalyzedDocs') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
|
||||||
|
import { useAnalysisStore } from '../../analysis/store'
|
||||||
|
import { useDocumentStore } from '../../document/store'
|
||||||
|
import { useI18n } from '../../../shared/i18n'
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
select: [docId: string]
|
||||||
|
uploaded: [docId: string]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const documentStore = useDocumentStore()
|
||||||
|
const analysisStore = useAnalysisStore()
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const fileInput = ref<HTMLInputElement | null>(null)
|
||||||
|
const dragging = ref(false)
|
||||||
|
const uploading = ref(false)
|
||||||
|
const uploadError = ref<string | null>(null)
|
||||||
|
|
||||||
|
// Docs that have at least one analysis with document_json — the graph can
|
||||||
|
// be primed for them without a fresh Docling run. Others need analysis
|
||||||
|
// first (handled by the upload path or the workspace's silent analyze).
|
||||||
|
const docsWithAnalysis = computed(() => {
|
||||||
|
const analyzedDocIds = new Set(
|
||||||
|
analysisStore.analyses
|
||||||
|
.filter((a) => a.hasDocumentJson && a.status === 'COMPLETED')
|
||||||
|
.map((a) => a.documentId),
|
||||||
|
)
|
||||||
|
return documentStore.documents
|
||||||
|
.filter((d) => analyzedDocIds.has(d.id))
|
||||||
|
.sort((a, b) => (b.createdAt ?? '').localeCompare(a.createdAt ?? ''))
|
||||||
|
})
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
// Fetch both lists in parallel — pickers without prior state need them.
|
||||||
|
await Promise.all([
|
||||||
|
documentStore.documents.length ? Promise.resolve() : documentStore.load(),
|
||||||
|
analysisStore.analyses.length ? Promise.resolve() : analysisStore.load(),
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
function formatDate(iso: string): string {
|
||||||
|
try {
|
||||||
|
return new Date(iso).toLocaleDateString()
|
||||||
|
} catch {
|
||||||
|
return iso
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function openFilePicker(): void {
|
||||||
|
fileInput.value?.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPdf(file: File): boolean {
|
||||||
|
return file.type === 'application/pdf' || file.name.toLowerCase().endsWith('.pdf')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleFile(file: File): Promise<void> {
|
||||||
|
uploadError.value = null
|
||||||
|
if (!isPdf(file)) {
|
||||||
|
uploadError.value = t('upload.invalidFormat')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
uploading.value = true
|
||||||
|
try {
|
||||||
|
const doc = await documentStore.upload(file)
|
||||||
|
if (doc) emit('uploaded', doc.id)
|
||||||
|
} catch (e) {
|
||||||
|
uploadError.value = (e as Error).message || t('upload.uploading')
|
||||||
|
} finally {
|
||||||
|
uploading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onFileSelect(e: Event): Promise<void> {
|
||||||
|
const target = e.target as HTMLInputElement
|
||||||
|
const file = target.files?.[0]
|
||||||
|
if (file) await handleFile(file)
|
||||||
|
target.value = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onDrop(e: DragEvent): Promise<void> {
|
||||||
|
dragging.value = false
|
||||||
|
const file = e.dataTransfer?.files?.[0]
|
||||||
|
if (file) await handleFile(file)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.rp-picker {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 32px;
|
||||||
|
padding: 48px max(24px, 6vw);
|
||||||
|
max-width: 960px;
|
||||||
|
margin: 0 auto;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rp-picker-header {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rp-picker-title {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rp-picker-subtitle {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rp-picker-upload {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rp-drop {
|
||||||
|
border: 2px dashed var(--border-light);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 40px 16px;
|
||||||
|
text-align: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rp-drop:hover,
|
||||||
|
.rp-drop.dragging {
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: var(--accent-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rp-drop.uploading {
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rp-drop-state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rp-upload-icon {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rp-drop-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rp-drop-sub {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rp-upload-error {
|
||||||
|
margin: 0;
|
||||||
|
padding: 8px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--error, #dc2626);
|
||||||
|
background: rgba(220, 38, 38, 0.08);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rp-list-title {
|
||||||
|
margin: 0 0 16px;
|
||||||
|
font-size: 13px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rp-doc-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rp-doc-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 14px;
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
font-family: inherit;
|
||||||
|
color: inherit;
|
||||||
|
transition: all var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rp-doc-card:hover {
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: var(--accent-muted);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rp-doc-card-icon {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: var(--border-light);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rp-doc-card-icon svg {
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rp-doc-card-body {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rp-doc-name {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rp-doc-meta {
|
||||||
|
margin-top: 2px;
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-family: 'IBM Plex Mono', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rp-doc-meta-dot {
|
||||||
|
margin: 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rp-empty-hint {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-style: italic;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border: 2px solid var(--border-light);
|
||||||
|
border-top-color: var(--accent);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.6s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
485
frontend/src/features/reasoning/ui/ReasoningPanel.vue
Normal file
485
frontend/src/features/reasoning/ui/ReasoningPanel.vue
Normal file
|
|
@ -0,0 +1,485 @@
|
||||||
|
<template>
|
||||||
|
<aside
|
||||||
|
v-if="store.hasTrace || store.importDialogOpen"
|
||||||
|
class="reasoning-panel"
|
||||||
|
data-e2e="reasoning-panel"
|
||||||
|
>
|
||||||
|
<header class="rp-header">
|
||||||
|
<h3>{{ t('reasoning.panelTitle') }}</h3>
|
||||||
|
<div class="rp-header-actions">
|
||||||
|
<button
|
||||||
|
class="rp-btn-ghost rp-btn-toggle"
|
||||||
|
:class="{ active: store.focusMode }"
|
||||||
|
:aria-pressed="store.focusMode"
|
||||||
|
data-e2e="reasoning-focus-toggle"
|
||||||
|
:title="t('reasoning.focusHint')"
|
||||||
|
@click="store.toggleFocusMode()"
|
||||||
|
>
|
||||||
|
<span class="rp-dot" />
|
||||||
|
{{ t('reasoning.focus') }}
|
||||||
|
</button>
|
||||||
|
<button class="rp-btn-ghost" @click="store.openImportDialog()">
|
||||||
|
{{ t('reasoning.reimport') }}
|
||||||
|
</button>
|
||||||
|
<button class="rp-btn-ghost" @click="onClear">{{ t('reasoning.clear') }}</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section v-if="envelope" class="rp-meta">
|
||||||
|
<div v-if="envelope.query" class="rp-query">
|
||||||
|
<span class="rp-meta-label">{{ t('reasoning.query') }}</span>
|
||||||
|
<span class="rp-meta-value">{{ envelope.query }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="rp-meta-row">
|
||||||
|
<span v-if="envelope.filename" class="rp-meta-chip">{{ envelope.filename }}</span>
|
||||||
|
<span v-if="envelope.model?.ollama_name" class="rp-meta-chip">
|
||||||
|
{{ envelope.model.ollama_name }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-if="result" class="rp-answer">
|
||||||
|
<div class="rp-answer-header">
|
||||||
|
<span class="rp-answer-label">{{ t('reasoning.answerLabel') }}</span>
|
||||||
|
<span class="rp-answer-actions">
|
||||||
|
<span class="rp-converged" :class="{ yes: result.converged, no: !result.converged }">
|
||||||
|
{{ result.converged ? t('reasoning.converged') : t('reasoning.notConverged') }}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
class="rp-copy-btn"
|
||||||
|
:title="t('reasoning.copyAnswer')"
|
||||||
|
data-e2e="reasoning-copy-answer"
|
||||||
|
@click="copyAnswer"
|
||||||
|
>
|
||||||
|
{{ copied ? t('reasoning.copied') : t('reasoning.copy') }}
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<!-- eslint-disable-next-line vue/no-v-html -- sanitized by DOMPurify -->
|
||||||
|
<div class="rp-answer-body markdown-body" v-html="renderedAnswer" />
|
||||||
|
<div class="rp-answer-footer">
|
||||||
|
<span class="rp-stats">
|
||||||
|
{{ store.presentCount }} / {{ store.iterations.length }} {{ t('reasoning.resolved') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section v-if="store.missingCount > 0" class="rp-warn" data-e2e="reasoning-missing-warn">
|
||||||
|
{{ missingWarning }}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="rp-iterations">
|
||||||
|
<h4 class="rp-section-title">{{ t('reasoning.iterationsTitle') }}</h4>
|
||||||
|
<div v-if="store.iterations.length === 0" class="rp-empty">
|
||||||
|
{{ t('reasoning.noIterations') }}
|
||||||
|
</div>
|
||||||
|
<div v-else class="rp-iteration-list">
|
||||||
|
<IterationCard
|
||||||
|
v-for="it in store.iterations"
|
||||||
|
:key="it.iteration"
|
||||||
|
:iteration="it"
|
||||||
|
:active="store.activeIteration === it.iteration"
|
||||||
|
@focus="(n) => emit('iterationFocus', n)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<ImportTraceDialog />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { Core } from 'cytoscape'
|
||||||
|
import DOMPurify from 'dompurify'
|
||||||
|
import { marked } from 'marked'
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
|
||||||
|
import { useI18n } from '../../../shared/i18n'
|
||||||
|
import {
|
||||||
|
applyReasoningOverlay,
|
||||||
|
buildDegradedOverlay,
|
||||||
|
clearReasoningOverlay,
|
||||||
|
} from '../graphReasoningOverlay'
|
||||||
|
import { useReasoningStore } from '../store'
|
||||||
|
import IterationCard from './IterationCard.vue'
|
||||||
|
import ImportTraceDialog from './ImportTraceDialog.vue'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
/**
|
||||||
|
* The live Cytoscape instance from the GraphView. May be `null` while the
|
||||||
|
* graph is loading or if Maintain hasn't been run for this document.
|
||||||
|
* Passed down from StudioPage via `graphViewRef.cy`.
|
||||||
|
*/
|
||||||
|
cy: Core | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
// Iteration clicks bubble up to the workspace, which dispatches focus to
|
||||||
|
// both the graph and the PDF directly — keeping the panel ignorant of its
|
||||||
|
// siblings and avoiding watch-based plumbing that misfires on repeat clicks.
|
||||||
|
const emit = defineEmits<{ iterationFocus: [iteration: number] }>()
|
||||||
|
|
||||||
|
const store = useReasoningStore()
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const result = computed(() => store.rawResult)
|
||||||
|
const envelope = computed(() => store.envelope)
|
||||||
|
|
||||||
|
// Render the answer as markdown so numbered lists, bold, etc. render properly.
|
||||||
|
// Models tend to produce markdown-formatted answers (numbered lists especially),
|
||||||
|
// and plain-text `pre-wrap` made them near-unreadable.
|
||||||
|
const renderedAnswer = computed(() => {
|
||||||
|
const raw = result.value?.answer ?? ''
|
||||||
|
if (!raw.trim()) return ''
|
||||||
|
return DOMPurify.sanitize(marked.parse(raw, { async: false }) as string)
|
||||||
|
})
|
||||||
|
|
||||||
|
const copied = ref(false)
|
||||||
|
let copyResetTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
|
||||||
|
async function copyAnswer(): Promise<void> {
|
||||||
|
const text = result.value?.answer
|
||||||
|
if (!text) return
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text)
|
||||||
|
copied.value = true
|
||||||
|
if (copyResetTimer) clearTimeout(copyResetTimer)
|
||||||
|
copyResetTimer = setTimeout(() => {
|
||||||
|
copied.value = false
|
||||||
|
}, 1800)
|
||||||
|
} catch (e) {
|
||||||
|
console.warn('Copy failed', e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const missingWarning = computed(() => {
|
||||||
|
// Full miss + no cy → the graph simply isn't loaded. Different message
|
||||||
|
// than "N sections are actually missing from the graph".
|
||||||
|
if (!props.cy && store.missingCount > 0 && store.presentCount === 0) {
|
||||||
|
return t('reasoning.graphNotLoadedWarn')
|
||||||
|
}
|
||||||
|
return t('reasoning.missingWarn').replace('{n}', String(store.missingCount))
|
||||||
|
})
|
||||||
|
|
||||||
|
function reapplyOverlay(): void {
|
||||||
|
if (!store.rawResult) {
|
||||||
|
if (props.cy) clearReasoningOverlay(props.cy)
|
||||||
|
store.setOverlay(null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// When the Cytoscape instance is available (graph loaded for this doc) we
|
||||||
|
// run the full overlay: mark visited nodes, draw REASONING_NEXT arrows.
|
||||||
|
// Otherwise (404 on the graph endpoint, or Maintain not run yet) we still
|
||||||
|
// build the iteration list in "degraded" mode so the user can read the
|
||||||
|
// reasoning — they just won't see nodes highlighted.
|
||||||
|
const out = props.cy
|
||||||
|
? applyReasoningOverlay(props.cy, store.rawResult, { focusMode: store.focusMode })
|
||||||
|
: buildDegradedOverlay(store.rawResult)
|
||||||
|
store.setOverlay(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reapply whenever cy, rawResult, or focusMode changes. This handles:
|
||||||
|
// - User imports trace after graph loaded (rawResult changes).
|
||||||
|
// - User navigates to a different doc which swaps cy (cy changes).
|
||||||
|
// - Graph loads AFTER the trace was already imported (cy null → non-null).
|
||||||
|
// - User toggles focus mode (focusMode changes) — dim in, dim out.
|
||||||
|
// - User clears the trace (rawResult → null → clearReasoningOverlay).
|
||||||
|
watch(
|
||||||
|
() => [props.cy, store.rawResult, store.focusMode] as const,
|
||||||
|
() => reapplyOverlay(),
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
function onClear(): void {
|
||||||
|
if (props.cy) clearReasoningOverlay(props.cy)
|
||||||
|
store.reset()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.reasoning-panel {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
width: 340px;
|
||||||
|
flex: 0 0 340px;
|
||||||
|
padding: 16px;
|
||||||
|
border-left: 1px solid var(--border);
|
||||||
|
background: var(--bg);
|
||||||
|
overflow-y: auto;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rp-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rp-header h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rp-header-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rp-btn-ghost {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 4px 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rp-btn-ghost:hover {
|
||||||
|
background: var(--border-light);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rp-btn-toggle {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rp-btn-toggle .rp-dot {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--border);
|
||||||
|
transition: background var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rp-btn-toggle.active {
|
||||||
|
border-color: #ea580c;
|
||||||
|
color: #ea580c;
|
||||||
|
background: rgba(234, 88, 12, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rp-btn-toggle.active .rp-dot {
|
||||||
|
background: #ea580c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rp-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
font-size: 12px;
|
||||||
|
padding-bottom: 8px;
|
||||||
|
border-bottom: 1px solid var(--border-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rp-meta-label {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rp-meta-value {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text);
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rp-meta-row {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rp-meta-chip {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--border-light);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 10px;
|
||||||
|
font-family: 'IBM Plex Mono', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rp-answer {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 14px 16px;
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid #ea580c;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
box-shadow: 0 1px 3px rgba(234, 88, 12, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rp-answer-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rp-answer-label {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.8px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
color: #ea580c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rp-answer-actions {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rp-converged {
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 10px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rp-converged.yes {
|
||||||
|
background: rgba(22, 163, 74, 0.15);
|
||||||
|
color: #15803d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rp-converged.no {
|
||||||
|
background: rgba(234, 179, 8, 0.15);
|
||||||
|
color: #a16207;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rp-copy-btn {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
padding: 2px 8px;
|
||||||
|
font-size: 10px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rp-copy-btn:hover {
|
||||||
|
background: var(--border-light);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rp-stats {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-family: 'IBM Plex Mono', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rp-answer-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
border-top: 1px solid var(--border-light);
|
||||||
|
padding-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Markdown-rendered answer body. Mirrors a subset of MarkdownViewer styles,
|
||||||
|
* tuned for a narrow right-rail context (tighter sizes than the full viewer). */
|
||||||
|
.rp-answer-body {
|
||||||
|
font-size: 13.5px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rp-answer-body :deep(p) {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rp-answer-body :deep(p:last-child) {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rp-answer-body :deep(ol),
|
||||||
|
.rp-answer-body :deep(ul) {
|
||||||
|
margin: 4px 0 8px;
|
||||||
|
padding-left: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rp-answer-body :deep(li) {
|
||||||
|
margin: 2px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rp-answer-body :deep(strong) {
|
||||||
|
color: var(--text);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rp-answer-body :deep(code) {
|
||||||
|
font-family: 'IBM Plex Mono', monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
background: var(--border-light);
|
||||||
|
padding: 1px 5px;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rp-answer-body :deep(pre) {
|
||||||
|
font-family: 'IBM Plex Mono', monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
background: var(--border-light);
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
overflow-x: auto;
|
||||||
|
margin: 6px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rp-answer-body :deep(h1),
|
||||||
|
.rp-answer-body :deep(h2),
|
||||||
|
.rp-answer-body :deep(h3),
|
||||||
|
.rp-answer-body :deep(h4) {
|
||||||
|
margin: 10px 0 4px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rp-answer-body :deep(a) {
|
||||||
|
color: #ea580c;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rp-warn {
|
||||||
|
padding: 8px 10px;
|
||||||
|
background: rgba(234, 179, 8, 0.1);
|
||||||
|
border: 1px solid rgba(234, 179, 8, 0.3);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: #a16207;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rp-section-title {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
font-size: 11px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rp-iteration-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rp-empty {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
283
frontend/src/features/reasoning/ui/ReasoningWorkspace.vue
Normal file
283
frontend/src/features/reasoning/ui/ReasoningWorkspace.vue
Normal file
|
|
@ -0,0 +1,283 @@
|
||||||
|
<template>
|
||||||
|
<div class="rw-root" data-e2e="reasoning-workspace">
|
||||||
|
<header class="rw-topbar">
|
||||||
|
<button class="rw-back-btn" data-e2e="reasoning-back" @click="emit('back')">
|
||||||
|
← {{ t('reasoning.changeDoc') }}
|
||||||
|
</button>
|
||||||
|
<div class="rw-doc-title" :title="docFilename ?? docId">
|
||||||
|
{{ docFilename ?? docId }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Main-pane toggle: graph vs docling markdown. Overlay panel on the
|
||||||
|
right stays mounted in both modes so the iteration list keeps
|
||||||
|
living context — only the main pane swaps. -->
|
||||||
|
<div class="rw-mode-switch" role="tablist" :aria-label="t('reasoning.modeSwitchLabel')">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
class="rw-mode-btn"
|
||||||
|
:class="{ active: mode === 'graph' }"
|
||||||
|
:aria-selected="mode === 'graph'"
|
||||||
|
data-e2e="reasoning-mode-graph"
|
||||||
|
@click="mode = 'graph'"
|
||||||
|
>
|
||||||
|
{{ t('reasoning.modeGraph') }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
class="rw-mode-btn"
|
||||||
|
:class="{ active: mode === 'document' }"
|
||||||
|
:aria-selected="mode === 'document'"
|
||||||
|
data-e2e="reasoning-mode-document"
|
||||||
|
@click="mode = 'document'"
|
||||||
|
>
|
||||||
|
{{ t('reasoning.modeDocument') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
class="rw-action-btn rw-action-ghost"
|
||||||
|
data-e2e="reasoning-workspace-import"
|
||||||
|
@click="reasoningStore.openImportDialog()"
|
||||||
|
>
|
||||||
|
{{ t('reasoning.importBtn') }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="rw-action-btn"
|
||||||
|
data-e2e="reasoning-workspace-run"
|
||||||
|
@click="reasoningStore.openRunDialog()"
|
||||||
|
>
|
||||||
|
{{ t('reasoning.runBtn') }}
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="rw-body">
|
||||||
|
<!-- Keep GraphView mounted via v-show rather than v-if so the Cytoscape
|
||||||
|
instance + its layout state survive a toggle to document mode and
|
||||||
|
back — rebuilding is expensive and would reset pan/zoom. -->
|
||||||
|
<GraphView
|
||||||
|
v-show="mode === 'graph'"
|
||||||
|
ref="graphViewRef"
|
||||||
|
:doc-id="docId"
|
||||||
|
:fetcher="fetchReasoningGraph"
|
||||||
|
@node-focus="onGraphNodeFocus"
|
||||||
|
/>
|
||||||
|
<!-- v-show (not v-if) so the StructureViewer's scroll-to-focused watch
|
||||||
|
sees transitions from null → sectionRef that happen while we're in
|
||||||
|
graph mode. Otherwise the viewer mounts with an already-set prop
|
||||||
|
and the initial scroll never fires. -->
|
||||||
|
<DocumentView
|
||||||
|
v-show="mode === 'document'"
|
||||||
|
ref="documentViewRef"
|
||||||
|
:doc-id="docId"
|
||||||
|
:focused-self-ref="focusedSelfRef"
|
||||||
|
@element-focus="onPdfElementFocus"
|
||||||
|
/>
|
||||||
|
<ReasoningPanel :cy="graphCy" @iteration-focus="onIterationFocus" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<RunReasoningDialog :doc-id="docId" :doc-filename="docFilename" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||||
|
|
||||||
|
import GraphView from '../../analysis/ui/GraphView.vue'
|
||||||
|
import { useI18n } from '../../../shared/i18n'
|
||||||
|
import { fetchReasoningGraph } from '../api'
|
||||||
|
import { focusIteration, nodeIdForSectionRef } from '../graphReasoningOverlay'
|
||||||
|
import { useReasoningStore } from '../store'
|
||||||
|
import DocumentView from './DocumentView.vue'
|
||||||
|
import ReasoningPanel from './ReasoningPanel.vue'
|
||||||
|
import RunReasoningDialog from './RunReasoningDialog.vue'
|
||||||
|
|
||||||
|
type WorkspaceMode = 'graph' | 'document'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
docId: string
|
||||||
|
docFilename?: string | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{ back: [] }>()
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const reasoningStore = useReasoningStore()
|
||||||
|
|
||||||
|
const graphViewRef = ref<InstanceType<typeof GraphView> | null>(null)
|
||||||
|
const graphCy = computed(() => graphViewRef.value?.cy ?? null)
|
||||||
|
const documentViewRef = ref<InstanceType<typeof DocumentView> | null>(null)
|
||||||
|
|
||||||
|
const mode = ref<WorkspaceMode>('graph')
|
||||||
|
|
||||||
|
// Shared focused element (Docling self_ref like "#/texts/12") — the one
|
||||||
|
// bridge between graph and PDF. Clicking a node in the graph sets this,
|
||||||
|
// clicking a bbox in the PDF sets this. When set, both views highlight
|
||||||
|
// the corresponding element. Persists across mode toggles so jumping from
|
||||||
|
// Graph → Document preserves the currently-looked-at element.
|
||||||
|
const focusedSelfRef = ref<string | null>(null)
|
||||||
|
|
||||||
|
function onGraphNodeFocus(selfRef: string | null): void {
|
||||||
|
focusedSelfRef.value = selfRef
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPdfElementFocus(selfRef: string): void {
|
||||||
|
focusedSelfRef.value = selfRef
|
||||||
|
// Mirror the selection on the graph side — if the user switches back to
|
||||||
|
// graph mode, they'll see the same element selected + centered.
|
||||||
|
graphViewRef.value?.selectBySelfRef(selfRef)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Click on an iteration card in the reasoning panel. We dispatch focus to
|
||||||
|
// both the graph and the PDF imperatively (rather than via watches on
|
||||||
|
// `activeIteration` / `focusedSelfRef`). Watches misfire when the user
|
||||||
|
// re-clicks the same iteration: Vue collapses synchronous "flip via null"
|
||||||
|
// mutations and only sees the final value, equal to the previous one — so
|
||||||
|
// nothing scrolls. Calling `focusIteration` and `scrollToFocused` directly
|
||||||
|
// works on every click regardless of state.
|
||||||
|
function onIterationFocus(iteration: number): void {
|
||||||
|
reasoningStore.setActiveIteration(iteration)
|
||||||
|
const hit = reasoningStore.iterations.find((i) => i.iteration === iteration)
|
||||||
|
if (!hit?.present || !hit.sectionRef) return
|
||||||
|
if (graphCy.value) focusIteration(graphCy.value, nodeIdForSectionRef(hit.sectionRef))
|
||||||
|
focusedSelfRef.value = hit.sectionRef
|
||||||
|
documentViewRef.value?.scrollToFocused(hit.sectionRef)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset the reasoning store when switching docs — a trace imported for one
|
||||||
|
// document is meaningless on another. The main-pane mode resets too so a
|
||||||
|
// new doc opens on the graph (consistent default).
|
||||||
|
watch(
|
||||||
|
() => props.docId,
|
||||||
|
() => {
|
||||||
|
reasoningStore.reset()
|
||||||
|
mode.value = 'graph'
|
||||||
|
focusedSelfRef.value = null
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
// Clean up so a later navigation back to the workspace starts fresh.
|
||||||
|
onBeforeUnmount(() => reasoningStore.reset())
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.rw-root {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rw-topbar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
background: var(--bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rw-back-btn {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
padding: 5px 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rw-back-btn:hover {
|
||||||
|
background: var(--border-light);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rw-doc-title {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rw-action-btn {
|
||||||
|
background: var(--accent);
|
||||||
|
color: white;
|
||||||
|
border: 0;
|
||||||
|
padding: 6px 14px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rw-action-btn:hover {
|
||||||
|
filter: brightness(0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Secondary action next to the primary Run button — import is a rarer path. */
|
||||||
|
.rw-action-ghost {
|
||||||
|
background: transparent;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rw-action-ghost:hover {
|
||||||
|
background: var(--border-light);
|
||||||
|
color: var(--text);
|
||||||
|
filter: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Segmented control for the main-pane mode (graph vs document). Sits
|
||||||
|
* between the doc title and the action buttons. */
|
||||||
|
.rw-mode-switch {
|
||||||
|
display: inline-flex;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rw-mode-btn {
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
padding: 5px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rw-mode-btn + .rw-mode-btn {
|
||||||
|
border-left: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rw-mode-btn:hover:not(.active) {
|
||||||
|
background: var(--border-light);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rw-mode-btn.active {
|
||||||
|
background: var(--accent);
|
||||||
|
color: #fff;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rw-body {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rw-body > :deep(.graph-view),
|
||||||
|
.rw-body > :deep(.rdv-root) {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
296
frontend/src/features/reasoning/ui/RunReasoningDialog.vue
Normal file
296
frontend/src/features/reasoning/ui/RunReasoningDialog.vue
Normal file
|
|
@ -0,0 +1,296 @@
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
v-if="store.runDialogOpen"
|
||||||
|
class="run-modal-backdrop"
|
||||||
|
data-e2e="reasoning-run-modal"
|
||||||
|
@click.self="close"
|
||||||
|
>
|
||||||
|
<div class="run-modal" role="dialog" aria-modal="true" :aria-label="t('reasoning.runTitle')">
|
||||||
|
<div class="run-modal-header">
|
||||||
|
<h3>{{ t('reasoning.runTitle') }}</h3>
|
||||||
|
<button
|
||||||
|
class="run-modal-close"
|
||||||
|
:aria-label="t('reasoning.close')"
|
||||||
|
:disabled="store.running"
|
||||||
|
@click="close"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="run-modal-hint">{{ t('reasoning.runHint') }}</p>
|
||||||
|
|
||||||
|
<label class="run-field">
|
||||||
|
<span class="run-field-label">{{ t('reasoning.runQueryLabel') }}</span>
|
||||||
|
<textarea
|
||||||
|
v-model="query"
|
||||||
|
class="run-field-input"
|
||||||
|
rows="3"
|
||||||
|
:placeholder="t('reasoning.runQueryPlaceholder')"
|
||||||
|
:disabled="store.running"
|
||||||
|
data-e2e="reasoning-run-query"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<label class="run-field">
|
||||||
|
<span class="run-field-label">{{ t('reasoning.runModelLabel') }}</span>
|
||||||
|
<input
|
||||||
|
v-model="modelId"
|
||||||
|
type="text"
|
||||||
|
class="run-field-input"
|
||||||
|
:placeholder="t('reasoning.runModelPlaceholder')"
|
||||||
|
:disabled="store.running"
|
||||||
|
data-e2e="reasoning-run-model"
|
||||||
|
/>
|
||||||
|
<span class="run-field-sub">{{ t('reasoning.runModelSub') }}</span>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div v-if="store.running" class="run-loading" data-e2e="reasoning-run-loading">
|
||||||
|
<div class="spinner" />
|
||||||
|
<span>{{ t('reasoning.running') }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="errorMsg" class="run-modal-error" data-e2e="reasoning-run-error">
|
||||||
|
{{ errorMsg }}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="run-modal-actions">
|
||||||
|
<button class="run-ghost" :disabled="store.running" @click="close">
|
||||||
|
{{ t('reasoning.cancel') }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="run-primary"
|
||||||
|
:disabled="!query.trim() || store.running"
|
||||||
|
data-e2e="reasoning-run-submit"
|
||||||
|
@click="submit"
|
||||||
|
>
|
||||||
|
{{ t('reasoning.runSubmit') }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
import { useI18n } from '../../../shared/i18n'
|
||||||
|
import { runReasoning } from '../api'
|
||||||
|
import { useReasoningStore } from '../store'
|
||||||
|
import type { SidecarEnvelope } from '../types'
|
||||||
|
|
||||||
|
const props = defineProps<{ docId: string; docFilename?: string | null }>()
|
||||||
|
|
||||||
|
const store = useReasoningStore()
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const query = ref('')
|
||||||
|
const modelId = ref('')
|
||||||
|
const errorMsg = ref<string | null>(null)
|
||||||
|
|
||||||
|
function close(): void {
|
||||||
|
if (store.running) return // don't let the user close mid-run
|
||||||
|
store.closeRunDialog()
|
||||||
|
errorMsg.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit(): Promise<void> {
|
||||||
|
const q = query.value.trim()
|
||||||
|
if (!q) return
|
||||||
|
errorMsg.value = null
|
||||||
|
store.setRunning(true)
|
||||||
|
try {
|
||||||
|
const result = await runReasoning(props.docId, q, modelId.value.trim() || undefined)
|
||||||
|
// Synthesize a sidecar-like envelope so the panel can show what was asked
|
||||||
|
// and which model answered, same as an imported trace.
|
||||||
|
const envelope: SidecarEnvelope = {
|
||||||
|
filename: props.docFilename ?? undefined,
|
||||||
|
query: q,
|
||||||
|
model: modelId.value.trim()
|
||||||
|
? { ollama_name: modelId.value.trim(), hf_model_name: null }
|
||||||
|
: undefined,
|
||||||
|
result,
|
||||||
|
}
|
||||||
|
store.setResult(result, envelope)
|
||||||
|
// Keep the query for the user's reference but close the dialog.
|
||||||
|
store.closeRunDialog()
|
||||||
|
} catch (e) {
|
||||||
|
errorMsg.value = (e as Error).message || t('reasoning.runErrUnknown')
|
||||||
|
} finally {
|
||||||
|
store.setRunning(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.run-modal-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(15, 23, 42, 0.55);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.run-modal {
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 20px;
|
||||||
|
width: min(560px, 100%);
|
||||||
|
max-height: 90vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
box-shadow: 0 12px 48px rgba(15, 23, 42, 0.25);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.run-modal-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.run-modal-header h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.run-modal-close {
|
||||||
|
background: transparent;
|
||||||
|
border: 0;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.run-modal-close:hover:not(:disabled) {
|
||||||
|
background: var(--border-light);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.run-modal-close:disabled {
|
||||||
|
opacity: 0.4;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.run-modal-hint {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.run-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.run-field-label {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.run-field-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-family: inherit;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
resize: vertical;
|
||||||
|
}
|
||||||
|
|
||||||
|
.run-field-input:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.run-field-sub {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.run-loading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: var(--accent-muted, rgba(234, 88, 12, 0.08));
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.run-modal-error {
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: rgba(220, 38, 38, 0.08);
|
||||||
|
color: var(--error, #dc2626);
|
||||||
|
font-size: 12px;
|
||||||
|
font-family: 'IBM Plex Mono', monospace;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.run-modal-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.run-primary {
|
||||||
|
background: var(--accent);
|
||||||
|
color: white;
|
||||||
|
border: 0;
|
||||||
|
padding: 7px 16px;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.run-primary:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.run-ghost {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
padding: 7px 14px;
|
||||||
|
font-size: 13px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.run-ghost:disabled {
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border: 2px solid var(--border-light);
|
||||||
|
border-top-color: var(--accent);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.6s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
259
frontend/src/pages/ReasoningPage.vue
Normal file
259
frontend/src/pages/ReasoningPage.vue
Normal file
|
|
@ -0,0 +1,259 @@
|
||||||
|
<template>
|
||||||
|
<div v-if="phase === 'picker'" class="rp-host">
|
||||||
|
<ReasoningDocPicker @select="onSelect" @uploaded="onUploaded" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="phase === 'preparing'" class="rp-host rp-centered" data-e2e="reasoning-preparing">
|
||||||
|
<div class="rp-prep-card">
|
||||||
|
<div class="spinner-large" />
|
||||||
|
<div class="rp-prep-title">{{ prepTitle }}</div>
|
||||||
|
<div v-if="prepHint" class="rp-prep-hint">{{ prepHint }}</div>
|
||||||
|
<button class="rp-ghost" @click="goToPicker">{{ t('reasoning.cancel') }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="phase === 'error'" class="rp-host rp-centered" data-e2e="reasoning-error">
|
||||||
|
<div class="rp-error-card">
|
||||||
|
<div class="rp-error-title">{{ t('reasoning.prepError') }}</div>
|
||||||
|
<p class="rp-error-msg">{{ errorMsg }}</p>
|
||||||
|
<div class="rp-error-actions">
|
||||||
|
<button class="rp-primary" @click="retry">{{ t('reasoning.retry') }}</button>
|
||||||
|
<button class="rp-ghost" @click="goToPicker">{{ t('reasoning.pickAnother') }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="phase === 'ready' && currentDocId" class="rp-host">
|
||||||
|
<ReasoningWorkspace
|
||||||
|
:doc-id="currentDocId"
|
||||||
|
:doc-filename="currentDocFilename"
|
||||||
|
@back="goToPicker"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
|
||||||
|
import { useAnalysisStore } from '../features/analysis/store'
|
||||||
|
import { useDocumentStore } from '../features/document/store'
|
||||||
|
import ReasoningDocPicker from '../features/reasoning/ui/ReasoningDocPicker.vue'
|
||||||
|
import ReasoningWorkspace from '../features/reasoning/ui/ReasoningWorkspace.vue'
|
||||||
|
import { useI18n } from '../shared/i18n'
|
||||||
|
|
||||||
|
const props = defineProps<{ docId?: string }>()
|
||||||
|
|
||||||
|
type Phase = 'picker' | 'preparing' | 'ready' | 'error'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const documentStore = useDocumentStore()
|
||||||
|
const analysisStore = useAnalysisStore()
|
||||||
|
|
||||||
|
const phase = ref<Phase>('picker')
|
||||||
|
const errorMsg = ref<string>('')
|
||||||
|
const prepTitle = ref<string>('')
|
||||||
|
const prepHint = ref<string | null>(null)
|
||||||
|
|
||||||
|
// When navigating via `/reasoning/:docId`, the router prop populates here.
|
||||||
|
// `route.params.docId` is the reactive source of truth.
|
||||||
|
const currentDocId = computed<string | null>(() => {
|
||||||
|
const raw = props.docId ?? (route.params.docId as string | undefined)
|
||||||
|
return raw || null
|
||||||
|
})
|
||||||
|
|
||||||
|
const currentDocFilename = computed<string | null>(() => {
|
||||||
|
if (!currentDocId.value) return null
|
||||||
|
const doc = documentStore.documents.find((d) => d.id === currentDocId.value)
|
||||||
|
return doc?.filename ?? null
|
||||||
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drive the doc through the prepare pipeline: ensure a completed analysis
|
||||||
|
* exists (run one silently if not). The graph itself is built on demand from
|
||||||
|
* SQLite by `/api/documents/:id/reasoning-graph`, so no priming step is needed.
|
||||||
|
*/
|
||||||
|
async function prepareDoc(docId: string): Promise<void> {
|
||||||
|
phase.value = 'preparing'
|
||||||
|
errorMsg.value = ''
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (documentStore.documents.length === 0) await documentStore.load()
|
||||||
|
if (analysisStore.analyses.length === 0) await analysisStore.load()
|
||||||
|
|
||||||
|
const completed = analysisStore.analyses.find(
|
||||||
|
(a) => a.documentId === docId && a.status === 'COMPLETED' && a.hasDocumentJson,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!completed) {
|
||||||
|
// Silent analysis with defaults — the tunnel doesn't expose pipeline
|
||||||
|
// options (see design §(b) silencieux).
|
||||||
|
prepTitle.value = t('reasoning.analyzing')
|
||||||
|
prepHint.value = t('reasoning.analyzingHint')
|
||||||
|
await analysisStore.run(docId)
|
||||||
|
await waitForAnalysisIdle()
|
||||||
|
const again = analysisStore.analyses.find(
|
||||||
|
(a) => a.documentId === docId && a.status === 'COMPLETED' && a.hasDocumentJson,
|
||||||
|
)
|
||||||
|
if (!again) {
|
||||||
|
throw new Error(analysisStore.error || t('reasoning.prepErrAnalysis'))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
phase.value = 'ready'
|
||||||
|
} catch (e) {
|
||||||
|
errorMsg.value = (e as Error).message || t('reasoning.prepErrUnknown')
|
||||||
|
phase.value = 'error'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function waitForAnalysisIdle(timeoutMs = 10 * 60 * 1000): Promise<void> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (!analysisStore.running) return resolve()
|
||||||
|
const started = Date.now()
|
||||||
|
const id = window.setInterval(() => {
|
||||||
|
if (!analysisStore.running) {
|
||||||
|
window.clearInterval(id)
|
||||||
|
resolve()
|
||||||
|
} else if (Date.now() - started > timeoutMs) {
|
||||||
|
window.clearInterval(id)
|
||||||
|
reject(new Error(t('reasoning.prepErrTimeout')))
|
||||||
|
}
|
||||||
|
}, 500)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSelect(docId: string): void {
|
||||||
|
router.push({ name: 'reasoning-doc', params: { docId } })
|
||||||
|
}
|
||||||
|
|
||||||
|
function onUploaded(docId: string): void {
|
||||||
|
router.push({ name: 'reasoning-doc', params: { docId } })
|
||||||
|
}
|
||||||
|
|
||||||
|
function goToPicker(): void {
|
||||||
|
router.push({ name: 'reasoning' })
|
||||||
|
}
|
||||||
|
|
||||||
|
async function retry(): Promise<void> {
|
||||||
|
if (currentDocId.value) await prepareDoc(currentDocId.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// React to route param changes (pushes, back/forward).
|
||||||
|
watch(
|
||||||
|
currentDocId,
|
||||||
|
(id) => {
|
||||||
|
if (!id) {
|
||||||
|
phase.value = 'picker'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
void prepareDoc(id)
|
||||||
|
},
|
||||||
|
{ immediate: false },
|
||||||
|
)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
if (currentDocId.value) void prepareDoc(currentDocId.value)
|
||||||
|
else phase.value = 'picker'
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.rp-host {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rp-centered {
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 48px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rp-prep-card,
|
||||||
|
.rp-error-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 32px 40px;
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
text-align: center;
|
||||||
|
max-width: 480px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rp-prep-title,
|
||||||
|
.rp-error-title {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rp-prep-hint,
|
||||||
|
.rp-error-msg {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rp-error-msg {
|
||||||
|
font-family: 'IBM Plex Mono', monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
background: rgba(220, 38, 38, 0.08);
|
||||||
|
color: var(--error, #dc2626);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rp-error-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rp-primary {
|
||||||
|
background: var(--accent);
|
||||||
|
color: white;
|
||||||
|
border: 0;
|
||||||
|
padding: 6px 14px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rp-ghost {
|
||||||
|
background: transparent;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
padding: 6px 14px;
|
||||||
|
font-size: 12px;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner-large {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
border: 3px solid var(--border-light);
|
||||||
|
border-top-color: var(--accent);
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -84,6 +84,27 @@
|
||||||
</svg>
|
</svg>
|
||||||
{{ t('studio.ingest') }}
|
{{ t('studio.ingest') }}
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="chunkingEnabled && ingestionEnabled && ingestionStore.available"
|
||||||
|
class="toggle-btn"
|
||||||
|
data-e2e="toggle-btn maintain-btn"
|
||||||
|
:class="{ active: mode === 'maintain' }"
|
||||||
|
@click="mode = 'maintain'"
|
||||||
|
:disabled="!analysisStore.currentAnalysis"
|
||||||
|
>
|
||||||
|
<svg class="toggle-icon" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path
|
||||||
|
d="M10 3.5a6.5 6.5 0 100 13 6.5 6.5 0 000-13zM6 10a4 4 0 118 0 4 4 0 01-8 0zm4-2a2 2 0 100 4 2 2 0 000-4z"
|
||||||
|
/>
|
||||||
|
<path
|
||||||
|
d="M10 1v2M10 17v2M1 10h2M17 10h2M3.5 3.5l1.4 1.4M15.1 15.1l1.4 1.4M3.5 16.5l1.4-1.4M15.1 4.9l1.4-1.4"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.5"
|
||||||
|
fill="none"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
{{ t('studio.maintain') }}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="topbar-actions">
|
<div class="topbar-actions">
|
||||||
|
|
@ -475,6 +496,11 @@
|
||||||
:chunk-count="analysisStore.currentChunks?.length ?? 0"
|
:chunk-count="analysisStore.currentChunks?.length ?? 0"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- MAINTAIN MODE -->
|
||||||
|
<div v-if="mode === 'maintain'" class="maintain-panel">
|
||||||
|
<GraphView :doc-id="analysisStore.currentAnalysis?.documentId ?? null" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -489,6 +515,7 @@ import { useIngestionStore } from '../features/ingestion/store'
|
||||||
import { DocumentUpload, DocumentList } from '../features/document/index'
|
import { DocumentUpload, DocumentList } from '../features/document/index'
|
||||||
import { ResultTabs } from '../features/analysis/index'
|
import { ResultTabs } from '../features/analysis/index'
|
||||||
import BboxOverlay from '../features/analysis/ui/BboxOverlay.vue'
|
import BboxOverlay from '../features/analysis/ui/BboxOverlay.vue'
|
||||||
|
import GraphView from '../features/analysis/ui/GraphView.vue'
|
||||||
import { ChunkPanel } from '../features/chunking'
|
import { ChunkPanel } from '../features/chunking'
|
||||||
import { IngestPanel } from '../features/ingestion'
|
import { IngestPanel } from '../features/ingestion'
|
||||||
import { useFeatureFlag } from '../features/feature-flags'
|
import { useFeatureFlag } from '../features/feature-flags'
|
||||||
|
|
@ -1404,10 +1431,11 @@ onBeforeUnmount(() => {
|
||||||
padding-top: 16px;
|
padding-top: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Verify / Prepare / Ingest panels */
|
/* Verify / Prepare / Ingest / Maintain panels */
|
||||||
.verify-panel,
|
.verify-panel,
|
||||||
.prepare-panel,
|
.prepare-panel,
|
||||||
.ingest-panel-wrapper {
|
.ingest-panel-wrapper,
|
||||||
|
.maintain-panel {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ const messages: Messages = {
|
||||||
'nav.studio': 'Studio',
|
'nav.studio': 'Studio',
|
||||||
'nav.documents': 'Documents',
|
'nav.documents': 'Documents',
|
||||||
'nav.history': 'Historique',
|
'nav.history': 'Historique',
|
||||||
|
'nav.reasoning': 'Raisonnement',
|
||||||
'nav.settings': 'Paramètres',
|
'nav.settings': 'Paramètres',
|
||||||
'nav.collapse': 'Réduire la barre latérale',
|
'nav.collapse': 'Réduire la barre latérale',
|
||||||
'nav.expand': 'Développer la barre latérale',
|
'nav.expand': 'Développer la barre latérale',
|
||||||
|
|
@ -81,6 +82,17 @@ const messages: Messages = {
|
||||||
'results.elements': 'Éléments',
|
'results.elements': 'Éléments',
|
||||||
'results.markdown': 'Markdown',
|
'results.markdown': 'Markdown',
|
||||||
'results.images': 'Images',
|
'results.images': 'Images',
|
||||||
|
'results.graph': 'Graphe',
|
||||||
|
'results.graphLoading': 'Chargement du graphe…',
|
||||||
|
'results.graphEmpty': 'Pas encore de graphe pour ce document (activez Neo4j).',
|
||||||
|
// GraphView — node details panel & interactions
|
||||||
|
'graph.nodeDetails': 'Détails du nœud',
|
||||||
|
'graph.close': 'Fermer',
|
||||||
|
'graph.page': 'Page',
|
||||||
|
'graph.text': 'Texte',
|
||||||
|
'graph.provenances': 'Provenances ({n})',
|
||||||
|
'graph.contains': 'Contenu ({n})',
|
||||||
|
'results.retry': 'Réessayer',
|
||||||
'results.pageOf': 'Page {current} sur {total}',
|
'results.pageOf': 'Page {current} sur {total}',
|
||||||
'results.noElements': 'Aucun élément détecté sur cette page',
|
'results.noElements': 'Aucun élément détecté sur cette page',
|
||||||
'results.noImages': 'Aucune image détectée dans ce document',
|
'results.noImages': 'Aucune image détectée dans ce document',
|
||||||
|
|
@ -110,6 +122,86 @@ const messages: Messages = {
|
||||||
// Chunking
|
// Chunking
|
||||||
'studio.prepare': 'Préparer',
|
'studio.prepare': 'Préparer',
|
||||||
'studio.ingest': 'Ingérer',
|
'studio.ingest': 'Ingérer',
|
||||||
|
'studio.maintain': 'Maintenir',
|
||||||
|
// Reasoning trace (R&D v1 — overlays a docling-agent RAGResult on the graph)
|
||||||
|
'reasoning.importBtn': 'Importer une trace de raisonnement',
|
||||||
|
'reasoning.importTitle': 'Importer une trace de raisonnement',
|
||||||
|
'reasoning.importHint':
|
||||||
|
'Dépose un JSON RAGResult produit par docling-agent (ou par le script R&D experiments/reasoning-trace).',
|
||||||
|
'reasoning.drop': 'Glisse un fichier .json ici',
|
||||||
|
'reasoning.dropSub': 'ou clique pour le choisir',
|
||||||
|
'reasoning.parsing': 'Analyse du fichier...',
|
||||||
|
'reasoning.pasteToggle': 'Coller le JSON à la place',
|
||||||
|
'reasoning.pastePlaceholder': "Colle ici le contenu JSON d'un RAGResult...",
|
||||||
|
'reasoning.pasteSubmit': 'Charger',
|
||||||
|
'reasoning.close': 'Fermer',
|
||||||
|
'reasoning.errJson': 'JSON invalide : {msg}',
|
||||||
|
'reasoning.errShape':
|
||||||
|
"Le fichier n'a pas la forme d'un RAGResult (answer, converged, iterations).",
|
||||||
|
'reasoning.panelTitle': 'Trace de raisonnement',
|
||||||
|
'reasoning.focus': 'Focus',
|
||||||
|
'reasoning.focusHint':
|
||||||
|
'Atténuer les éléments non visités pour faire ressortir le chemin de raisonnement.',
|
||||||
|
'reasoning.reimport': 'Réimporter',
|
||||||
|
'reasoning.clear': 'Effacer',
|
||||||
|
'reasoning.query': 'Question',
|
||||||
|
'reasoning.converged': 'Convergé',
|
||||||
|
'reasoning.notConverged': 'Itérations max atteintes',
|
||||||
|
'reasoning.resolved': 'sections résolues',
|
||||||
|
'reasoning.answerLabel': 'Réponse',
|
||||||
|
'reasoning.copy': 'Copier',
|
||||||
|
'reasoning.copied': 'Copié ✓',
|
||||||
|
'reasoning.copyAnswer': 'Copier la réponse dans le presse-papier',
|
||||||
|
'reasoning.reasonPlaceholder': '— pas de justification structurée',
|
||||||
|
'reasoning.missingWarn':
|
||||||
|
'{n} section(s) introuvable(s) dans le graphe. Le document a peut-être été re-analysé — relance « Maintenir » ou régénère la trace.',
|
||||||
|
'reasoning.graphNotLoadedWarn':
|
||||||
|
'Le graphe Neo4j de ce document n\u2019est pas chargé — les itérations sont affichées mais ne peuvent pas être positionnées sur la structure. Lance « prime_neo4j » ou re-déclenche une analyse.',
|
||||||
|
'reasoning.iterationsTitle': 'Itérations',
|
||||||
|
'reasoning.noIterations': "L'agent n'a visité aucune section (document sans en-têtes ?).",
|
||||||
|
'reasoning.statusAnswered': 'Répondu',
|
||||||
|
'reasoning.statusMore': 'Continue',
|
||||||
|
'reasoning.statusMissing': 'Absent',
|
||||||
|
'reasoning.charsLabel': '{n} caractères',
|
||||||
|
// Reasoning page (standalone tunnel)
|
||||||
|
'reasoning.pageTitle': 'Reasoning Trace',
|
||||||
|
'reasoning.pageSubtitle':
|
||||||
|
'Importe un PDF, puis dépose une trace RAGResult produite par docling-agent pour visualiser le chemin de raisonnement sur le graphe du document.',
|
||||||
|
'reasoning.dropPdf': 'Dépose un PDF',
|
||||||
|
'reasoning.dropPdfHint': 'ou clique pour en choisir un',
|
||||||
|
'reasoning.uploading': 'Import du document...',
|
||||||
|
'reasoning.existingDocs': 'Documents déjà analysés',
|
||||||
|
'reasoning.noAnalyzedDocs':
|
||||||
|
'Aucun des documents existants n\u2019a encore été analysé — lance-en un depuis Studio, ou dépose un nouveau PDF ci-dessus.',
|
||||||
|
'reasoning.pagesCount': '{n} pages',
|
||||||
|
'reasoning.changeDoc': 'Changer de document',
|
||||||
|
'reasoning.modeSwitchLabel': 'Mode d\u2019affichage',
|
||||||
|
'reasoning.modeGraph': 'Graphe',
|
||||||
|
'reasoning.modeDocument': 'Document',
|
||||||
|
'reasoning.docNoContent': 'Aucun contenu rendu disponible pour ce document.',
|
||||||
|
'reasoning.analyzing': 'Analyse du document...',
|
||||||
|
'reasoning.analyzingHint':
|
||||||
|
'Docling analyse le PDF avec la configuration par défaut. Cela peut prendre 1 à 3 minutes selon la taille.',
|
||||||
|
'reasoning.runBtn': 'Lancer le reasoning',
|
||||||
|
'reasoning.runTitle': 'Lancer docling-agent',
|
||||||
|
'reasoning.runHint':
|
||||||
|
'Pose une question au document. Le backend appelle docling-agent via Ollama et renvoie la trace dès que la boucle converge (20-40s).',
|
||||||
|
'reasoning.runQueryLabel': 'Question',
|
||||||
|
'reasoning.runQueryPlaceholder': 'Ex : Quelles sont les obligations du fournisseur ?',
|
||||||
|
'reasoning.runModelLabel': 'Modèle (optionnel)',
|
||||||
|
'reasoning.runModelPlaceholder': 'gpt-oss:20b',
|
||||||
|
'reasoning.runModelSub':
|
||||||
|
'Nom du modèle Ollama. Laisser vide pour utiliser le défaut serveur (RAG_MODEL_ID).',
|
||||||
|
'reasoning.runSubmit': 'Lancer',
|
||||||
|
'reasoning.running': 'docling-agent tourne... (20-40s)',
|
||||||
|
'reasoning.runErrUnknown': 'Erreur inconnue lors de l\u2019appel à docling-agent.',
|
||||||
|
'reasoning.cancel': 'Annuler',
|
||||||
|
'reasoning.retry': 'Réessayer',
|
||||||
|
'reasoning.pickAnother': 'Choisir un autre document',
|
||||||
|
'reasoning.prepError': 'Préparation impossible',
|
||||||
|
'reasoning.prepErrAnalysis': "L'analyse Docling a échoué ou n'a pas produit de document_json.",
|
||||||
|
'reasoning.prepErrTimeout': "L'analyse prend trop de temps — réessaye plus tard.",
|
||||||
|
'reasoning.prepErrUnknown': 'Erreur inconnue.',
|
||||||
'chunking.settings': 'Chunking',
|
'chunking.settings': 'Chunking',
|
||||||
'chunking.chunkerType': 'Type de chunker',
|
'chunking.chunkerType': 'Type de chunker',
|
||||||
'chunking.maxTokens': 'Tokens max',
|
'chunking.maxTokens': 'Tokens max',
|
||||||
|
|
@ -189,6 +281,7 @@ const messages: Messages = {
|
||||||
'nav.studio': 'Studio',
|
'nav.studio': 'Studio',
|
||||||
'nav.documents': 'Documents',
|
'nav.documents': 'Documents',
|
||||||
'nav.history': 'History',
|
'nav.history': 'History',
|
||||||
|
'nav.reasoning': 'Reasoning',
|
||||||
'nav.settings': 'Settings',
|
'nav.settings': 'Settings',
|
||||||
'nav.collapse': 'Collapse sidebar',
|
'nav.collapse': 'Collapse sidebar',
|
||||||
'nav.expand': 'Expand sidebar',
|
'nav.expand': 'Expand sidebar',
|
||||||
|
|
@ -253,6 +346,17 @@ const messages: Messages = {
|
||||||
'results.elements': 'Elements',
|
'results.elements': 'Elements',
|
||||||
'results.markdown': 'Markdown',
|
'results.markdown': 'Markdown',
|
||||||
'results.images': 'Images',
|
'results.images': 'Images',
|
||||||
|
'results.graph': 'Graph',
|
||||||
|
'results.graphLoading': 'Loading graph…',
|
||||||
|
'results.graphEmpty': 'No graph yet for this document (enable Neo4j).',
|
||||||
|
// GraphView — node details panel & interactions
|
||||||
|
'graph.nodeDetails': 'Node details',
|
||||||
|
'graph.close': 'Close',
|
||||||
|
'graph.page': 'Page',
|
||||||
|
'graph.text': 'Text',
|
||||||
|
'graph.provenances': 'Provenances ({n})',
|
||||||
|
'graph.contains': 'Contents ({n})',
|
||||||
|
'results.retry': 'Retry',
|
||||||
'results.pageOf': 'Page {current} of {total}',
|
'results.pageOf': 'Page {current} of {total}',
|
||||||
'results.noElements': 'No elements detected on this page',
|
'results.noElements': 'No elements detected on this page',
|
||||||
'results.noImages': 'No images detected in this document',
|
'results.noImages': 'No images detected in this document',
|
||||||
|
|
@ -279,6 +383,84 @@ const messages: Messages = {
|
||||||
|
|
||||||
'studio.prepare': 'Prepare',
|
'studio.prepare': 'Prepare',
|
||||||
'studio.ingest': 'Ingest',
|
'studio.ingest': 'Ingest',
|
||||||
|
'studio.maintain': 'Maintain',
|
||||||
|
// Reasoning trace (R&D v1 — overlays a docling-agent RAGResult on the graph)
|
||||||
|
'reasoning.importBtn': 'Import reasoning trace',
|
||||||
|
'reasoning.importTitle': 'Import reasoning trace',
|
||||||
|
'reasoning.importHint':
|
||||||
|
'Drop a RAGResult JSON produced by docling-agent (or by the experiments/reasoning-trace R&D script).',
|
||||||
|
'reasoning.drop': 'Drop a .json file here',
|
||||||
|
'reasoning.dropSub': 'or click to pick one',
|
||||||
|
'reasoning.parsing': 'Parsing file...',
|
||||||
|
'reasoning.pasteToggle': 'Paste JSON instead',
|
||||||
|
'reasoning.pastePlaceholder': 'Paste a RAGResult JSON payload here...',
|
||||||
|
'reasoning.pasteSubmit': 'Load',
|
||||||
|
'reasoning.close': 'Close',
|
||||||
|
'reasoning.errJson': 'Invalid JSON: {msg}',
|
||||||
|
'reasoning.errShape': "File doesn't look like a RAGResult (answer, converged, iterations).",
|
||||||
|
'reasoning.panelTitle': 'Reasoning trace',
|
||||||
|
'reasoning.focus': 'Focus',
|
||||||
|
'reasoning.focusHint': 'Dim non-visited elements to make the reasoning path stand out.',
|
||||||
|
'reasoning.reimport': 'Re-import',
|
||||||
|
'reasoning.clear': 'Clear',
|
||||||
|
'reasoning.query': 'Question',
|
||||||
|
'reasoning.converged': 'Converged',
|
||||||
|
'reasoning.notConverged': 'Max iterations',
|
||||||
|
'reasoning.resolved': 'sections resolved',
|
||||||
|
'reasoning.answerLabel': 'Answer',
|
||||||
|
'reasoning.copy': 'Copy',
|
||||||
|
'reasoning.copied': 'Copied ✓',
|
||||||
|
'reasoning.copyAnswer': 'Copy answer to clipboard',
|
||||||
|
'reasoning.reasonPlaceholder': '— no structured rationale',
|
||||||
|
'reasoning.missingWarn':
|
||||||
|
'{n} section(s) missing from the graph. The document may have been re-analyzed — re-run Maintain or regenerate the trace.',
|
||||||
|
'reasoning.graphNotLoadedWarn':
|
||||||
|
"This document's Neo4j graph isn't loaded — iterations are shown but can't be positioned on the structure. Run prime_neo4j or trigger a fresh analysis.",
|
||||||
|
'reasoning.iterationsTitle': 'Iterations',
|
||||||
|
'reasoning.noIterations': 'Agent visited no section (document without headers?).',
|
||||||
|
'reasoning.statusAnswered': 'Answered',
|
||||||
|
'reasoning.statusMore': 'More needed',
|
||||||
|
'reasoning.statusMissing': 'Missing',
|
||||||
|
'reasoning.charsLabel': '{n} chars',
|
||||||
|
// Reasoning page (standalone tunnel)
|
||||||
|
'reasoning.pageTitle': 'Reasoning Trace',
|
||||||
|
'reasoning.pageSubtitle':
|
||||||
|
"Drop a PDF, then import a RAGResult trace from docling-agent to visualize the reasoning path on the document's graph.",
|
||||||
|
'reasoning.dropPdf': 'Drop a PDF',
|
||||||
|
'reasoning.dropPdfHint': 'or click to pick one',
|
||||||
|
'reasoning.uploading': 'Uploading document...',
|
||||||
|
'reasoning.existingDocs': 'Previously analyzed documents',
|
||||||
|
'reasoning.noAnalyzedDocs':
|
||||||
|
'None of your existing documents have been analyzed yet — run one from Studio, or drop a new PDF above.',
|
||||||
|
'reasoning.pagesCount': '{n} pages',
|
||||||
|
'reasoning.changeDoc': 'Change document',
|
||||||
|
'reasoning.modeSwitchLabel': 'View mode',
|
||||||
|
'reasoning.modeGraph': 'Graph',
|
||||||
|
'reasoning.modeDocument': 'Document',
|
||||||
|
'reasoning.docNoContent': 'No rendered content available for this document.',
|
||||||
|
'reasoning.analyzing': 'Analyzing document...',
|
||||||
|
'reasoning.analyzingHint':
|
||||||
|
'Docling is parsing the PDF with default settings. May take 1–3 minutes depending on size.',
|
||||||
|
'reasoning.runBtn': 'Run reasoning',
|
||||||
|
'reasoning.runTitle': 'Run docling-agent',
|
||||||
|
'reasoning.runHint':
|
||||||
|
'Ask a question against this document. The backend calls docling-agent over Ollama and returns the trace once the loop converges (20–40s).',
|
||||||
|
'reasoning.runQueryLabel': 'Question',
|
||||||
|
'reasoning.runQueryPlaceholder': 'e.g. What are the supplier obligations?',
|
||||||
|
'reasoning.runModelLabel': 'Model (optional)',
|
||||||
|
'reasoning.runModelPlaceholder': 'gpt-oss:20b',
|
||||||
|
'reasoning.runModelSub':
|
||||||
|
'Ollama model name. Leave empty to use the server default (RAG_MODEL_ID).',
|
||||||
|
'reasoning.runSubmit': 'Run',
|
||||||
|
'reasoning.running': 'docling-agent is thinking… (20–40s)',
|
||||||
|
'reasoning.runErrUnknown': 'Unknown error while calling docling-agent.',
|
||||||
|
'reasoning.cancel': 'Cancel',
|
||||||
|
'reasoning.retry': 'Retry',
|
||||||
|
'reasoning.pickAnother': 'Pick another document',
|
||||||
|
'reasoning.prepError': 'Preparation failed',
|
||||||
|
'reasoning.prepErrAnalysis': 'Docling analysis failed or produced no document_json.',
|
||||||
|
'reasoning.prepErrTimeout': 'Analysis is taking too long — try again later.',
|
||||||
|
'reasoning.prepErrUnknown': 'Unknown error.',
|
||||||
'chunking.settings': 'Chunking',
|
'chunking.settings': 'Chunking',
|
||||||
'chunking.chunkerType': 'Chunker type',
|
'chunking.chunkerType': 'Chunker type',
|
||||||
'chunking.maxTokens': 'Max tokens',
|
'chunking.maxTokens': 'Max tokens',
|
||||||
|
|
|
||||||
|
|
@ -67,6 +67,10 @@ export interface PageElement {
|
||||||
bbox: [number, number, number, number]
|
bbox: [number, number, number, number]
|
||||||
content: string
|
content: string
|
||||||
level: number
|
level: number
|
||||||
|
/** Docling `self_ref` — "#/texts/12", "#/tables/3", etc. Empty string for
|
||||||
|
* items that don't have one (rare). Lets callers correlate a bbox with
|
||||||
|
* the matching graph node without fuzzy bbox matching. */
|
||||||
|
self_ref?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Backend serializes with snake_case (dataclasses.asdict)
|
// Backend serializes with snake_case (dataclasses.asdict)
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,23 @@
|
||||||
<span class="nav-label">{{ t('nav.search') }}</span>
|
<span class="nav-label">{{ t('nav.search') }}</span>
|
||||||
</RouterLink>
|
</RouterLink>
|
||||||
|
|
||||||
|
<RouterLink
|
||||||
|
v-if="reasoningEnabled"
|
||||||
|
to="/reasoning"
|
||||||
|
class="nav-item"
|
||||||
|
data-e2e="nav-reasoning"
|
||||||
|
:class="{
|
||||||
|
active: route.name === 'reasoning' || route.name === 'reasoning-doc',
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<svg class="nav-icon" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path
|
||||||
|
d="M10 2a5 5 0 00-5 5c0 1.72.87 3.24 2.2 4.14.47.32.8.84.8 1.4V14a1 1 0 001 1h2a1 1 0 001-1v-1.46c0-.56.33-1.08.8-1.4A5 5 0 0010 2zM8 17a1 1 0 011-1h2a1 1 0 110 2H9a1 1 0 01-1-1z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span class="nav-label">{{ t('nav.reasoning') }}</span>
|
||||||
|
</RouterLink>
|
||||||
|
|
||||||
<RouterLink
|
<RouterLink
|
||||||
to="/history"
|
to="/history"
|
||||||
class="nav-item"
|
class="nav-item"
|
||||||
|
|
@ -138,6 +155,7 @@ import { useIngestionStore } from '../../features/ingestion/store'
|
||||||
const featureStore = useFeatureFlagStore()
|
const featureStore = useFeatureFlagStore()
|
||||||
const ingestionStore = useIngestionStore()
|
const ingestionStore = useIngestionStore()
|
||||||
const ingestionEnabled = computed(() => featureStore.isEnabled('ingestion'))
|
const ingestionEnabled = computed(() => featureStore.isEnabled('ingestion'))
|
||||||
|
const reasoningEnabled = computed(() => featureStore.isEnabled('reasoning'))
|
||||||
const version = computed(() => featureStore.appVersion)
|
const version = computed(() => featureStore.appVersion)
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
|
||||||
|
|
@ -57,7 +57,7 @@ nav:
|
||||||
- Audit:
|
- Audit:
|
||||||
- Audit Master: audit/master.md
|
- Audit Master: audit/master.md
|
||||||
- Audits:
|
- Audits:
|
||||||
- Clean Architecture: audit/audits/01-clean-architecture.md
|
- Hexagonal Architecture: audit/audits/01-clean-architecture.md
|
||||||
- DDD: audit/audits/02-ddd.md
|
- DDD: audit/audits/02-ddd.md
|
||||||
- Clean Code: audit/audits/03-clean-code.md
|
- Clean Code: audit/audits/03-clean-code.md
|
||||||
- KISS: audit/audits/04-kiss.md
|
- KISS: audit/audits/04-kiss.md
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue