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()),