feat(reasoning): reasoning-trace viewer v1 with SQLite-backed graph
Adds the `docling-agent` reasoning-trace viewer as a Studio tunnel, per `docs/design/reasoning-trace.md`. Users pick an analyzed document, import a RAGResult JSON, and the iterations are overlaid on the document graph. Graph source is decoupled from Neo4j: a new pure builder (`infra/docling_graph.build_graph_payload`) reads `document_json` from SQLite and emits the same Cytoscape-shaped payload that `fetch_graph` returns from Neo4j. Neo4j stays exclusive to the Maintain ingestion pipeline. Shared DoclingDocument helpers live in `infra/docling_tree.py` so TreeWriter and the builder can't drift on label taxonomy or tree walks. Also removes the Cytoscape minimap (cytoscape-navigator) from GraphView: second render instance hurt perf on large documents for no UX win. Backend - new `GET /api/documents/:id/reasoning-graph` (SQLite-only) - new `infra/docling_tree.py`, `infra/docling_graph.py` - `analysis_repo.find_latest_completed_by_document` - tests: `test_docling_graph.py` (builder), `test_graph_api.py` (endpoint) Frontend - `features/reasoning/` — store, overlay, types, panel, import dialog, workspace, doc picker - new `ReasoningPage` + `/reasoning` and `/reasoning/:docId` routes - `GraphView` gains a `fetcher` prop so reasoning can inject the SQLite-backed fetcher while Maintain keeps using the Neo4j one - drops minimap (nav container, dep, CSS) - legend filters + section parenting extracted for reuse - i18n base strings (FR + EN)
This commit is contained in:
parent
f8675aea83
commit
8103460e9c
47 changed files with 5834 additions and 177 deletions
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.
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
"""Graph API — returns a cytoscape-shaped view of the Neo4j graph for a doc.
|
"""Graph API — returns a cytoscape-shaped view of the document structure.
|
||||||
|
|
||||||
v0.5 contract:
|
Two endpoints:
|
||||||
- Returns the **full** graph for the document (see design §8.4)
|
- `/graph` — read from Neo4j. Rich graph (elements + chunks + pages + merges).
|
||||||
- Hard cap at 200 pages; beyond that, HTTP 413 with `truncated: true`
|
Requires the Maintain step (IngestionPipeline) to have run for the document.
|
||||||
- No pagination (ships in v0.6)
|
- `/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
|
from __future__ import annotations
|
||||||
|
|
@ -13,6 +15,7 @@ import logging
|
||||||
from fastapi import APIRouter, HTTPException, Request
|
from fastapi import APIRouter, HTTPException, Request
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
from infra.docling_graph import build_graph_payload
|
||||||
from infra.neo4j import fetch_graph
|
from infra.neo4j import fetch_graph
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
@ -74,3 +77,47 @@ async def get_document_graph(doc_id: str, request: Request) -> GraphResponse:
|
||||||
truncated=payload.truncated,
|
truncated=payload.truncated,
|
||||||
page_count=payload.page_count,
|
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,
|
||||||
|
)
|
||||||
|
|
|
||||||
159
document-parser/infra/docling_graph.py
Normal file
159
document-parser/infra/docling_graph.py
Normal file
|
|
@ -0,0 +1,159 @@
|
||||||
|
"""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 (
|
||||||
|
dfs_order,
|
||||||
|
element_label,
|
||||||
|
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]]) -> dict[str, Any]:
|
||||||
|
first_page = provs[0].get("page_no") if provs else None
|
||||||
|
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": (item.get("text") or "")[: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))
|
||||||
|
|
||||||
|
# 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:
|
||||||
|
continue
|
||||||
|
by_ref[ref] = item
|
||||||
|
provs = iter_provs(item)
|
||||||
|
nodes.append(_element_node(doc_id, item, provs))
|
||||||
|
|
||||||
|
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).
|
||||||
|
for a, b in pairwise(dfs_order(doc_data)):
|
||||||
|
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,
|
||||||
|
)
|
||||||
136
document-parser/infra/docling_tree.py
Normal file
136
document-parser/infra/docling_tree.py
Normal file
|
|
@ -0,0 +1,136 @@
|
||||||
|
"""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
|
||||||
|
"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 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]) -> list[str]:
|
||||||
|
"""Return `self_ref`s in reading order (DFS pre-order from body)."""
|
||||||
|
by_ref: dict[str, dict[str, Any]] = {}
|
||||||
|
for _, item in iter_items(doc_data):
|
||||||
|
ref = item.get("self_ref")
|
||||||
|
if ref:
|
||||||
|
by_ref[ref] = item
|
||||||
|
body = doc_data.get("body") or {}
|
||||||
|
order: list[str] = []
|
||||||
|
|
||||||
|
def walk(children: list[dict[str, Any]] | None) -> None:
|
||||||
|
if not children:
|
||||||
|
return
|
||||||
|
for ch in children:
|
||||||
|
ref = ch.get("$ref") or ch.get("cref")
|
||||||
|
if not ref:
|
||||||
|
continue
|
||||||
|
order.append(ref)
|
||||||
|
child = by_ref.get(ref)
|
||||||
|
if child:
|
||||||
|
walk(child.get("children"))
|
||||||
|
|
||||||
|
walk(body.get("children"))
|
||||||
|
return order
|
||||||
|
|
||||||
|
|
||||||
|
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"),
|
||||||
|
}
|
||||||
|
|
@ -25,9 +25,31 @@ class GraphPayload:
|
||||||
# block contributes a single row — avoids the cartesian product that chained
|
# block contributes a single row — avoids the cartesian product that chained
|
||||||
# OPTIONAL MATCH on 6+ edge types would produce (hangs on multi-page docs).
|
# 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/
|
# 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 = """
|
_FETCH_GRAPH = """
|
||||||
MATCH (d:Document {id: $doc_id})
|
MATCH (d:Document {id: $doc_id})
|
||||||
CALL { WITH d MATCH (e:Element {doc_id: d.id}) RETURN collect(e) AS elements }
|
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 (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 (c:Chunk {doc_id: d.id}) RETURN collect(c) AS chunks }
|
||||||
CALL {
|
CALL {
|
||||||
|
|
@ -42,7 +64,10 @@ CALL {
|
||||||
}
|
}
|
||||||
CALL {
|
CALL {
|
||||||
WITH d
|
WITH d
|
||||||
MATCH (er:Element {doc_id: d.id})-[:ON_PAGE]->(pr:Page)
|
// 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
|
RETURN collect({from: er.self_ref, to: pr.page_no, type: 'ON_PAGE'}) AS on_page_edges
|
||||||
}
|
}
|
||||||
CALL {
|
CALL {
|
||||||
|
|
@ -66,17 +91,25 @@ RETURN d AS document, elements, pages, chunks,
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
def _element_node(doc_id: str, e: dict[str, Any]) -> dict[str, Any]:
|
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
|
# 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
|
# driver side; when we project nodes via RETURN, the driver wraps them as Node
|
||||||
# objects, so we convert below.
|
# 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 {
|
return {
|
||||||
"id": f"elem::{e.get('self_ref')}",
|
"id": f"elem::{e.get('self_ref')}",
|
||||||
"group": "element",
|
"group": "element",
|
||||||
"docling_label": e.get("docling_label"),
|
"docling_label": e.get("docling_label"),
|
||||||
"self_ref": e.get("self_ref"),
|
"self_ref": e.get("self_ref"),
|
||||||
"text": (e.get("text") or "")[:200],
|
"text": (e.get("text") or "")[:200],
|
||||||
"prov_page": e.get("prov_page"),
|
"prov_page": first_page,
|
||||||
|
"provs": provs or [],
|
||||||
"level": e.get("level"),
|
"level": e.get("level"),
|
||||||
"doc_id": doc_id,
|
"doc_id": doc_id,
|
||||||
}
|
}
|
||||||
|
|
@ -171,11 +204,17 @@ async def fetch_graph(
|
||||||
)
|
)
|
||||||
|
|
||||||
# Element nodes, keeping the specific label (:SectionHeader, etc.).
|
# Element nodes, keeping the specific label (:SectionHeader, etc.).
|
||||||
for e in record["elements"] or []:
|
# 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:
|
if e is None:
|
||||||
continue
|
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"]
|
labels = [label for label in e.labels if label != "Element"]
|
||||||
node = _element_node(doc_id, dict(e))
|
node = _element_node(doc_id, dict(e), provs=provs)
|
||||||
node["label"] = labels[0] if labels else "TextElement"
|
node["label"] = labels[0] if labels else "TextElement"
|
||||||
nodes.append(node)
|
nodes.append(node)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,12 @@ CONSTRAINTS: tuple[str, ...] = (
|
||||||
INDEXES: tuple[str, ...] = (
|
INDEXES: tuple[str, ...] = (
|
||||||
"CREATE INDEX element_doc IF NOT EXISTS FOR (e:Element) ON (e.doc_id)",
|
"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)",
|
"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, ...] = (
|
FULLTEXT_INDEXES: tuple[str, ...] = (
|
||||||
|
|
|
||||||
|
|
@ -16,80 +16,41 @@ from dataclasses import dataclass
|
||||||
from datetime import UTC, datetime
|
from datetime import UTC, datetime
|
||||||
from typing import TYPE_CHECKING, Any
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
|
from infra.docling_tree import (
|
||||||
|
dfs_order,
|
||||||
|
element_label,
|
||||||
|
iter_items,
|
||||||
|
iter_pages,
|
||||||
|
iter_provs,
|
||||||
|
parent_ref,
|
||||||
|
)
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from infra.neo4j.driver import Neo4jDriver
|
from infra.neo4j.driver import Neo4jDriver
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
# Docling label → specific Neo4j label. Every node also carries :Element.
|
|
||||||
_LABEL_MAP: dict[str, str] = {
|
|
||||||
"section_header": "SectionHeader",
|
|
||||||
"title": "SectionHeader",
|
|
||||||
"paragraph": "Paragraph",
|
|
||||||
"text": "Paragraph",
|
|
||||||
"list_item": "ListItem",
|
|
||||||
"list": "ListItem",
|
|
||||||
"table": "Table",
|
|
||||||
"picture": "Figure",
|
|
||||||
"formula": "Formula",
|
|
||||||
"code": "Code",
|
|
||||||
"caption": "Caption",
|
|
||||||
"footnote": "Footnote",
|
|
||||||
"page_header": "PageHeader",
|
|
||||||
"page_footer": "PageFooter",
|
|
||||||
}
|
|
||||||
_DEFAULT_LABEL = "TextElement"
|
|
||||||
|
|
||||||
|
|
||||||
def _element_label(docling_label: str) -> str:
|
|
||||||
return _LABEL_MAP.get(docling_label.lower(), _DEFAULT_LABEL)
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class TreeWriteResult:
|
class TreeWriteResult:
|
||||||
doc_id: str
|
doc_id: str
|
||||||
elements_written: int
|
elements_written: int
|
||||||
pages_written: int
|
pages_written: int
|
||||||
|
provenances_written: int = 0
|
||||||
|
|
||||||
def _iter_items(doc_data: dict[str, Any]):
|
|
||||||
"""Yield every item from texts/tables/pictures/groups with its source list."""
|
|
||||||
for key in ("texts", "tables", "pictures", "groups"):
|
|
||||||
for item in doc_data.get(key, []) or []:
|
|
||||||
yield key, item
|
|
||||||
|
|
||||||
|
|
||||||
def _first_prov(item: dict[str, Any]) -> tuple[int | None, list[float] | None]:
|
|
||||||
prov = item.get("prov") or []
|
|
||||||
if not prov:
|
|
||||||
return None, None
|
|
||||||
p0 = prov[0]
|
|
||||||
bbox = p0.get("bbox")
|
|
||||||
bbox_list: list[float] | None = None
|
|
||||||
if isinstance(bbox, dict):
|
|
||||||
bbox_list = [bbox.get("l", 0.0), bbox.get("t", 0.0), bbox.get("r", 0.0), bbox.get("b", 0.0)]
|
|
||||||
elif isinstance(bbox, list):
|
|
||||||
bbox_list = list(bbox)
|
|
||||||
return p0.get("page_no"), bbox_list
|
|
||||||
|
|
||||||
|
|
||||||
def _parent_ref(item: dict[str, Any]) -> str | None:
|
|
||||||
parent = item.get("parent")
|
|
||||||
if isinstance(parent, dict):
|
|
||||||
return parent.get("$ref") or parent.get("cref")
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _element_props(item: dict[str, Any], doc_id: str) -> dict[str, Any]:
|
def _element_props(item: dict[str, Any], doc_id: str) -> dict[str, Any]:
|
||||||
page, bbox = _first_prov(item)
|
"""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] = {
|
props: dict[str, Any] = {
|
||||||
"doc_id": doc_id,
|
"doc_id": doc_id,
|
||||||
"self_ref": item.get("self_ref") or "",
|
"self_ref": item.get("self_ref") or "",
|
||||||
"docling_label": (item.get("label") or "").lower(),
|
"docling_label": (item.get("label") or "").lower(),
|
||||||
"text": item.get("text") or "",
|
"text": item.get("text") or "",
|
||||||
"prov_page": page,
|
|
||||||
"prov_bbox": bbox,
|
|
||||||
}
|
}
|
||||||
# Type-specific extras.
|
# Type-specific extras.
|
||||||
if "level" in item:
|
if "level" in item:
|
||||||
|
|
@ -103,32 +64,6 @@ def _element_props(item: dict[str, Any], doc_id: str) -> dict[str, Any]:
|
||||||
return props
|
return props
|
||||||
|
|
||||||
|
|
||||||
def _dfs_order(doc_data: dict[str, Any]) -> list[str]:
|
|
||||||
"""Return self_refs in reading order (DFS pre-order from body)."""
|
|
||||||
by_ref: dict[str, dict[str, Any]] = {}
|
|
||||||
for _, item in _iter_items(doc_data):
|
|
||||||
ref = item.get("self_ref")
|
|
||||||
if ref:
|
|
||||||
by_ref[ref] = item
|
|
||||||
body = doc_data.get("body") or {}
|
|
||||||
order: list[str] = []
|
|
||||||
|
|
||||||
def walk(children: list[dict[str, Any]] | None) -> None:
|
|
||||||
if not children:
|
|
||||||
return
|
|
||||||
for ch in children:
|
|
||||||
ref = ch.get("$ref") or ch.get("cref")
|
|
||||||
if not ref:
|
|
||||||
continue
|
|
||||||
order.append(ref)
|
|
||||||
child = by_ref.get(ref)
|
|
||||||
if child:
|
|
||||||
walk(child.get("children"))
|
|
||||||
|
|
||||||
walk(body.get("children"))
|
|
||||||
return order
|
|
||||||
|
|
||||||
|
|
||||||
async def write_document(
|
async def write_document(
|
||||||
neo: Neo4jDriver,
|
neo: Neo4jDriver,
|
||||||
*,
|
*,
|
||||||
|
|
@ -148,36 +83,28 @@ async def write_document(
|
||||||
ingested_at = datetime.now(tz=UTC).isoformat()
|
ingested_at = datetime.now(tz=UTC).isoformat()
|
||||||
|
|
||||||
elements: list[dict[str, Any]] = []
|
elements: list[dict[str, Any]] = []
|
||||||
for _, item in _iter_items(doc_data):
|
# 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")
|
ref = item.get("self_ref")
|
||||||
if not ref:
|
if not ref:
|
||||||
continue
|
continue
|
||||||
specific = _element_label(item.get("label") or "")
|
specific = element_label(item.get("label") or "")
|
||||||
elements.append(
|
elements.append(
|
||||||
{
|
{
|
||||||
"specific_label": specific,
|
"specific_label": specific,
|
||||||
"parent_ref": _parent_ref(item),
|
"parent_ref": parent_ref(item),
|
||||||
**_element_props(item, doc_id),
|
**_element_props(item, doc_id),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
for prov in iter_provs(item):
|
||||||
|
provenances.append({"doc_id": doc_id, "self_ref": ref, **prov})
|
||||||
|
|
||||||
pages: list[dict[str, Any]] = []
|
pages: list[dict[str, Any]] = [{"doc_id": doc_id, **p} for p in iter_pages(doc_data)]
|
||||||
for page_no_str, page_obj in (doc_data.get("pages") or {}).items():
|
|
||||||
try:
|
|
||||||
page_no = int(page_no_str)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
continue
|
|
||||||
size = page_obj.get("size") or {}
|
|
||||||
pages.append(
|
|
||||||
{
|
|
||||||
"doc_id": doc_id,
|
|
||||||
"page_no": page_no,
|
|
||||||
"width": size.get("width"),
|
|
||||||
"height": size.get("height"),
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
reading_order = _dfs_order(doc_data)
|
reading_order = dfs_order(doc_data)
|
||||||
|
|
||||||
async with (
|
async with (
|
||||||
neo.driver.session(database=neo.database) as session,
|
neo.driver.session(database=neo.database) as session,
|
||||||
|
|
@ -190,7 +117,9 @@ async def write_document(
|
||||||
"DETACH DELETE d, n",
|
"DETACH DELETE d, n",
|
||||||
doc_id=doc_id,
|
doc_id=doc_id,
|
||||||
)
|
)
|
||||||
# Also wipe orphan elements/chunks that may still reference this doc.
|
# 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 (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)
|
await tx.run("MATCH (p:Page {doc_id: $doc_id}) DETACH DELETE p", doc_id=doc_id)
|
||||||
|
|
||||||
|
|
@ -241,8 +170,6 @@ async def write_document(
|
||||||
self_ref: e.self_ref,
|
self_ref: e.self_ref,
|
||||||
docling_label: e.docling_label,
|
docling_label: e.docling_label,
|
||||||
text: e.text,
|
text: e.text,
|
||||||
prov_page: e.prov_page,
|
|
||||||
prov_bbox: e.prov_bbox,
|
|
||||||
level: e.level,
|
level: e.level,
|
||||||
caption: e.caption,
|
caption: e.caption,
|
||||||
cells_json: e.cells_json
|
cells_json: e.cells_json
|
||||||
|
|
@ -291,21 +218,48 @@ async def write_document(
|
||||||
rows=root_rows,
|
rows=root_rows,
|
||||||
)
|
)
|
||||||
|
|
||||||
# 7. ON_PAGE from first provenance.
|
# 7. Provenance nodes — one per (element, prov-entry) pair. Mirrors
|
||||||
on_page_rows = [
|
# Docling's `item.prov = list[ProvenanceItem]` 1:1 so a single item
|
||||||
{"doc_id": doc_id, "self_ref": e["self_ref"], "page_no": e["prov_page"]}
|
# that spans page breaks (or appears twice in the layout) keeps every
|
||||||
for e in elements
|
# (page, bbox, charspan) without losing data.
|
||||||
if e["prov_page"] is not None
|
if provenances:
|
||||||
]
|
|
||||||
if on_page_rows:
|
|
||||||
await tx.run(
|
await tx.run(
|
||||||
"""
|
"""
|
||||||
UNWIND $rows AS r
|
UNWIND $rows AS r
|
||||||
MATCH (e:Element {doc_id: r.doc_id, self_ref: r.self_ref})
|
MATCH (e:Element {doc_id: r.doc_id, self_ref: r.self_ref})
|
||||||
MATCH (p:Page {doc_id: r.doc_id, page_no: r.page_no})
|
CREATE (pv:Provenance {
|
||||||
MERGE (e)-[:ON_PAGE]->(p)
|
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=on_page_rows,
|
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.
|
# 8. NEXT chain in DFS pre-order.
|
||||||
|
|
@ -327,9 +281,15 @@ async def write_document(
|
||||||
await tx.commit()
|
await tx.commit()
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
"Neo4j: wrote doc %s (%d elements, %d pages)",
|
"Neo4j: wrote doc %s (%d elements, %d pages, %d provenances)",
|
||||||
doc_id,
|
doc_id,
|
||||||
len(elements),
|
len(elements),
|
||||||
len(pages),
|
len(pages),
|
||||||
|
len(provenances),
|
||||||
|
)
|
||||||
|
return TreeWriteResult(
|
||||||
|
doc_id=doc_id,
|
||||||
|
elements_written=len(elements),
|
||||||
|
pages_written=len(pages),
|
||||||
|
provenances_written=len(provenances),
|
||||||
)
|
)
|
||||||
return TreeWriteResult(doc_id=doc_id, elements_written=len(elements), pages_written=len(pages))
|
|
||||||
|
|
|
||||||
|
|
@ -167,6 +167,12 @@ 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()
|
||||||
|
# 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.neo4j = await _init_neo4j()
|
||||||
app.state.analysis_service = _build_analysis_service(
|
app.state.analysis_service = _build_analysis_service(
|
||||||
document_repo, analysis_repo, neo4j_driver=app.state.neo4j
|
document_repo, analysis_repo, neo4j_driver=app.state.neo4j
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -102,8 +102,10 @@ async def test_fetch_graph_returns_full_payload(neo4j_driver):
|
||||||
assert groups == {"document", "element", "page", "chunk"}
|
assert groups == {"document", "element", "page", "chunk"}
|
||||||
|
|
||||||
edge_types = {e["type"] for e in payload.edges}
|
edge_types = {e["type"] for e in payload.edges}
|
||||||
# Every edge kind written by TreeWriter and ChunkWriter should be present.
|
# Every edge kind this fixture can produce should be present.
|
||||||
assert {"HAS_ROOT", "PARENT_OF", "NEXT", "ON_PAGE", "HAS_CHUNK", "DERIVED_FROM"} <= edge_types
|
# 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):
|
async def test_fetch_graph_missing_doc_returns_none(neo4j_driver):
|
||||||
|
|
|
||||||
|
|
@ -86,6 +86,8 @@ async def test_write_creates_expected_structure(neo4j_driver):
|
||||||
|
|
||||||
assert result.elements_written == 4
|
assert result.elements_written == 4
|
||||||
assert result.pages_written == 2
|
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:
|
async with neo4j_driver.driver.session(database=neo4j_driver.database) as s:
|
||||||
assert (
|
assert (
|
||||||
|
|
@ -131,16 +133,28 @@ async def test_write_creates_expected_structure(neo4j_driver):
|
||||||
)
|
)
|
||||||
== 3
|
== 3
|
||||||
)
|
)
|
||||||
# ON_PAGE: one per element with prov.
|
# Post-v0.6: ON_PAGE attaches Provenance to Page, not Element directly.
|
||||||
|
# Traverse through the Provenance node.
|
||||||
assert (
|
assert (
|
||||||
await _count(
|
await _count(
|
||||||
s,
|
s,
|
||||||
"MATCH (:Element {doc_id: $id})-[:ON_PAGE]->(:Page {doc_id: $id}) "
|
"MATCH (:Element {doc_id: $id})-[:HAS_PROV]->"
|
||||||
|
"(:Provenance)-[:ON_PAGE]->(:Page {doc_id: $id}) "
|
||||||
"RETURN count(*) AS n",
|
"RETURN count(*) AS n",
|
||||||
id="doc-fixture",
|
id="doc-fixture",
|
||||||
)
|
)
|
||||||
== 4
|
== 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):
|
async def test_rewrite_is_idempotent_replace(neo4j_driver):
|
||||||
|
|
|
||||||
|
|
@ -501,7 +501,8 @@ class TestRemoteChunkingPath:
|
||||||
markdown="# Title\nParagraph text here.",
|
markdown="# Title\nParagraph text here.",
|
||||||
html="<h1>Title</h1><p>Paragraph text here.</p>",
|
html="<h1>Title</h1><p>Paragraph text here.</p>",
|
||||||
pages_json="[]",
|
pages_json="[]",
|
||||||
document_json=json.dumps({
|
document_json=json.dumps(
|
||||||
|
{
|
||||||
"schema_name": "DoclingDocument",
|
"schema_name": "DoclingDocument",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"name": "test",
|
"name": "test",
|
||||||
|
|
@ -510,7 +511,11 @@ class TestRemoteChunkingPath:
|
||||||
"filename": "test.pdf",
|
"filename": "test.pdf",
|
||||||
"binary_hash": 0,
|
"binary_hash": 0,
|
||||||
},
|
},
|
||||||
"furniture": {"self_ref": "#/furniture", "children": [], "content_layer": "furniture"},
|
"furniture": {
|
||||||
|
"self_ref": "#/furniture",
|
||||||
|
"children": [],
|
||||||
|
"content_layer": "furniture",
|
||||||
|
},
|
||||||
"body": {"self_ref": "#/body", "children": [], "content_layer": "body"},
|
"body": {"self_ref": "#/body", "children": [], "content_layer": "body"},
|
||||||
"groups": [],
|
"groups": [],
|
||||||
"texts": [],
|
"texts": [],
|
||||||
|
|
@ -519,7 +524,8 @@ class TestRemoteChunkingPath:
|
||||||
"key_value_items": [],
|
"key_value_items": [],
|
||||||
"form_items": [],
|
"form_items": [],
|
||||||
"pages": {},
|
"pages": {},
|
||||||
}),
|
}
|
||||||
|
),
|
||||||
)
|
)
|
||||||
analysis_repo.find_by_id = AsyncMock(return_value=job)
|
analysis_repo.find_by_id = AsyncMock(return_value=job)
|
||||||
analysis_repo.update_chunks = AsyncMock(return_value=True)
|
analysis_repo.update_chunks = AsyncMock(return_value=True)
|
||||||
|
|
|
||||||
238
document-parser/tests/test_docling_graph.py
Normal file
238
document-parser/tests/test_docling_graph.py
Normal file
|
|
@ -0,0 +1,238 @@
|
||||||
|
"""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_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)
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
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'
|
||||||
|
|
|
||||||
18
frontend/package-lock.json
generated
18
frontend/package-lock.json
generated
|
|
@ -10,6 +10,8 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cytoscape": "^3.30.0",
|
"cytoscape": "^3.30.0",
|
||||||
"cytoscape-dagre": "^2.5.0",
|
"cytoscape-dagre": "^2.5.0",
|
||||||
|
"cytoscape-expand-collapse": "^4.1.1",
|
||||||
|
"cytoscape-navigator": "^2.0.2",
|
||||||
"dompurify": "^3.3.3",
|
"dompurify": "^3.3.3",
|
||||||
"marked": "^17.0.4",
|
"marked": "^17.0.4",
|
||||||
"pinia": "^2.3.0",
|
"pinia": "^2.3.0",
|
||||||
|
|
@ -1866,6 +1868,22 @@
|
||||||
"cytoscape": "^3.2.22"
|
"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/cytoscape-navigator": {
|
||||||
|
"version": "2.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/cytoscape-navigator/-/cytoscape-navigator-2.0.2.tgz",
|
||||||
|
"integrity": "sha512-TZFBLFWEMW858UOt4rzusOjtDj7YT5vNx2uCwpUuicUYbaWCHHcUROBZWO+hiuSPWpVhvGLFlOq3NBcAVYOAgw==",
|
||||||
|
"peerDependencies": {
|
||||||
|
"cytoscape": "^2.6.0 || ^3.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/dagre": {
|
"node_modules/dagre": {
|
||||||
"version": "0.8.5",
|
"version": "0.8.5",
|
||||||
"resolved": "https://registry.npmjs.org/dagre/-/dagre-0.8.5.tgz",
|
"resolved": "https://registry.npmjs.org/dagre/-/dagre-0.8.5.tgz",
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cytoscape": "^3.30.0",
|
"cytoscape": "^3.30.0",
|
||||||
"cytoscape-dagre": "^2.5.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",
|
||||||
|
|
@ -28,9 +29,9 @@
|
||||||
"@eslint/js": "^9.0.0",
|
"@eslint/js": "^9.0.0",
|
||||||
"@types/cytoscape": "^3.21.4",
|
"@types/cytoscape": "^3.21.4",
|
||||||
"@types/cytoscape-dagre": "^2.3.3",
|
"@types/cytoscape-dagre": "^2.3.3",
|
||||||
"@vitest/mocker": "^4.1.2",
|
|
||||||
"@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',
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,22 @@
|
||||||
import { apiFetch } from '../../shared/api/http'
|
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 {
|
export interface GraphNode {
|
||||||
id: string
|
id: string
|
||||||
group: 'document' | 'element' | 'page' | 'chunk'
|
group: 'document' | 'element' | 'page' | 'chunk'
|
||||||
|
|
@ -8,6 +25,8 @@ export interface GraphNode {
|
||||||
self_ref?: string
|
self_ref?: string
|
||||||
text?: string
|
text?: string
|
||||||
prov_page?: number | null
|
prov_page?: number | null
|
||||||
|
/** Full list of provenances (page + bbox) — used by the bbox-highlight UI. */
|
||||||
|
provs?: GraphProvenance[]
|
||||||
level?: number | null
|
level?: number | null
|
||||||
page_no?: number
|
page_no?: number
|
||||||
chunk_index?: number
|
chunk_index?: number
|
||||||
|
|
|
||||||
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
|
||||||
|
}
|
||||||
|
|
@ -18,26 +18,65 @@
|
||||||
{{ payload?.page_count }} pages
|
{{ payload?.page_count }} pages
|
||||||
</span>
|
</span>
|
||||||
<span class="graph-legend">
|
<span class="graph-legend">
|
||||||
<span class="legend-chip legend-document">Document</span>
|
<button
|
||||||
<span class="legend-chip legend-section">Section</span>
|
v-for="chip in LEGEND_CHIPS"
|
||||||
<span class="legend-chip legend-paragraph">Paragraph</span>
|
:key="chip.key"
|
||||||
<span class="legend-chip legend-table">Table</span>
|
type="button"
|
||||||
<span class="legend-chip legend-figure">Figure</span>
|
class="legend-chip"
|
||||||
<span class="legend-chip legend-page">Page</span>
|
:class="[`legend-${chip.key}`, { 'legend-off': hiddenChips.has(chip.key) }]"
|
||||||
<span class="legend-chip legend-chunk">Chunk</span>
|
: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>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="graph-body">
|
||||||
|
<div class="graph-canvas-wrap">
|
||||||
<div ref="containerRef" class="graph-canvas" data-e2e="graph-canvas" />
|
<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" @close="closeDetails" />
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import type { Core } from 'cytoscape'
|
||||||
import { onMounted, onBeforeUnmount, ref, watch, nextTick } from 'vue'
|
import { onMounted, onBeforeUnmount, ref, watch, nextTick } from 'vue'
|
||||||
import { useI18n } from '../../../shared/i18n'
|
import { useI18n } from '../../../shared/i18n'
|
||||||
import { fetchDocumentGraph, type GraphPayload } from '../graphApi'
|
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'
|
||||||
|
|
||||||
const props = defineProps<{ docId: string | null }>()
|
// `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 { t } = useI18n()
|
const { t } = useI18n()
|
||||||
|
|
||||||
const containerRef = ref<HTMLDivElement | null>(null)
|
const containerRef = ref<HTMLDivElement | null>(null)
|
||||||
|
|
@ -45,8 +84,22 @@ const payload = ref<GraphPayload | null>(null)
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const error = ref<string | null>(null)
|
const error = ref<string | null>(null)
|
||||||
const empty = ref(false)
|
const empty = ref(false)
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// Exposed via defineExpose so parent components (e.g. the reasoning trace
|
||||||
let cy: any | null = null
|
// 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)
|
||||||
|
|
||||||
|
// 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> = {
|
const NODE_COLORS: Record<string, string> = {
|
||||||
document: '#1E293B',
|
document: '#1E293B',
|
||||||
|
|
@ -87,7 +140,7 @@ async function load(): Promise<void> {
|
||||||
error.value = null
|
error.value = null
|
||||||
empty.value = false
|
empty.value = false
|
||||||
try {
|
try {
|
||||||
payload.value = await fetchDocumentGraph(props.docId)
|
payload.value = await props.fetcher(props.docId)
|
||||||
if (!payload.value.nodes.length) {
|
if (!payload.value.nodes.length) {
|
||||||
empty.value = true
|
empty.value = true
|
||||||
return
|
return
|
||||||
|
|
@ -96,6 +149,9 @@ async function load(): Promise<void> {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
await nextTick()
|
await nextTick()
|
||||||
await renderGraph()
|
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) {
|
} catch (e) {
|
||||||
error.value = (e as Error).message || 'Failed to load graph'
|
error.value = (e as Error).message || 'Failed to load graph'
|
||||||
console.error('Failed to load graph', e)
|
console.error('Failed to load graph', e)
|
||||||
|
|
@ -106,26 +162,47 @@ async function load(): Promise<void> {
|
||||||
|
|
||||||
async function renderGraph(): Promise<void> {
|
async function renderGraph(): Promise<void> {
|
||||||
if (!containerRef.value || !payload.value) return
|
if (!containerRef.value || !payload.value) return
|
||||||
// Dynamic import keeps cytoscape out of the main chunk.
|
// Dynamic imports keep the heavy Cytoscape bundle out of the main chunk.
|
||||||
const [{ default: cytoscape }, { default: dagre }] = await Promise.all([
|
const [{ default: cytoscape }, { default: dagre }, ecMod] = await Promise.all([
|
||||||
import('cytoscape'),
|
import('cytoscape'),
|
||||||
import('cytoscape-dagre'),
|
import('cytoscape-dagre'),
|
||||||
|
import('cytoscape-expand-collapse'),
|
||||||
])
|
])
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
;(cytoscape as any).use(dagre)
|
|
||||||
|
|
||||||
if (cy) {
|
const C = cytoscape as any
|
||||||
cy.destroy()
|
C.use(dagre)
|
||||||
cy = null
|
// 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).
|
||||||
|
const parentMap = mergeParentMaps(
|
||||||
|
explicitParentMap(payload.value.edges),
|
||||||
|
computeSectionParents(payload.value.nodes, payload.value.edges),
|
||||||
|
)
|
||||||
|
|
||||||
const elements = [
|
const elements = [
|
||||||
...payload.value.nodes.map((n) => ({
|
...payload.value.nodes.map((n) => ({
|
||||||
data: {
|
data: {
|
||||||
id: n.id,
|
id: n.id,
|
||||||
label: nodeLabel(n),
|
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),
|
bg: nodeColor(n),
|
||||||
group: n.group,
|
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: parentMap.get(n.id),
|
||||||
raw: n,
|
raw: n,
|
||||||
},
|
},
|
||||||
})),
|
})),
|
||||||
|
|
@ -139,7 +216,7 @@ async function renderGraph(): Promise<void> {
|
||||||
})),
|
})),
|
||||||
]
|
]
|
||||||
|
|
||||||
cy = cytoscape({
|
cy.value = cytoscape({
|
||||||
container: containerRef.value,
|
container: containerRef.value,
|
||||||
elements,
|
elements,
|
||||||
style: [
|
style: [
|
||||||
|
|
@ -200,8 +277,47 @@ async function renderGraph(): Promise<void> {
|
||||||
selector: 'edge[type = "DERIVED_FROM"]',
|
selector: 'edge[type = "DERIVED_FROM"]',
|
||||||
style: { 'line-color': '#DC2626', 'target-arrow-color': '#DC2626' },
|
style: { 'line-color': '#DC2626', 'target-arrow-color': '#DC2626' },
|
||||||
},
|
},
|
||||||
|
// Reasoning-trace overlay: visited-node class + synthetic REASONING_NEXT edges.
|
||||||
|
...reasoningOverlayStyles,
|
||||||
|
// Legend-driven visibility: chips toggle the `.hidden` class on
|
||||||
|
// matching nodes. `display: none` cascades to connected edges for free.
|
||||||
|
{ selector: 'node.hidden', style: { display: 'none' } },
|
||||||
|
// Compound nodes (section scope + explicit PARENT_OF containers). A
|
||||||
|
// node with :parent children is rendered as a bounding box here —
|
||||||
|
// visually groups the section's content together. Minimal padding so
|
||||||
|
// the layout stays compact.
|
||||||
|
{
|
||||||
|
selector: ':parent',
|
||||||
|
style: {
|
||||||
|
'background-opacity': 0.08,
|
||||||
|
'background-color': '#F97316',
|
||||||
|
'border-color': '#FDBA74',
|
||||||
|
'border-width': 1,
|
||||||
|
'padding-top': '12px',
|
||||||
|
'padding-bottom': '12px',
|
||||||
|
'padding-left': '12px',
|
||||||
|
'padding-right': '12px',
|
||||||
|
'text-valign': 'top',
|
||||||
|
'text-halign': 'center',
|
||||||
|
'text-margin-y': -4,
|
||||||
|
shape: 'round-rectangle',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// Click-selected node: stronger border so the user sees which one
|
||||||
|
// populated the details panel.
|
||||||
|
{
|
||||||
|
selector: 'node.nd-selected',
|
||||||
|
style: {
|
||||||
|
'border-width': 4,
|
||||||
|
'border-color': '#0EA5E9',
|
||||||
|
'overlay-color': '#0EA5E9',
|
||||||
|
'overlay-opacity': 0.12,
|
||||||
|
'overlay-padding': 4,
|
||||||
|
'z-index': 60,
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
layout: {
|
layout: {
|
||||||
name: 'dagre',
|
name: 'dagre',
|
||||||
rankDir: 'TB',
|
rankDir: 'TB',
|
||||||
|
|
@ -211,13 +327,117 @@ async function renderGraph(): Promise<void> {
|
||||||
} as any,
|
} as any,
|
||||||
wheelSensitivity: 0.15,
|
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')
|
||||||
|
})
|
||||||
|
// Click on background → close details panel.
|
||||||
|
cy.value.on('tap', (evt) => {
|
||||||
|
if (evt.target === cy.value) {
|
||||||
|
selectedNode.value = null
|
||||||
|
cy.value?.nodes('.nd-selected').removeClass('nd-selected')
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 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')
|
||||||
}
|
}
|
||||||
|
|
||||||
function disposeGraph(): void {
|
function disposeGraph(): void {
|
||||||
if (cy) {
|
if (cy.value) {
|
||||||
cy.destroy()
|
cy.value.destroy()
|
||||||
cy = null
|
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)
|
onMounted(load)
|
||||||
|
|
@ -229,6 +449,10 @@ watch(
|
||||||
load()
|
load()
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Let parent components observe the live Cytoscape instance (e.g. the
|
||||||
|
// reasoning-trace overlay reads it via `graphViewRef.value?.cy`).
|
||||||
|
defineExpose({ cy, load })
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
@ -267,6 +491,21 @@ watch(
|
||||||
padding: 2px 8px;
|
padding: 2px 8px;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
color: #f8fafc;
|
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 {
|
.legend-document {
|
||||||
|
|
@ -292,12 +531,43 @@ watch(
|
||||||
background: #dc2626;
|
background: #dc2626;
|
||||||
}
|
}
|
||||||
|
|
||||||
.graph-canvas {
|
.graph-body {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
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);
|
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 {
|
.graph-placeholder {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
|
||||||
278
frontend/src/features/analysis/ui/NodeDetailsPanel.vue
Normal file
278
frontend/src/features/analysis/ui/NodeDetailsPanel.vue
Normal file
|
|
@ -0,0 +1,278 @@
|
||||||
|
<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>
|
||||||
|
</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 }>()
|
||||||
|
defineEmits<{ close: [] }>()
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
</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;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
@ -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
|
||||||
|
|
@ -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', () => {
|
||||||
|
|
|
||||||
15
frontend/src/features/reasoning/api.ts
Normal file
15
frontend/src/features/reasoning/api.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
import { apiFetch } from '../../shared/api/http'
|
||||||
|
import type { GraphPayload } from '../analysis/graphApi'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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`)
|
||||||
|
}
|
||||||
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)
|
||||||
|
})
|
||||||
|
})
|
||||||
128
frontend/src/features/reasoning/store.ts
Normal file
128
frontend/src/features/reasoning/store.ts
Normal file
|
|
@ -0,0 +1,128 @@
|
||||||
|
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)
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 {
|
||||||
|
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
|
||||||
|
focusMode.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
// state
|
||||||
|
importDialogOpen,
|
||||||
|
rawResult,
|
||||||
|
envelope,
|
||||||
|
overlay,
|
||||||
|
activeIteration,
|
||||||
|
error,
|
||||||
|
focusMode,
|
||||||
|
// computed
|
||||||
|
hasTrace,
|
||||||
|
iterations,
|
||||||
|
presentCount,
|
||||||
|
missingCount,
|
||||||
|
// actions
|
||||||
|
openImportDialog,
|
||||||
|
closeImportDialog,
|
||||||
|
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
|
||||||
|
}
|
||||||
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>
|
||||||
172
frontend/src/features/reasoning/ui/IterationCard.vue
Normal file
172
frontend/src/features/reasoning/ui/IterationCard.vue
Normal file
|
|
@ -0,0 +1,172 @@
|
||||||
|
<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">{{ iteration.reason }}</p>
|
||||||
|
<p v-if="iteration.response && iteration.canAnswer" class="it-response">
|
||||||
|
{{ iteration.response }}
|
||||||
|
</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')
|
||||||
|
})
|
||||||
|
</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-response {
|
||||||
|
margin: 6px 0 0;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.45;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-style: italic;
|
||||||
|
padding: 6px 8px;
|
||||||
|
border-left: 2px solid #ea580c;
|
||||||
|
background: rgba(234, 88, 12, 0.04);
|
||||||
|
border-radius: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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>
|
||||||
352
frontend/src/features/reasoning/ui/ReasoningPanel.vue
Normal file
352
frontend/src/features/reasoning/ui/ReasoningPanel.vue
Normal file
|
|
@ -0,0 +1,352 @@
|
||||||
|
<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-converged" :class="{ yes: result.converged, no: !result.converged }">
|
||||||
|
{{ result.converged ? t('reasoning.converged') : t('reasoning.notConverged') }}
|
||||||
|
</span>
|
||||||
|
<span class="rp-stats">
|
||||||
|
{{ store.presentCount }} / {{ store.iterations.length }} {{ t('reasoning.resolved') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p class="rp-answer-text">{{ result.answer }}</p>
|
||||||
|
</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="onFocus"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<ImportTraceDialog />
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { Core } from 'cytoscape'
|
||||||
|
import { computed, watch } from 'vue'
|
||||||
|
|
||||||
|
import { useI18n } from '../../../shared/i18n'
|
||||||
|
import {
|
||||||
|
applyReasoningOverlay,
|
||||||
|
buildDegradedOverlay,
|
||||||
|
clearReasoningOverlay,
|
||||||
|
focusIteration,
|
||||||
|
nodeIdForSectionRef,
|
||||||
|
} 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
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const store = useReasoningStore()
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const result = computed(() => store.rawResult)
|
||||||
|
const envelope = computed(() => store.envelope)
|
||||||
|
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 onFocus(iteration: number): void {
|
||||||
|
store.setActiveIteration(iteration)
|
||||||
|
if (!props.cy) return
|
||||||
|
const hit = store.iterations.find((i) => i.iteration === iteration)
|
||||||
|
if (!hit || !hit.present) return
|
||||||
|
focusIteration(props.cy, nodeIdForSectionRef(hit.sectionRef))
|
||||||
|
}
|
||||||
|
|
||||||
|
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: 6px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: var(--accent-muted, rgba(234, 88, 12, 0.04));
|
||||||
|
border: 1px solid var(--border-light);
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rp-answer-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
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-stats {
|
||||||
|
font-size: 10px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-family: 'IBM Plex Mono', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rp-answer-text {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: var(--text);
|
||||||
|
white-space: pre-wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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>
|
||||||
130
frontend/src/features/reasoning/ui/ReasoningWorkspace.vue
Normal file
130
frontend/src/features/reasoning/ui/ReasoningWorkspace.vue
Normal file
|
|
@ -0,0 +1,130 @@
|
||||||
|
<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>
|
||||||
|
<button
|
||||||
|
class="rw-action-btn"
|
||||||
|
data-e2e="reasoning-workspace-import"
|
||||||
|
@click="reasoningStore.openImportDialog()"
|
||||||
|
>
|
||||||
|
{{ t('reasoning.importBtn') }}
|
||||||
|
</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="rw-body">
|
||||||
|
<GraphView ref="graphViewRef" :doc-id="docId" :fetcher="fetchReasoningGraph" />
|
||||||
|
<ReasoningPanel :cy="graphCy" />
|
||||||
|
</div>
|
||||||
|
</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 { useReasoningStore } from '../store'
|
||||||
|
import ReasoningPanel from './ReasoningPanel.vue'
|
||||||
|
|
||||||
|
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)
|
||||||
|
|
||||||
|
// Reset the reasoning store when switching docs — a trace imported for one
|
||||||
|
// document is meaningless on another.
|
||||||
|
watch(
|
||||||
|
() => props.docId,
|
||||||
|
() => reasoningStore.reset(),
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
.rw-body {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rw-body > :deep(.graph-view) {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
</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>
|
||||||
|
|
@ -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',
|
||||||
|
|
@ -84,6 +85,12 @@ const messages: Messages = {
|
||||||
'results.graph': 'Graphe',
|
'results.graph': 'Graphe',
|
||||||
'results.graphLoading': 'Chargement du graphe…',
|
'results.graphLoading': 'Chargement du graphe…',
|
||||||
'results.graphEmpty': 'Pas encore de graphe pour ce document (activez Neo4j).',
|
'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})',
|
||||||
'results.retry': 'Réessayer',
|
'results.retry': 'Réessayer',
|
||||||
'results.pageOf': 'Page {current} sur {total}',
|
'results.pageOf': 'Page {current} sur {total}',
|
||||||
'results.noElements': 'Aucun élément détecté sur cette page',
|
'results.noElements': 'Aucun élément détecté sur cette page',
|
||||||
|
|
@ -115,6 +122,63 @@ const messages: Messages = {
|
||||||
'studio.prepare': 'Préparer',
|
'studio.prepare': 'Préparer',
|
||||||
'studio.ingest': 'Ingérer',
|
'studio.ingest': 'Ingérer',
|
||||||
'studio.maintain': 'Maintenir',
|
'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.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.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.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',
|
||||||
|
|
@ -194,6 +258,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',
|
||||||
|
|
@ -261,6 +326,12 @@ const messages: Messages = {
|
||||||
'results.graph': 'Graph',
|
'results.graph': 'Graph',
|
||||||
'results.graphLoading': 'Loading graph…',
|
'results.graphLoading': 'Loading graph…',
|
||||||
'results.graphEmpty': 'No graph yet for this document (enable Neo4j).',
|
'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})',
|
||||||
'results.retry': 'Retry',
|
'results.retry': 'Retry',
|
||||||
'results.pageOf': 'Page {current} of {total}',
|
'results.pageOf': 'Page {current} of {total}',
|
||||||
'results.noElements': 'No elements detected on this page',
|
'results.noElements': 'No elements detected on this page',
|
||||||
|
|
@ -289,6 +360,61 @@ const messages: Messages = {
|
||||||
'studio.prepare': 'Prepare',
|
'studio.prepare': 'Prepare',
|
||||||
'studio.ingest': 'Ingest',
|
'studio.ingest': 'Ingest',
|
||||||
'studio.maintain': 'Maintain',
|
'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.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.analyzing': 'Analyzing document...',
|
||||||
|
'reasoning.analyzingHint':
|
||||||
|
'Docling is parsing the PDF with default settings. May take 1–3 minutes depending on size.',
|
||||||
|
'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',
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue