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)
84 lines
2.4 KiB
TypeScript
84 lines
2.4 KiB
TypeScript
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)
|
|
})
|
|
})
|