docling-studio/frontend/src/features/reasoning/ui/IterationCard.vue
Pier-Jean Malandrino bef7ec4686 feat(reasoning): live docling-agent runner + UX polish
Backend — live runner
- New `POST /api/documents/:id/rag` endpoint. Loads `document_json` from
  SQLite, reconstructs the DoclingDocument, wraps the model id in
  `ModelIdentifier(ollama_name=...)`, and calls `agent._rag_loop`
  off-thread (blocking sync call). Returns a `RAGResult` in the shape
  the existing v1 import path already consumes, so the frontend overlay
  is fully reused.
- `_rag_loop` is private upstream; we call it because `run()` wraps the
  answer in a synthetic DoclingDocument and drops the iteration trace.
- Settings: `RAG_ENABLED`, `OLLAMA_HOST`, `RAG_MODEL_ID`. Router mounts
  unconditionally; handler 503s when the flag is off or deps aren't
  installed. `rag_available` surfaced in `/api/health`.
- Maps known docling-agent bugs to readable HTTP errors: 502 with
  "the model couldn't produce a parseable answer" when `_rag_loop`
  raises `IndexError` from `find_json_dicts([])[0]` after 3 + 3
  rejection-sampling retries (model-dependent).
- Tests: 11 cases (flag off, query empty, no analysis, happy path,
  model_id wrap, Ollama env, IndexError → 502, other errors → 500,
  deps missing → 503).

Backend — bug fix
- Default `BATCH_PAGE_SIZE` flipped from `10` to `0` to match the
  dataclass default. The old default silently dropped `document_json`
  (see `domain/services.merge_results`) for any doc > 10 pages, which
  broke the reasoning tunnel. Set `BATCH_PAGE_SIZE>0` explicitly on
  memory-constrained deploys if batching is wanted.

Frontend — runner UX
- `features/reasoning/api.ts:runReasoning()` — POST wrapper.
- `RunReasoningDialog.vue` — query textarea + optional model_id
  override. Blocks close while running, 20-40s loading state,
  synthesises a sidecar-shaped envelope so the panel surfaces query +
  model the same way an imported trace would.
- `ReasoningWorkspace.vue` — primary "Run reasoning" button; "Import
  trace" relegated to ghost secondary.
- Store: `runDialogOpen`, `running`, `setRunning`.

Frontend — answer polish
- Answer rendered through `marked` + DOMPurify (models emit markdown
  lists; `pre-wrap` rendered them as plain "1. …" strings).
- Dedicated answer block with orange border, "ANSWER" label, "Copy"
  button (clipboard + "Copied ✓" feedback).
- IterationCard: drop the duplicate `response` block (the main answer
  is authoritative); style reasons equal to `"fallback"` (docling-agent
  `select_from_failure` placeholder) as italic muted "— no structured
  rationale".

Frontend — node details contents
- Clicking a SectionHeader (or any node with compound children) lists
  its contained elements in `NodeDetailsPanel` under a new "Contents"
  block. Children come from the same `parentMap` used for Cytoscape
  compound parenting (explicit PARENT_OF + synthetic section scope),
  inverted once and cached as a computed.
- Click a child row → pan the viewport to it + swap the selection.

Housekeeping
- `cytoscape-navigator` removed from `package-lock.json` (follow-up
  from the minimap removal in the previous commit).
2026-04-21 17:11:54 +02:00

173 lines
3.8 KiB
Vue

<template>
<button
type="button"
class="it-card"
:class="{ active, missing: !iteration.present, converged: iteration.canAnswer }"
:data-e2e="`reasoning-iteration-${iteration.iteration}`"
@click="$emit('focus', iteration.iteration)"
>
<div class="it-row">
<span class="it-badge">{{ iteration.iteration }}</span>
<span class="it-ref" :title="iteration.sectionRef">{{ iteration.sectionRef }}</span>
<span
class="it-status"
:class="{
ok: iteration.canAnswer,
more: !iteration.canAnswer && iteration.present,
missing: !iteration.present,
}"
>
{{ statusLabel }}
</span>
</div>
<p v-if="iteration.reason" class="it-reason" :class="{ placeholder: isPlaceholderReason }">
{{ isPlaceholderReason ? t('reasoning.reasonPlaceholder') : iteration.reason }}
</p>
<div class="it-meta">
<span v-if="iteration.sectionTextLength">
{{ t('reasoning.charsLabel').replace('{n}', String(iteration.sectionTextLength)) }}
</span>
</div>
</button>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from '../../../shared/i18n'
import type { ResolvedIteration } from '../types'
const props = defineProps<{
iteration: ResolvedIteration
active: boolean
}>()
defineEmits<{ focus: [iteration: number] }>()
const { t } = useI18n()
const statusLabel = computed(() => {
if (!props.iteration.present) return t('reasoning.statusMissing')
if (props.iteration.canAnswer) return t('reasoning.statusAnswered')
return t('reasoning.statusMore')
})
// docling-agent emits the literal string "fallback" for `reason` when its
// `select_from_failure` branch runs (the model's structured output didn't
// parse N times in a row). Don't show that noise — render a dash-style
// placeholder the user can visually skip.
const isPlaceholderReason = computed(() => {
const r = (props.iteration.reason || '').trim().toLowerCase()
return r === '' || r === 'fallback'
})
</script>
<style scoped>
.it-card {
display: block;
width: 100%;
text-align: left;
background: var(--bg);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 10px 12px;
cursor: pointer;
transition: all var(--transition);
font-family: inherit;
color: inherit;
}
.it-card:hover {
border-color: var(--accent);
background: var(--accent-muted);
}
.it-card.active {
border-color: #ea580c;
box-shadow: 0 0 0 2px rgba(234, 88, 12, 0.2);
background: rgba(234, 88, 12, 0.06);
}
.it-card.missing {
opacity: 0.6;
}
.it-row {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
}
.it-badge {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border-radius: 50%;
background: #ea580c;
color: #ffffff;
font-weight: 700;
font-size: 11px;
}
.it-card.missing .it-badge {
background: var(--text-muted);
}
.it-ref {
font-family: 'IBM Plex Mono', monospace;
font-size: 11px;
color: var(--text-secondary);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1 1 auto;
}
.it-status {
flex: 0 0 auto;
font-size: 10px;
font-weight: 600;
padding: 2px 8px;
border-radius: 10px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.it-status.ok {
background: rgba(22, 163, 74, 0.15);
color: #15803d;
}
.it-status.more {
background: rgba(234, 179, 8, 0.15);
color: #a16207;
}
.it-status.missing {
background: var(--border-light);
color: var(--text-muted);
}
.it-reason {
margin: 8px 0 0;
font-size: 12px;
line-height: 1.45;
color: var(--text);
}
.it-reason.placeholder {
color: var(--text-muted);
font-style: italic;
}
.it-meta {
margin-top: 6px;
font-size: 10px;
color: var(--text-muted);
font-family: 'IBM Plex Mono', monospace;
}
</style>