docling-studio/frontend/src/features/reasoning/graphReasoningOverlay.ts
Pier-Jean Malandrino 8fe24a8efc refactor(audit): remediate 0.5.0 audit findings — clean architecture, security, DRY, SOLID, perf
Closes the 12 MAJ raised by the release/0.5.0 audit pipeline (cf.
docs/audit/reports/release-0.5.0/summary.md → summary-reaudit.md).

Volet 1 — Reasoning architecture (audits 01/02/06/07 strengthening)
  * Domain ports: LLMProvider, ReasoningRunner, ReasoningParseError
  * Domain DTOs: LLMProviderType, ReasoningResult, ReasoningIteration
  * infra/llm/ollama_provider.py — OllamaProvider with health_check
  * infra/docling_agent_reasoning.py — runner adapter, encapsulates the
    private _rag_loop call (tracked at docling-project/docling-agent#26),
    commits OLLAMA_HOST once at boot (eliminates the per-request env race),
    translates upstream IndexError into ReasoningParseError
  * api/reasoning.py — zero coupling to docling-agent / mellea / docling-core,
    consumes app.state.reasoning_runner via the port
  * main.py — DI wires OllamaProvider + DoclingAgentReasoningRunner at boot
    when REASONING_ENABLED=true and deps are importable
  * Rename RAG_* env vars → REASONING_*, endpoint /rag → /reasoning,
    type RAGResult → ReasoningResult, frontend feature flag wiring,
    i18n strings, tests, docs (BREAKING — pre-1.0 surface, no external
    consumers in production)
  * 17 new tests: adapter unit tests with sys.modules stubs, OllamaProvider
    httpx tests, R3 concurrent-host isolation, R6 multi-iteration trace
    serialization, R13 Protocol conformance via isinstance
  * E2E Karate scenario: nav-reasoning hidden when REASONING_ENABLED=false
  * README — Live Reasoning section (env vars, archi, link to issue #26)

Bloc B — Security (audit 08, dev-only context)
  * docker-compose.yml — DEV DEFAULTS header, OpenSearch DISABLE_SECURITY_PLUGIN
    flagged as dev-only with link to OpenSearch security docs
  * main.py — boot warning if NEO4J_URI is set with the default 'changeme'
    password, so prod operators can't silently inherit it

Bloc C — DRY frontend (audit 05)
  * shared/storage/keys.ts — STORAGE_KEYS centralised (theme, locale)
  * features/settings/store.ts — dead apiUrl ref + orphan i18n keys removed
  * api/schemas.py — DOCUMENT_STATUS_UPLOADED constant

Bloc D — Quality (audits 02/06/07/09/10/12)
  * domain/ports.py — DocumentConverter.supports_page_batching property
    (LSP fix, replaces isinstance(ServeConverter) check)
  * domain/ports.py — VectorStore.ping() (encapsulation, replaces
    _vector_store._client.info() reach-around)
  * api/analyses.py + api/ingestion.py — path params {job_id} → {analysis_id}
    aligned with the user-facing terminology (URLs unchanged)
  * api/documents.py — Path.read_bytes() + generate_preview() wrapped in
    asyncio.to_thread, unblocks the FastAPI event loop on /preview
  * infra/docling_tree.py — PEP 604 union for isinstance (Ruff UP038)
  * src/__tests__/integration/ — cross-feature integration test relocated
    out of features/history/ so feature folders stay self-contained
  * Tightened terminal `assert X is not None` checks (isinstance(.., datetime),
    exact value comparisons)

Validation
  * 446 backend pytest, 202 frontend vitest — all green
  * ruff + ruff format + ESLint + Prettier + vue-tsc clean
  * Re-audit verdict: 0 CRIT / 0 MAJ, score ~94/100, GO

Closes #200
2026-04-29 09:20:19 +02:00

278 lines
8.8 KiB
TypeScript

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