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
247 lines
8.2 KiB
TypeScript
247 lines
8.2 KiB
TypeScript
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 { ReasoningResult } 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<ReasoningResult['iterations'][number]>>,
|
|
): ReasoningResult {
|
|
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()
|
|
})
|
|
})
|