recon + sqlglot validator + drill_down package; guard ReAct dimension picks against candidate list

This commit is contained in:
2026-06-03 07:15:02 -03:00
parent e124a8a7d9
commit 2dad62f7e7
38 changed files with 1954 additions and 596 deletions

View File

@@ -1,19 +1,19 @@
<script setup lang="ts">
import { nextTick, ref, watch } from 'vue'
import { computed, nextTick, ref, watch } from 'vue'
import { Panel } from 'soleprint-ui'
import { useRunStream } from '../composables/useRunStream'
import { useRunStream, type GraphNode, type ToolCall } from '../composables/useRunStream'
const question = ref('How many loans are currently in debt (status D)?')
const examples = [
'How many loans are currently in debt (status D)?',
'Which districts have the highest average loan amount?',
'Compare loan default rates between 1995 and 1996.',
'What share of accounts have at least one credit card?',
'Which dimensions best explain regional differences in loan default rates?',
"What's driving variation in average loan size across districts?",
]
const {
status, runId, rationale, planSteps, graphNodes, log,
toolCalls, answer, error, isRunning, ask,
status, runId, rationale, graphNodes, log,
toolCalls, subEventsByAnalysis, answer, error, isRunning, ask,
} = useRunStream()
const wrapLog = ref(true)
@@ -27,6 +27,11 @@ function toggleNode(id: string) {
expanded.value = s
}
// Trace pane grows to 2/3 when the run gets visually dense.
const traceDense = computed(() =>
graphNodes.value.length > 4 || toolCalls.value.length > 6,
)
watch(() => log.value.length, async () => {
await nextTick()
if (logEl.value) logEl.value.scrollTop = logEl.value.scrollHeight
@@ -47,10 +52,63 @@ function statusOf(s: string): 'idle' | 'processing' | 'live' | 'error' {
if (s === 'error') return 'error'
return 'idle'
}
function focusNode(id: string | null) {
if (!id) return
if (!expanded.value.has(id)) toggleNode(id)
nextTick(() => {
const el = document.getElementById(`node-${id}`)
el?.scrollIntoView({ behavior: 'smooth', block: 'center' })
el?.classList.add('flash')
setTimeout(() => el?.classList.remove('flash'), 1200)
})
}
function focusFirstTool(id: string) {
const first = toolCalls.value.findIndex(c => c.analysisId === id)
if (first < 0) return
nextTick(() => {
const el = document.getElementById(`tc-${first}`)
el?.scrollIntoView({ behavior: 'smooth', block: 'center' })
el?.classList.add('flash')
setTimeout(() => el?.classList.remove('flash'), 1200)
})
}
function toolIndex(call: ToolCall): number {
return toolCalls.value.indexOf(call)
}
function subEvents(nodeId: string): ToolCall[] {
return subEventsByAnalysis.value[nodeId] || []
}
function shortPreview(call: ToolCall): string {
if (call.tool === 'text_to_sql' && call.output?.sql) return call.output.sql
if (call.tool === 'execute_sql' && call.input?.sql) return call.input.sql
if (call.tool === 'generate_pair' && call.output) {
return `A: ${call.output.a?.label}\n${call.output.a?.sql}\n\nB: ${call.output.b?.label}\n${call.output.b?.sql}`
}
return ''
}
function isExpandable(n: GraphNode): boolean {
return !!n.detail || subEvents(n.id).length > 0
}
function nodeClasses(n: GraphNode) {
return [
'node', n.kind, n.status,
{
expanded: expanded.value.has(n.id),
expandable: isExpandable(n),
},
]
}
</script>
<template>
<div class="ask-layout">
<div :class="['ask-layout', { dense: traceDense }]">
<!-- Left / top: ask + answer (the user's surface) -->
<div class="ask-pane">
<Panel title="Ask" :status="statusOf(status)">
@@ -87,25 +145,43 @@ function statusOf(s: string): 'idle' | 'processing' | 'live' | 'error' {
<div class="graph">
<div v-for="(n, idx) in graphNodes" :key="n.id" class="graph-row">
<span class="connector" v-if="idx > 0"></span>
<span v-if="idx > 0 && n.kind !== 'fallback'" class="connector"></span>
<span v-else-if="n.kind === 'fallback'" class="connector fallback-arrow"> fallback</span>
<div
:class="['node', n.kind, n.status, {
expanded: expanded.has(n.id),
expandable: !!n.detail,
}]"
@click="n.detail && toggleNode(n.id)"
:role="n.detail ? 'button' : undefined"
:tabindex="n.detail ? 0 : undefined"
@keydown.enter="n.detail && toggleNode(n.id)"
@keydown.space.prevent="n.detail && toggleNode(n.id)"
:id="`node-${n.id}`"
:class="nodeClasses(n)"
@click="isExpandable(n) && toggleNode(n.id)"
:role="isExpandable(n) ? 'button' : undefined"
:tabindex="isExpandable(n) ? 0 : undefined"
>
<div class="node-head">
<span class="chev" v-if="n.detail">{{ expanded.has(n.id) ? '' : '' }}</span>
<span v-if="isExpandable(n)" class="chev">
{{ expanded.has(n.id) ? '' : '' }}
</span>
<span class="dot" />
<span class="label">{{ n.label }}</span>
<span v-if="n.kind === 'analysis' && subEvents(n.id).length" class="step-count">
{{ subEvents(n.id).length }} call(s)
</span>
<span v-if="n.detail && !expanded.has(n.id)" class="detail-preview">{{ n.detail }}</span>
<button v-if="n.kind === 'analysis' && subEvents(n.id).length"
class="goto" @click.stop="focusFirstTool(n.id)"
title="Jump to tool call list"></button>
</div>
<div v-if="expanded.has(n.id)" class="node-body">
<div v-if="n.detail" class="detail-full">{{ n.detail }}</div>
<div v-if="subEvents(n.id).length" class="subgraph">
<div class="subgraph-label">tool calls</div>
<div v-for="c in subEvents(n.id)" :key="toolIndex(c)"
:class="['sub-node', c.error ? 'error' : (c.t_ended ? 'done' : 'running')]"
@click.stop="document.getElementById(`tc-${toolIndex(c)}`)?.scrollIntoView({ behavior: 'smooth', block: 'center' })">
<span class="sub-dot" />
<span class="sub-tool">{{ c.tool }}</span>
<span class="sub-time">{{ c.t_ended ? `${c.t_ended - c.t_started}ms` : '…' }}</span>
</div>
</div>
</div>
<div v-if="n.detail && expanded.has(n.id)" class="detail-full">{{ n.detail }}</div>
</div>
</div>
</div>
@@ -113,16 +189,19 @@ function statusOf(s: string): 'idle' | 'processing' | 'live' | 'error' {
<Panel title="Tool calls" class="tools-panel">
<div v-if="toolCalls.length === 0" class="muted small">No tool calls yet.</div>
<div v-for="(t, i) in toolCalls" :key="i" :class="['tool', t.error ? 'err' : (t.t_ended ? 'done' : 'pending')]">
<div v-for="(t, i) in toolCalls" :key="i"
:id="`tc-${i}`"
:class="['tool', t.error ? 'err' : (t.t_ended ? 'done' : 'pending')]">
<div class="tool-head">
<span class="tool-name">{{ t.tool }}</span>
<span v-if="t.analysisId" class="tool-chip"
@click="focusNode(t.analysisId)"
:title="`Jump to ${t.analysisId} node`">
{{ t.analysisId }}
</span>
<span class="tool-name">· {{ t.tool }}</span>
<span class="tool-time">{{ t.t_ended ? `${t.t_ended - t.t_started}ms` : '…' }}</span>
</div>
<pre v-if="t.tool === 'text_to_sql' && t.output?.sql" class="tool-body sql">{{ t.output.sql }}</pre>
<pre v-else-if="t.tool === 'execute_sql' && t.input?.sql" class="tool-body sql">{{ t.input.sql }}</pre>
<pre v-else-if="t.tool === 'generate_pair' && t.output" class="tool-body sql">A ({{ t.output.a?.label }}): {{ t.output.a?.sql }}
B ({{ t.output.b?.label }}): {{ t.output.b?.sql }}</pre>
<pre v-if="shortPreview(t)" class="tool-body sql">{{ shortPreview(t) }}</pre>
<pre v-if="t.output?.preview && t.output.preview.length" class="tool-body rows">{{ JSON.stringify(t.output.preview, null, 2) }}</pre>
<div v-if="t.error" class="tool-error">{{ t.error }}</div>
</div>
@@ -156,15 +235,13 @@ B ({{ t.output.b?.label }}): {{ t.output.b?.sql }}</pre>
gap: 16px;
height: calc(100vh - 80px);
min-height: 0;
transition: grid-template-columns 0.25s ease;
}
.ask-layout.dense { grid-template-columns: 1fr 2fr; }
.ask-pane,
.trace-pane {
display: flex;
flex-direction: column;
gap: 12px;
overflow: auto;
min-height: 0;
.ask-pane, .trace-pane {
display: flex; flex-direction: column; gap: 12px;
overflow: auto; min-height: 0;
}
.trace-pane {
@@ -172,161 +249,69 @@ B ({{ t.output.b?.label }}): {{ t.output.b?.sql }}</pre>
padding-left: 16px;
}
.trace-pane > .tools-panel {
flex: 1;
min-height: 0;
}
.trace-pane > .log-panel {
flex: 1;
min-height: 200px;
}
.trace-pane > .tools-panel { flex: 1; min-height: 0; }
.trace-pane > .log-panel { flex: 1; min-height: 200px; }
/* ── Ask form ── */
.ask-form {
padding: 12px;
display: flex;
flex-direction: column;
gap: 10px;
}
.ask-form { padding: 12px; display: flex; flex-direction: column; gap: 10px; }
textarea {
width: 100%;
background: var(--surface-2);
color: var(--text-primary);
border: var(--panel-border);
padding: 10px;
font-family: var(--font-mono);
font-size: 13px;
resize: vertical;
width: 100%; background: var(--surface-2); color: var(--text-primary);
border: var(--panel-border); padding: 10px;
font-family: var(--font-mono); font-size: 13px; resize: vertical;
}
.ask-actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.hint {
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-dim);
}
.ask-actions { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.hint { font-family: var(--font-mono); font-size: 11px; color: var(--text-dim); }
.run-btn {
background: var(--accent);
color: white;
border: none;
padding: 6px 18px;
font-family: var(--font-mono);
font-size: 12px;
cursor: pointer;
background: var(--accent); color: white; border: none;
padding: 6px 18px; font-family: var(--font-mono); font-size: 12px; cursor: pointer;
}
.run-btn:hover { background: var(--accent-dim); }
.run-btn:disabled { opacity: 0.5; cursor: not-allowed; }
.examples {
display: flex;
flex-direction: column;
gap: 4px;
}
.examples { display: flex; flex-direction: column; gap: 4px; }
.example {
text-align: left;
background: transparent;
border: none;
border-left: 2px solid var(--surface-3);
padding: 4px 10px;
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-dim);
cursor: pointer;
}
.example:hover {
color: var(--text-primary);
border-left-color: var(--accent);
text-align: left; background: transparent; border: none;
border-left: 2px solid var(--surface-3); padding: 4px 10px;
font-family: var(--font-mono); font-size: 11px;
color: var(--text-dim); cursor: pointer;
}
.example:hover { color: var(--text-primary); border-left-color: var(--accent); }
/* ── Answer block ── */
.answer-block {
padding: 16px;
font-size: 14px;
color: var(--text-primary);
line-height: 1.6;
}
.error-block {
padding: 16px;
color: var(--status-error, #f55);
font-family: var(--font-mono);
font-size: 12px;
}
.muted {
padding: 16px;
color: var(--text-dim);
font-family: var(--font-mono);
font-size: 12px;
}
/* ── Answer ── */
.answer-block { padding: 16px; font-size: 14px; color: var(--text-primary); line-height: 1.6; }
.error-block { padding: 16px; color: var(--status-error, #f55); font-family: var(--font-mono); font-size: 12px; }
.muted { padding: 16px; color: var(--text-dim); font-family: var(--font-mono); font-size: 12px; }
.muted.small { padding: 8px 12px; font-size: 11px; }
/* ── Plan + graph ── */
.rationale {
padding: 10px 12px;
font-size: 12px;
color: var(--text-secondary);
border-bottom: 1px solid var(--surface-3);
font-style: italic;
padding: 10px 12px; font-size: 12px; color: var(--text-secondary);
border-bottom: 1px solid var(--surface-3); font-style: italic;
}
.graph { display: flex; flex-direction: column; padding: 10px 12px; gap: 2px; }
.graph-row { display: flex; flex-direction: column; align-items: flex-start; }
.connector { font-family: var(--font-mono); color: var(--text-dim); font-size: 10px; margin-left: 6px; }
.connector.fallback-arrow { color: var(--status-processing, #ffc107); }
.graph {
display: flex;
flex-direction: column;
padding: 10px 12px;
gap: 2px;
}
.graph-row {
display: flex;
flex-direction: column;
align-items: flex-start;
}
.connector {
font-family: var(--font-mono);
color: var(--text-dim);
font-size: 10px;
margin-left: 6px;
}
.node {
display: flex;
flex-direction: column;
display: flex; flex-direction: column;
padding: 6px 10px;
background: var(--surface-2);
border: var(--panel-border);
font-family: var(--font-mono);
font-size: 12px;
width: 100%;
box-sizing: border-box;
background: var(--surface-2); border: var(--panel-border);
font-family: var(--font-mono); font-size: 12px;
width: 100%; box-sizing: border-box;
transition: background 0.2s ease, opacity 0.2s ease, box-shadow 0.4s ease;
}
.node.expandable { cursor: pointer; }
.node.expandable:hover { background: var(--surface-3, #1a2340); }
.node.expandable:focus-visible { outline: 1px solid var(--accent); outline-offset: -1px; }
.node.skipped { opacity: 0.4; }
.node.fallback { border-left: 2px solid var(--status-processing, #ffc107); }
.node.flash { box-shadow: 0 0 0 2px var(--accent); }
.node-head {
display: flex;
align-items: center;
gap: 10px;
}
.node .chev {
color: var(--text-dim);
font-size: 10px;
width: 10px;
flex-shrink: 0;
}
.node-head { display: flex; align-items: center; gap: 10px; }
.node .chev { color: var(--text-dim); font-size: 10px; width: 10px; flex-shrink: 0; }
.node .dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--status-idle, #555);
flex-shrink: 0;
width: 8px; height: 8px; border-radius: 50%;
background: var(--status-idle, #555); flex-shrink: 0;
}
.node.running .dot {
background: var(--status-processing, #ffc107);
@@ -335,124 +320,111 @@ textarea {
}
.node.done .dot { background: var(--status-live, #00c853); }
.node.error .dot { background: var(--status-error, #ef4444); }
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
@keyframes pulse { 0%,100%{opacity:1;} 50%{opacity:0.5;} }
.node .label { font-weight: 500; }
.node .step-count { font-size: 10px; color: var(--text-dim); margin-left: 4px; }
.node .detail-preview {
margin-left: auto;
color: var(--text-dim);
font-size: 11px;
text-align: right;
max-width: 60%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-left: auto; color: var(--text-dim);
font-size: 11px; text-align: right; max-width: 60%;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.node .detail-full {
margin-top: 6px;
padding: 6px 0 2px 20px;
color: var(--text-secondary);
font-size: 11px;
line-height: 1.5;
white-space: pre-wrap;
word-break: break-word;
.node .goto {
margin-left: auto;
background: transparent; border: 1px solid var(--surface-3); color: var(--text-dim);
width: 22px; height: 18px; font-size: 11px; cursor: pointer; padding: 0; line-height: 1;
}
.node .goto:hover { color: var(--accent); border-color: var(--accent); }
.node .detail-preview + .goto { margin-left: 8px; }
.node-body {
margin-top: 6px; padding-top: 6px;
border-top: 1px dashed var(--surface-3);
}
.detail-full {
padding: 4px 0 8px 20px;
color: var(--text-secondary); font-size: 11px; line-height: 1.5;
white-space: pre-wrap; word-break: break-word;
}
.subgraph { padding-left: 20px; }
.subgraph-label {
font-size: 10px; color: var(--text-dim); text-transform: uppercase;
letter-spacing: 0.5px; margin-bottom: 4px;
}
.sub-node {
display: flex; align-items: center; gap: 8px;
padding: 3px 8px; background: var(--surface-3, #1a2340);
border-left: 2px solid var(--surface-3); cursor: pointer;
font-size: 11px; margin-bottom: 2px;
}
.sub-node:hover { background: var(--surface-2); }
.sub-node.done { border-left-color: var(--status-live, #00c853); }
.sub-node.running { border-left-color: var(--status-processing, #ffc107); }
.sub-node.error { border-left-color: var(--status-error, #ef4444); }
.sub-node .sub-dot {
width: 6px; height: 6px; border-radius: 50%; background: var(--text-dim);
}
.sub-node.done .sub-dot { background: var(--status-live, #00c853); }
.sub-node.running .sub-dot { background: var(--status-processing, #ffc107); }
.sub-node.error .sub-dot { background: var(--status-error, #ef4444); }
.sub-tool { color: var(--text-secondary); }
.sub-time { margin-left: auto; color: var(--text-dim); font-size: 10px; }
/* ── Tool call cards ── */
.tool {
margin: 8px;
border: var(--panel-border);
border-left-width: 3px;
margin: 8px; border: var(--panel-border); border-left-width: 3px;
background: var(--surface-2);
transition: box-shadow 0.4s ease;
}
.tool.flash { box-shadow: 0 0 0 2px var(--accent); }
.tool.done { border-left-color: var(--status-live, #00c853); }
.tool.pending { border-left-color: var(--status-processing, #ffc107); }
.tool.err { border-left-color: var(--status-error, #ef4444); }
.tool-head {
display: flex;
justify-content: space-between;
display: flex; align-items: center; gap: 6px;
padding: 6px 10px;
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-secondary);
font-family: var(--font-mono); font-size: 11px; color: var(--text-secondary);
border-bottom: 1px solid var(--surface-3);
}
.tool-name { font-weight: 600; }
.tool-time { color: var(--text-dim); }
.tool-chip {
background: var(--surface-3); color: var(--text-primary);
padding: 1px 6px; border-radius: 2px;
cursor: pointer; font-weight: 600;
}
.tool-chip:hover { background: var(--accent-dim); color: white; }
.tool-name { color: var(--text-dim); }
.tool-time { margin-left: auto; color: var(--text-dim); }
.tool-body {
margin: 0;
padding: 8px 10px;
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-secondary);
white-space: pre-wrap;
word-break: break-word;
max-height: 200px;
overflow: auto;
margin: 0; padding: 8px 10px;
font-family: var(--font-mono); font-size: 11px; color: var(--text-secondary);
white-space: pre-wrap; word-break: break-word;
max-height: 200px; overflow: auto;
}
.tool-body.sql { color: #7ab0ff; }
.tool-body.rows { color: var(--text-dim); }
.tool-error {
padding: 8px 10px;
color: var(--status-error, #f55);
font-family: var(--font-mono);
font-size: 11px;
}
.tool-error { padding: 8px 10px; color: var(--status-error, #f55); font-family: var(--font-mono); font-size: 11px; }
/* ── Event log ── */
.wrap-toggle {
display: inline-flex;
align-items: center;
gap: 4px;
font-family: var(--font-mono);
font-size: 11px;
color: var(--text-dim);
cursor: pointer;
user-select: none;
display: inline-flex; align-items: center; gap: 4px;
font-family: var(--font-mono); font-size: 11px; color: var(--text-dim);
cursor: pointer; user-select: none;
}
.wrap-toggle input { margin: 0; }
.log {
height: 100%;
overflow: auto;
padding: 4px 0;
font-family: var(--font-mono);
font-size: 11px;
line-height: 1.5;
height: 100%; overflow: auto; padding: 4px 0;
font-family: var(--font-mono); font-size: 11px; line-height: 1.5;
}
.log-row {
display: grid;
grid-template-columns: 64px 52px 100px 1fr;
gap: 8px;
padding: 2px 10px;
align-items: baseline;
display: grid; grid-template-columns: 64px 52px 100px 1fr;
gap: 8px; padding: 2px 10px; align-items: baseline;
}
.log:not(.wrap) .log-msg {
white-space: pre;
overflow-x: auto;
}
.log.wrap .log-msg {
white-space: pre-wrap;
word-break: break-word;
}
.log:not(.wrap) .log-msg { white-space: pre; overflow-x: auto; }
.log.wrap .log-msg { white-space: pre-wrap; word-break: break-word; }
.log-ts { color: var(--text-dim); }
.log-level {
text-transform: uppercase;
font-size: 10px;
letter-spacing: 0.5px;
}
.log-level { text-transform: uppercase; font-size: 10px; letter-spacing: 0.5px; }
.log-row.info .log-level { color: var(--text-secondary); }
.log-row.debug .log-level { color: var(--text-dim); }
.log-row.error .log-level { color: var(--status-error, #ef4444); }
@@ -463,15 +435,13 @@ textarea {
/* ── Mobile: stack vertically ── */
@media (max-width: 900px) {
.ask-layout {
.ask-layout, .ask-layout.dense {
grid-template-columns: 1fr;
height: auto;
}
.trace-pane {
border-left: none;
border-top: var(--panel-border);
padding-left: 0;
padding-top: 16px;
border-left: none; border-top: var(--panel-border);
padding-left: 0; padding-top: 16px;
}
}
</style>