Why: - Final benchmark selected fixed_size with neighbor expansion ±3 as the winning Candidate. Changes: - Set neighbor_prev/neighbor_next defaults to 3 in Settings. - Default Query tab to fixed_size ±3/3 and Benchmarks to single-strategy fixed_size. Impact: - API and Dashboard runs use ±3 unless explicitly overridden. Co-authored-by: Cursor <cursoragent@cursor.com>
3614 lines
161 KiB
HTML
3614 lines
161 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||
<title>RAG Benchmarker</title>
|
||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||
<style>
|
||
:root {
|
||
--bg-base: #0F1419;
|
||
--bg-card: #1A2332;
|
||
--bg-input: #243044;
|
||
--bg-hover: #2D3A4A;
|
||
--border: #2D3A4A;
|
||
--border-focus: #3B82F6;
|
||
--text-heading: #E8EDF2;
|
||
--text-body: #E8EDF2;
|
||
--text-muted: #7A8BA0;
|
||
--accent: #F59E0B;
|
||
--accent-hover: #D97706;
|
||
--success: #10B981;
|
||
--danger: #F43F5E;
|
||
--warning: #F59E0B;
|
||
--teal: #14B8A6;
|
||
--violet: #8B5CF6;
|
||
--radius: 6px;
|
||
--radius-lg: 10px;
|
||
--shadow: 0 1px 3px rgba(0,0,0,0.4);
|
||
}
|
||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||
body {
|
||
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||
background: var(--bg-base);
|
||
color: var(--text-body);
|
||
font-size: 14px;
|
||
line-height: 1.5;
|
||
}
|
||
code, pre, .mono {
|
||
font-family: 'SF Mono', Consolas, 'Liberation Mono', monospace;
|
||
}
|
||
/* -- Tab bar ---------------------------------------------- */
|
||
.tab-bar {
|
||
display: flex;
|
||
align-items: center;
|
||
background: var(--bg-card);
|
||
border-bottom: 1px solid var(--border);
|
||
padding: 0 16px;
|
||
height: 48px;
|
||
gap: 0;
|
||
}
|
||
.tab-bar .logo {
|
||
font-weight: 700;
|
||
font-size: 15px;
|
||
color: var(--accent);
|
||
margin-right: 24px;
|
||
white-space: nowrap;
|
||
letter-spacing: -0.3px;
|
||
}
|
||
.tab-bar .logo span { color: var(--text-muted); font-weight: 400; }
|
||
.tab-btn {
|
||
background: none; border: none; color: var(--text-muted);
|
||
font-family: inherit; font-size: 13px; font-weight: 500;
|
||
padding: 12px 16px; cursor: pointer; position: relative;
|
||
transition: color 0.15s;
|
||
}
|
||
.tab-btn:hover { color: var(--text-body); }
|
||
.tab-btn.active { color: var(--text-heading); }
|
||
.tab-btn.active::after {
|
||
content: ''; position: absolute; bottom: -1px; left: 12px; right: 12px;
|
||
height: 2px; background: var(--accent); border-radius: 1px;
|
||
}
|
||
/* -- 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);
|
||
border-radius: var(--radius-lg); padding: 20px; margin-bottom: 16px;
|
||
box-shadow: var(--shadow);
|
||
}
|
||
.card-title {
|
||
font-size: 16px; font-weight: 600; color: var(--text-heading);
|
||
margin-bottom: 12px; display: flex; align-items: center; gap: 8px;
|
||
}
|
||
/* -- Status cards grid ------------------------------------ */
|
||
.status-grid {
|
||
display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
|
||
gap: 12px; margin-bottom: 20px;
|
||
}
|
||
.status-card {
|
||
background: var(--bg-card); border: 1px solid var(--border);
|
||
border-radius: var(--radius); padding: 16px; text-align: center;
|
||
}
|
||
.status-card .label { font-size: 12px; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 4px; }
|
||
.status-card .value { font-size: 28px; font-weight: 700; color: var(--text-heading); }
|
||
.status-card .value.accent { color: var(--accent); }
|
||
.status-card .value.green { color: var(--success); }
|
||
/* -- Buttons ----------------------------------------------- */
|
||
.btn {
|
||
font-family: inherit; font-size: 13px; font-weight: 500;
|
||
padding: 6px 14px; border-radius: var(--radius); border: 1px solid transparent;
|
||
cursor: pointer; transition: all 0.15s; display: inline-flex;
|
||
align-items: center; gap: 6px;
|
||
}
|
||
.btn-primary {
|
||
background: var(--accent); color: #000; border-color: var(--accent);
|
||
}
|
||
.btn-primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); }
|
||
.btn-secondary {
|
||
background: var(--bg-input); color: var(--text-body); border-color: var(--border);
|
||
}
|
||
.btn-secondary:hover { background: var(--bg-hover); border-color: var(--text-muted); }
|
||
.btn-danger {
|
||
background: transparent; color: var(--danger); border-color: var(--danger);
|
||
}
|
||
.btn-danger:hover { background: var(--danger); color: #fff; }
|
||
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
|
||
.btn-sm { font-size: 12px; padding: 4px 10px; }
|
||
/* -- Inputs ------------------------------------------------ */
|
||
input[type="text"], input[type="number"], select, textarea {
|
||
font-family: inherit; font-size: 13px; color: var(--text-body);
|
||
background: var(--bg-input); border: 1px solid var(--border);
|
||
border-radius: var(--radius); padding: 8px 12px; width: 100%;
|
||
outline: none; transition: border-color 0.15s;
|
||
}
|
||
input:focus, select:focus, textarea:focus { border-color: var(--border-focus); }
|
||
input::placeholder, textarea::placeholder { color: var(--text-muted); }
|
||
textarea { resize: vertical; min-height: 80px; }
|
||
select { cursor: pointer; }
|
||
label { display: block; font-size: 12px; font-weight: 500; color: var(--text-muted); margin-bottom: 4px; text-transform: uppercase; letter-spacing: 0.3px; }
|
||
.form-group { margin-bottom: 14px; }
|
||
/* -- Table ------------------------------------------------- */
|
||
table { width: 100%; border-collapse: collapse; }
|
||
th {
|
||
text-align: left; padding: 10px 12px; font-size: 12px; font-weight: 600;
|
||
color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.3px;
|
||
border-bottom: 1px solid var(--border);
|
||
}
|
||
td {
|
||
padding: 10px 12px; border-bottom: 1px solid var(--border);
|
||
font-size: 13px; vertical-align: top;
|
||
}
|
||
tr:hover td { background: var(--bg-hover); }
|
||
.empty-state {
|
||
text-align: center; padding: 40px 20px; color: var(--text-muted);
|
||
}
|
||
.empty-state .icon { font-size: 36px; margin-bottom: 8px; }
|
||
/* -- Badges ------------------------------------------------ */
|
||
.badge {
|
||
display: inline-block; font-size: 11px; font-weight: 600; padding: 2px 8px;
|
||
border-radius: 10px; text-transform: uppercase; letter-spacing: 0.3px;
|
||
}
|
||
.badge-success { background: rgba(16,185,129,0.15); color: var(--success); }
|
||
.badge-warning { background: rgba(245,158,11,0.15); color: var(--warning); }
|
||
.badge-danger { background: rgba(244,63,94,0.15); color: var(--danger); }
|
||
.badge-accent { background: rgba(245,158,11,0.15); color: var(--accent); }
|
||
/* -- Loading / Toast -------------------------------------- */
|
||
.spinner {
|
||
display: inline-block; width: 16px; height: 16px;
|
||
border: 2px solid var(--border); border-top-color: var(--accent);
|
||
border-radius: 50%; animation: spin 0.6s linear infinite;
|
||
}
|
||
@keyframes spin { to { transform: rotate(360deg); } }
|
||
.toast-container { position: fixed; bottom: 20px; right: 20px; z-index: 999; }
|
||
.toast {
|
||
padding: 10px 16px; border-radius: var(--radius); margin-top: 8px;
|
||
font-size: 13px; font-weight: 500; box-shadow: 0 4px 12px rgba(0,0,0,0.5);
|
||
animation: fadeIn 0.2s;
|
||
}
|
||
.toast-success { background: #065f46; color: #a7f3d0; }
|
||
.toast-error { background: #7f1d1d; color: #fecaca; }
|
||
@keyframes fadeIn { from { opacity: 0; transform: translateY(8px); } }
|
||
/* -- File upload drop zone -------------------------------- */
|
||
.drop-zone {
|
||
border: 2px dashed var(--border); border-radius: var(--radius-lg);
|
||
padding: 40px; text-align: center; cursor: pointer; transition: all 0.2s;
|
||
}
|
||
.drop-zone:hover, .drop-zone.drag-over {
|
||
border-color: var(--accent); background: rgba(245,158,11,0.05);
|
||
}
|
||
.drop-zone .icon { font-size: 32px; margin-bottom: 8px; }
|
||
.drop-zone .text { color: var(--text-muted); font-size: 13px; }
|
||
.drop-zone .text strong { color: var(--accent); }
|
||
/* -- Grid helpers ------------------------------------------ */
|
||
.row { display: flex; gap: 12px; align-items: flex-end; flex-wrap: wrap; }
|
||
.col { flex: 1; min-width: 160px; }
|
||
.mb-0 { margin-bottom: 0; }
|
||
.mb-2 { margin-bottom: 8px; }
|
||
.mb-4 { margin-bottom: 16px; }
|
||
.mt-4 { margin-top: 16px; }
|
||
.text-muted { color: var(--text-muted); }
|
||
.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;
|
||
cursor: pointer; padding: 14px 16px; background: var(--bg-card);
|
||
border: 1px solid var(--border); border-radius: var(--radius-lg);
|
||
margin-bottom: 8px; transition: background 0.15s;
|
||
user-select: none;
|
||
}
|
||
.collapse-header:hover { background: var(--bg-hover); }
|
||
.collapse-header.open { border-radius: var(--radius-lg) var(--radius-lg) 0 0; margin-bottom: 0; }
|
||
.collapse-header .chevron {
|
||
transition: transform 0.2s; font-size: 12px; color: var(--text-muted);
|
||
}
|
||
.collapse-header.open .chevron { transform: rotate(90deg); }
|
||
.collapse-body {
|
||
border: 1px solid var(--border); border-top: none;
|
||
border-radius: 0 0 var(--radius-lg) var(--radius-lg);
|
||
padding: 16px; margin-bottom: 16px; background: var(--bg-card);
|
||
}
|
||
/* -- Chunk preview expandable rows ----------------------- */
|
||
.chunk-row { cursor: pointer; }
|
||
.chunk-row:hover td { background: var(--bg-hover); }
|
||
.chunk-detail {
|
||
background: var(--bg-base); border: 1px solid var(--border);
|
||
border-radius: var(--radius); padding: 12px; margin: 4px 0 4px 0;
|
||
font-size: 12px; line-height: 1.7; color: var(--text-body);
|
||
white-space: pre-wrap; word-break: break-word; max-height: 300px;
|
||
overflow-y: auto;
|
||
}
|
||
.chunk-detail .meta { color: var(--text-muted); margin-top: 6px; }
|
||
/* -- Cost estimator cards -------------------------------- */
|
||
.cost-result { display: flex; gap: 12px; flex-wrap: wrap; margin-top: 16px; }
|
||
.cost-card {
|
||
background: var(--bg-base); border: 1px solid var(--border);
|
||
border-radius: var(--radius); padding: 14px 18px; flex: 1; min-width: 140px;
|
||
}
|
||
.cost-card .label { font-size: 11px; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 4px; }
|
||
.cost-card .value { font-size: 20px; font-weight: 700; color: var(--text-heading); }
|
||
.cost-card .value.amber { color: var(--accent); }
|
||
.cost-card .sub { font-size: 11px; color: var(--text-muted); margin-top: 2px; }
|
||
/* ── Comparison table ───────────────────────────────────── */
|
||
.cmp-table th { position: sticky; top: 0; background: var(--bg-card); z-index: 1; }
|
||
.cmp-table td.best { color: var(--accent); font-weight: 700; }
|
||
.cmp-table td.worst { color: var(--text-muted); opacity: 0.6; }
|
||
.cmp-highlight { background: rgba(245,158,11,0.08); }
|
||
.cmp-exp-label { font-size: 11px; font-weight: 600; padding: 3px 8px; border-radius: 4px; display: inline-block; margin-bottom: 4px; }
|
||
.cmp-exp-0 { background: rgba(245,158,11,0.15); color: var(--accent); }
|
||
.cmp-exp-1 { background: rgba(16,185,129,0.15); color: var(--success); }
|
||
.cmp-exp-2 { background: rgba(99,102,241,0.15); color: #818cf8; }
|
||
.cmp-exp-3 { background: rgba(244,63,94,0.15); color: #fb7185; }
|
||
.cmp-divider { height: 1px; background: var(--border); margin: 20px 0; }
|
||
/* ── Decision Board ─────────────────────────────────────── */
|
||
.decision-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
||
@media (max-width: 960px) { .decision-grid { grid-template-columns: 1fr; } }
|
||
.decision-cand {
|
||
border: 1px solid var(--border); border-radius: var(--radius);
|
||
padding: 12px 14px; margin-bottom: 8px; cursor: pointer;
|
||
background: var(--bg-base); transition: border-color 0.15s, background 0.15s;
|
||
}
|
||
.decision-cand:hover { border-color: #3a3a42; }
|
||
.decision-cand.selected {
|
||
border-color: var(--accent); background: rgba(234,179,8,0.08);
|
||
}
|
||
.decision-cand .cand-title { font-weight: 600; color: var(--text-heading); margin-bottom: 6px; }
|
||
.decision-metrics { display: flex; flex-wrap: wrap; gap: 8px 14px; font-size: 12px; color: var(--text-muted); }
|
||
.decision-metrics strong { color: var(--text-body); font-variant-numeric: tabular-nums; }
|
||
.decision-legend {
|
||
display: flex; flex-wrap: wrap; gap: 10px 16px; align-items: center;
|
||
margin-bottom: 14px; padding: 12px 14px;
|
||
background: rgba(15, 20, 25, 0.55);
|
||
border: 1px solid rgba(255,255,255,0.06);
|
||
border-radius: 10px;
|
||
font-size: 12px; color: var(--text-muted);
|
||
}
|
||
.decision-legend-item { display: inline-flex; align-items: center; gap: 7px; }
|
||
.decision-legend-swatch {
|
||
width: 22px; height: 14px; border-radius: 4px;
|
||
border: 1px solid rgba(255,255,255,0.08);
|
||
box-shadow: inset 3px 0 0 var(--swatch-accent, transparent);
|
||
flex-shrink: 0;
|
||
}
|
||
.decision-matrix-wrap {
|
||
overflow-x: auto;
|
||
border: 1px solid rgba(255,255,255,0.06);
|
||
border-radius: 12px;
|
||
background: rgba(15, 20, 25, 0.35);
|
||
}
|
||
.decision-matrix {
|
||
width: 100%; border-collapse: separate; border-spacing: 0;
|
||
font-size: 13px; margin: 0;
|
||
}
|
||
.decision-matrix th,
|
||
.decision-matrix td {
|
||
padding: 10px 12px;
|
||
border-bottom: 1px solid rgba(255,255,255,0.05);
|
||
border-right: 1px solid rgba(255,255,255,0.04);
|
||
text-align: center;
|
||
vertical-align: middle;
|
||
}
|
||
.decision-matrix th:last-child,
|
||
.decision-matrix td:last-child { border-right: none; }
|
||
.decision-matrix tbody tr:last-child th,
|
||
.decision-matrix tbody tr:last-child td { border-bottom: none; }
|
||
.decision-matrix thead th {
|
||
position: sticky; top: 0; z-index: 2;
|
||
background: rgba(21, 32, 51, 0.96);
|
||
backdrop-filter: blur(8px);
|
||
color: #94A3B8;
|
||
font-size: 11px; font-weight: 600;
|
||
letter-spacing: 0.03em;
|
||
white-space: nowrap;
|
||
}
|
||
.decision-matrix .sticky-col {
|
||
position: sticky; left: 0; z-index: 1;
|
||
text-align: left; font-weight: 500; white-space: nowrap;
|
||
background: #1A2332; color: var(--text-heading);
|
||
min-width: 160px; max-width: 220px;
|
||
}
|
||
.decision-matrix thead .sticky-col { z-index: 3; background: rgba(21, 32, 51, 0.96); }
|
||
.decision-matrix tr.mean-row td,
|
||
.decision-matrix tr.mean-row .sticky-col {
|
||
border-top: 1px solid rgba(255,255,255,0.1);
|
||
background: rgba(15, 20, 25, 0.65);
|
||
font-weight: 700;
|
||
}
|
||
.decision-matrix tr.wins-row td,
|
||
.decision-matrix tr.wins-row .sticky-col {
|
||
background: rgba(15, 20, 25, 0.45);
|
||
color: var(--text-muted);
|
||
font-weight: 600;
|
||
}
|
||
.decision-matrix th.col-duel {
|
||
color: #FBBF24;
|
||
}
|
||
.decision-heat {
|
||
font-variant-numeric: tabular-nums;
|
||
font-size: 12.5px; font-weight: 600;
|
||
min-width: 76px;
|
||
letter-spacing: 0.01em;
|
||
transition: filter 0.15s ease;
|
||
}
|
||
.decision-heat:hover { filter: brightness(1.12); }
|
||
.decision-heat .best-mark,
|
||
.decision-legend .best-mark,
|
||
.best-mark {
|
||
display: inline-block;
|
||
font-size: 9px; font-weight: 700; line-height: 1;
|
||
margin-right: 5px; padding: 2px 5px;
|
||
border-radius: 999px; vertical-align: middle;
|
||
letter-spacing: 0.04em;
|
||
}
|
||
.decision-heat .best-mark.fs,
|
||
.decision-legend .best-mark.fs,
|
||
.best-mark.fs {
|
||
background: rgba(56, 189, 248, 0.15);
|
||
color: #7DD3FC;
|
||
border: 1px solid rgba(56, 189, 248, 0.28);
|
||
}
|
||
.decision-heat .best-mark.sem,
|
||
.decision-legend .best-mark.sem,
|
||
.best-mark.sem {
|
||
background: rgba(167, 139, 250, 0.15);
|
||
color: #C4B5FD;
|
||
border: 1px solid rgba(167, 139, 250, 0.28);
|
||
}
|
||
.decision-duel {
|
||
display: grid; grid-template-columns: 1fr auto 1fr; gap: 16px; align-items: stretch;
|
||
}
|
||
@media (max-width: 800px) { .decision-duel { grid-template-columns: 1fr; } }
|
||
.decision-vs {
|
||
display: flex; align-items: center; justify-content: center;
|
||
font-weight: 700; color: var(--text-muted); font-size: 18px;
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div id="root"></div>
|
||
<script src="https://unpkg.com/react@18/umd/react.production.min.js" crossorigin></script>
|
||
<script src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js" crossorigin></script>
|
||
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
|
||
<script type="text/babel">
|
||
const { useState, useEffect, useRef, useCallback } = React;
|
||
|
||
const API = ''; // same origin
|
||
|
||
// -- API helpers --------------------------------------------
|
||
async function api(path, opts = {}) {
|
||
const { method = 'GET', body, isForm } = opts;
|
||
const headers = {};
|
||
const fetchOpts = { method };
|
||
if (body && !isForm) {
|
||
headers['Content-Type'] = 'application/json';
|
||
fetchOpts.body = JSON.stringify(body);
|
||
}
|
||
if (isForm && body) {
|
||
fetchOpts.body = body;
|
||
}
|
||
fetchOpts.headers = headers;
|
||
const res = await fetch(API + path, fetchOpts);
|
||
if (!res.ok) {
|
||
const text = await res.text().catch(() => '');
|
||
throw new Error(`${res.status}: ${text}`);
|
||
}
|
||
return res.json();
|
||
}
|
||
|
||
// -- Format filter helpers (ADR-0017) -----------------------
|
||
function isPdfFilename(name) {
|
||
return (name || '').toLowerCase().endsWith('.pdf');
|
||
}
|
||
function isWordFilename(name) {
|
||
const n = (name || '').toLowerCase();
|
||
return n.endsWith('.doc') || n.endsWith('.docx');
|
||
}
|
||
function filterDocuments(docs, format) {
|
||
if (format === 'pdf') return (docs || []).filter(d => isPdfFilename(d.filename));
|
||
if (format === 'word') return (docs || []).filter(d => isWordFilename(d.filename));
|
||
return docs || [];
|
||
}
|
||
function filterExperiments(exps, format) {
|
||
if (format === 'pdf') return (exps || []).filter(e => isPdfFilename(e.document_filename || ''));
|
||
if (format === 'word') return (exps || []).filter(e => isWordFilename(e.document_filename || ''));
|
||
return exps || [];
|
||
}
|
||
function formatAccept(format) {
|
||
if (format === 'pdf') return '.pdf';
|
||
if (format === 'word') return '.doc,.docx';
|
||
return '.doc,.docx,.pdf';
|
||
}
|
||
function formatDropLabel(format) {
|
||
if (format === 'pdf') return '.pdf';
|
||
if (format === 'word') return '.doc or .docx';
|
||
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 }) {
|
||
return React.createElement('div', { className: 'toast-container' },
|
||
toasts.map(t =>
|
||
React.createElement('div', {
|
||
key: t.id,
|
||
className: `toast toast-${t.type}`,
|
||
onClick: () => removeToast(t.id),
|
||
}, t.message)
|
||
)
|
||
);
|
||
}
|
||
|
||
// -- Tab: Home ----------------------------------------------
|
||
function HomeTab() {
|
||
const [stats, setStats] = useState(null);
|
||
const [loading, setLoading] = useState(true);
|
||
|
||
useEffect(() => {
|
||
Promise.all([
|
||
api('/documents').catch(() => ({ total: 0 })),
|
||
api('/experiments?limit=500').catch(() => []),
|
||
]).then(([docs, exps]) => {
|
||
setStats({ docs: docs.total || 0, experiments: (exps?.items || []).length });
|
||
setLoading(false);
|
||
});
|
||
}, []);
|
||
|
||
return React.createElement('div', null,
|
||
React.createElement('h1', { style: { color: 'var(--text-heading)', marginBottom: '16px', fontSize: '22px' } },
|
||
'RAG Chunking Benchmarker'),
|
||
React.createElement('p', { className: 'text-muted mb-4', style: { maxWidth: '600px' } },
|
||
'Compare five chunking strategies on your documents. Upload, chunk, query, benchmark, and evaluate.'),
|
||
React.createElement('div', { className: 'status-grid' },
|
||
React.createElement('div', { className: 'status-card' },
|
||
React.createElement('div', { className: 'label' }, 'Documents'),
|
||
React.createElement('div', { className: 'value accent' },
|
||
loading ? React.createElement('span', { className: 'spinner' }) : stats?.docs || 0)),
|
||
React.createElement('div', { className: 'status-card' },
|
||
React.createElement('div', { className: 'label' }, 'Experiments'),
|
||
React.createElement('div', { className: 'value green' },
|
||
loading ? React.createElement('span', { className: 'spinner' }) : stats?.experiments || 0)),
|
||
React.createElement('div', { className: 'status-card' },
|
||
React.createElement('div', { className: 'label' }, 'Strategies'),
|
||
React.createElement('div', { className: 'value' }, '5')),
|
||
React.createElement('div', { className: 'status-card' },
|
||
React.createElement('div', { className: 'label' }, 'Server'),
|
||
React.createElement('div', { className: 'value green' }, '●'))
|
||
),
|
||
React.createElement('div', { className: 'card mt-4' },
|
||
React.createElement('div', { className: 'card-title' }, '⚡ Quick Start'),
|
||
React.createElement('ol', { style: { paddingLeft: '20px', lineHeight: '2' } },
|
||
React.createElement('li', null, 'Upload a Word document in ', React.createElement('b', null, 'Documents'), ' or a Text PDF in ', React.createElement('b', null, 'PDF')),
|
||
React.createElement('li', null, 'Process it with chunking strategies'),
|
||
React.createElement('li', null, 'Ask a question in ', React.createElement('b', null, 'Query'), ' (or inside PDF Workspace)'),
|
||
React.createElement('li', null, 'Run a full benchmark in ', React.createElement('b', null, 'Benchmarks'), ' / PDF Workspace'),
|
||
React.createElement('li', null, 'Pick a final Strategy on the ', React.createElement('b', null, 'Decision'), ' board'),
|
||
React.createElement('li', null, 'Check system health in the ', React.createElement('b', null, 'Admin'), ' tab')
|
||
)
|
||
)
|
||
);
|
||
}
|
||
|
||
// -- Tab: Documents -----------------------------------------
|
||
function DocumentsTab({ documents, setDocuments, strategies, addToast, formatFilter = 'word', hideTitle = false }) {
|
||
const [uploading, setUploading] = useState(false);
|
||
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(() => {});
|
||
}, [setDocuments]);
|
||
|
||
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 || '';
|
||
if (formatFilter === 'pdf' && !isPdfFilename(name)) {
|
||
addToast('Only .pdf files are allowed here', 'error');
|
||
return;
|
||
}
|
||
if (formatFilter === 'word' && !isWordFilename(name)) {
|
||
addToast('Only .doc / .docx files are allowed here', 'error');
|
||
return;
|
||
}
|
||
setUploading(true);
|
||
try {
|
||
const form = new FormData();
|
||
form.append('file', file);
|
||
await api('/documents', { method: 'POST', body: form, isForm: true });
|
||
addToast('Document uploaded successfully', 'success');
|
||
fetchDocs();
|
||
} catch (e) {
|
||
addToast(`Upload failed: ${e.message}`, 'error');
|
||
} finally {
|
||
setUploading(false);
|
||
}
|
||
};
|
||
|
||
const handleFileDrop = (e) => {
|
||
e.preventDefault(); setDragOver(false);
|
||
const file = e.dataTransfer.files[0];
|
||
if (file) handleUpload(file);
|
||
};
|
||
|
||
const handleProcess = async (docId) => {
|
||
const strats = selectedStrategies[docId];
|
||
if (!strats || strats.length === 0) {
|
||
addToast('Select at least one strategy', 'error');
|
||
return;
|
||
}
|
||
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,
|
||
});
|
||
addToast(`Processed with ${strats.length} strategy(ies)`, 'success');
|
||
fetchDocs();
|
||
} catch (e) {
|
||
addToast(`Process failed: ${e.message}`, 'error');
|
||
} finally {
|
||
setProcessing(p => ({ ...p, [docId]: false }));
|
||
}
|
||
};
|
||
|
||
const handleDelete = async (docId) => {
|
||
if (!confirm('Delete this document and all its vectors?')) return;
|
||
try {
|
||
await api(`/documents/${docId}`, { method: 'DELETE' });
|
||
addToast('Document deleted', 'success');
|
||
fetchDocs();
|
||
} catch (e) {
|
||
addToast(`Delete failed: ${e.message}`, 'error');
|
||
}
|
||
};
|
||
|
||
const toggleStrategy = (docId, stratName) => {
|
||
setSelectedStrategies(prev => {
|
||
const current = prev[docId] || [];
|
||
const next = current.includes(stratName)
|
||
? current.filter(s => s !== stratName)
|
||
: [...current, stratName];
|
||
return { ...prev, [docId]: next };
|
||
});
|
||
};
|
||
|
||
const selectAllStrategies = (docId) => {
|
||
setSelectedStrategies(prev => ({
|
||
...prev,
|
||
[docId]: strategies.map(s => s.name)
|
||
}));
|
||
};
|
||
|
||
return React.createElement('div', null,
|
||
!hideTitle && React.createElement('h1', { style: { color: 'var(--text-heading)', marginBottom: '16px', fontSize: '22px' } }, 'Documents'),
|
||
|
||
// Upload zone
|
||
React.createElement('div', {
|
||
className: `drop-zone ${dragOver ? 'drag-over' : ''}`,
|
||
onDragOver: (e) => { e.preventDefault(); setDragOver(true); },
|
||
onDragLeave: () => setDragOver(false),
|
||
onDrop: handleFileDrop,
|
||
onClick: () => fileRef.current?.click(),
|
||
},
|
||
React.createElement('div', { className: 'icon' }, uploading ? '⏳' : '📄'),
|
||
React.createElement('div', { className: 'text' },
|
||
uploading
|
||
? 'Uploading...'
|
||
: React.createElement('span', null, 'Drag & drop a ', React.createElement('strong', null, formatDropLabel(formatFilter)), ' file, or click to browse')),
|
||
React.createElement('input', {
|
||
ref: fileRef, type: 'file', accept: formatAccept(formatFilter), style: { display: 'none' },
|
||
onChange: (e) => handleUpload(e.target.files[0])
|
||
})
|
||
),
|
||
|
||
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' },
|
||
React.createElement('div', { className: 'card-title mb-0' }, `Uploaded Documents (${visibleDocs.length})`),
|
||
React.createElement('button', { className: 'btn btn-secondary btn-sm', onClick: fetchDocs }, '↻ Refresh')
|
||
),
|
||
visibleDocs.length === 0
|
||
? React.createElement('div', { className: 'empty-state' },
|
||
React.createElement('div', { className: 'icon' }, '📭'),
|
||
React.createElement('div', null, formatFilter === 'pdf' ? 'No PDF documents uploaded yet' : 'No documents uploaded yet'))
|
||
: React.createElement('table', null,
|
||
React.createElement('thead', null,
|
||
React.createElement('tr', null,
|
||
React.createElement('th', null, 'Filename'),
|
||
React.createElement('th', null, 'Paragraphs'),
|
||
React.createElement('th', null, 'Chunks'),
|
||
React.createElement('th', null, 'Strategies'),
|
||
React.createElement('th', null, 'Created'),
|
||
React.createElement('th', null, 'Actions')
|
||
)
|
||
),
|
||
React.createElement('tbody', null,
|
||
visibleDocs.map(doc =>
|
||
React.createElement('tr', { key: doc.id },
|
||
React.createElement('td', null,
|
||
React.createElement('span', { className: 'truncate', style: { display: 'inline-block' } }, doc.filename)),
|
||
React.createElement('td', null, doc.paragraph_count),
|
||
React.createElement('td', null,
|
||
Object.keys(doc.chunk_counts || {}).length > 0
|
||
? Object.entries(doc.chunk_counts).map(([k, v]) =>
|
||
React.createElement('span', { key: k, className: 'badge badge-accent', style: { marginRight: '4px' } }, `${k}: ${v}`))
|
||
: React.createElement('span', { className: 'text-muted text-sm' }, 'Not processed')
|
||
),
|
||
React.createElement('td', { style: { maxWidth: '260px' } },
|
||
React.createElement('div', { style: { display: 'flex', flexWrap: 'wrap', gap: '4px' } },
|
||
strategies.map(s =>
|
||
React.createElement('label', {
|
||
key: s.name,
|
||
style: {
|
||
display: 'inline-flex', alignItems: 'center', gap: '4px',
|
||
fontSize: '11px', padding: '2px 8px', borderRadius: '4px',
|
||
cursor: 'pointer', border: '1px solid var(--border)',
|
||
background: (selectedStrategies[doc.id] || []).includes(s.name) ? 'rgba(245,158,11,0.15)' : 'transparent',
|
||
color: (selectedStrategies[doc.id] || []).includes(s.name) ? 'var(--accent)' : 'var(--text-muted)',
|
||
}
|
||
},
|
||
React.createElement('input', {
|
||
type: 'checkbox', style: { display: 'none' },
|
||
checked: (selectedStrategies[doc.id] || []).includes(s.name),
|
||
onChange: () => toggleStrategy(doc.id, s.name),
|
||
}),
|
||
s.name
|
||
)
|
||
)
|
||
),
|
||
React.createElement('button', {
|
||
className: 'btn btn-secondary btn-sm', style: { marginTop: '4px', fontSize: '11px' },
|
||
onClick: () => selectAllStrategies(doc.id)
|
||
}, 'Select All')
|
||
),
|
||
React.createElement('td', { className: 'text-sm text-muted' },
|
||
new Date(doc.created_at).toLocaleDateString()),
|
||
React.createElement('td', null,
|
||
React.createElement('div', { style: { display: 'flex', gap: '4px', flexWrap: 'wrap' } },
|
||
React.createElement('button', {
|
||
className: 'btn btn-primary btn-sm',
|
||
disabled: processing[doc.id] || !selectedStrategies[doc.id]?.length,
|
||
onClick: () => handleProcess(doc.id),
|
||
},
|
||
processing[doc.id]
|
||
? React.createElement('span', { className: 'spinner' })
|
||
: 'Process'
|
||
),
|
||
React.createElement('button', {
|
||
className: 'btn btn-danger btn-sm',
|
||
onClick: () => handleDelete(doc.id),
|
||
}, '✕')
|
||
)
|
||
)
|
||
)
|
||
)
|
||
)
|
||
)
|
||
)
|
||
);
|
||
}
|
||
|
||
// -- 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: 'fixed_size', question: '', top_k: 5,
|
||
neighbor_prev: 3, neighbor_next: 3, 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');
|
||
return;
|
||
}
|
||
setLoading(true);
|
||
try {
|
||
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) {
|
||
addToast(`Query failed: ${e.message}`, 'error');
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
return React.createElement('div', null,
|
||
!hideTitle && React.createElement('h1', { style: { color: 'var(--text-heading)', marginBottom: '16px', fontSize: '22px' } }, 'Query'),
|
||
|
||
React.createElement('div', { className: 'card' },
|
||
React.createElement('div', { className: 'row' },
|
||
React.createElement('div', { className: 'col' },
|
||
React.createElement('label', null, 'Document'),
|
||
React.createElement('select', {
|
||
value: form.document_id,
|
||
onChange: e => setForm({ ...form, document_id: e.target.value })
|
||
},
|
||
React.createElement('option', { value: '' }, 'Select document...'),
|
||
visibleDocs.map(d => React.createElement('option', { key: d.id, value: d.id }, d.filename))
|
||
)
|
||
),
|
||
React.createElement('div', { className: 'col' },
|
||
React.createElement('label', null, 'Strategy'),
|
||
React.createElement('select', {
|
||
value: form.strategy,
|
||
onChange: e => setForm({ ...form, strategy: e.target.value })
|
||
},
|
||
React.createElement('option', { value: '' }, 'Select strategy...'),
|
||
strategies.map(s => React.createElement('option', { key: s.name, value: s.name }, s.name))
|
||
)
|
||
),
|
||
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', {
|
||
placeholder: 'Type your question here...',
|
||
value: form.question,
|
||
onChange: e => setForm({ ...form, question: e.target.value })
|
||
})
|
||
),
|
||
React.createElement('button', {
|
||
className: 'btn btn-primary',
|
||
onClick: handleQuery,
|
||
disabled: loading,
|
||
}, loading ? React.createElement('span', { className: 'spinner' }) : '⚡ Ask Question')
|
||
),
|
||
|
||
result && React.createElement('div', { className: 'card mt-4' },
|
||
React.createElement('div', { className: 'card-title' }, '💡 Answer'),
|
||
React.createElement('div', { style: { marginBottom: '16px', lineHeight: '1.8', color: 'var(--text-body)' } }, result.answer),
|
||
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)}` +
|
||
(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 (LLM context)'),
|
||
React.createElement('table', null,
|
||
React.createElement('thead', null,
|
||
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 ${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;
|
||
|
||
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 => {
|
||
const map = {};
|
||
(e.per_question || []).forEach(pq => { map[pq.question_id || pq.question?.substring(0, 30)] = pq; });
|
||
return map;
|
||
});
|
||
|
||
const fmt = (v) => typeof v === 'number' ? v.toFixed(2) : (v ?? 'N/A');
|
||
const pct = (v) => typeof v === 'number' ? (v * 100).toFixed(0) + '%' : 'N/A';
|
||
const expName = (e) => e.document_filename || e.document_id?.substring(0, 8) || e.id?.substring(0, 8);
|
||
|
||
const rank = (vals, lowerIsBetter) => {
|
||
const nums = vals.map(v => typeof v === 'number' ? v : null);
|
||
const valid = nums.filter(v => v !== null);
|
||
if (valid.length === 0) return vals.map(() => '');
|
||
const max = Math.max(...valid);
|
||
const min = Math.min(...valid);
|
||
if (lowerIsBetter) return nums.map(v => v === min ? 'best' : (v === max && valid.length > 1 ? 'worst' : ''));
|
||
return nums.map(v => v === max ? 'best' : (v === min && valid.length > 1 ? 'worst' : ''));
|
||
};
|
||
|
||
// Overall score for a strategy in a single experiment
|
||
const strategyScore = (aggMetrics, strat) => {
|
||
const m = aggMetrics[strat];
|
||
if (!m) return null;
|
||
return ((m.avg_context_relevance || 0) * 0.3 + (m.avg_answer_similarity || 0) * 0.4 + (m.avg_faithfulness || 0) * 0.3) * (1 - (m.hallucination_rate || 0));
|
||
};
|
||
|
||
// Average score for a strategy across all experiments
|
||
const strategyAvgScore = (strat) => {
|
||
const scores = experiments.map(e => strategyScore(e.aggregate_metrics || {}, strat)).filter(v => v !== null);
|
||
return scores.length > 0 ? scores.reduce((a, b) => a + b, 0) / scores.length : null;
|
||
};
|
||
|
||
// Find overall best strategy
|
||
let overallBest = 'N/A', overallBestScore = -1;
|
||
allStrategies.forEach(s => {
|
||
const avg = strategyAvgScore(s);
|
||
if (avg !== null && avg > overallBestScore) { overallBestScore = avg; overallBest = s; }
|
||
});
|
||
|
||
// Strategy comparison rows: for each strategy, show score per experiment
|
||
const stratRows = allStrategies.map(s => {
|
||
const vals = experiments.map(e => strategyScore(e.aggregate_metrics || {}, s));
|
||
return { strat: s, vals, avg: strategyAvgScore(s) };
|
||
});
|
||
// Sort by average score descending
|
||
stratRows.sort((a, b) => (b.avg || 0) - (a.avg || 0));
|
||
|
||
// Column ranks: for each experiment, which strategy is best/worst
|
||
const colRanks = experiments.map((e, ei) => {
|
||
const colVals = stratRows.map(r => r.vals[ei]);
|
||
return rank(colVals, false);
|
||
});
|
||
|
||
// Per-metric averages per strategy across experiments
|
||
const stratMetricAvg = (strat, metric) => {
|
||
const vals = experiments.map(e => (e.aggregate_metrics || {})[strat]?.[metric]).filter(v => v != null);
|
||
return vals.length > 0 ? vals.reduce((a, b) => a + b, 0) / vals.length : null;
|
||
};
|
||
|
||
// Best strategy per experiment
|
||
const calcBest = (e) => {
|
||
const aggs = e.aggregate_metrics || {};
|
||
let best = 'N/A', bestScore = -1;
|
||
Object.entries(aggs).forEach(([strat, m]) => {
|
||
const score = ((m.avg_context_relevance || 0) * 0.3 + (m.avg_answer_similarity || 0) * 0.4 + (m.avg_faithfulness || 0) * 0.3) * (1 - (m.hallucination_rate || 0));
|
||
if (score > bestScore) { bestScore = score; best = strat; }
|
||
});
|
||
return best;
|
||
};
|
||
|
||
return React.createElement('div', { className: 'card mt-4' },
|
||
// Header
|
||
React.createElement('div', { className: 'flex-between mb-4' },
|
||
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} 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' }
|
||
},
|
||
React.createElement('span', { style: { fontSize: '28px' } }, '🏆'),
|
||
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} experiments`)
|
||
)
|
||
),
|
||
|
||
// Strategy Comparison Table (rows = strategies, cols = experiments)
|
||
React.createElement('h3', { style: { fontSize: '14px', fontWeight: 600, color: 'var(--text-heading)', marginBottom: '8px' } }, 'Per-Strategy Scores'),
|
||
React.createElement('table', { className: 'cmp-table', style: { marginBottom: '20px' } },
|
||
React.createElement('thead', null,
|
||
React.createElement('tr', null,
|
||
React.createElement('th', null, 'Strategy'),
|
||
experiments.map((e, i) =>
|
||
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')
|
||
)
|
||
),
|
||
React.createElement('tbody', null,
|
||
stratRows.map(({ strat, vals, avg }, si) => {
|
||
const avgRank = rank(stratRows.map(r => r.avg), false);
|
||
return React.createElement('tr', {
|
||
key: strat,
|
||
style: strat === overallBest ? { background: 'rgba(16,185,129,0.08)' } : {}
|
||
},
|
||
React.createElement('td', null, React.createElement('span', { className: 'badge badge-accent' }, strat)),
|
||
experiments.map((e, ei) =>
|
||
React.createElement('td', { key: e.id, className: colRanks[ei]?.[si] || '' },
|
||
vals[ei] != null ? fmt(vals[ei]) : React.createElement('span', { className: 'text-muted' }, '—'))
|
||
),
|
||
React.createElement('td', { className: avgRank[si], style: { fontWeight: 700, background: 'rgba(245,158,11,0.05)' } },
|
||
avg != null ? fmt(avg) : '—')
|
||
);
|
||
})
|
||
)
|
||
),
|
||
|
||
React.createElement('div', { className: 'cmp-divider' }),
|
||
|
||
// Metrics Breakdown (rows = strategies, showing per-metric averages)
|
||
React.createElement('h3', { style: { fontSize: '14px', fontWeight: 600, color: 'var(--text-heading)', marginBottom: '8px' } }, 'Metrics Breakdown'),
|
||
React.createElement('table', { className: 'cmp-table', style: { marginBottom: '20px' } },
|
||
React.createElement('thead', null,
|
||
React.createElement('tr', null,
|
||
React.createElement('th', null, 'Strategy'),
|
||
React.createElement('th', null, 'Context Relevance'),
|
||
React.createElement('th', null, 'Answer Similarity'),
|
||
React.createElement('th', null, 'Faithfulness'),
|
||
React.createElement('th', null, 'Hallucination'),
|
||
React.createElement('th', null, 'Best In')
|
||
)
|
||
),
|
||
React.createElement('tbody', null,
|
||
allStrategies.map(strat => {
|
||
const vals = metrics.map(m => stratMetricAvg(strat, m));
|
||
const ranks = metrics.map((m, mi) => {
|
||
const allVals = allStrategies.map(s => stratMetricAvg(s, m)).filter(v => v != null);
|
||
const isLower = m === 'hallucination_rate';
|
||
const best = isLower ? Math.min(...allVals) : Math.max(...allVals);
|
||
return vals[mi] === best && allVals.length > 1 ? 'best' : '';
|
||
});
|
||
// Count how many metrics this strategy is best in
|
||
const bestCount = ranks.filter(r => r === 'best').length;
|
||
return React.createElement('tr', { key: strat, style: strat === overallBest ? { background: 'rgba(16,185,129,0.08)' } : {} },
|
||
React.createElement('td', null, React.createElement('span', { className: 'badge badge-accent' }, strat)),
|
||
metrics.map((m, mi) =>
|
||
React.createElement('td', { key: m, className: ranks[mi] },
|
||
m === 'hallucination_rate' ? pct(vals[mi]) : fmt(vals[mi]))
|
||
),
|
||
React.createElement('td', { style: { textAlign: 'center' } },
|
||
bestCount > 0 ? React.createElement('span', { className: 'badge badge-success' }, `${bestCount}/${metrics.length}`) :
|
||
React.createElement('span', { className: 'text-muted text-sm' }, '—'))
|
||
);
|
||
})
|
||
)
|
||
),
|
||
|
||
React.createElement('div', { className: 'cmp-divider' }),
|
||
|
||
// Per-Experiment Best Strategy
|
||
React.createElement('h3', { style: { fontSize: '14px', fontWeight: 600, color: 'var(--text-heading)', marginBottom: '8px' } }, 'Best Strategy Per Document'),
|
||
React.createElement('div', { style: { display: 'flex', gap: '12px', flexWrap: 'wrap', marginBottom: '20px' } },
|
||
experiments.map((e, i) => {
|
||
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 || (e.questions || []).length || '?'} questions`)
|
||
);
|
||
})
|
||
),
|
||
|
||
React.createElement('div', { className: 'cmp-divider' }),
|
||
|
||
// Per-Question Comparison (strategies as columns)
|
||
React.createElement('h3', { style: { fontSize: '14px', fontWeight: 600, color: 'var(--text-heading)', marginBottom: '8px' } }, 'Per-Question Breakdown'),
|
||
React.createElement('div', { className: 'text-sm text-muted mb-2' }, `Showing ${Math.min(allQIds.length, 10)} of ${allQIds.length} questions — score = answer_similarity`),
|
||
React.createElement('table', { className: 'cmp-table' },
|
||
React.createElement('thead', null,
|
||
React.createElement('tr', null,
|
||
React.createElement('th', { style: { maxWidth: '200px' } }, 'Question'),
|
||
allStrategies.map(s =>
|
||
React.createElement('th', { key: s, style: { textAlign: 'center' } }, s)
|
||
)
|
||
)
|
||
),
|
||
React.createElement('tbody', null,
|
||
allQIds.slice(0, 10).map(qId => {
|
||
const qText = experiments[0]?.questions?.find(q => (q.id || q.question?.substring(0, 30)) === qId)?.question || qId;
|
||
const stratScores = allStrategies.map(s => {
|
||
const scores = experiments.map((e, i) => {
|
||
const pq = expPerQ[i]?.[qId];
|
||
return pq?.strategies?.[s]?.scores?.answer_similarity;
|
||
}).filter(v => v != null);
|
||
return scores.length > 0 ? scores.reduce((a, b) => a + b, 0) / scores.length : null;
|
||
});
|
||
const sRanks = rank(stratScores, false);
|
||
return React.createElement('tr', { key: qId },
|
||
React.createElement('td', { className: 'text-sm', style: { maxWidth: '200px' } }, qText?.substring(0, 60) + (qText?.length > 60 ? '...' : '')),
|
||
allStrategies.map((s, si) =>
|
||
React.createElement('td', { key: s, className: sRanks[si], style: { textAlign: 'center', padding: '4px' } },
|
||
stratScores[si] != null ? React.createElement('span', {
|
||
className: 'badge',
|
||
style: {
|
||
background: stratScores[si] >= 7 ? 'rgba(16,185,129,0.15)' : stratScores[si] >= 4 ? 'rgba(245,158,11,0.15)' : 'rgba(244,63,94,0.15)',
|
||
color: stratScores[si] >= 7 ? 'var(--success)' : stratScores[si] >= 4 ? 'var(--warning)' : 'var(--danger)',
|
||
}
|
||
}, stratScores[si].toFixed(1)) : React.createElement('span', { className: 'text-muted' }, '—')
|
||
)
|
||
)
|
||
);
|
||
})
|
||
)
|
||
)
|
||
);
|
||
}
|
||
|
||
// -- Tab: Benchmarks ----------------------------------------
|
||
function BenchmarksTab({ documents, strategies, addToast, questionsFile, formatFilter = 'word', hideTitle = false }) {
|
||
const [experiments, setExperiments] = useState([]);
|
||
const [form, setForm] = useState({
|
||
document_id: '', strategies: ['fixed_size'],
|
||
questions_file: questionsFile, top_k: 5,
|
||
neighbor_prev: 3, neighbor_next: 3, 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);
|
||
|
||
// Comparison state
|
||
const [selectedExps, setSelectedExps] = useState(new Set());
|
||
const [compareMode, setCompareMode] = useState(false);
|
||
const [compareData, setCompareData] = useState([]);
|
||
const [compareLoading, setCompareLoading] = useState(false);
|
||
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?limit=500').then(d => setExperiments(d?.items || (Array.isArray(d) ? d : []))).catch(() => {});
|
||
}, []);
|
||
|
||
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 }));
|
||
}, [questionsFile]);
|
||
|
||
const toggleStrat = (name) => {
|
||
setForm(prev => ({
|
||
...prev,
|
||
strategies: prev.strategies.includes(name)
|
||
? prev.strategies.filter(s => s !== name)
|
||
: [...prev.strategies, name]
|
||
}));
|
||
};
|
||
|
||
const toggleExpSelect = (id) => {
|
||
setSelectedExps(prev => {
|
||
const next = new Set(prev);
|
||
if (next.has(id)) next.delete(id); else next.add(id);
|
||
return next;
|
||
});
|
||
};
|
||
|
||
const selectAllExps = () => {
|
||
if (selectedExps.size === visibleExps.length) {
|
||
setSelectedExps(new Set());
|
||
} else {
|
||
setSelectedExps(new Set(visibleExps.map(e => e.id)));
|
||
}
|
||
};
|
||
|
||
const handleCompare = async () => {
|
||
if (selectedExps.size < 2) {
|
||
addToast('Select at least 2 experiments to compare', 'error');
|
||
return;
|
||
}
|
||
setCompareLoading(true);
|
||
try {
|
||
const details = await Promise.all(
|
||
[...selectedExps].map(id => api(`/benchmarks/${id}`))
|
||
);
|
||
setCompareData(details);
|
||
setCompareMode(true);
|
||
} catch (e) {
|
||
addToast(`Failed to load experiment details: ${e.message}`, 'error');
|
||
} finally {
|
||
setCompareLoading(false);
|
||
}
|
||
};
|
||
|
||
const handleExpand = async (expId) => {
|
||
if (expandedExp === expId) {
|
||
setExpandedExp(null); setExpandedData(null); return;
|
||
}
|
||
setExpandedExp(expId); setExpandedLoading(true);
|
||
try {
|
||
const data = await api(`/benchmarks/${expId}`);
|
||
setExpandedData(data);
|
||
} catch (e) {
|
||
addToast(`Failed to load details: ${e.message}`, 'error');
|
||
} finally {
|
||
setExpandedLoading(false);
|
||
}
|
||
};
|
||
|
||
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 {
|
||
await api(`/experiments/${expId}`, { method: 'DELETE' });
|
||
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');
|
||
}
|
||
};
|
||
|
||
const handleRunBenchmark = async () => {
|
||
if (!form.document_id || form.strategies.length === 0) {
|
||
addToast('Select document and at least one strategy', 'error');
|
||
return;
|
||
}
|
||
setRunning(true);
|
||
try {
|
||
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) {
|
||
addToast(`Benchmark failed: ${e.message}`, 'error');
|
||
} finally {
|
||
setRunning(false);
|
||
}
|
||
};
|
||
|
||
// If in comparison mode, show comparison view
|
||
if (compareMode) {
|
||
return React.createElement(ComparisonView, {
|
||
experiments: compareData,
|
||
addToast,
|
||
onBack: () => { setCompareMode(false); setCompareData([]); setSelectedExps(new Set()); }
|
||
});
|
||
}
|
||
|
||
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'),
|
||
|
||
// Run benchmark form
|
||
React.createElement('div', { className: 'card' },
|
||
React.createElement('div', { className: 'card-title' }, '🚀 Run Benchmark'),
|
||
React.createElement('div', { className: 'row' },
|
||
React.createElement('div', { className: 'col' },
|
||
React.createElement('label', null, 'Document'),
|
||
React.createElement('select', {
|
||
value: form.document_id,
|
||
onChange: e => setForm({ ...form, document_id: e.target.value })
|
||
},
|
||
React.createElement('option', { value: '' }, 'Select document...'),
|
||
visibleDocs.map(d => React.createElement('option', { key: d.id, value: d.id }, d.filename))
|
||
)
|
||
),
|
||
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' } },
|
||
strategies.map(s =>
|
||
React.createElement('label', {
|
||
key: s.name, style: {
|
||
display: 'inline-flex', alignItems: 'center', gap: '6px',
|
||
padding: '6px 12px', borderRadius: 'var(--radius)', cursor: 'pointer',
|
||
border: '1px solid var(--border)',
|
||
background: form.strategies.includes(s.name) ? 'rgba(245,158,11,0.15)' : 'transparent',
|
||
color: form.strategies.includes(s.name) ? 'var(--accent)' : 'var(--text-muted)',
|
||
fontSize: '13px',
|
||
}
|
||
},
|
||
React.createElement('input', {
|
||
type: 'checkbox', style: { display: 'none' },
|
||
checked: form.strategies.includes(s.name),
|
||
onChange: () => toggleStrat(s.name),
|
||
}),
|
||
s.name
|
||
)
|
||
)
|
||
)
|
||
),
|
||
React.createElement('div', { className: 'form-group' },
|
||
React.createElement('label', null, 'Questions File (path)'),
|
||
React.createElement('input', {
|
||
type: 'text', value: form.questions_file,
|
||
onChange: e => setForm({ ...form, questions_file: e.target.value }),
|
||
placeholder: 'e.g. files/questions.json'
|
||
})
|
||
),
|
||
React.createElement('div', { className: 'row mb-0' },
|
||
React.createElement('div', { style: { flex: 'none' } },
|
||
React.createElement('button', {
|
||
className: 'btn btn-primary', onClick: handleRunBenchmark, disabled: running,
|
||
}, running ? React.createElement('span', { className: 'spinner' }) : '⚡ Run Benchmark')
|
||
),
|
||
React.createElement('div', { style: { flex: 'none' } },
|
||
React.createElement('button', {
|
||
className: 'btn btn-secondary',
|
||
onClick: () => { setForm(prev => ({ ...prev, dry_run: true })); handleRunBenchmark(); },
|
||
disabled: running,
|
||
}, 'Cost Estimate')
|
||
)
|
||
)
|
||
),
|
||
|
||
// Experiments list
|
||
React.createElement('div', { className: 'card mt-4' },
|
||
React.createElement('div', { className: 'flex-between mb-2' },
|
||
React.createElement('div', { className: 'card-title mb-0' }, `Experiments (${visibleExps.length})`),
|
||
React.createElement('div', { style: { display: 'flex', gap: '6px', alignItems: 'center' } },
|
||
selectedExps.size >= 2 ? React.createElement('button', {
|
||
className: 'btn btn-primary btn-sm',
|
||
onClick: handleCompare,
|
||
disabled: compareLoading,
|
||
}, compareLoading ? React.createElement('span', { className: 'spinner' }) : `Compare (${selectedExps.size})`) : null,
|
||
React.createElement('button', { className: 'btn btn-secondary btn-sm', onClick: fetchExperiments }, '↻ Refresh')
|
||
)
|
||
),
|
||
visibleExps.length === 0
|
||
? React.createElement('div', { className: 'empty-state' },
|
||
React.createElement('div', { className: 'icon' }, '📊'),
|
||
React.createElement('div', null, formatFilter === 'pdf' ? 'No PDF experiments yet' : 'No experiments yet'))
|
||
: React.createElement('table', null,
|
||
React.createElement('thead', null,
|
||
React.createElement('tr', null,
|
||
React.createElement('th', { style: { width: '36px' } },
|
||
React.createElement('input', {
|
||
type: 'checkbox',
|
||
checked: visibleExps.length > 0 && selectedExps.size === visibleExps.length,
|
||
onChange: selectAllExps,
|
||
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: '160px' } }, '')
|
||
)
|
||
),
|
||
React.createElement('tbody', null,
|
||
visibleExps.map((exp, expIndex) => React.createElement(React.Fragment, { key: exp.id },
|
||
React.createElement('tr', {
|
||
className: selectedExps.has(exp.id) ? 'cmp-highlight' : '',
|
||
style: { cursor: 'pointer' },
|
||
onClick: () => handleExpand(exp.id),
|
||
},
|
||
React.createElement('td', { onClick: e => e.stopPropagation() },
|
||
React.createElement('input', {
|
||
type: 'checkbox',
|
||
checked: selectedExps.has(exp.id),
|
||
onChange: () => toggleExpSelect(exp.id),
|
||
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 ?? (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: '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', 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', minHeight: '32px' },
|
||
'aria-label': 'Delete experiment',
|
||
}, '✕')
|
||
)
|
||
),
|
||
// Expanded details
|
||
expandedExp === exp.id && React.createElement('tr', null,
|
||
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),
|
||
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' },
|
||
React.createElement('div', { className: 'text-sm', style: { fontWeight: 500, marginBottom: '4px', color: 'var(--text-heading)' } },
|
||
`Questions (${expandedData.questions.length})`),
|
||
React.createElement('div', { style: { maxHeight: '200px', overflowY: 'auto', border: '1px solid var(--border)', borderRadius: 'var(--radius)' } },
|
||
expandedData.questions.map((q, i) =>
|
||
React.createElement('div', {
|
||
key: q.id || i,
|
||
style: { padding: '6px 10px', borderBottom: '1px solid var(--border)', fontSize: '12px' }
|
||
},
|
||
React.createElement('span', { className: 'badge badge-accent', style: { marginRight: '6px' } }, q.id || `q${i+1}`),
|
||
React.createElement('span', null, q.question?.substring(0, 100) + (q.question?.length > 100 ? '...' : ''))
|
||
)
|
||
)
|
||
)
|
||
),
|
||
// Per-question scores
|
||
expandedData.per_question && expandedData.per_question.length > 0 && React.createElement('div', null,
|
||
React.createElement('div', { className: 'text-sm', style: { fontWeight: 500, marginBottom: '4px', color: 'var(--text-heading)' } }, 'Per-Question Scores'),
|
||
React.createElement('div', { style: { maxHeight: '200px', overflowY: 'auto', border: '1px solid var(--border)', borderRadius: 'var(--radius)' } },
|
||
React.createElement('table', { style: { width: '100%', fontSize: '11px' } },
|
||
React.createElement('thead', null,
|
||
React.createElement('tr', null,
|
||
React.createElement('th', { style: { padding: '4px 6px' } }, 'Q'),
|
||
(expandedData.strategies_used || []).map(s =>
|
||
React.createElement('th', { key: s, style: { padding: '4px 6px' } }, s))
|
||
)
|
||
),
|
||
React.createElement('tbody', null,
|
||
expandedData.per_question.map((pq, i) =>
|
||
React.createElement('tr', { key: i },
|
||
React.createElement('td', { style: { padding: '4px 6px', maxWidth: '150px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } },
|
||
pq.question?.substring(0, 40) + (pq.question?.length > 40 ? '...' : '')),
|
||
(expandedData.strategies_used || []).map(s => {
|
||
const score = pq.strategies?.[s]?.scores?.answer_similarity;
|
||
return React.createElement('td', { key: s, style: { padding: '4px 6px', textAlign: 'center' } },
|
||
score != null ? React.createElement('span', {
|
||
className: 'badge',
|
||
style: {
|
||
background: score >= 7 ? 'rgba(16,185,129,0.15)' : score >= 4 ? 'rgba(245,158,11,0.15)' : 'rgba(244,63,94,0.15)',
|
||
color: score >= 7 ? 'var(--success)' : score >= 4 ? 'var(--warning)' : 'var(--danger)',
|
||
}
|
||
}, score.toFixed(1)) : '—'
|
||
);
|
||
})
|
||
)
|
||
)
|
||
)
|
||
)
|
||
),
|
||
// 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
|
||
)
|
||
)
|
||
))
|
||
)
|
||
)
|
||
)
|
||
);
|
||
}
|
||
|
||
// -- Collapsible Section Helper ------------------------------
|
||
function CollapseSection({ title, icon, open, onToggle, badge, children }) {
|
||
return React.createElement(React.Fragment, null,
|
||
React.createElement('div', {
|
||
className: `collapse-header ${open ? 'open' : ''}`,
|
||
onClick: onToggle,
|
||
},
|
||
React.createElement('div', { style: { display: 'flex', alignItems: 'center', gap: '8px' } },
|
||
React.createElement('span', null, icon),
|
||
React.createElement('span', { style: { fontWeight: 600, fontSize: '14px', color: 'var(--text-heading)' } }, title),
|
||
badge != null ? React.createElement('span', { className: 'badge badge-accent' }, badge) : null
|
||
),
|
||
React.createElement('span', { className: 'chevron' }, '▶')
|
||
),
|
||
open ? React.createElement('div', { className: 'collapse-body' }, children) : null
|
||
);
|
||
}
|
||
|
||
// -- Shared: Chunk Preview Panel ----------------------------
|
||
function ChunkPreviewPanel({ documents, strategies, addToast, formatFilter = 'all' }) {
|
||
const visibleDocs = filterDocuments(documents, formatFilter);
|
||
const [chunkDocId, setChunkDocId] = useState('');
|
||
const [chunkStrategy, setChunkStrategy] = useState('');
|
||
const [chunkData, setChunkData] = useState(null);
|
||
const [chunkLoading, setChunkLoading] = useState(false);
|
||
const [expandedChunks, setExpandedChunks] = useState({});
|
||
|
||
const loadChunks = async () => {
|
||
if (!chunkDocId) { addToast('Select a document', 'error'); return; }
|
||
setChunkLoading(true);
|
||
setChunkData(null);
|
||
setExpandedChunks({});
|
||
try {
|
||
let url = `/admin/chunks/${chunkDocId}`;
|
||
if (chunkStrategy) url += `?strategy=${chunkStrategy}`;
|
||
const data = await api(url);
|
||
setChunkData(data);
|
||
} catch (e) { addToast(`Chunk load failed: ${e.message}`, 'error'); }
|
||
finally { setChunkLoading(false); }
|
||
};
|
||
|
||
return React.createElement(React.Fragment, null,
|
||
React.createElement('div', { className: 'row mb-4' },
|
||
React.createElement('div', { className: 'col' },
|
||
React.createElement('label', null, 'Document'),
|
||
React.createElement('select', {
|
||
value: chunkDocId,
|
||
onChange: e => { setChunkDocId(e.target.value); setChunkData(null); }
|
||
},
|
||
React.createElement('option', { value: '' }, 'Select document...'),
|
||
visibleDocs.map(d => React.createElement('option', { key: d.id, value: d.id }, d.filename))
|
||
)
|
||
),
|
||
React.createElement('div', { className: 'col' },
|
||
React.createElement('label', null, 'Strategy (optional)'),
|
||
React.createElement('select', {
|
||
value: chunkStrategy,
|
||
onChange: e => { setChunkStrategy(e.target.value); setChunkData(null); }
|
||
},
|
||
React.createElement('option', { value: '' }, 'All strategies'),
|
||
strategies.map(s => React.createElement('option', { key: s.name, value: s.name }, s.name))
|
||
)
|
||
),
|
||
React.createElement('div', { style: { flex: 'none' } },
|
||
React.createElement('button', {
|
||
className: 'btn btn-primary', onClick: loadChunks, disabled: chunkLoading || !chunkDocId,
|
||
}, chunkLoading ? React.createElement('span', { className: 'spinner' }) : 'Load Chunks')
|
||
)
|
||
),
|
||
chunkData && Object.entries(chunkData.strategies || {}).map(([stratName, stratData]) =>
|
||
React.createElement('div', { key: stratName, style: { marginBottom: '16px' } },
|
||
React.createElement('div', { className: 'flex-between mb-2' },
|
||
React.createElement('h3', { style: { fontSize: '14px', fontWeight: 600, color: 'var(--text-heading)' } },
|
||
`${stratName} `,
|
||
React.createElement('span', { className: 'badge badge-accent' }, `${stratData.count} chunks`)),
|
||
stratData.error ? React.createElement('span', { className: 'badge badge-danger' }, stratData.error) : null
|
||
),
|
||
stratData.count === 0
|
||
? React.createElement('div', { className: 'text-sm text-muted', style: { padding: '8px 0' } }, 'No chunks for this strategy')
|
||
: React.createElement('table', null,
|
||
React.createElement('thead', null,
|
||
React.createElement('tr', null,
|
||
React.createElement('th', { style: { width: '40px' } }, '#'),
|
||
React.createElement('th', null, 'Text'),
|
||
React.createElement('th', { style: { width: '60px' } }, 'Tokens'),
|
||
React.createElement('th', { style: { width: '60px' } }, 'Chars'),
|
||
)
|
||
),
|
||
React.createElement('tbody', null,
|
||
(stratData.chunks || []).map((chunk, i) => {
|
||
const key = `${stratName}-${chunk.chunk_index ?? i}`;
|
||
const isExpanded = expandedChunks[key];
|
||
return React.createElement(React.Fragment, { key },
|
||
React.createElement('tr', {
|
||
className: 'chunk-row',
|
||
onClick: () => setExpandedChunks(prev => ({ ...prev, [key]: !prev[key] })),
|
||
},
|
||
React.createElement('td', { className: 'text-muted text-sm' }, chunk.chunk_index ?? i + 1),
|
||
React.createElement('td', { className: 'text-sm' },
|
||
React.createElement('span', { style: { display: '-webkit-box', WebkitLineClamp: isExpanded ? 'unset' : 1, WebkitBoxOrient: 'vertical', overflow: isExpanded ? 'visible' : 'hidden' } },
|
||
chunk.text || '')),
|
||
React.createElement('td', { className: 'text-sm text-muted' }, chunk.token_count),
|
||
React.createElement('td', { className: 'text-sm text-muted' }, chunk.character_count),
|
||
),
|
||
isExpanded ? React.createElement('tr', null,
|
||
React.createElement('td', { colSpan: 4 },
|
||
React.createElement('div', { className: 'chunk-detail' },
|
||
React.createElement('div', null, chunk.text || '(empty)'),
|
||
React.createElement('div', { className: 'meta' },
|
||
`chunk_id: ${chunk.chunk_id} | parent: ${chunk.parent_id || 'none'}`)
|
||
)
|
||
)
|
||
) : null
|
||
);
|
||
})
|
||
)
|
||
)
|
||
)
|
||
)
|
||
);
|
||
}
|
||
|
||
// -- Tab: PDF Workspace -------------------------------------
|
||
function PdfWorkspaceTab({ documents, setDocuments, strategies, addToast, questionsFile }) {
|
||
const [openSections, setOpenSections] = useState({
|
||
upload: true, chunks: false, query: false, benchmarks: false,
|
||
});
|
||
const toggle = (key) => setOpenSections(prev => ({ ...prev, [key]: !prev[key] }));
|
||
const pdfCount = filterDocuments(documents, 'pdf').length;
|
||
|
||
return React.createElement('div', null,
|
||
React.createElement('h1', { style: { color: 'var(--text-heading)', marginBottom: '8px', fontSize: '22px' } }, 'PDF Workspace'),
|
||
React.createElement('p', { className: 'text-muted mb-4', style: { maxWidth: '640px' } },
|
||
'Text PDF only — upload, process, preview chunks, query, and benchmark. Scanned/image PDFs are rejected at upload.'),
|
||
|
||
React.createElement(CollapseSection, {
|
||
title: 'Upload & Process', icon: '📄', open: openSections.upload,
|
||
onToggle: () => toggle('upload'),
|
||
badge: pdfCount ? `${pdfCount} PDFs` : '0',
|
||
},
|
||
React.createElement(DocumentsTab, {
|
||
documents, setDocuments, strategies, addToast,
|
||
formatFilter: 'pdf', hideTitle: true,
|
||
})
|
||
),
|
||
|
||
React.createElement(CollapseSection, {
|
||
title: 'Chunk Preview', icon: '📦', open: openSections.chunks,
|
||
onToggle: () => toggle('chunks'),
|
||
},
|
||
React.createElement(ChunkPreviewPanel, {
|
||
documents, strategies, addToast, formatFilter: 'pdf',
|
||
})
|
||
),
|
||
|
||
React.createElement(CollapseSection, {
|
||
title: 'Query', icon: '⚡', open: openSections.query,
|
||
onToggle: () => toggle('query'),
|
||
},
|
||
React.createElement(QueryTab, {
|
||
documents, strategies, addToast,
|
||
formatFilter: 'pdf', hideTitle: true,
|
||
})
|
||
),
|
||
|
||
React.createElement(CollapseSection, {
|
||
title: 'Benchmarks', icon: '📊', open: openSections.benchmarks,
|
||
onToggle: () => toggle('benchmarks'),
|
||
},
|
||
React.createElement(BenchmarksTab, {
|
||
documents, strategies, addToast, questionsFile,
|
||
formatFilter: 'pdf', hideTitle: true,
|
||
})
|
||
)
|
||
);
|
||
}
|
||
|
||
// -- Tab: Decision Board (ADR-0026) --------------------------
|
||
const DECISION_DOC_FILENAMES = [
|
||
'bazresi.docx',
|
||
'customer1.docx',
|
||
'fire.docx',
|
||
'general-havades-individuals.doc',
|
||
'havades.docx',
|
||
'life-time-individual.docx',
|
||
'moavenin.docx',
|
||
'Refah.docx',
|
||
'website.docx',
|
||
'lifetime-compensation.docx',
|
||
];
|
||
|
||
function decisionComposite(m) {
|
||
if (!m) return null;
|
||
return ((m.avg_context_relevance || 0) * 0.3
|
||
+ (m.avg_answer_similarity || 0) * 0.4
|
||
+ (m.avg_faithfulness || 0) * 0.3) * (1 - (m.hallucination_rate || 0));
|
||
}
|
||
|
||
function decisionFmt(v, digits) {
|
||
if (v == null || Number.isNaN(v)) return '—';
|
||
return Number(v).toFixed(digits == null ? 2 : digits);
|
||
}
|
||
|
||
function decisionPct(v) {
|
||
if (v == null || Number.isNaN(v)) return '—';
|
||
return `${(Number(v) * 100).toFixed(0)}%`;
|
||
}
|
||
|
||
function decisionHeatBin(score) {
|
||
if (score == null || Number.isNaN(score)) {
|
||
return {
|
||
key: 'missing', label: '—',
|
||
bg: 'transparent', fg: 'var(--text-muted)', accent: 'transparent',
|
||
};
|
||
}
|
||
const s = Number(score);
|
||
// Modern dark-dashboard scale: translucent wash + luminous text + left accent rail
|
||
// (no solid primary blocks — avoids Windows-98 / crayon look)
|
||
if (s < 8.0) {
|
||
return {
|
||
key: 'lt80', label: '<8.0',
|
||
bg: 'rgba(244, 63, 94, 0.14)', fg: '#FB7185', accent: '#F43F5E',
|
||
};
|
||
}
|
||
if (s < 8.5) {
|
||
return {
|
||
key: '80_85', label: '8.0–8.5',
|
||
bg: 'rgba(251, 146, 60, 0.13)', fg: '#FB923C', accent: '#F97316',
|
||
};
|
||
}
|
||
if (s < 9.0) {
|
||
return {
|
||
key: '85_90', label: '8.5–9.0',
|
||
bg: 'rgba(250, 204, 21, 0.14)', fg: '#FDE047', accent: '#EAB308',
|
||
};
|
||
}
|
||
if (s < 9.5) {
|
||
return {
|
||
key: '90_95', label: '9.0–9.5',
|
||
bg: 'rgba(45, 212, 191, 0.13)', fg: '#2DD4BF', accent: '#14B8A6',
|
||
};
|
||
}
|
||
return {
|
||
key: 'ge95', label: '≥9.5',
|
||
bg: 'rgba(52, 211, 153, 0.16)', fg: '#34D399', accent: '#10B981',
|
||
};
|
||
}
|
||
|
||
function decisionHeatStyle(score) {
|
||
const bin = decisionHeatBin(score);
|
||
const style = { background: bin.bg, color: bin.fg };
|
||
if (bin.accent && bin.accent !== 'transparent') {
|
||
style.boxShadow = `inset 3px 0 0 ${bin.accent}`;
|
||
}
|
||
return style;
|
||
}
|
||
|
||
/** Per-row (or mean-row) ids of best fixed_size and best semantic Candidate. */
|
||
function decisionFamilyBestIds(candidates, scoreOf) {
|
||
let bestFs = null, bestFsScore = -Infinity;
|
||
let bestSem = null, bestSemScore = -Infinity;
|
||
(candidates || []).forEach(c => {
|
||
const s = scoreOf(c);
|
||
if (s == null || Number.isNaN(s)) return;
|
||
if (c.family === 'fixed_size' && s > bestFsScore) {
|
||
bestFsScore = s;
|
||
bestFs = c.id;
|
||
}
|
||
if (c.family === 'semantic' && s > bestSemScore) {
|
||
bestSemScore = s;
|
||
bestSem = c.id;
|
||
}
|
||
});
|
||
return { fixed: bestFs, semantic: bestSem };
|
||
}
|
||
|
||
const DECISION_HEAT_LEGEND = [
|
||
decisionHeatBin(null),
|
||
decisionHeatBin(7.9),
|
||
decisionHeatBin(8.2),
|
||
decisionHeatBin(8.7),
|
||
decisionHeatBin(9.2),
|
||
decisionHeatBin(9.6),
|
||
];
|
||
|
||
function DecisionHeatLegend() {
|
||
return React.createElement('div', { className: 'decision-legend', role: 'list', 'aria-label': 'Composite score color guide' },
|
||
React.createElement('span', { style: { fontWeight: 600, color: 'var(--text-body)' } }, 'Score guide'),
|
||
DECISION_HEAT_LEGEND.map(bin =>
|
||
React.createElement('span', { key: bin.key, className: 'decision-legend-item', role: 'listitem' },
|
||
React.createElement('span', {
|
||
className: 'decision-legend-swatch',
|
||
style: {
|
||
background: bin.bg === 'transparent' ? 'rgba(255,255,255,0.04)' : bin.bg,
|
||
boxShadow: bin.accent && bin.accent !== 'transparent' ? `inset 3px 0 0 ${bin.accent}` : undefined,
|
||
},
|
||
'aria-hidden': true,
|
||
}),
|
||
bin.label
|
||
)
|
||
),
|
||
React.createElement('span', {
|
||
style: { width: 1, height: 14, background: 'rgba(255,255,255,0.1)', margin: '0 4px' },
|
||
'aria-hidden': true,
|
||
}),
|
||
React.createElement('span', { className: 'decision-legend-item' },
|
||
React.createElement('span', { className: 'best-mark fs' }, 'F'),
|
||
'best fixed_size'
|
||
),
|
||
React.createElement('span', { className: 'decision-legend-item' },
|
||
React.createElement('span', { className: 'best-mark sem' }, 'S'),
|
||
'best semantic'
|
||
)
|
||
);
|
||
}
|
||
|
||
function meanOf(nums) {
|
||
const vals = nums.filter(v => v != null && !Number.isNaN(v));
|
||
if (!vals.length) return null;
|
||
return vals.reduce((a, b) => a + b, 0) / vals.length;
|
||
}
|
||
|
||
function buildDecisionBoard(experiments, corpusId, excludedIds) {
|
||
const excluded = new Set(excludedIds || []);
|
||
const pool = (experiments || []).filter(e => {
|
||
if (excluded.has(e.id)) return false;
|
||
const fn = e.document_filename || '';
|
||
if (!DECISION_DOC_FILENAMES.includes(fn)) return false;
|
||
if (corpusIdOf(e) !== corpusId) return false;
|
||
const strats = e.strategies_used || [];
|
||
return strats.length === 1;
|
||
});
|
||
|
||
// Newest first assumed from API; keep first hit per cell
|
||
const cellMap = {}; // key: `${fn}||${candId}` -> exp
|
||
const fsLevels = new Set([0, 1, 2, 3]);
|
||
const semBounds = new Set();
|
||
|
||
pool.forEach(e => {
|
||
const strat = (e.strategies_used || [])[0];
|
||
const fn = e.document_filename;
|
||
if (strat === 'fixed_size') {
|
||
const { prev, next } = neighborCounts(e);
|
||
if (prev !== next || !fsLevels.has(prev)) return;
|
||
const candId = `fixed_size:±${prev}`;
|
||
const key = `${fn}||${candId}`;
|
||
if (!cellMap[key]) cellMap[key] = e;
|
||
} else if (strat === 'semantic') {
|
||
const b = boundaryIdOf(e);
|
||
if (!b) return;
|
||
semBounds.add(b);
|
||
const candId = `semantic:${b}`;
|
||
const key = `${fn}||${candId}`;
|
||
if (!cellMap[key]) cellMap[key] = e;
|
||
}
|
||
});
|
||
|
||
const fixedCands = [0, 1, 2, 3].map(n => ({
|
||
id: `fixed_size:±${n}`,
|
||
family: 'fixed_size',
|
||
label: `fixed_size ±${n}/${n}`,
|
||
short: `±${n}`,
|
||
level: n,
|
||
}));
|
||
const semanticCands = [...semBounds].sort().map(b => ({
|
||
id: `semantic:${b}`,
|
||
family: 'semantic',
|
||
label: `semantic @ ${b}`,
|
||
short: b,
|
||
boundary: b,
|
||
}));
|
||
|
||
function metricsFor(exp, family) {
|
||
if (!exp) return null;
|
||
return (exp.aggregate_metrics || {})[family] || null;
|
||
}
|
||
|
||
function summarize(cands) {
|
||
return cands.map(c => {
|
||
const perDoc = {};
|
||
const composites = [];
|
||
const metricBags = {
|
||
avg_context_relevance: [],
|
||
avg_answer_similarity: [],
|
||
avg_faithfulness: [],
|
||
hallucination_rate: [],
|
||
};
|
||
let filled = 0;
|
||
DECISION_DOC_FILENAMES.forEach(fn => {
|
||
const exp = cellMap[`${fn}||${c.id}`];
|
||
const m = metricsFor(exp, c.family);
|
||
const score = decisionComposite(m);
|
||
perDoc[fn] = { exp, metrics: m, composite: score };
|
||
if (score != null) {
|
||
filled += 1;
|
||
composites.push(score);
|
||
Object.keys(metricBags).forEach(k => {
|
||
if (m && m[k] != null) metricBags[k].push(m[k]);
|
||
});
|
||
}
|
||
});
|
||
return {
|
||
...c,
|
||
perDoc,
|
||
filled,
|
||
totalDocs: DECISION_DOC_FILENAMES.length,
|
||
meanComposite: meanOf(composites),
|
||
meanMetrics: {
|
||
avg_context_relevance: meanOf(metricBags.avg_context_relevance),
|
||
avg_answer_similarity: meanOf(metricBags.avg_answer_similarity),
|
||
avg_faithfulness: meanOf(metricBags.avg_faithfulness),
|
||
hallucination_rate: meanOf(metricBags.hallucination_rate),
|
||
},
|
||
wins: 0,
|
||
};
|
||
});
|
||
}
|
||
|
||
const fixedSummaries = summarize(fixedCands);
|
||
const semanticSummaries = summarize(semanticCands);
|
||
|
||
function assignWins(summaries) {
|
||
DECISION_DOC_FILENAMES.forEach(fn => {
|
||
let best = null;
|
||
let bestScore = -Infinity;
|
||
summaries.forEach(s => {
|
||
const sc = s.perDoc[fn]?.composite;
|
||
if (sc == null) return;
|
||
if (sc > bestScore) {
|
||
bestScore = sc;
|
||
best = s;
|
||
}
|
||
});
|
||
if (best) best.wins += 1;
|
||
});
|
||
}
|
||
assignWins(fixedSummaries);
|
||
assignWins(semanticSummaries);
|
||
|
||
function autoPick(summaries) {
|
||
if (!summaries.length) return null;
|
||
return [...summaries].sort((a, b) => {
|
||
const ma = a.meanComposite == null ? -1 : a.meanComposite;
|
||
const mb = b.meanComposite == null ? -1 : b.meanComposite;
|
||
if (mb !== ma) return mb - ma;
|
||
if (b.wins !== a.wins) return b.wins - a.wins;
|
||
return a.id.localeCompare(b.id);
|
||
})[0];
|
||
}
|
||
|
||
return {
|
||
cellMap,
|
||
fixedSummaries,
|
||
semanticSummaries,
|
||
autoFixed: autoPick(fixedSummaries),
|
||
autoSemantic: autoPick(semanticSummaries),
|
||
allCandidates: [...fixedSummaries, ...semanticSummaries],
|
||
};
|
||
}
|
||
|
||
function DecisionTab({ addToast }) {
|
||
const [experiments, setExperiments] = useState([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [corpusId, setCorpusId] = useState('text-embedding-3-large');
|
||
const [embModels, setEmbModels] = useState([]);
|
||
const [excludedIds, setExcludedIds] = useState([]);
|
||
const [overrideFixed, setOverrideFixed] = useState(null);
|
||
const [overrideSemantic, setOverrideSemantic] = useState(null);
|
||
|
||
const refresh = useCallback(() => {
|
||
setLoading(true);
|
||
Promise.all([
|
||
api('/experiments?limit=500'),
|
||
api('/admin/embedding-models'),
|
||
]).then(([ex, emb]) => {
|
||
setExperiments(ex?.items || []);
|
||
setEmbModels(emb?.models || []);
|
||
if (emb?.corpus_id) {
|
||
setCorpusId(prev => prev || emb.corpus_id);
|
||
}
|
||
}).catch(() => addToast('Failed to load Decision Board data', 'error'))
|
||
.finally(() => setLoading(false));
|
||
}, [addToast]);
|
||
|
||
useEffect(() => { refresh(); }, [refresh]);
|
||
|
||
const board = React.useMemo(
|
||
() => buildDecisionBoard(experiments, corpusId, excludedIds),
|
||
[experiments, corpusId, excludedIds]
|
||
);
|
||
|
||
const winnerFixed = (overrideFixed && board.fixedSummaries.find(c => c.id === overrideFixed))
|
||
|| board.autoFixed;
|
||
const winnerSemantic = (overrideSemantic && board.semanticSummaries.find(c => c.id === overrideSemantic))
|
||
|| board.autoSemantic;
|
||
|
||
const excludeId = (id) => {
|
||
if (!id) return;
|
||
setExcludedIds(prev => prev.includes(id) ? prev : [...prev, id]);
|
||
};
|
||
const unexcludeId = (id) => setExcludedIds(prev => prev.filter(x => x !== id));
|
||
|
||
const fsFilled = board.fixedSummaries.reduce((a, c) => a + c.filled, 0);
|
||
const fsTotal = board.fixedSummaries.length * DECISION_DOC_FILENAMES.length;
|
||
const semFilled = board.semanticSummaries.reduce((a, c) => a + c.filled, 0);
|
||
const semTotal = board.semanticSummaries.length * DECISION_DOC_FILENAMES.length;
|
||
|
||
// Stage-2 head-to-head
|
||
let duel = null;
|
||
if (winnerFixed && winnerSemantic) {
|
||
let fsWins = 0, semWins = 0, ties = 0;
|
||
const perDoc = DECISION_DOC_FILENAMES.map(fn => {
|
||
const a = winnerFixed.perDoc[fn]?.composite;
|
||
const b = winnerSemantic.perDoc[fn]?.composite;
|
||
let winner = '—';
|
||
if (a != null && b != null) {
|
||
if (a > b) { fsWins += 1; winner = 'fixed'; }
|
||
else if (b > a) { semWins += 1; winner = 'semantic'; }
|
||
else { ties += 1; winner = 'tie'; }
|
||
}
|
||
return { fn, a, b, winner };
|
||
});
|
||
const recommend = (winnerFixed.meanComposite || 0) >= (winnerSemantic.meanComposite || 0)
|
||
? winnerFixed : winnerSemantic;
|
||
// Prefer win-count if mean close? stick to mean primary with wins as display
|
||
duel = { fsWins, semWins, ties, perDoc, recommend };
|
||
}
|
||
|
||
const renderCandCard = (c, selected, onSelect, familyAutoId) =>
|
||
React.createElement('div', {
|
||
key: c.id,
|
||
className: `decision-cand${selected ? ' selected' : ''}`,
|
||
onClick: () => onSelect(selected ? null : (c.id === familyAutoId ? null : c.id)),
|
||
title: 'Click to override family winner; click selected again to return to auto',
|
||
},
|
||
React.createElement('div', { className: 'cand-title', style: { display: 'flex', justifyContent: 'space-between', gap: 8 } },
|
||
React.createElement('span', null,
|
||
React.createElement('input', {
|
||
type: 'radio',
|
||
checked: !!selected,
|
||
readOnly: true,
|
||
style: { marginRight: 8 },
|
||
}),
|
||
c.label,
|
||
c.id === familyAutoId
|
||
? React.createElement('span', { className: 'badge badge-accent', style: { marginLeft: 8 } }, 'auto')
|
||
: null
|
||
),
|
||
React.createElement('span', { style: { color: 'var(--accent)', fontVariantNumeric: 'tabular-nums' } },
|
||
decisionFmt(c.meanComposite))
|
||
),
|
||
React.createElement('div', { className: 'decision-metrics' },
|
||
React.createElement('span', null, 'Wins ', React.createElement('strong', null, `${c.wins}/${c.totalDocs}`)),
|
||
React.createElement('span', null, 'Coverage ', React.createElement('strong', null, `${c.filled}/${c.totalDocs}`)),
|
||
React.createElement('span', null, 'Context ', React.createElement('strong', null, decisionFmt(c.meanMetrics.avg_context_relevance))),
|
||
React.createElement('span', null, 'Similarity ', React.createElement('strong', null, decisionFmt(c.meanMetrics.avg_answer_similarity))),
|
||
React.createElement('span', null, 'Faithfulness ', React.createElement('strong', null, decisionFmt(c.meanMetrics.avg_faithfulness))),
|
||
React.createElement('span', null, 'Halluc. ', React.createElement('strong', null, decisionPct(c.meanMetrics.hallucination_rate)))
|
||
)
|
||
);
|
||
|
||
const duelPanel = (c, side) => {
|
||
if (!c) {
|
||
return React.createElement('div', { className: 'card', style: { margin: 0 } },
|
||
React.createElement('div', { className: 'text-muted' }, `No ${side} winner yet`));
|
||
}
|
||
const isRec = duel && duel.recommend && duel.recommend.id === c.id;
|
||
return React.createElement('div', {
|
||
className: 'card',
|
||
style: {
|
||
margin: 0,
|
||
borderColor: isRec ? 'var(--accent)' : undefined,
|
||
boxShadow: isRec ? '0 0 0 1px rgba(234,179,8,0.35)' : undefined,
|
||
},
|
||
},
|
||
React.createElement('div', { className: 'flex-between mb-2' },
|
||
React.createElement('div', { className: 'card-title mb-0' }, c.label),
|
||
isRec ? React.createElement('span', { className: 'badge badge-success' }, 'Recommended') : null
|
||
),
|
||
React.createElement('div', { style: { fontSize: 28, fontWeight: 700, color: 'var(--accent)', marginBottom: 8 } },
|
||
decisionFmt(c.meanComposite)),
|
||
React.createElement('div', { className: 'decision-metrics', style: { marginBottom: 8 } },
|
||
React.createElement('span', null, 'Doc wins vs other ', React.createElement('strong', null,
|
||
side === 'fixed' ? (duel ? duel.fsWins : '—') : (duel ? duel.semWins : '—'))),
|
||
React.createElement('span', null, 'Family wins ', React.createElement('strong', null, `${c.wins}/${c.totalDocs}`)),
|
||
React.createElement('span', null, 'Coverage ', React.createElement('strong', null, `${c.filled}/${c.totalDocs}`))
|
||
),
|
||
React.createElement('table', { className: 'cmp-table' },
|
||
React.createElement('tbody', null,
|
||
[['Context Relevance', c.meanMetrics.avg_context_relevance, false],
|
||
['Answer Similarity', c.meanMetrics.avg_answer_similarity, false],
|
||
['Faithfulness', c.meanMetrics.avg_faithfulness, false],
|
||
['Hallucination Rate', c.meanMetrics.hallucination_rate, true]].map(([label, val, isPct]) =>
|
||
React.createElement('tr', { key: label },
|
||
React.createElement('td', null, label),
|
||
React.createElement('td', { style: { fontWeight: 600 } }, isPct ? decisionPct(val) : decisionFmt(val))
|
||
)
|
||
)
|
||
)
|
||
)
|
||
);
|
||
};
|
||
|
||
return React.createElement('div', null,
|
||
React.createElement('div', { className: 'flex-between mb-2' },
|
||
React.createElement('h1', { style: { color: 'var(--text-heading)', margin: 0, fontSize: '22px' } }, 'Decision Board'),
|
||
React.createElement('button', { className: 'btn btn-secondary btn-sm', onClick: refresh, disabled: loading },
|
||
loading ? React.createElement('span', { className: 'spinner' }) : '↻ Refresh')
|
||
),
|
||
React.createElement('p', { className: 'text-sm text-muted', style: { marginBottom: 16, maxWidth: 720 } },
|
||
'Two-stage final selection: pick the best fixed_size Neighbor level and best semantic Boundary, then compare those winners across the 10-doc evaluation set. Mean composite ranks stage 1; win-counts are shown; click a Candidate to override.'
|
||
),
|
||
|
||
// Header controls
|
||
React.createElement('div', { className: 'card mb-4' },
|
||
React.createElement('div', { className: 'row mb-0', style: { alignItems: 'flex-end' } },
|
||
React.createElement(EmbeddingModelSelect, {
|
||
label: 'Corpus filter',
|
||
value: corpusId,
|
||
onChange: (v) => { setCorpusId(v); setOverrideFixed(null); setOverrideSemantic(null); },
|
||
models: embModels,
|
||
role: 'corpus',
|
||
title: 'Only single-strategy Experiments under this Corpus Embedding Model',
|
||
style: { maxWidth: 360 },
|
||
}),
|
||
React.createElement('div', { className: 'col' },
|
||
React.createElement('div', { className: 'text-sm text-muted' }, 'Coverage'),
|
||
React.createElement('div', { style: { fontWeight: 600, color: 'var(--text-heading)', marginTop: 6 } },
|
||
`fixed_size ${fsFilled}/${fsTotal || 40} · semantic ${semFilled}/${semTotal || '—'}`)
|
||
)
|
||
),
|
||
excludedIds.length > 0 && React.createElement('div', { style: { marginTop: 12 } },
|
||
React.createElement('div', { className: 'text-sm text-muted mb-1' }, 'Excluded Experiments (next-newest fills the cell)'),
|
||
React.createElement('div', { style: { display: 'flex', flexWrap: 'wrap', gap: 6 } },
|
||
excludedIds.map(id =>
|
||
React.createElement('button', {
|
||
key: id,
|
||
className: 'btn btn-secondary btn-sm',
|
||
onClick: () => unexcludeId(id),
|
||
title: 'Click to restore',
|
||
}, `✕ ${id.substring(0, 8)}…`)
|
||
)
|
||
)
|
||
)
|
||
),
|
||
|
||
loading && React.createElement('div', { style: { textAlign: 'center', padding: 24 } },
|
||
React.createElement('span', { className: 'spinner' })),
|
||
|
||
!loading && React.createElement(React.Fragment, null,
|
||
// Stage 1
|
||
React.createElement('h2', { style: { fontSize: 16, color: 'var(--text-heading)', marginBottom: 10 } }, 'Stage 1 — Best Candidate per family'),
|
||
React.createElement('div', { className: 'decision-grid mb-4' },
|
||
React.createElement('div', { className: 'card', style: { margin: 0 } },
|
||
React.createElement('div', { className: 'card-title' }, 'fixed_size (Neighbor Expansion)'),
|
||
React.createElement('div', { className: 'text-sm text-muted mb-2' },
|
||
'Auto winner: ', board.autoFixed ? board.autoFixed.label : '—',
|
||
overrideFixed ? ' · override active' : ''),
|
||
board.fixedSummaries.map(c => renderCandCard(
|
||
c,
|
||
winnerFixed && winnerFixed.id === c.id,
|
||
setOverrideFixed,
|
||
board.autoFixed?.id
|
||
))
|
||
),
|
||
React.createElement('div', { className: 'card', style: { margin: 0 } },
|
||
React.createElement('div', { className: 'card-title' }, 'semantic (Boundary Embedding Model)'),
|
||
React.createElement('div', { className: 'text-sm text-muted mb-2' },
|
||
'Auto winner: ', board.autoSemantic ? board.autoSemantic.label : '—',
|
||
overrideSemantic ? ' · override active' : ''),
|
||
board.semanticSummaries.length === 0
|
||
? React.createElement('div', { className: 'empty-state' }, 'No semantic single-strategy Experiments for this Corpus')
|
||
: board.semanticSummaries.map(c => renderCandCard(
|
||
c,
|
||
winnerSemantic && winnerSemantic.id === c.id,
|
||
setOverrideSemantic,
|
||
board.autoSemantic?.id
|
||
))
|
||
)
|
||
),
|
||
|
||
// Stage 2
|
||
React.createElement('h2', { style: { fontSize: 16, color: 'var(--text-heading)', marginBottom: 10 } }, 'Stage 2 — Family showdown'),
|
||
React.createElement('div', { className: 'decision-duel mb-4' },
|
||
duelPanel(winnerFixed, 'fixed'),
|
||
React.createElement('div', { className: 'decision-vs' }, 'vs'),
|
||
duelPanel(winnerSemantic, 'semantic')
|
||
),
|
||
duel && React.createElement('div', { className: 'card mb-4' },
|
||
React.createElement('div', { className: 'card-title' }, 'Head-to-head by document'),
|
||
React.createElement('div', { className: 'text-sm text-muted mb-2' },
|
||
`fixed_size wins ${duel.fsWins} · semantic wins ${duel.semWins} · ties ${duel.ties}`),
|
||
React.createElement('table', { className: 'cmp-table' },
|
||
React.createElement('thead', null,
|
||
React.createElement('tr', null,
|
||
React.createElement('th', null, 'Document'),
|
||
React.createElement('th', null, winnerFixed?.short || 'fixed'),
|
||
React.createElement('th', null, winnerSemantic?.short || 'semantic'),
|
||
React.createElement('th', null, 'Winner')
|
||
)
|
||
),
|
||
React.createElement('tbody', null,
|
||
duel.perDoc.map(row =>
|
||
React.createElement('tr', { key: row.fn },
|
||
React.createElement('td', null, row.fn),
|
||
React.createElement('td', { style: { textAlign: 'center', fontVariantNumeric: 'tabular-nums' } }, decisionFmt(row.a)),
|
||
React.createElement('td', { style: { textAlign: 'center', fontVariantNumeric: 'tabular-nums' } }, decisionFmt(row.b)),
|
||
React.createElement('td', null,
|
||
row.winner === 'fixed' ? React.createElement('span', { className: 'badge badge-accent' }, 'fixed_size')
|
||
: row.winner === 'semantic' ? React.createElement('span', { className: 'badge badge-success' }, 'semantic')
|
||
: row.winner === 'tie' ? React.createElement('span', { className: 'badge' }, 'tie')
|
||
: React.createElement('span', { className: 'text-muted' }, '—')
|
||
)
|
||
)
|
||
)
|
||
)
|
||
)
|
||
),
|
||
|
||
// Matrix
|
||
React.createElement('h2', { style: { fontSize: 16, color: 'var(--text-heading)', marginBottom: 10 } }, 'Per-document matrix'),
|
||
React.createElement('div', { className: 'card', style: { paddingBottom: 14 } },
|
||
React.createElement(DecisionHeatLegend),
|
||
React.createElement('div', { className: 'decision-matrix-wrap' },
|
||
React.createElement('table', { className: 'decision-matrix' },
|
||
React.createElement('thead', null,
|
||
React.createElement('tr', null,
|
||
React.createElement('th', { className: 'sticky-col' }, 'Document'),
|
||
board.allCandidates.map(c =>
|
||
React.createElement('th', {
|
||
key: c.id,
|
||
className: (winnerFixed && c.id === winnerFixed.id) || (winnerSemantic && c.id === winnerSemantic.id)
|
||
? 'col-duel' : undefined,
|
||
title: c.label,
|
||
}, c.family === 'fixed_size' ? c.short : (c.short || '').substring(0, 14))
|
||
)
|
||
)
|
||
),
|
||
React.createElement('tbody', null,
|
||
DECISION_DOC_FILENAMES.map(fn => {
|
||
const rowBest = decisionFamilyBestIds(board.allCandidates, c => c.perDoc[fn]?.composite);
|
||
return React.createElement('tr', { key: fn },
|
||
React.createElement('td', { className: 'sticky-col' }, fn),
|
||
board.allCandidates.map(c => {
|
||
const cell = c.perDoc[fn];
|
||
const score = cell?.composite;
|
||
const exp = cell?.exp;
|
||
const mark = c.id === rowBest.fixed ? 'fs' : (c.id === rowBest.semantic ? 'sem' : null);
|
||
return React.createElement('td', {
|
||
key: c.id,
|
||
className: 'decision-heat',
|
||
style: { ...decisionHeatStyle(score), cursor: exp ? 'pointer' : 'default' },
|
||
title: exp
|
||
? `${c.label} · ${fn}\ncomposite=${decisionFmt(score)}${mark ? `\nbest ${mark === 'fs' ? 'fixed_size' : 'semantic'} in row` : ''}\nid=${exp.id}\nClick: report · Shift+click: exclude`
|
||
: `${c.label} · ${fn}: missing`,
|
||
onClick: (e) => {
|
||
if (!exp) return;
|
||
if (e.shiftKey) {
|
||
excludeId(exp.id);
|
||
addToast(`Excluded ${exp.id.substring(0, 8)}…`, 'success');
|
||
return;
|
||
}
|
||
window.open(`/benchmarks/${exp.id}/report`, '_blank');
|
||
},
|
||
},
|
||
mark
|
||
? React.createElement('span', {
|
||
className: `best-mark ${mark}`,
|
||
'aria-label': mark === 'fs' ? 'Best fixed_size in row' : 'Best semantic in row',
|
||
}, mark === 'fs' ? 'F' : 'S')
|
||
: null,
|
||
decisionFmt(score)
|
||
);
|
||
})
|
||
);
|
||
}),
|
||
(() => {
|
||
const meanBest = decisionFamilyBestIds(board.allCandidates, c => c.meanComposite);
|
||
return React.createElement('tr', { key: '_mean', className: 'mean-row' },
|
||
React.createElement('td', { className: 'sticky-col' }, 'Mean'),
|
||
board.allCandidates.map(c => {
|
||
const mark = c.id === meanBest.fixed ? 'fs' : (c.id === meanBest.semantic ? 'sem' : null);
|
||
return React.createElement('td', {
|
||
key: c.id,
|
||
className: 'decision-heat',
|
||
style: decisionHeatStyle(c.meanComposite),
|
||
},
|
||
mark
|
||
? React.createElement('span', {
|
||
className: `best-mark ${mark}`,
|
||
'aria-label': mark === 'fs' ? 'Best fixed_size mean' : 'Best semantic mean',
|
||
}, mark === 'fs' ? 'F' : 'S')
|
||
: null,
|
||
decisionFmt(c.meanComposite)
|
||
);
|
||
})
|
||
);
|
||
})(),
|
||
React.createElement('tr', { key: '_wins', className: 'wins-row' },
|
||
React.createElement('td', { className: 'sticky-col' }, 'Wins'),
|
||
board.allCandidates.map(c =>
|
||
React.createElement('td', { key: c.id }, `${c.wins}/${c.totalDocs}`)
|
||
)
|
||
)
|
||
)
|
||
)
|
||
),
|
||
React.createElement('div', { className: 'text-sm text-muted', style: { marginTop: 12 } },
|
||
'Click a cell to open its Experiment report. Shift+click to exclude. ',
|
||
React.createElement('span', { className: 'best-mark fs' }, 'F'),
|
||
' / ',
|
||
React.createElement('span', { className: 'best-mark sem' }, 'S'),
|
||
' mark the best score in each family per row.'
|
||
)
|
||
)
|
||
)
|
||
);
|
||
}
|
||
|
||
// -- Tab: Admin ---------------------------------------------
|
||
function AdminTab({ addToast, documents, strategies, setActiveTab, setQuestionsFile }) {
|
||
// Collapse state for each section
|
||
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: [] })),
|
||
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([]);
|
||
|
||
// -- Questions state --
|
||
const [qFiles, setQFiles] = useState([]);
|
||
const [qLoading, setQLoading] = useState(false);
|
||
const [expandedQFile, setExpandedQFile] = useState(null);
|
||
const [qFileDetail, setQFileDetail] = useState(null);
|
||
const [qUploading, setQUploading] = useState(false);
|
||
const qFileRef = React.useRef(null);
|
||
|
||
const fetchQFiles = useCallback(async () => {
|
||
setQLoading(true);
|
||
try {
|
||
const data = await api('/admin/questions');
|
||
setQFiles(data.files || []);
|
||
} catch (e) { addToast(`Failed to load questions: ${e.message}`, 'error'); }
|
||
finally { setQLoading(false); }
|
||
}, [addToast]);
|
||
useEffect(() => { fetchQFiles(); }, [fetchQFiles]);
|
||
|
||
const toggleQFile = async (fileId) => {
|
||
if (expandedQFile === fileId) { setExpandedQFile(null); setQFileDetail(null); return; }
|
||
setExpandedQFile(fileId);
|
||
try {
|
||
const data = await api(`/admin/questions/${fileId}`);
|
||
setQFileDetail(data.data || data);
|
||
} catch (e) { addToast(`Failed to load ${fileId}: ${e.message}`, 'error'); }
|
||
};
|
||
|
||
const uploadQFile = async (file) => {
|
||
if (!file) return;
|
||
setQUploading(true);
|
||
try {
|
||
const form = new FormData();
|
||
form.append('file', file);
|
||
await api('/admin/questions/upload', { method: 'POST', body: form, isForm: true });
|
||
addToast('Questions file uploaded', 'success');
|
||
fetchQFiles();
|
||
} catch (e) { addToast(`Upload failed: ${e.message}`, 'error'); }
|
||
finally { setQUploading(false); }
|
||
};
|
||
|
||
const deleteQFile = async (fileId) => {
|
||
if (!confirm(`Delete "${fileId}"?`)) return;
|
||
try {
|
||
await api(`/admin/questions/${fileId}`, { method: 'DELETE' });
|
||
addToast(`Deleted ${fileId}`, 'success');
|
||
fetchQFiles();
|
||
} catch (e) { addToast(`Delete failed: ${e.message}`, 'error'); }
|
||
};
|
||
|
||
const useQFile = (fileId) => {
|
||
const path = `files/${fileId}`;
|
||
if (setQuestionsFile) setQuestionsFile(path);
|
||
addToast(`Set questions file: ${path}`, 'success');
|
||
setActiveTab('benchmarks');
|
||
};
|
||
|
||
// -- Cost Estimator state --
|
||
const [costQ, setCostQ] = useState(21);
|
||
const [costS, setCostS] = useState(5);
|
||
const [costResult, setCostResult] = useState(null);
|
||
const [costLoading, setCostLoading] = useState(false);
|
||
|
||
const estimateCost = async () => {
|
||
setCostLoading(true);
|
||
try {
|
||
const data = await api(`/admin/cost-estimate?num_questions=${costQ}&num_strategies=${costS}`, { method: 'POST' });
|
||
setCostResult(data);
|
||
} catch (e) { addToast(`Cost estimate failed: ${e.message}`, 'error'); }
|
||
finally { setCostLoading(false); }
|
||
};
|
||
|
||
return React.createElement('div', null,
|
||
React.createElement('h1', { style: { color: 'var(--text-heading)', marginBottom: '16px', fontSize: '22px' } }, 'Admin'),
|
||
|
||
// -- 1. Health --
|
||
React.createElement(CollapseSection, {
|
||
title: '🏥 Health', icon: '', open: openSections.health,
|
||
onToggle: () => toggle('health'),
|
||
badge: health?.status === 'ok' ? '● OK' : (healthLoading ? '...' : '● Down'),
|
||
},
|
||
healthLoading
|
||
? React.createElement('div', { style: { padding: '20px', textAlign: 'center' } }, React.createElement('span', { className: 'spinner' }))
|
||
: React.createElement(React.Fragment, null,
|
||
React.createElement('div', { className: 'flex-between mb-2' },
|
||
React.createElement('div', null),
|
||
React.createElement('button', { className: 'btn btn-secondary btn-sm', onClick: fetchHealth }, '↻ Refresh')
|
||
),
|
||
React.createElement('div', { className: 'status-grid' },
|
||
React.createElement('div', { className: 'status-card' },
|
||
React.createElement('div', { className: 'label' }, 'Server'),
|
||
React.createElement('div', { className: `value ${health?.status === 'ok' ? 'green' : ''}` },
|
||
health?.status === 'ok' ? '● OK' : '● Down')),
|
||
React.createElement('div', { className: 'status-card' },
|
||
React.createElement('div', { className: 'label' }, 'Qdrant'),
|
||
React.createElement('div', { className: `value ${health?.qdrant_connected ? 'green' : ''}` },
|
||
health?.qdrant_connected ? '● Connected' : '● Disconnected')),
|
||
React.createElement('div', { className: 'status-card' },
|
||
React.createElement('div', { className: 'label' }, 'SQLite'),
|
||
React.createElement('div', { className: `value ${health?.sqlite_ok ? 'green' : ''}` },
|
||
health?.sqlite_ok ? '● OK' : '● Down')),
|
||
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')),
|
||
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')
|
||
)
|
||
)
|
||
)
|
||
)
|
||
)
|
||
),
|
||
|
||
// -- 2. Qdrant Collections --
|
||
React.createElement(CollapseSection, {
|
||
title: '🗄️ Qdrant Collections', icon: '', open: openSections.qdrant,
|
||
onToggle: () => toggle('qdrant'),
|
||
badge: collectionsData.length ? `${collectionsData.length} collections` : '0',
|
||
},
|
||
collectionsData.length === 0
|
||
? React.createElement('div', { className: 'empty-state' }, 'No collections found')
|
||
: React.createElement('table', null,
|
||
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,
|
||
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', {
|
||
className: 'btn btn-danger btn-sm',
|
||
onClick: async () => {
|
||
if (!confirm(`Delete collection "${c.name}"?`)) return;
|
||
try {
|
||
await api(`/admin/qdrant/collections/${c.name}`, { method: 'DELETE' });
|
||
addToast(`Deleted ${c.name}`, 'success');
|
||
fetchHealth();
|
||
} catch (e) { addToast(`Delete failed: ${e.message}`, 'error'); }
|
||
}
|
||
}, 'Delete'))
|
||
)
|
||
)
|
||
)
|
||
)
|
||
),
|
||
|
||
// -- 3. Chunk Preview --
|
||
React.createElement(CollapseSection, {
|
||
title: '📦 Chunk Preview', icon: '', open: openSections.chunks,
|
||
onToggle: () => toggle('chunks'),
|
||
},
|
||
React.createElement(ChunkPreviewPanel, {
|
||
documents, strategies, addToast, formatFilter: 'all',
|
||
})
|
||
),
|
||
|
||
// -- 4. Questions Management --
|
||
React.createElement(CollapseSection, {
|
||
title: '❓ Questions Dataset', icon: '', open: openSections.questions,
|
||
onToggle: () => toggle('questions'),
|
||
badge: qFiles.length ? `${qFiles.length} files` : '0',
|
||
},
|
||
React.createElement('div', { className: 'flex-between mb-4' },
|
||
React.createElement('button', {
|
||
className: 'btn btn-primary btn-sm',
|
||
onClick: () => qFileRef.current?.click(),
|
||
}, qUploading ? React.createElement('span', { className: 'spinner' }) : '↑ Upload .json'),
|
||
React.createElement('input', {
|
||
ref: qFileRef, type: 'file', accept: '.json', style: { display: 'none' },
|
||
onChange: e => uploadQFile(e.target.files[0]),
|
||
}),
|
||
React.createElement('button', { className: 'btn btn-secondary btn-sm', onClick: fetchQFiles }, '↻ Refresh')
|
||
),
|
||
qLoading
|
||
? React.createElement('div', { style: { padding: '20px', textAlign: 'center' } }, React.createElement('span', { className: 'spinner' }))
|
||
: qFiles.length === 0
|
||
? React.createElement('div', { className: 'empty-state' }, 'No question files found in files/')
|
||
: React.createElement('table', null,
|
||
React.createElement('thead', null,
|
||
React.createElement('tr', null,
|
||
React.createElement('th', null, 'File'),
|
||
React.createElement('th', { style: { width: '60px' } }, 'Questions'),
|
||
React.createElement('th', { style: { width: '80px' } }, 'Size'),
|
||
React.createElement('th', { style: { width: '120px' } }, 'Actions'),
|
||
)
|
||
),
|
||
React.createElement('tbody', null,
|
||
qFiles.map(f => React.createElement(React.Fragment, { key: f.id },
|
||
React.createElement('tr', {
|
||
style: { cursor: 'pointer' },
|
||
onClick: () => toggleQFile(f.id),
|
||
},
|
||
React.createElement('td', { className: 'mono text-sm' }, f.id),
|
||
React.createElement('td', null,
|
||
React.createElement('span', { className: 'badge badge-accent' }, f.questions_count >= 0 ? f.questions_count : '?')),
|
||
React.createElement('td', { className: 'text-sm text-muted' },
|
||
f.size_bytes > 1024 ? `${(f.size_bytes / 1024).toFixed(1)} KB` : `${f.size_bytes} B`),
|
||
React.createElement('td', null,
|
||
React.createElement('div', { style: { display: 'flex', gap: '4px' }, onClick: e => e.stopPropagation() },
|
||
React.createElement('button', {
|
||
className: 'btn btn-primary btn-sm',
|
||
onClick: () => useQFile(f.id),
|
||
}, 'Use'),
|
||
React.createElement('button', {
|
||
className: 'btn btn-danger btn-sm',
|
||
onClick: () => deleteQFile(f.id),
|
||
}, '✕')
|
||
)
|
||
),
|
||
expandedQFile === f.id && React.createElement('tr', null,
|
||
React.createElement('td', { colSpan: 4 },
|
||
qFileDetail
|
||
? React.createElement('div', { className: 'chunk-detail', style: { maxHeight: '400px' } },
|
||
(qFileDetail.questions || []).map((q, i) =>
|
||
React.createElement('div', { key: q.id || i, style: { marginBottom: '12px', paddingBottom: '12px', borderBottom: '1px solid var(--border)' } },
|
||
React.createElement('div', { style: { display: 'flex', gap: '6px', marginBottom: '4px', alignItems: 'center' } },
|
||
React.createElement('span', { className: 'badge badge-accent' }, q.id || `q${i + 1}`),
|
||
q.category ? React.createElement('span', { className: 'badge badge-warning' }, q.category) : null,
|
||
q.difficulty ? React.createElement('span', { className: `badge ${q.difficulty === 'hard' ? 'badge-danger' : q.difficulty === 'medium' ? 'badge-warning' : 'badge-success'}` }, q.difficulty) : null,
|
||
),
|
||
React.createElement('div', { style: { fontWeight: 500, marginBottom: '4px' } }, q.question),
|
||
q.expected_answer ? React.createElement('div', { className: 'text-sm text-muted', style: { lineHeight: '1.6' } },
|
||
React.createElement('strong', { style: { color: 'var(--text-body)' } }, 'Expected: '), q.expected_answer) : null,
|
||
)
|
||
)
|
||
)
|
||
: React.createElement('div', { style: { padding: '10px', textAlign: 'center' } }, React.createElement('span', { className: 'spinner' }))
|
||
))
|
||
)
|
||
))
|
||
)
|
||
)
|
||
),
|
||
|
||
// -- 5. Cost Estimator --
|
||
React.createElement(CollapseSection, {
|
||
title: '💰 Cost Estimator', icon: '', open: openSections.cost,
|
||
onToggle: () => toggle('cost'),
|
||
},
|
||
React.createElement('div', { className: 'row mb-0' },
|
||
React.createElement('div', { className: 'col', style: { maxWidth: '160px' } },
|
||
React.createElement('label', null, 'Questions'),
|
||
React.createElement('input', {
|
||
type: 'number', min: 1, max: 500, value: costQ,
|
||
onChange: e => setCostQ(parseInt(e.target.value) || 1),
|
||
})
|
||
),
|
||
React.createElement('div', { className: 'col', style: { maxWidth: '160px' } },
|
||
React.createElement('label', null, 'Strategies'),
|
||
React.createElement('input', {
|
||
type: 'number', min: 1, max: 5, value: costS,
|
||
onChange: e => setCostS(parseInt(e.target.value) || 1),
|
||
})
|
||
),
|
||
React.createElement('div', { style: { flex: 'none' } },
|
||
React.createElement('button', {
|
||
className: 'btn btn-primary', onClick: estimateCost, disabled: costLoading,
|
||
}, costLoading ? React.createElement('span', { className: 'spinner' }) : 'Estimate')
|
||
)
|
||
),
|
||
costResult && React.createElement('div', { className: 'cost-result' },
|
||
React.createElement('div', { className: 'cost-card' },
|
||
React.createElement('div', { className: 'label' }, 'Total Cost'),
|
||
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()),
|
||
),
|
||
React.createElement('div', { className: 'cost-card' },
|
||
React.createElement('div', { className: 'label' }, 'Output Tokens'),
|
||
React.createElement('div', { className: 'value' }, (costResult.estimated_tokens?.output || 0).toLocaleString()),
|
||
),
|
||
React.createElement('div', { className: 'cost-card' },
|
||
React.createElement('div', { className: 'label' }, 'Embedding'),
|
||
React.createElement('div', { className: 'value' }, `$${costResult.cost_breakdown?.embedding?.toFixed(4) || '0'}`),
|
||
),
|
||
React.createElement('div', { className: 'cost-card' },
|
||
React.createElement('div', { className: 'label' }, 'Queries'),
|
||
React.createElement('div', { className: 'value' }, `$${costResult.cost_breakdown?.queries?.toFixed(4) || '0'}`),
|
||
),
|
||
React.createElement('div', { className: 'cost-card' },
|
||
React.createElement('div', { className: 'label' }, 'Evaluation'),
|
||
React.createElement('div', { className: 'value' }, `$${costResult.cost_breakdown?.evaluation?.toFixed(4) || '0'}`),
|
||
)
|
||
)
|
||
)
|
||
);
|
||
}
|
||
|
||
// -- Main App -----------------------------------------------
|
||
function App() {
|
||
const [activeTab, setActiveTab] = useState('home');
|
||
const [documents, setDocuments] = useState([]);
|
||
const [strategies, setStrategies] = useState([]);
|
||
const [toasts, setToasts] = useState([]);
|
||
const [questionsFile, setQuestionsFile] = useState('files/questions.json');
|
||
|
||
useEffect(() => {
|
||
api('/strategies').then(d => setStrategies(d.strategies || [])).catch(() => {});
|
||
api('/documents').then(d => setDocuments(d.items || [])).catch(() => {});
|
||
}, []);
|
||
|
||
const addToast = useCallback((message, type = 'success') => {
|
||
const id = ++_toastId;
|
||
setToasts(prev => [...prev, { id, message, type }]);
|
||
setTimeout(() => setToasts(prev => prev.filter(t => t.id !== id)), 3000);
|
||
}, []);
|
||
|
||
const removeToast = useCallback((id) => {
|
||
setToasts(prev => prev.filter(t => t.id !== id));
|
||
}, []);
|
||
|
||
const tabs = [
|
||
{ id: 'home', label: 'Home' },
|
||
{ id: 'documents', label: 'Documents' },
|
||
{ id: 'pdf', label: 'PDF' },
|
||
{ id: 'query', label: 'Query' },
|
||
{ id: 'benchmarks', label: 'Benchmarks' },
|
||
{ id: 'decision', label: 'Decision' },
|
||
{ id: 'admin', label: 'Admin' },
|
||
];
|
||
|
||
const renderTab = () => {
|
||
switch (activeTab) {
|
||
case 'home': return React.createElement(HomeTab);
|
||
case 'documents': return React.createElement(DocumentsTab, {
|
||
documents, setDocuments, strategies, addToast, formatFilter: 'word' });
|
||
case 'pdf': return React.createElement(PdfWorkspaceTab, {
|
||
documents, setDocuments, strategies, addToast, questionsFile });
|
||
case 'query': return React.createElement(QueryTab, {
|
||
documents, strategies, addToast, formatFilter: 'word' });
|
||
case 'benchmarks': return React.createElement(BenchmarksTab, {
|
||
documents, strategies, addToast, questionsFile, formatFilter: 'word' });
|
||
case 'decision': return React.createElement(DecisionTab, { addToast });
|
||
case 'admin': return React.createElement(AdminTab, {
|
||
addToast, documents, strategies, setActiveTab, setQuestionsFile });
|
||
default: return null;
|
||
}
|
||
};
|
||
|
||
return React.createElement(React.Fragment, null,
|
||
React.createElement('div', { className: 'tab-bar' },
|
||
React.createElement('div', { className: 'logo' }, 'RAG ', React.createElement('span', null, 'Benchmarker')),
|
||
tabs.map(t =>
|
||
React.createElement('button', {
|
||
key: t.id,
|
||
className: `tab-btn ${activeTab === t.id ? 'active' : ''}`,
|
||
onClick: () => setActiveTab(t.id),
|
||
}, t.label)
|
||
)
|
||
),
|
||
React.createElement('div', { className: 'main' }, renderTab()),
|
||
React.createElement(ToastContainer, { toasts, removeToast })
|
||
);
|
||
}
|
||
|
||
ReactDOM.createRoot(document.getElementById('root')).render(React.createElement(App));
|
||
</script>
|
||
</body>
|
||
</html>
|