Files
chunking_strategies_evaluation/src/static/index.html
Mahdi Bazrafshan d5ccec2f2c feat(config): apply fixed_size ±3 as stabilized defaults
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>
2026-08-22 13:27:15 +03:30

3614 lines
161 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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>