From bdf0c36e201ed81294c0fd90bb6e7d6556002f65 Mon Sep 17 00:00:00 2001 From: Mahdi Bazrafshan Date: Mon, 10 Aug 2026 14:13:07 +0330 Subject: [PATCH] feat(dashboard): add embedding controls, neighbor ui, and retrieval inspect Co-authored-by: Cursor --- src/static/index.html | 1085 +++++++++++++++++++++++++++++++++++++++-- 1 file changed, 1057 insertions(+), 28 deletions(-) diff --git a/src/static/index.html b/src/static/index.html index ea4c01b..ba68a80 100644 --- a/src/static/index.html +++ b/src/static/index.html @@ -72,6 +72,7 @@ } /* -- Main content ----------------------------------------- */ .main { padding: 24px; max-width: 1200px; margin: 0 auto; } + .main:has(.inspect-shell) { max-width: 1440px; } /* -- Cards ------------------------------------------------ */ .card { background: var(--bg-card); border: 1px solid var(--border); @@ -192,6 +193,196 @@ .text-sm { font-size: 12px; } .flex-between { display: flex; justify-content: space-between; align-items: center; } .truncate { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 300px; } + /* -- Retrieval Inspect (ADR-0025) --------------------------- */ + .inspect-shell { + display: grid; + grid-template-columns: 260px minmax(0, 1fr); + gap: 16px; + min-height: calc(100vh - 96px); + align-items: stretch; + } + @media (max-width: 900px) { + .inspect-shell { grid-template-columns: 1fr; } + } + .inspect-rail { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + display: flex; + flex-direction: column; + min-height: 420px; + max-height: calc(100vh - 96px); + overflow: hidden; + } + .inspect-rail-head { + padding: 14px 14px 10px; + border-bottom: 1px solid var(--border); + flex-shrink: 0; + } + .inspect-rail-list { + overflow-y: auto; + flex: 1; + padding: 6px; + } + .inspect-q-btn { + width: 100%; + text-align: left; + background: transparent; + border: 1px solid transparent; + border-radius: var(--radius); + color: var(--text-body); + font-family: inherit; + font-size: 12px; + padding: 10px 10px; + cursor: pointer; + transition: background 0.15s, border-color 0.15s; + margin-bottom: 4px; + } + .inspect-q-btn:hover { background: var(--bg-hover); } + .inspect-q-btn:focus-visible { + outline: 2px solid var(--border-focus); + outline-offset: 1px; + } + .inspect-q-btn.active { + background: rgba(245,158,11,0.1); + border-color: rgba(245,158,11,0.35); + } + .inspect-q-id { + font-weight: 600; + color: var(--text-heading); + margin-bottom: 2px; + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; + } + .inspect-q-preview { + color: var(--text-muted); + line-height: 1.35; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + } + .inspect-main { + display: flex; + flex-direction: column; + gap: 12px; + min-width: 0; + } + .inspect-toolbar { + display: flex; + flex-wrap: wrap; + gap: 12px; + align-items: flex-end; + justify-content: space-between; + } + .inspect-answer { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: 16px 18px; + } + .inspect-answer-compare { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; + margin-top: 4px; + } + @media (max-width: 800px) { + .inspect-answer-compare { grid-template-columns: 1fr; } + } + .inspect-answer-col { + background: var(--bg-base); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 12px 14px; + min-width: 0; + } + .inspect-answer-col-label { + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.4px; + color: var(--text-muted); + margin-bottom: 8px; + } + .inspect-answer-col-body { + white-space: pre-wrap; + word-break: break-word; + line-height: 1.55; + color: var(--text-body); + font-size: 14px; + } + .inspect-answer-col-body.is-empty { + color: var(--text-muted); + font-style: italic; + } + .inspect-scores { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-top: 10px; + } + .inspect-score-pill { + background: var(--bg-input); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 6px 10px; + font-size: 12px; + } + .inspect-score-pill .k { color: var(--text-muted); margin-right: 6px; text-transform: uppercase; letter-spacing: 0.3px; font-size: 10px; } + .inspect-score-pill .v { font-weight: 600; color: var(--text-heading); font-variant-numeric: tabular-nums; } + .inspect-tree-wrap { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--radius-lg); + padding: 16px 18px 20px; + } + .inspect-hit { + border: 1px solid var(--border); + border-radius: var(--radius-lg); + background: var(--bg-base); + padding: 14px; + margin-bottom: 14px; + } + .inspect-hit:last-child { margin-bottom: 0; } + .inspect-block { + border-radius: var(--radius); + padding: 12px 14px; + margin-bottom: 8px; + white-space: pre-wrap; + word-break: break-word; + line-height: 1.6; + font-size: 13px; + } + .inspect-block:last-child { margin-bottom: 0; } + .inspect-block-prev, + .inspect-block-next { + background: rgba(122, 139, 160, 0.08); + border-left: 3px solid var(--text-muted); + color: var(--text-body); + } + .inspect-block-hit { + background: rgba(245, 158, 11, 0.08); + border-left: 3px solid var(--accent); + color: var(--text-heading); + } + .inspect-block-meta { + display: flex; + flex-wrap: wrap; + gap: 8px; + align-items: center; + margin-bottom: 8px; + } + .inspect-empty { + padding: 32px 16px; + text-align: center; + color: var(--text-muted); + } + @media (prefers-reduced-motion: reduce) { + .inspect-q-btn { transition: none; } + } /* -- Collapsible sections -------------------------------- */ .collapse-header { display: flex; align-items: center; justify-content: space-between; @@ -305,6 +496,42 @@ function formatDropLabel(format) { return '.doc, .docx, or .pdf'; } +function strategyNeedsBoundary(strats) { + return (strats || []).some(s => s === 'semantic' || s === 'semantic_parent_child'); +} + +function corpusIdOf(exp) { + return exp?.embedding_model_id + || exp?.benchmark_config?.corpus_embedding_model_id + || exp?.benchmark_config?.embedding_model_id + || 'text-embedding-3-large'; +} + +function boundaryIdOf(exp) { + return exp?.boundary_embedding_model_id + || exp?.benchmark_config?.boundary_embedding_model_id + || null; +} + +function EmbeddingModelSelect({ label, value, onChange, models, role, title, style }) { + return React.createElement('div', { className: 'col', style: style || undefined }, + React.createElement('label', { title: title || undefined }, label), + React.createElement('select', { + value: value || '', + onChange: e => onChange(e.target.value), + disabled: !(models || []).length, + }, + !(models || []).length + ? React.createElement('option', { value: '' }, 'Loading models…') + : (models || []).map(m => { + const isDefault = role === 'corpus' ? m.is_corpus_default : m.is_boundary_default; + return React.createElement('option', { key: m.id, value: m.id }, + `${m.display_name || m.id}${isDefault ? ' (Admin default)' : ''}`); + }) + ) + ); +} + // -- Toast system ------------------------------------------- let _toastId = 0; function ToastContainer({ toasts, removeToast }) { @@ -374,8 +601,12 @@ function DocumentsTab({ documents, setDocuments, strategies, addToast, formatFil const [processing, setProcessing] = useState({}); const [selectedStrategies, setSelectedStrategies] = useState({}); const [dragOver, setDragOver] = useState(false); + const [embModels, setEmbModels] = useState([]); + const [processCorpusId, setProcessCorpusId] = useState(''); + const [processBoundaryId, setProcessBoundaryId] = useState(''); const fileRef = useRef(null); const visibleDocs = filterDocuments(documents, formatFilter); + const anySemanticSelected = Object.values(selectedStrategies).some(strategyNeedsBoundary); const fetchDocs = useCallback(() => { api('/documents').then(d => setDocuments(d.items || [])).catch(() => {}); @@ -383,6 +614,14 @@ function DocumentsTab({ documents, setDocuments, strategies, addToast, formatFil useEffect(() => { fetchDocs(); }, [fetchDocs]); + useEffect(() => { + api('/admin/embedding-models').then(emb => { + setEmbModels(emb?.models || []); + if (emb?.corpus_id) setProcessCorpusId(emb.corpus_id); + if (emb?.boundary_id) setProcessBoundaryId(emb.boundary_id); + }).catch(() => {}); + }, []); + const handleUpload = async (file) => { if (!file) return; const name = file.name || ''; @@ -422,9 +661,14 @@ function DocumentsTab({ documents, setDocuments, strategies, addToast, formatFil } setProcessing(p => ({ ...p, [docId]: true })); try { + const body = { strategies: strats }; + if (processCorpusId) body.corpus_model_id = processCorpusId; + if (strategyNeedsBoundary(strats) && processBoundaryId) { + body.boundary_model_id = processBoundaryId; + } await api(`/documents/${docId}/process`, { method: 'POST', - body: { strategies: strats } + body, }); addToast(`Processed with ${strats.length} strategy(ies)`, 'success'); fetchDocs(); @@ -485,6 +729,36 @@ function DocumentsTab({ documents, setDocuments, strategies, addToast, formatFil }) ), + React.createElement('div', { className: 'card mt-4' }, + React.createElement('div', { className: 'card-title' }, 'Process Embedding Models'), + React.createElement('p', { className: 'text-sm text-muted mb-2' }, + 'Corpus embeds finished chunks into Qdrant. Boundary is used only for Semantic Boundary Detection.'), + React.createElement('div', { className: 'row' }, + React.createElement(EmbeddingModelSelect, { + label: 'Corpus', + role: 'corpus', + value: processCorpusId, + onChange: setProcessCorpusId, + models: embModels, + title: 'Storage + later query vector space', + }), + anySemanticSelected + ? React.createElement(EmbeddingModelSelect, { + label: 'Boundary', + role: 'boundary', + value: processBoundaryId, + onChange: setProcessBoundaryId, + models: embModels, + title: 'Semantic cuts only (semantic / semantic_parent_child)', + }) + : React.createElement('div', { className: 'col' }, + React.createElement('label', null, 'Boundary'), + React.createElement('div', { className: 'text-sm text-muted', style: { paddingTop: '8px' } }, + 'Select a semantic strategy to choose Boundary') + ) + ) + ), + // Documents table React.createElement('div', { className: 'card mt-4' }, React.createElement('div', { className: 'flex-between mb-2' }, @@ -573,12 +847,340 @@ function DocumentsTab({ documents, setDocuments, strategies, addToast, formatFil } // -- Tab: Query --------------------------------------------- +function ExpansionTreeView({ tree }) { + if (!tree || !tree.length) return null; + return React.createElement('div', { className: 'mt-4' }, + React.createElement('h3', { + style: { fontSize: '14px', fontWeight: 600, color: 'var(--text-heading)', marginBottom: '8px' }, + }, 'Expansion Tree'), + React.createElement('p', { className: 'text-sm text-muted mb-2' }, + 'Top-k hits (score order). For each hit: previous (upper) and next (downer) neighbors.'), + tree.map((node, i) => + React.createElement('div', { + key: node.chunk_id || i, + style: { + border: '1px solid var(--border)', + borderRadius: 'var(--radius)', + padding: '10px 12px', + marginBottom: '8px', + background: 'var(--bg-base)', + }, + }, + React.createElement('div', { className: 'flex-between mb-2' }, + React.createElement('span', { className: 'badge badge-accent' }, `Hit #${i + 1}`), + React.createElement('span', { className: 'badge badge-success' }, + typeof node.score === 'number' ? node.score.toFixed(3) : 'N/A') + ), + (node.neighbors_prev || []).map((n, j) => + React.createElement('div', { + key: `p-${j}`, + className: 'text-sm', + style: { padding: '4px 8px', marginBottom: '4px', borderLeft: '3px solid var(--text-muted)', color: 'var(--text-muted)' }, + }, + React.createElement('span', { className: 'badge', style: { marginRight: '6px' } }, `↑ prev idx ${n.chunk_index ?? '?'}`), + (n.text || '').substring(0, 200) + ((n.text || '').length > 200 ? '...' : '') + ) + ), + React.createElement('div', { + className: 'text-sm', + style: { padding: '6px 8px', marginBottom: '4px', borderLeft: '3px solid var(--accent)', fontWeight: 500 }, + }, + React.createElement('span', { className: 'badge badge-accent', style: { marginRight: '6px' } }, + `● hit idx ${node.chunk_index ?? '?'}`), + (node.text || '').substring(0, 280) + ((node.text || '').length > 280 ? '...' : '') + ), + (node.neighbors_next || []).map((n, j) => + React.createElement('div', { + key: `n-${j}`, + className: 'text-sm', + style: { padding: '4px 8px', marginBottom: '4px', borderLeft: '3px solid var(--text-muted)', color: 'var(--text-muted)' }, + }, + React.createElement('span', { className: 'badge', style: { marginRight: '6px' } }, `↓ next idx ${n.chunk_index ?? '?'}`), + (n.text || '').substring(0, 200) + ((n.text || '').length > 200 ? '...' : '') + ) + ) + ) + ) + ); +} + +function fallbackTreeFromChunks(chunks) { + if (!chunks || !chunks.length) return []; + const hasRoles = chunks.some(c => c.role); + const hits = hasRoles ? chunks.filter(c => c.role !== 'neighbor') : chunks; + return hits.map(c => ({ + chunk_id: c.chunk_id, + score: c.score, + text: c.text, + chunk_index: c.chunk_index, + neighbors_prev: [], + neighbors_next: [], + })); +} + +function InspectTextBlock({ kind, label, text, score }) { + const cls = kind === 'hit' + ? 'inspect-block inspect-block-hit' + : kind === 'prev' + ? 'inspect-block inspect-block-prev' + : 'inspect-block inspect-block-next'; + return React.createElement('div', { className: cls }, + React.createElement('div', { className: 'inspect-block-meta' }, + React.createElement('span', { + className: kind === 'hit' ? 'badge badge-accent' : 'badge', + }, label), + score != null && React.createElement('span', { className: 'badge badge-success' }, + typeof score === 'number' ? score.toFixed(3) : String(score)) + ), + React.createElement('div', null, (text && String(text).length) ? text : '(empty)') + ); +} + +function ExpansionTreeFull({ tree, strategy }) { + if (!tree || !tree.length) { + return React.createElement('div', { className: 'inspect-empty' }, + strategy === 'fixed_size' + ? 'No Expansion Tree for this question.' + : 'No retrieved chunks for this question. Neighbor Expansion applies to fixed_size only.'); + } + const showNeighborHint = strategy !== 'fixed_size'; + return React.createElement('div', null, + showNeighborHint && React.createElement('p', { className: 'text-sm text-muted mb-2' }, + 'Neighbor Expansion is fixed_size only — neighbors are empty for this Strategy.'), + tree.map((node, i) => + React.createElement('article', { + key: node.chunk_id || i, + className: 'inspect-hit', + 'aria-label': `Hit ${i + 1}`, + }, + React.createElement('div', { className: 'flex-between mb-2' }, + React.createElement('span', { className: 'badge badge-accent' }, `Hit #${i + 1}`), + React.createElement('span', { className: 'badge badge-success' }, + typeof node.score === 'number' ? node.score.toFixed(3) : 'N/A') + ), + (node.neighbors_prev || []).map((n, j) => + React.createElement(InspectTextBlock, { + key: `p-${n.chunk_id || j}`, + kind: 'prev', + label: `↑ prev idx ${n.chunk_index ?? '?'}`, + text: n.text, + }) + ), + React.createElement(InspectTextBlock, { + kind: 'hit', + label: `● hit idx ${node.chunk_index ?? '?'}`, + text: node.text, + score: node.score, + }), + (node.neighbors_next || []).map((n, j) => + React.createElement(InspectTextBlock, { + key: `n-${n.chunk_id || j}`, + kind: 'next', + label: `↓ next idx ${n.chunk_index ?? '?'}`, + text: n.text, + }) + ) + ) + ) + ); +} + +function RetrievalInspectView({ experiment, onBack }) { + const strategies = experiment.strategies_used || []; + const pickDefault = () => ( + strategies.includes('fixed_size') ? 'fixed_size' : (strategies[0] || '') + ); + const [strategy, setStrategy] = useState(pickDefault); + const perQ = experiment.per_question || []; + const [qIdx, setQIdx] = useState(0); + + useEffect(() => { + setStrategy(pickDefault()); + setQIdx(0); + }, [experiment.id]); + + const pq = perQ[qIdx] || null; + const stratResult = (pq && strategy && pq.strategies?.[strategy]) || {}; + const rawTree = stratResult.expansion_tree; + const tree = (rawTree && rawTree.length) + ? rawTree + : fallbackTreeFromChunks(stratResult.retrieved_chunks); + const scores = stratResult.scores || {}; + const qError = stratResult.error || null; + const expectedAnswer = (pq && pq.expected_answer != null) ? String(pq.expected_answer) : ''; + const generatedAnswer = stratResult.answer || ''; + const qLabel = (id, i) => id || `Q${i + 1}`; + + const scorePills = [ + { key: 'context_relevance', label: 'Context' }, + { key: 'answer_similarity', label: 'Similarity' }, + { key: 'faithfulness', label: 'Faithfulness' }, + ]; + + return React.createElement('div', { className: 'inspect-shell' }, + React.createElement('aside', { className: 'inspect-rail', 'aria-label': 'Questions' }, + React.createElement('div', { className: 'inspect-rail-head' }, + React.createElement('button', { + type: 'button', + className: 'btn btn-secondary btn-sm', + onClick: onBack, + style: { minHeight: '36px', marginBottom: '10px' }, + 'aria-label': 'Back to experiment list', + }, '← Back'), + React.createElement('div', { + style: { fontSize: '13px', fontWeight: 600, color: 'var(--text-heading)', marginBottom: '4px' }, + }, 'Retrieval Inspect'), + React.createElement('div', { className: 'text-sm text-muted truncate', style: { maxWidth: '100%' }, + title: experiment.document_filename || experiment.document_id }, + experiment.document_filename || experiment.document_id || 'Experiment'), + React.createElement('div', { style: { display: 'flex', flexWrap: 'wrap', gap: '6px', marginTop: '8px' } }, + React.createElement(NeighborBadge, { exp: experiment }), + React.createElement('span', { className: 'badge' }, + `top_k=${experiment.benchmark_config?.top_k ?? '—'}`) + ) + ), + React.createElement('div', { + className: 'inspect-rail-list', + role: 'listbox', + 'aria-label': 'Experiment questions', + }, + perQ.length === 0 + ? React.createElement('div', { className: 'inspect-empty' }, 'No per-question results') + : perQ.map((row, i) => { + const sr = row.strategies?.[strategy] || {}; + const hasErr = !!sr.error; + const hasTree = (sr.expansion_tree || []).length > 0 + || (sr.retrieved_chunks || []).length > 0; + const preview = row.question || ''; + return React.createElement('button', { + type: 'button', + key: row.question_id || row.id || i, + role: 'option', + 'aria-selected': qIdx === i, + className: `inspect-q-btn${qIdx === i ? ' active' : ''}`, + onClick: () => setQIdx(i), + }, + React.createElement('div', { className: 'inspect-q-id' }, + qLabel(row.question_id || row.id, i), + hasErr && React.createElement('span', { className: 'badge badge-danger' }, 'error'), + !hasErr && hasTree && React.createElement('span', { className: 'badge badge-success' }, 'tree'), + !hasErr && !hasTree && React.createElement('span', { className: 'badge' }, 'empty') + ), + React.createElement('div', { className: 'inspect-q-preview' }, preview || '(no question text)') + ); + }) + ) + ), + + React.createElement('main', { className: 'inspect-main', id: 'inspect-main' }, + React.createElement('div', { className: 'inspect-toolbar' }, + React.createElement('div', { style: { flex: '1 1 220px', minWidth: 0 } }, + React.createElement('label', { htmlFor: 'inspect-strategy' }, 'Strategy'), + React.createElement('select', { + id: 'inspect-strategy', + value: strategy, + onChange: e => setStrategy(e.target.value), + style: { minHeight: '40px' }, + }, + strategies.length === 0 + ? React.createElement('option', { value: '' }, 'No strategies') + : strategies.map(s => React.createElement('option', { key: s, value: s }, s)) + ) + ), + React.createElement('div', { className: 'text-sm text-muted', style: { paddingBottom: '6px' } }, + pq ? `${qLabel(pq.question_id || pq.id, qIdx)} of ${perQ.length}` : '') + ), + + !pq + ? React.createElement('div', { className: 'inspect-empty card' }, 'Select a question') + : React.createElement(React.Fragment, null, + React.createElement('section', { className: 'inspect-answer', 'aria-label': 'Answer and scores' }, + React.createElement('div', { + className: 'text-sm', + style: { fontWeight: 600, color: 'var(--text-heading)', marginBottom: '8px' }, + }, pq.question || '(no question text)'), + qError + ? React.createElement('div', { + className: 'badge badge-danger', + style: { display: 'inline-block', marginBottom: '8px', whiteSpace: 'normal' }, + }, String(qError)) + : null, + React.createElement('div', { + className: 'inspect-answer-compare', + 'aria-label': 'Generated and Expected answers', + }, + React.createElement('div', { className: 'inspect-answer-col' }, + React.createElement('div', { className: 'inspect-answer-col-label' }, 'Generated'), + React.createElement('div', { + className: `inspect-answer-col-body${!generatedAnswer && !qError ? ' is-empty' : ''}`, + }, generatedAnswer || (qError ? '' : '(no answer)')) + ), + React.createElement('div', { className: 'inspect-answer-col' }, + React.createElement('div', { className: 'inspect-answer-col-label' }, 'Expected'), + React.createElement('div', { + className: `inspect-answer-col-body${!expectedAnswer.trim() ? ' is-empty' : ''}`, + }, expectedAnswer.trim() ? expectedAnswer : '(no expected answer)') + ) + ), + React.createElement('div', { className: 'inspect-scores', role: 'list' }, + scorePills.map(p => + React.createElement('div', { + key: p.key, + className: 'inspect-score-pill', + role: 'listitem', + }, + React.createElement('span', { className: 'k' }, p.label), + React.createElement('span', { className: 'v' }, + scores[p.key] != null ? Number(scores[p.key]).toFixed(1) : '—') + ) + ), + React.createElement('div', { className: 'inspect-score-pill', role: 'listitem' }, + React.createElement('span', { className: 'k' }, 'Hallucination'), + React.createElement('span', { + className: 'v', + style: { + color: scores.hallucination + ? 'var(--danger)' + : scores.hallucination === false + ? 'var(--success)' + : undefined, + }, + }, scores.hallucination == null ? '—' : (scores.hallucination ? 'yes' : 'no')) + ) + ) + ), + React.createElement('section', { className: 'inspect-tree-wrap', 'aria-label': 'Expansion Tree' }, + React.createElement('h2', { + style: { fontSize: '14px', fontWeight: 600, color: 'var(--text-heading)', margin: '0 0 6px' }, + }, 'Expansion Tree'), + React.createElement('p', { className: 'text-sm text-muted mb-2' }, + 'Full text · previous → hit → next (score order)'), + React.createElement(ExpansionTreeFull, { tree, strategy }) + ) + ) + ) + ); +} + function QueryTab({ documents, strategies, addToast, formatFilter = 'word', hideTitle = false }) { - const [form, setForm] = useState({ document_id: '', strategy: '', question: '', top_k: 5 }); + const [form, setForm] = useState({ + document_id: '', strategy: '', question: '', top_k: 5, + neighbor_prev: 0, neighbor_next: 0, corpus_model_id: '', + }); const [result, setResult] = useState(null); const [loading, setLoading] = useState(false); + const [embModels, setEmbModels] = useState([]); const visibleDocs = filterDocuments(documents, formatFilter); + useEffect(() => { + api('/admin/embedding-models').then(emb => { + setEmbModels(emb?.models || []); + if (emb?.corpus_id) { + setForm(prev => prev.corpus_model_id ? prev : { ...prev, corpus_model_id: emb.corpus_id }); + } + }).catch(() => {}); + }, []); + const handleQuery = async () => { if (!form.document_id || !form.strategy || !form.question.trim()) { addToast('Fill in all fields', 'error'); @@ -586,7 +1188,9 @@ function QueryTab({ documents, strategies, addToast, formatFilter = 'word', hide } setLoading(true); try { - const data = await api('/queries', { method: 'POST', body: form }); + const body = { ...form }; + if (!body.corpus_model_id) delete body.corpus_model_id; + const data = await api('/queries', { method: 'POST', body }); setResult(data); addToast('Query complete', 'success'); } catch (e) { @@ -621,14 +1225,38 @@ function QueryTab({ documents, strategies, addToast, formatFilter = 'word', hide strategies.map(s => React.createElement('option', { key: s.name, value: s.name }, s.name)) ) ), - React.createElement('div', { className: 'col', style: { maxWidth: '100px' } }, + React.createElement(EmbeddingModelSelect, { + label: 'Corpus', + role: 'corpus', + value: form.corpus_model_id, + onChange: v => setForm({ ...form, corpus_model_id: v }), + models: embModels, + title: 'Must match the Model Corpus used at process time', + }), + React.createElement('div', { className: 'col', style: { maxWidth: '90px' } }, React.createElement('label', null, 'Top K'), React.createElement('input', { type: 'number', min: 1, max: 20, value: form.top_k, onChange: e => setForm({ ...form, top_k: parseInt(e.target.value) || 5 }) }) + ), + React.createElement('div', { className: 'col', style: { maxWidth: '90px' } }, + React.createElement('label', { title: 'fixed_size only' }, 'Prev'), + React.createElement('input', { + type: 'number', min: 0, max: 5, value: form.neighbor_prev, + onChange: e => setForm({ ...form, neighbor_prev: parseInt(e.target.value) || 0 }) + }) + ), + React.createElement('div', { className: 'col', style: { maxWidth: '90px' } }, + React.createElement('label', { title: 'fixed_size only' }, 'Next'), + React.createElement('input', { + type: 'number', min: 0, max: 5, value: form.neighbor_next, + onChange: e => setForm({ ...form, neighbor_next: parseInt(e.target.value) || 0 }) + }) ) ), + React.createElement('p', { className: 'text-sm text-muted mt-2' }, + 'Corpus embeds the question into the same vector space as stored chunks. Neighbor Expansion (Prev/Next) applies to fixed_size only.'), React.createElement('div', { className: 'form-group mt-4' }, React.createElement('label', null, 'Question'), React.createElement('textarea', { @@ -650,29 +1278,91 @@ function QueryTab({ documents, strategies, addToast, formatFilter = 'word', hide React.createElement('div', { className: 'flex-between mb-2' }, React.createElement('div', { className: 'text-sm text-muted' }, `Latency: ${result.latency_breakdown?.total?.toFixed(1) || 'N/A'}s | ` + - `Tokens: ${Object.values(result.token_usage || {}).reduce((a, b) => a + b, 0)}`), + `Tokens: ${Object.values(result.token_usage || {}).reduce((a, b) => a + b, 0)}` + + (result.corpus_embedding_model_id ? ` | Corpus: ${result.corpus_embedding_model_id}` : '')), React.createElement('span', { className: 'badge badge-accent' }, result.strategy) ), - React.createElement('h3', { style: { fontSize: '14px', fontWeight: 600, color: 'var(--text-heading)', marginBottom: '8px' } }, 'Retrieved Chunks'), + React.createElement('h3', { style: { fontSize: '14px', fontWeight: 600, color: 'var(--text-heading)', marginBottom: '8px' } }, 'Retrieved Chunks (LLM context)'), React.createElement('table', null, React.createElement('thead', null, - React.createElement('tr', null, React.createElement('th', null, '#'), React.createElement('th', null, 'Score'), React.createElement('th', null, 'Text')) + React.createElement('tr', null, + React.createElement('th', null, '#'), + React.createElement('th', null, 'Role'), + React.createElement('th', null, 'Score'), + React.createElement('th', null, 'Text')) ), React.createElement('tbody', null, (result.retrieved_chunks || []).map((c, i) => React.createElement('tr', { key: i }, React.createElement('td', { className: 'text-muted' }, i + 1), React.createElement('td', null, - React.createElement('span', { className: 'badge badge-success' }, c.score?.toFixed(3) || 'N/A')), + React.createElement('span', { + className: `badge ${c.role === 'neighbor' ? 'badge-warning' : 'badge-accent'}`, + }, c.role || 'hit')), + React.createElement('td', null, + React.createElement('span', { className: 'badge badge-success' }, + c.score != null ? c.score.toFixed(3) : '—')), React.createElement('td', { className: 'text-sm' }, (c.text || '').substring(0, 300) + '...') ) ) ) - ) + ), + React.createElement(ExpansionTreeView, { tree: result.expansion_tree }) ) ); } +// -- Neighbor Expansion provenance helpers (ADR-0023) ---------- +function neighborCounts(exp) { + const cfg = exp?.benchmark_config || {}; + return { + prev: cfg.neighbor_prev ?? 0, + next: cfg.neighbor_next ?? 0, + }; +} + +function NeighborBadge({ exp, style }) { + const { prev, next } = neighborCounts(exp); + const hasFixed = (exp?.strategies_used || []).includes('fixed_size'); + const title = hasFixed + ? `neighbor_prev=${prev}, neighbor_next=${next} (applies to fixed_size)` + : `neighbor_prev=${prev}, neighbor_next=${next} (no fixed_size in this run — knobs unused)`; + return React.createElement('span', { + className: `badge ${hasFixed ? 'badge-accent' : ''}`, + title, + style: { + fontFamily: 'var(--font-mono, monospace)', + opacity: hasFixed ? 1 : 0.55, + ...(style || {}), + }, + }, `±${prev}/${next}`); +} + +function compareConfigMismatches(experiments) { + const neighborKeys = new Set( + experiments.map(e => { + const { prev, next } = neighborCounts(e); + return `${prev}/${next}`; + }) + ); + const corpusKeys = new Set(experiments.map(corpusIdOf)); + const boundaries = experiments.map(boundaryIdOf); + const issues = []; + if (neighborKeys.size > 1) { + issues.push(`Neighbor Expansion differs (${[...neighborKeys].map(k => '±' + k).join(' vs ')}) — compare fixed_size carefully`); + } + if (corpusKeys.size > 1) { + issues.push(`Corpus Embedding Model differs (${[...corpusKeys].join(' vs ')})`); + } + if (boundaries.some(Boolean)) { + const boundaryKeys = new Set(boundaries.map(b => b || '(none)')); + if (boundaryKeys.size > 1) { + issues.push(`Boundary Embedding Model differs (${[...boundaryKeys].join(' vs ')})`); + } + } + return issues; +} + // -- Comparison View Component ------------------------------ function ComparisonView({ experiments, addToast, onBack }) { if (!experiments || experiments.length < 2) return null; @@ -680,6 +1370,7 @@ function ComparisonView({ experiments, addToast, onBack }) { const allStrategies = [...new Set(experiments.flatMap(e => e.strategies_used || []))]; const metrics = ['avg_context_relevance', 'avg_answer_similarity', 'avg_faithfulness', 'hallucination_rate']; const metricLabels = { avg_context_relevance: 'Context Relevance', avg_answer_similarity: 'Answer Similarity', avg_faithfulness: 'Faithfulness', hallucination_rate: 'Hallucination Rate' }; + const configIssues = compareConfigMismatches(experiments); const allQIds = [...new Set(experiments.flatMap(e => (e.questions || []).map(q => q.id || q.question?.substring(0, 30))))]; const expPerQ = experiments.map(e => { @@ -759,11 +1450,30 @@ function ComparisonView({ experiments, addToast, onBack }) { React.createElement('div', null, React.createElement('div', { className: 'card-title mb-2' }, '📊 Strategy Comparison'), React.createElement('div', { className: 'text-sm text-muted' }, - `Comparing ${allStrategies.length} strategies across ${experiments.length} documents`) + `Comparing ${allStrategies.length} strategies across ${experiments.length} experiments`) ), React.createElement('button', { className: 'btn btn-secondary btn-sm', onClick: onBack }, '← Back to List') ), + configIssues.length > 0 && React.createElement('div', { + style: { + background: 'rgba(245,158,11,0.12)', + border: '1px solid rgba(245,158,11,0.35)', + borderRadius: 'var(--radius)', + padding: '12px 16px', + marginBottom: '16px', + fontSize: '13px', + color: 'var(--warning, #f59e0b)', + }, + }, + React.createElement('div', { style: { fontWeight: 600, marginBottom: '4px' } }, 'Configs differ'), + configIssues.map((msg, i) => + React.createElement('div', { key: i, className: 'text-sm', style: { opacity: 0.95 } }, `• ${msg}`) + ), + React.createElement('div', { className: 'text-sm text-muted', style: { marginTop: '6px' } }, + 'Intentional Neighbor Expansion A/B is fine — just don’t treat fixed_size scores as identical setups.') + ), + // Overall Winner Banner React.createElement('div', { style: { background: 'rgba(16,185,129,0.1)', border: '1px solid rgba(16,185,129,0.3)', borderRadius: 'var(--radius)', padding: '16px 20px', marginBottom: '20px', display: 'flex', alignItems: 'center', gap: '12px' } @@ -772,7 +1482,7 @@ function ComparisonView({ experiments, addToast, onBack }) { React.createElement('div', null, React.createElement('div', { style: { fontSize: '13px', color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.5px', marginBottom: '2px' } }, 'Best Strategy Overall'), React.createElement('div', { style: { fontSize: '20px', fontWeight: 700, color: 'var(--success)' } }, overallBest), - React.createElement('div', { className: 'text-sm text-muted' }, `Average score: ${fmt(overallBestScore)} across ${experiments.length} documents`) + React.createElement('div', { className: 'text-sm text-muted' }, `Average score: ${fmt(overallBestScore)} across ${experiments.length} experiments`) ) ), @@ -783,7 +1493,22 @@ function ComparisonView({ experiments, addToast, onBack }) { React.createElement('tr', null, React.createElement('th', null, 'Strategy'), experiments.map((e, i) => - React.createElement('th', { key: e.id }, React.createElement('span', { className: `cmp-exp-label cmp-exp-${i}` }, expName(e))) + React.createElement('th', { key: e.id }, + React.createElement('div', null, + React.createElement('span', { className: `cmp-exp-label cmp-exp-${i}` }, expName(e))), + React.createElement('div', { style: { marginTop: '4px' } }, + React.createElement(NeighborBadge, { exp: e })), + React.createElement('div', { + className: 'text-sm text-muted', + style: { fontSize: '10px', marginTop: '2px', fontWeight: 400 }, + title: `Corpus: ${corpusIdOf(e)}` + (boundaryIdOf(e) ? ` | Boundary: ${boundaryIdOf(e)}` : ''), + }, + `C: ${(corpusIdOf(e) || '').substring(0, 14) || '—'}`, + boundaryIdOf(e) + ? React.createElement('div', null, `B: ${boundaryIdOf(e).substring(0, 14)}`) + : null + ) + ) ), React.createElement('th', { style: { background: 'rgba(234,179,8,0.1)' } }, 'Avg') ) @@ -856,8 +1581,10 @@ function ComparisonView({ experiments, addToast, onBack }) { const best = calcBest(e); return React.createElement('div', { key: e.id, style: { background: 'var(--bg-base)', border: '1px solid var(--border)', borderRadius: 'var(--radius)', padding: '12px 16px', flex: '1', minWidth: '180px' } }, React.createElement('div', { className: `cmp-exp-label cmp-exp-${i}`, style: { marginBottom: '6px' } }, expName(e)), + React.createElement('div', { style: { marginBottom: '6px' } }, React.createElement(NeighborBadge, { exp: e })), React.createElement('div', { style: { fontSize: '16px', fontWeight: 600, color: 'var(--text-heading)' } }, best), - React.createElement('div', { className: 'text-sm text-muted' }, `${e.questions_count || '?'} questions`) + React.createElement('div', { className: 'text-sm text-muted' }, + `${e.questions_count || (e.questions || []).length || '?'} questions`) ); }) ), @@ -912,9 +1639,11 @@ function BenchmarksTab({ documents, strategies, addToast, questionsFile, formatF const [experiments, setExperiments] = useState([]); const [form, setForm] = useState({ document_id: '', strategies: strategies.map(s => s.name), - questions_file: questionsFile, top_k: 5, dry_run: false + questions_file: questionsFile, top_k: 5, + neighbor_prev: 0, neighbor_next: 0, dry_run: false, corpus_model_id: '', }); const [running, setRunning] = useState(false); + const [embModels, setEmbModels] = useState([]); const visibleDocs = filterDocuments(documents, formatFilter); const visibleExps = filterExperiments(experiments, formatFilter); @@ -926,6 +1655,9 @@ function BenchmarksTab({ documents, strategies, addToast, questionsFile, formatF const [expandedExp, setExpandedExp] = useState(null); const [expandedData, setExpandedData] = useState(null); const [expandedLoading, setExpandedLoading] = useState(false); + const [inspectMode, setInspectMode] = useState(false); + const [inspectData, setInspectData] = useState(null); + const [inspectLoadingId, setInspectLoadingId] = useState(null); const fetchExperiments = useCallback(() => { api('/experiments').then(d => setExperiments(d?.items || (Array.isArray(d) ? d : []))).catch(() => {}); @@ -933,6 +1665,15 @@ function BenchmarksTab({ documents, strategies, addToast, questionsFile, formatF useEffect(() => { fetchExperiments(); }, [fetchExperiments]); + useEffect(() => { + api('/admin/embedding-models').then(emb => { + setEmbModels(emb?.models || []); + if (emb?.corpus_id) { + setForm(prev => prev.corpus_model_id ? prev : { ...prev, corpus_model_id: emb.corpus_id }); + } + }).catch(() => {}); + }, []); + // Sync questionsFile prop from Admin tab's "Use This File" useEffect(() => { setForm(prev => ({ ...prev, questions_file: questionsFile })); @@ -997,6 +1738,19 @@ function BenchmarksTab({ documents, strategies, addToast, questionsFile, formatF } }; + const handleInspect = async (expId) => { + setInspectLoadingId(expId); + try { + const data = await api(`/benchmarks/${expId}`); + setInspectData(data); + setInspectMode(true); + } catch (e) { + addToast(`Failed to open Inspect: ${e.message}`, 'error'); + } finally { + setInspectLoadingId(null); + } + }; + const handleDelete = async (expId) => { if (!confirm('Delete this experiment?')) return; try { @@ -1004,6 +1758,7 @@ function BenchmarksTab({ documents, strategies, addToast, questionsFile, formatF addToast('Experiment deleted', 'success'); fetchExperiments(); if (expandedExp === expId) { setExpandedExp(null); setExpandedData(null); } + if (inspectData?.id === expId) { setInspectMode(false); setInspectData(null); } setSelectedExps(prev => { const n = new Set(prev); n.delete(expId); return n; }); } catch (e) { addToast(`Delete failed: ${e.message}`, 'error'); @@ -1017,7 +1772,9 @@ function BenchmarksTab({ documents, strategies, addToast, questionsFile, formatF } setRunning(true); try { - await api('/benchmarks', { method: 'POST', body: form }); + const body = { ...form }; + if (!body.corpus_model_id) delete body.corpus_model_id; + await api('/benchmarks', { method: 'POST', body }); addToast(`Benchmark ${form.dry_run ? 'estimate' : 'complete'}`, 'success'); if (!form.dry_run) fetchExperiments(); } catch (e) { @@ -1036,6 +1793,13 @@ function BenchmarksTab({ documents, strategies, addToast, questionsFile, formatF }); } + if (inspectMode && inspectData) { + return React.createElement(RetrievalInspectView, { + experiment: inspectData, + onBack: () => { setInspectMode(false); setInspectData(null); }, + }); + } + return React.createElement('div', null, !hideTitle && React.createElement('h1', { style: { color: 'var(--text-heading)', marginBottom: '16px', fontSize: '22px' } }, 'Benchmarks'), @@ -1053,14 +1817,38 @@ function BenchmarksTab({ documents, strategies, addToast, questionsFile, formatF visibleDocs.map(d => React.createElement('option', { key: d.id, value: d.id }, d.filename)) ) ), - React.createElement('div', { className: 'col', style: { maxWidth: '120px' } }, + React.createElement(EmbeddingModelSelect, { + label: 'Corpus', + role: 'corpus', + value: form.corpus_model_id, + onChange: v => setForm({ ...form, corpus_model_id: v }), + models: embModels, + title: 'Query embedding + Model Corpus for this Experiment', + }), + React.createElement('div', { className: 'col', style: { maxWidth: '90px' } }, React.createElement('label', null, 'Top K'), React.createElement('input', { type: 'number', min: 1, max: 20, value: form.top_k, onChange: e => setForm({ ...form, top_k: parseInt(e.target.value) || 5 }) }) + ), + React.createElement('div', { className: 'col', style: { maxWidth: '90px' } }, + React.createElement('label', { title: 'fixed_size only' }, 'Prev'), + React.createElement('input', { + type: 'number', min: 0, max: 5, value: form.neighbor_prev, + onChange: e => setForm({ ...form, neighbor_prev: parseInt(e.target.value) || 0 }) + }) + ), + React.createElement('div', { className: 'col', style: { maxWidth: '90px' } }, + React.createElement('label', { title: 'fixed_size only' }, 'Next'), + React.createElement('input', { + type: 'number', min: 0, max: 5, value: form.neighbor_next, + onChange: e => setForm({ ...form, neighbor_next: parseInt(e.target.value) || 0 }) + }) ) ), + React.createElement('p', { className: 'text-sm text-muted mt-2' }, + 'Corpus must match how the document was processed. Neighbor Expansion (Prev/Next) applies to fixed_size only.'), React.createElement('div', { className: 'form-group mt-4' }, React.createElement('label', null, 'Strategies'), React.createElement('div', { style: { display: 'flex', flexWrap: 'wrap', gap: '6px', marginTop: '4px' } }, @@ -1137,16 +1925,20 @@ function BenchmarksTab({ documents, strategies, addToast, questionsFile, formatF style: { cursor: 'pointer' } }) ), + React.createElement('th', { style: { width: '44px' }, title: 'Row index in this list (newest first)' }, '#'), React.createElement('th', null, 'Document'), + React.createElement('th', null, 'Corpus'), + React.createElement('th', { title: 'Boundary Embedding Model from process (when semantic was used)' }, 'Boundary'), + React.createElement('th', { title: 'Neighbor Expansion prev/next (fixed_size)' }, 'Neighbors'), React.createElement('th', null, 'Strategies'), React.createElement('th', null, 'Questions'), React.createElement('th', null, 'Best'), React.createElement('th', null, 'Created'), - React.createElement('th', { style: { width: '100px' } }, '') + React.createElement('th', { style: { width: '160px' } }, '') ) ), React.createElement('tbody', null, - visibleExps.map(exp => React.createElement(React.Fragment, { key: exp.id }, + visibleExps.map((exp, expIndex) => React.createElement(React.Fragment, { key: exp.id }, React.createElement('tr', { className: selectedExps.has(exp.id) ? 'cmp-highlight' : '', style: { cursor: 'pointer' }, @@ -1160,36 +1952,72 @@ function BenchmarksTab({ documents, strategies, addToast, questionsFile, formatF style: { cursor: 'pointer' } }) ), + React.createElement('td', { + className: 'text-sm text-muted', + style: { fontVariantNumeric: 'tabular-nums', fontWeight: 600 }, + }, expIndex + 1), React.createElement('td', { style: { fontWeight: 500 } }, exp.document_filename || exp.document_id?.substring(0, 12)), + React.createElement('td', null, + React.createElement('span', { + className: `badge ${(exp.embedding_provider || 'cloud') === 'local' ? 'badge-warning' : 'badge-accent'}`, + title: corpusIdOf(exp), + }, corpusIdOf(exp))), + React.createElement('td', null, + boundaryIdOf(exp) + ? React.createElement('span', { + className: 'badge badge-warning', + title: boundaryIdOf(exp), + }, boundaryIdOf(exp)) + : React.createElement('span', { className: 'text-muted text-sm' }, '—')), + React.createElement('td', null, React.createElement(NeighborBadge, { exp })), React.createElement('td', null, (exp.strategies_used || []).map(s => React.createElement('span', { key: s, className: 'badge badge-accent', style: { marginRight: '4px' } }, s))), - React.createElement('td', null, exp.questions_count), + React.createElement('td', null, exp.questions_count ?? (exp.questions || []).length ?? '—'), React.createElement('td', null, React.createElement('span', { className: 'badge badge-success' }, exp.best_strategy || 'N/A')), React.createElement('td', { className: 'text-sm text-muted' }, new Date(exp.created_at).toLocaleDateString()), - React.createElement('td', { onClick: e => e.stopPropagation(), style: { display: 'flex', gap: '4px' } }, + React.createElement('td', { + onClick: e => e.stopPropagation(), + style: { display: 'flex', gap: '6px', alignItems: 'center', flexWrap: 'wrap' }, + }, + React.createElement('button', { + type: 'button', + className: 'btn btn-secondary btn-sm', + onClick: () => handleInspect(exp.id), + disabled: inspectLoadingId === exp.id, + style: { minHeight: '32px', padding: '4px 10px' }, + 'aria-label': `Inspect retrieval for experiment ${exp.id}`, + }, inspectLoadingId === exp.id + ? React.createElement('span', { className: 'spinner' }) + : 'Inspect'), React.createElement('a', { href: `/benchmarks/${exp.id}/report`, target: '_blank', - style: { color: 'var(--accent)', textDecoration: 'none', fontSize: '12px' } + style: { color: 'var(--accent)', textDecoration: 'none', fontSize: '12px', minHeight: '32px', display: 'inline-flex', alignItems: 'center' } }, 'Report'), React.createElement('button', { className: 'btn btn-danger btn-sm', onClick: (e) => { e.stopPropagation(); handleDelete(exp.id); }, - style: { padding: '2px 8px', fontSize: '11px' } + style: { padding: '2px 8px', fontSize: '11px', minHeight: '32px' }, + 'aria-label': 'Delete experiment', }, '✕') ) ), // Expanded details expandedExp === exp.id && React.createElement('tr', null, - React.createElement('td', { colSpan: 7, style: { padding: '0' } }, + React.createElement('td', { colSpan: 11, style: { padding: '0' } }, expandedLoading ? React.createElement('div', { style: { padding: '16px', textAlign: 'center' } }, React.createElement('span', { className: 'spinner' })) : expandedData ? React.createElement('div', { style: { padding: '12px 16px', background: 'var(--bg-base)', borderBottom: '1px solid var(--border)' } }, React.createElement('div', { className: 'flex-between mb-2' }, React.createElement('div', { className: 'text-sm', style: { color: 'var(--text-heading)', fontWeight: 600 } }, - expandedData.document_filename || expandedData.document_id) + expandedData.document_filename || expandedData.document_id), + React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: '8px' } }, + React.createElement(NeighborBadge, { exp: expandedData }), + React.createElement('div', { className: 'text-sm text-muted' }, + `top_k=${expandedData.benchmark_config?.top_k ?? '—'}`) + ) ), // Questions list expandedData.questions && expandedData.questions.length > 0 && React.createElement('div', { className: 'mb-2' }, @@ -1240,6 +2068,16 @@ function BenchmarksTab({ documents, strategies, addToast, questionsFile, formatF ) ) ) + ), + // Expansion Tree samples (fixed_size) + expandedData.per_question.some(pq => + (pq.strategies?.fixed_size?.expansion_tree || []).length > 0 + ) && React.createElement('div', { className: 'mt-4' }, + React.createElement('div', { className: 'text-sm', style: { fontWeight: 500, marginBottom: '8px', color: 'var(--text-heading)' } }, + 'Expansion Tree (fixed_size, first question)'), + React.createElement(ExpansionTreeView, { + tree: expandedData.per_question[0]?.strategies?.fixed_size?.expansion_tree || [], + }) ) ) ) : null @@ -1432,25 +2270,102 @@ function PdfWorkspaceTab({ documents, setDocuments, strategies, addToast, questi // -- Tab: Admin --------------------------------------------- function AdminTab({ addToast, documents, strategies, setActiveTab, setQuestionsFile }) { // Collapse state for each section - const [openSections, setOpenSections] = useState({ health: true, qdrant: false, chunks: false, questions: false, cost: false }); + const [openSections, setOpenSections] = useState({ + health: true, embedding: true, qdrant: false, chunks: false, questions: false, cost: false, + }); const toggle = (key) => setOpenSections(prev => ({ ...prev, [key]: !prev[key] })); // -- Health state -- const [health, setHealth] = useState(null); const [healthLoading, setHealthLoading] = useState(true); + + // -- Embedding Models state -- + const [embModels, setEmbModels] = useState([]); + const [corpusEmbId, setCorpusEmbId] = useState(null); + const [boundaryEmbId, setBoundaryEmbId] = useState(null); + const [embSwitching, setEmbSwitching] = useState(null); + const [threshDrafts, setThreshDrafts] = useState({}); + const [threshSaving, setThreshSaving] = useState(null); + const fetchHealth = useCallback(() => { setHealthLoading(true); Promise.all([ api('/admin/health').catch(() => null), api('/admin/qdrant/collections').catch(() => ({ collections: [] })), - ]).then(([h, c]) => { + api('/admin/embedding-models').catch(() => ({ models: [], corpus_id: null, boundary_id: null })), + ]).then(([h, c, emb]) => { setHealth(h); setCollectionsData(c?.collections || []); + const models = emb?.models || []; + setEmbModels(models); + setCorpusEmbId(emb?.corpus_id || null); + setBoundaryEmbId(emb?.boundary_id || null); + setThreshDrafts(Object.fromEntries( + models.map(m => [m.id, String(m.semantic_threshold ?? m.default_semantic_threshold ?? '')]) + )); setHealthLoading(false); }); }, []); useEffect(() => { fetchHealth(); }, [fetchHealth]); + const setRoleEmbeddingModel = async (role, modelId) => { + const current = role === 'corpus' ? corpusEmbId : boundaryEmbId; + if (!modelId || modelId === current) return; + setEmbSwitching(role); + try { + const data = await api(`/admin/embedding-models/${role}`, { + method: 'PUT', + body: { id: modelId }, + }); + if (role === 'corpus') { + setCorpusEmbId(data.corpus_id); + setEmbModels(prev => prev.map(m => ({ + ...m, + is_corpus_default: m.id === data.corpus_id, + is_active: m.id === data.corpus_id, + }))); + addToast(`Default Corpus: ${data.model?.display_name || data.corpus_id}`, 'success'); + } else { + setBoundaryEmbId(data.boundary_id); + setEmbModels(prev => prev.map(m => ({ + ...m, + is_boundary_default: m.id === data.boundary_id, + }))); + addToast(`Default Boundary: ${data.model?.display_name || data.boundary_id}`, 'success'); + } + fetchHealth(); + } catch (e) { + addToast(`Failed to set Default ${role === 'corpus' ? 'Corpus' : 'Boundary'}: ${e.message}`, 'error'); + } finally { + setEmbSwitching(null); + } + }; + + const saveSemanticThreshold = async (modelId) => { + const raw = threshDrafts[modelId]; + const value = parseFloat(raw); + if (!Number.isFinite(value) || value <= 0 || value > 1) { + addToast('semantic_threshold must be in (0, 1]', 'error'); + return; + } + setThreshSaving(modelId); + try { + const data = await api(`/admin/embedding-models/${encodeURIComponent(modelId)}/semantic-threshold`, { + method: 'PUT', + body: { semantic_threshold: value }, + }); + setEmbModels(prev => prev.map(m => + m.id === modelId ? { ...m, semantic_threshold: data.semantic_threshold } : m + )); + setThreshDrafts(prev => ({ ...prev, [modelId]: String(data.semantic_threshold) })); + addToast(`Saved semantic_threshold=${data.semantic_threshold} for ${modelId}. Re-process to apply.`, 'success'); + } catch (e) { + addToast(`Failed to save threshold: ${e.message}`, 'error'); + } finally { + setThreshSaving(null); + } + }; + // -- Qdrant state -- const [collectionsData, setCollectionsData] = useState([]); @@ -1557,7 +2472,104 @@ function AdminTab({ addToast, documents, strategies, setActiveTab, setQuestionsF React.createElement('div', { className: 'status-card' }, React.createElement('div', { className: 'label' }, 'OpenAI'), React.createElement('div', { className: `value ${health?.openai_configured ? 'green' : ''}` }, - health?.openai_configured ? '● Configured' : '● Missing')) + health?.openai_configured ? '● Configured' : '● Missing')), + React.createElement('div', { className: 'status-card' }, + React.createElement('div', { className: 'label' }, 'Ollama'), + React.createElement('div', { className: `value ${health?.ollama_reachable ? 'green' : ''}` }, + health?.ollama_reachable ? '● Reachable' : '● Unreachable')), + React.createElement('div', { className: 'status-card' }, + React.createElement('div', { className: 'label' }, 'Corpus Embed'), + React.createElement('div', { className: 'value text-sm', style: { fontSize: '13px' } }, + health?.corpus_embedding_model?.id || health?.active_embedding_model?.id || '—')), + React.createElement('div', { className: 'status-card' }, + React.createElement('div', { className: 'label' }, 'Boundary Embed'), + React.createElement('div', { className: 'value text-sm', style: { fontSize: '13px' } }, + health?.boundary_embedding_model?.id || '—')) + ) + ) + ), + + // -- 1b. Embedding Models -- + React.createElement(CollapseSection, { + title: '🧬 Embedding Models', icon: '', open: openSections.embedding, + onToggle: () => toggle('embedding'), + badge: `C:${corpusEmbId || '—'} · B:${boundaryEmbId || '—'}`, + }, + React.createElement('p', { className: 'text-sm text-muted mb-4' }, + 'Default Corpus (storage + query) and Default Boundary (semantic cuts). Per-operation overrides on Documents / Query / Benchmarks. ', + 'semantic_threshold follows the Boundary model (higher → more cuts). Re-process after changing Boundary or threshold.'), + embModels.length === 0 + ? React.createElement('div', { className: 'empty-state' }, 'No Embedding Models in registry') + : React.createElement('table', null, + React.createElement('thead', null, + React.createElement('tr', null, + React.createElement('th', null, 'Model'), + React.createElement('th', null, 'Provider'), + React.createElement('th', null, 'Dim'), + React.createElement('th', { title: 'Cosine similarity cutoff for Semantic Boundary Detection' }, 'semantic_threshold'), + React.createElement('th', { style: { width: '120px' } }, 'Corpus'), + React.createElement('th', { style: { width: '120px' } }, 'Boundary') + ) + ), + React.createElement('tbody', null, + embModels.map(m => + React.createElement('tr', { + key: m.id, + className: (m.is_corpus_default || m.is_boundary_default) ? 'cmp-highlight' : '', + }, + React.createElement('td', null, + React.createElement('div', { style: { fontWeight: 500 } }, m.display_name), + React.createElement('div', { className: 'mono text-sm text-muted' }, m.model_name) + ), + React.createElement('td', null, + React.createElement('span', { + className: `badge ${m.provider === 'local' ? 'badge-warning' : 'badge-accent'}`, + }, m.provider) + ), + React.createElement('td', { className: 'mono' }, m.dimension), + React.createElement('td', null, + React.createElement('div', { className: 'flex gap-2', style: { alignItems: 'center' } }, + React.createElement('input', { + type: 'number', + min: 0.01, + max: 1, + step: 0.05, + className: 'mono', + style: { width: '72px', padding: '4px 6px' }, + value: threshDrafts[m.id] ?? '', + onChange: (e) => setThreshDrafts(prev => ({ ...prev, [m.id]: e.target.value })), + }), + React.createElement('button', { + className: 'btn btn-secondary btn-sm', + disabled: threshSaving === m.id, + onClick: () => saveSemanticThreshold(m.id), + }, threshSaving === m.id + ? React.createElement('span', { className: 'spinner' }) + : 'Save'), + React.createElement('span', { className: 'text-sm text-muted' }, + `default ${m.default_semantic_threshold}`) + ) + ), + React.createElement('td', null, + m.is_corpus_default + ? React.createElement('span', { className: 'badge badge-success' }, 'Default') + : React.createElement('button', { + className: 'btn btn-primary btn-sm', + disabled: embSwitching === 'corpus', + onClick: () => setRoleEmbeddingModel('corpus', m.id), + }, embSwitching === 'corpus' ? React.createElement('span', { className: 'spinner' }) : 'Set') + ), + React.createElement('td', null, + m.is_boundary_default + ? React.createElement('span', { className: 'badge badge-success' }, 'Default') + : React.createElement('button', { + className: 'btn btn-secondary btn-sm', + disabled: embSwitching === 'boundary', + onClick: () => setRoleEmbeddingModel('boundary', m.id), + }, embSwitching === 'boundary' ? React.createElement('span', { className: 'spinner' }) : 'Set') + ) + ) + ) ) ) ), @@ -1574,14 +2586,26 @@ function AdminTab({ addToast, documents, strategies, setActiveTab, setQuestionsF React.createElement('thead', null, React.createElement('tr', null, React.createElement('th', null, 'Collection'), + React.createElement('th', null, 'Embedding Model'), React.createElement('th', null, 'Points'), React.createElement('th', null, '') ) ), React.createElement('tbody', null, collectionsData.map(c => - React.createElement('tr', { key: c.name }, + React.createElement('tr', { + key: c.name, + className: c.is_active_corpus ? 'cmp-highlight' : '', + }, React.createElement('td', { className: 'mono' }, c.name), + React.createElement('td', null, + React.createElement('span', { + className: `badge ${c.is_active_corpus ? 'badge-success' : 'badge-accent'}`, + }, c.embedding_model_id || 'unknown'), + c.is_legacy ? React.createElement('span', { + className: 'badge badge-warning', style: { marginLeft: '4px' }, + }, 'legacy') : null + ), React.createElement('td', null, c.points_count ?? 'N/A'), React.createElement('td', null, React.createElement('button', { @@ -1721,6 +2745,11 @@ function AdminTab({ addToast, documents, strategies, setActiveTab, setQuestionsF React.createElement('div', { className: 'value amber' }, `$${costResult.estimated_cost_usd?.toFixed(4) || '0'}`), React.createElement('div', { className: 'sub' }, `${costResult.total_queries} queries × ${costResult.total_evaluations} evals`) ), + React.createElement('div', { className: 'cost-card' }, + React.createElement('div', { className: 'label' }, 'Embedding Model'), + React.createElement('div', { className: 'value', style: { fontSize: '14px' } }, costResult.embedding_model_id || '—'), + React.createElement('div', { className: 'sub' }, costResult.embedding_provider || ''), + ), React.createElement('div', { className: 'cost-card' }, React.createElement('div', { className: 'label' }, 'Input Tokens'), React.createElement('div', { className: 'value' }, (costResult.estimated_tokens?.input || 0).toLocaleString()),