Evaluation Results Dashboard
Single-file dashboard for reviewing CSVs of LLM-as-a-judge evaluations. Reviewers Agree or Disagree with per-dimension scores, override values, leave comments and overall notes, then download an annotated CSV (originals preserved). From-scratch scoring for empty CSVs, auto Reviewed/Unreviewed status from dim engagement, and a prominent review-progress strip.
GET
/v2/evaluation-tools/evaluation-results-dashboardRequires authenticationCode Example
html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Evaluation Results Dashboard</title>
<meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; connect-src 'none'; font-src 'none'; img-src 'none'; media-src 'none'; object-src 'none'; frame-src 'none'; form-action 'none'; base-uri 'none'">
<style>
:root {
--bg: #0f1117;
--surface: #161922;
--surface2: #1e2235;
--surface3: #252a40;
--border: #2a2f4a;
--text: #e2e8f0;
--muted: #7c87a0;
--dim: #4a5270;
--accent: #6366f1;
--accent-hover: #818cf8;
--accent-glow: rgba(99,102,241,0.12);
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height: 100%; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
background: var(--bg); color: var(--text);
display: flex; flex-direction: column; overflow: hidden; height: 100vh;
}
/* ── UPLOAD SCREEN ─────────────────────────────── */
#upload-screen {
flex: 1; display: flex; flex-direction: column;
align-items: center; justify-content: center; gap: 0;
padding: 40px;
}
#upload-screen.hidden { display: none; }
.upload-card {
background: var(--surface); border: 2px dashed var(--border);
border-radius: 20px; padding: 56px 72px; text-align: center;
max-width: 500px; width: 100%;
transition: border-color .2s, background .2s;
}
.upload-card.drag { border-color: var(--accent); background: var(--accent-glow); }
.u-icon { font-size: 48px; margin-bottom: 18px; }
.u-title { font-size: 22px; font-weight: 700; letter-spacing: -.02em; margin-bottom: 8px; }
.u-sub {
font-size: 13px; color: var(--muted); line-height: 1.6;
margin-bottom: 28px;
}
.u-hint { font-size: 12px; color: var(--dim); margin-top: 18px; }
/* ── MAIN APP ─────────────────────────────────── */
#app { display: none; flex: 1; flex-direction: column; overflow: hidden; }
#app.visible { display: flex; }
/* ── TOP BAR ──────────────────────────────────── */
.topbar {
background: var(--surface); border-bottom: 1px solid var(--border);
padding: 11px 20px; display: flex; align-items: center; gap: 12px;
flex-shrink: 0;
}
.topbar-title { font-size: 15px; font-weight: 700; letter-spacing: -.02em; }
.topbar-sep { color: var(--dim); }
.topbar-file { font-size: 13px; color: var(--muted); }
.spacer { flex: 1; }
.chip {
font-size: 12px; color: var(--muted);
background: var(--surface2); border: 1px solid var(--border);
padding: 4px 10px; border-radius: 20px;
}
.chip strong { color: var(--text); }
/* ── CONTROLS ─────────────────────────────────── */
.controls {
background: var(--surface); border-bottom: 1px solid var(--border);
padding: 8px 20px; display: flex; align-items: center; gap: 14px;
flex-shrink: 0; flex-wrap: wrap;
}
.cg { display: flex; align-items: center; gap: 6px; }
.cl { font-size: 12px; color: var(--muted); white-space: nowrap; }
input[type="range"] {
-webkit-appearance: none; appearance: none;
width: 80px; height: 4px; background: var(--surface3);
border-radius: 2px; outline: none; cursor: pointer;
}
input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none; width: 14px; height: 14px;
border-radius: 50%; background: var(--accent); cursor: pointer;
}
.rv { font-size: 12px; color: var(--accent-hover); font-weight: 600; min-width: 28px; text-align: center; }
select {
background: var(--surface2); border: 1px solid var(--border);
color: var(--text); border-radius: 6px; padding: 5px 8px; font-size: 13px; cursor: pointer;
}
.search {
background: var(--surface2); border: 1px solid var(--border);
color: var(--text); border-radius: 6px; padding: 5px 10px; font-size: 13px;
width: 190px; outline: none; transition: border-color .15s;
}
.search:focus { border-color: var(--accent); }
.search::placeholder { color: var(--dim); }
.filter-stat { font-size: 12px; color: var(--accent-hover); }
/* ── BUTTONS ──────────────────────────────────── */
.btn {
display: inline-flex; align-items: center; gap: 5px;
padding: 7px 14px; border-radius: 7px;
border: 1px solid var(--border); background: var(--surface2);
color: var(--text); font-size: 13px; font-weight: 500;
cursor: pointer; transition: all .15s; white-space: nowrap;
}
.btn:hover { border-color: var(--accent); color: var(--accent-hover); }
.btn-primary { background: var(--accent); border-color: var(--accent); color: #fff; }
.btn-primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); color: #fff; }
.btn-sm { padding: 5px 10px; font-size: 13px; }
.btn:disabled { opacity: .3; cursor: not-allowed; border-color: var(--border); color: var(--muted); }
#file-input { display: none; }
/* ── MAIN SPLIT ───────────────────────────────── */
.split { display: flex; flex: 1; min-height: 0; overflow: hidden; }
/* ── LEFT PANEL ───────────────────────────────── */
.left {
width: 340px; flex-shrink: 0;
border-right: 1px solid var(--border);
display: flex; flex-direction: column; overflow: hidden;
transition: width .18s ease, border-color .18s ease;
}
/* Collapsible chrome — hides filter rows and / or sidebar to free up
screen space when the reviewer wants a focused view. */
.controls.collapsed,
.tag-filter.collapsed { display: none !important; }
.left.collapsed { width: 32px; min-width: 32px; }
.left.collapsed .left-hdr { padding: 9px 0; justify-content: center; }
.left.collapsed .left-hdr-label { display: none; }
.left.collapsed .list { display: none; }
.btn.is-collapsed {
background: transparent; color: var(--dim);
}
.btn.is-collapsed:hover {
background: var(--surface2); color: var(--accent-hover);
}
.left-hdr {
padding: 9px 16px; flex-shrink: 0;
font-size: 11px; font-weight: 700; text-transform: uppercase;
letter-spacing: .08em; color: var(--muted);
border-bottom: 1px solid var(--border);
display: flex; align-items: center; justify-content: space-between; gap: 6px;
}
.left-toggle {
width: 22px; height: 22px; flex-shrink: 0;
background: transparent; border: 1px solid var(--border);
border-radius: 4px; color: var(--muted);
cursor: pointer; font: inherit; line-height: 1;
display: inline-flex; align-items: center; justify-content: center;
transition: all .15s;
}
.left-toggle::before { content: "«"; font-size: 13px; }
.left.collapsed .left-toggle::before { content: "»"; }
.left-toggle:hover { color: var(--accent-hover); border-color: var(--accent); }
.list { overflow-y: auto; flex: 1; min-height: 0; }
.item {
padding: 11px 16px; border-bottom: 1px solid var(--border);
cursor: pointer; transition: background .1s;
display: flex; align-items: flex-start; gap: 9px;
}
.item:hover { background: var(--surface2); }
.item.active {
background: rgba(99,102,241,.1);
border-left: 3px solid var(--accent);
padding-left: 13px;
}
.item-num { font-size: 11px; color: var(--dim); min-width: 18px; padding-top: 2px; }
.item-body { flex: 1; min-width: 0; }
.item-top { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }
.item-preview {
font-size: 13px; line-height: 1.4; color: var(--text);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
flex: 1; min-width: 0;
}
.badge {
font-size: 11px; font-weight: 700; padding: 2px 8px;
border-radius: 20px; display: inline-block; flex-shrink: 0;
}
.item-bottom { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.dots { display: flex; align-items: center; gap: 3px; flex-shrink: 0; }
.dot { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; }
.dots-more { font-size: 10px; color: var(--muted); margin-left: 2px; }
.item-tags { display: flex; flex-wrap: wrap; gap: 4px; flex: 1; min-width: 0; }
.item-tag {
font-size: 10px; padding: 1px 7px; border-radius: 10px;
background: var(--surface3); color: var(--muted);
white-space: nowrap; max-width: 120px;
overflow: hidden; text-overflow: ellipsis;
}
.item-tag.on { background: var(--accent-glow); color: var(--accent-hover); }
.item-tag.more { background: transparent; padding: 1px 4px; max-width: none; }
/* Tag filter bar */
.tag-filter {
background: var(--surface); border-bottom: 1px solid var(--border);
padding: 7px 20px; display: none; align-items: center; gap: 10px;
flex-shrink: 0; flex-wrap: wrap;
}
.tag-filter.visible { display: flex; }
.tag-filter-label { font-size: 12px; color: var(--muted); white-space: nowrap; }
.match-toggle {
display: inline-flex; border: 1px solid var(--border); border-radius: 10px;
overflow: hidden; font-size: 11px;
}
.match-opt {
background: transparent; border: none; color: var(--muted);
padding: 3px 10px; cursor: pointer; transition: all .15s; font: inherit;
}
.match-opt:hover { color: var(--text); }
.match-opt.active { background: var(--accent); color: #fff; }
.tag-chips { display: flex; flex-wrap: wrap; gap: 6px; flex: 1; }
.chip-filter {
font-size: 11px; padding: 3px 10px; border-radius: 12px;
background: var(--surface2); color: var(--muted);
border: 1px solid var(--border);
cursor: pointer; user-select: none; transition: all .15s;
white-space: nowrap; max-width: 180px;
overflow: hidden; text-overflow: ellipsis;
display: inline-flex; align-items: center;
}
.chip-filter .label { overflow: hidden; text-overflow: ellipsis; }
.chip-filter:hover { border-color: var(--accent); color: var(--text); }
.chip-filter.active { background: var(--accent); border-color: var(--accent); color: #fff; }
.chip-filter .count { opacity: .6; margin-left: 4px; }
.tag-clear, .tag-toggle {
font-size: 11px; padding: 3px 10px; border-radius: 12px;
background: transparent; border: 1px solid var(--border); color: var(--muted);
cursor: pointer; transition: all .15s; white-space: nowrap;
}
.tag-clear:hover, .tag-toggle:hover { color: var(--text); border-color: var(--accent); }
.no-results { padding: 32px 20px; text-align: center; color: var(--muted); font-size: 13px; }
/* ── RIGHT PANEL ──────────────────────────────── */
.right { flex: 1; min-height: 0; overflow-y: auto; overflow-x: hidden; }
.empty {
height: 100%;
display: flex; flex-direction: column;
align-items: center; justify-content: center;
gap: 10px; color: var(--muted);
}
.empty-icon { font-size: 32px; opacity: .4; }
.empty-title { font-size: 15px; font-weight: 500; }
.empty-sub { font-size: 13px; opacity: .7; }
/* Detail header */
.detail-hdr {
position: sticky; top: 0; z-index: 10;
padding: 13px 22px; border-bottom: 1px solid var(--border);
display: flex; align-items: center; gap: 10px;
background: var(--surface);
}
.detail-title { font-size: 13px; color: var(--muted); flex: 1; }
.detail-title strong { color: var(--text); font-size: 14px; }
.avg-badge {
font-size: 20px; font-weight: 800; letter-spacing: -.02em;
padding: 4px 13px; border-radius: 9px;
}
.avg-label { font-size: 11px; font-weight: 500; opacity: .6; }
.nav { padding: 5px 9px; border-radius: 6px; border: 1px solid var(--border); background: var(--surface2); color: var(--text); font-size: 16px; cursor: pointer; transition: all .15s; line-height: 1; }
.nav:hover { border-color: var(--accent); }
.nav:disabled { opacity: .25; cursor: not-allowed; }
/* Detail body */
.detail-body {
padding: 22px;
}
.detail-body > * + * { margin-top: 16px; }
/* Note box */
.note-box {
background: var(--surface); border: 1px solid var(--border); border-radius: 12px; overflow: hidden;
}
.box-hdr {
padding: 11px 18px; display: flex; align-items: center; justify-content: space-between;
cursor: pointer; user-select: none; border-bottom: 1px solid var(--border);
transition: background .15s;
}
.box-hdr:hover { background: var(--surface2); }
.box-label {
font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: .08em; color: var(--muted);
}
.chev { font-size: 10px; color: var(--dim); transition: transform .2s; }
.chev.open { transform: rotate(180deg); }
.note-body { padding: 18px 20px; }
.note-body.gone { display: none; }
.note-heading { font-size: 15px; font-weight: 700; color: var(--text); margin-bottom: 12px; line-height: 1.35; overflow-wrap: anywhere; }
.note-p { font-size: 14px; line-height: 1.75; color: #b8c5d8; margin-bottom: 10px; white-space: pre-wrap; overflow-wrap: anywhere; }
.note-p:last-child { margin-bottom: 0; }
/* Scores grid */
.scores-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); gap: 10px; }
.score-card {
background: var(--surface); border: 1px solid var(--border); border-radius: 12px;
padding: 15px 12px; text-align: center;
}
.sc-label { font-size: 10px; font-weight: 700; text-transform: uppercase; letter-spacing: .07em; color: var(--muted); margin-bottom: 8px; }
.sc-value { font-size: 28px; font-weight: 800; letter-spacing: -.02em; line-height: 1; margin-bottom: 10px; }
.sc-track { height: 4px; background: var(--surface3); border-radius: 2px; overflow: hidden; }
.sc-fill { height: 100%; border-radius: 2px; }
/* Feedback sections */
.fb-section {
background: var(--surface); border: 1px solid var(--border); border-radius: 12px; overflow: hidden;
}
.fb-hdr {
padding: 12px 17px; display: flex; align-items: center; gap: 10px;
cursor: pointer; user-select: none; transition: background .15s;
}
.fb-hdr:hover { background: var(--surface2); }
.fb-name { font-size: 14px; font-weight: 600; flex: 1; }
.fb-score { font-size: 12px; font-weight: 700; padding: 2px 9px; border-radius: 20px; }
.fb-body {
padding: 14px 17px; border-top: 1px solid var(--border);
font-size: 14px; line-height: 1.68; color: #a8b5cc;
overflow-wrap: anywhere;
}
.fb-body.gone { display: none; }
.fb-empty { font-style: italic; opacity: .5; }
/* Markdown rendering — applied inside .note-body and .fb-body */
.md-rendered p { font-size: 14px; line-height: 1.6; color: inherit; margin: 0 0 10px; overflow-wrap: anywhere; }
.md-rendered p:last-child { margin-bottom: 0; }
.md-rendered table { border-collapse: collapse; margin: 12px 0; font-size: 12.5px; max-width: 100%; display: block; overflow-x: auto; }
.md-rendered th, .md-rendered td { border: 1px solid var(--border); padding: 6px 10px; text-align: left; vertical-align: top; }
.md-rendered th { background: var(--surface2); font-weight: 600; color: var(--text); white-space: nowrap; }
.md-rendered code { background: var(--surface3); padding: 1px 5px; border-radius: 3px; font-size: .9em; font-family: 'SF Mono', Monaco, Menlo, monospace; }
.md-rendered strong { color: var(--text); font-weight: 600; }
.md-rendered em { font-style: italic; }
.md-rendered ul, .md-rendered ol { margin: 8px 0; padding-left: 22px; }
.md-rendered li { line-height: 1.6; margin-bottom: 4px; }
.md-rendered li:last-child { margin-bottom: 0; }
.md-rendered h1, .md-rendered h2, .md-rendered h3, .md-rendered h4 { font-weight: 700; margin: 14px 0 8px; line-height: 1.3; color: var(--text); }
.md-rendered h1 { font-size: 17px; }
.md-rendered h2 { font-size: 15px; }
.md-rendered h3 { font-size: 14px; }
.md-rendered h4 { font-size: 13px; }
.md-rendered > :first-child { margin-top: 0; }
/* ── REVIEWER UI ──────────────────────────────── */
.rev-strip {
padding: 11px 17px; border-top: 1px solid var(--border);
background: rgba(99,102,241,.05);
}
.rev-textarea {
background: var(--bg); border: 1px solid var(--border);
color: var(--text); border-radius: 6px;
font-family: inherit; outline: none;
transition: border-color .15s;
padding: 8px 11px; font-size: 13px;
width: 100%; min-height: 54px; resize: vertical;
line-height: 1.6;
}
.rev-textarea:focus { border-color: var(--accent); }
.rev-textarea::placeholder { color: var(--dim); }
.fb-collapsible.gone { display: none; }
/* Override score input — sits inside the dim header next to the LLM score badge */
.rev-score-inline {
width: 56px; height: 26px; padding: 2px 6px;
background: var(--surface2); border: 1px solid var(--border);
color: var(--text); border-radius: 6px; outline: none;
font-family: inherit; font-size: 12px; text-align: center;
font-variant-numeric: tabular-nums;
transition: all .15s;
}
.rev-score-inline:focus { border-color: var(--accent); }
.rev-score-inline::placeholder { color: var(--dim); }
.rev-score-inline::-webkit-outer-spin-button,
.rev-score-inline::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
.rev-score-inline.has-override {
border-color: var(--accent-hover);
background: rgba(99,102,241,.08);
color: var(--accent-hover);
font-weight: 700;
}
/* Mark Reviewed button — row-level explicit "I'm done" gesture */
.mark-reviewed-btn {
display: inline-flex; align-items: center; gap: 5px;
padding: 5px 12px; border-radius: 7px;
border: 1px solid var(--border); background: var(--surface2);
color: var(--muted); font-size: 12px; font-weight: 600;
cursor: pointer; transition: all .15s; white-space: nowrap; font: inherit;
}
.mark-reviewed-btn:hover { border-color: var(--accent); color: var(--accent-hover); }
.mark-reviewed-btn.reviewed {
background: rgba(34,197,94,.1); border-color: #22c55e; color: #22c55e;
}
.status-dot {
width: 7px; height: 7px; border-radius: 50%;
flex-shrink: 0; margin-top: 6px; box-sizing: border-box;
}
.status-dot.reviewed { background: #22c55e; }
.status-dot.unreviewed { background: transparent; border: 1px solid var(--border); }
/* Review progress strip — full-width below the topbar */
.review-progress {
position: relative; height: 30px;
background: var(--surface);
border-bottom: 1px solid var(--border);
flex-shrink: 0; overflow: hidden;
}
.rp-fill {
position: absolute; left: 0; top: 0; bottom: 0;
width: 0%;
background: linear-gradient(90deg, rgba(99,102,241,.18), rgba(99,102,241,.32));
transition: width .35s cubic-bezier(.4,.2,.2,1), background .35s;
}
.review-progress.complete .rp-fill {
background: linear-gradient(90deg, rgba(34,197,94,.22), rgba(34,197,94,.4));
}
.rp-content {
position: relative; z-index: 2;
height: 100%;
display: flex; align-items: center; gap: 10px;
padding: 0 22px;
font-size: 13px;
}
.rp-label {
font-size: 11px; font-weight: 700; text-transform: uppercase;
letter-spacing: .08em; color: var(--muted);
}
.rp-count {
font-weight: 800; font-size: 14px; letter-spacing: -.01em;
font-variant-numeric: tabular-nums; color: var(--text);
}
.review-progress.complete .rp-count { color: #22c55e; }
.rp-pct {
font-size: 12px; color: var(--muted);
font-variant-numeric: tabular-nums;
}
.rp-done {
display: none; font-size: 12px; font-weight: 700;
color: #22c55e; margin-left: auto;
}
.review-progress.complete .rp-done { display: inline; }
.dirty-pill {
display: none;
font-size: 11px; color: #f59e0b;
background: rgba(245,158,11,.12);
border: 1px solid rgba(245,158,11,.3);
padding: 3px 10px; border-radius: 20px;
white-space: nowrap;
}
.dirty-pill.visible { display: inline-block; }
/* Validation warning bar */
.warn-bar {
background: rgba(245,158,11,.06);
border-bottom: 1px solid rgba(245,158,11,.22);
padding: 7px 18px; display: none;
align-items: center; gap: 8px; flex-wrap: wrap;
flex-shrink: 0;
}
.warn-bar.visible { display: flex; }
.warn-icon { font-size: 13px; color: #f59e0b; flex-shrink: 0; }
.warn-item {
display: inline-flex; align-items: center; gap: 8px;
font-size: 12px; color: #d4a463; line-height: 1.4;
background: rgba(245,158,11,.09);
border: 1px solid rgba(245,158,11,.22);
padding: 4px 6px 4px 11px; border-radius: 14px;
}
.warn-x {
background: transparent; border: none; cursor: pointer;
color: #d4a463; font-size: 14px; line-height: 1;
padding: 0 4px; font-family: inherit; opacity: .55;
}
.warn-x:hover { opacity: 1; color: #fff; }
/* Scrollbar */
::-webkit-scrollbar { width: 10px; height: 10px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 5px; }
::-webkit-scrollbar-thumb:hover { background: var(--dim); }
</style>
</head>
<body>
<!-- ── UPLOAD SCREEN ──────────────────────────────────── -->
<div id="upload-screen">
<div class="upload-card" id="drop-zone">
<div class="u-icon">📊</div>
<div class="u-title">Evaluation Dashboard</div>
<div class="u-sub">
Load a CSV with evaluation results to explore scores<br>
and LLM-as-a-judge feedback across all dimensions.
</div>
<button class="btn btn-primary" onclick="document.getElementById('file-input').click()">
Upload CSV file
</button>
<div class="u-hint">Expected columns: a primary text column (e.g. <code>Note</code>, <code>Output</code>, <code>Sample</code>, <code>Response</code>), plus any number of <code><name> Score</code> / <code><name> Feedback</code> pairs. <code>Average Score</code> and <code>Tags</code> (comma-separated) are optional.</div>
</div>
</div>
<!-- ── MAIN APP ───────────────────────────────────────── -->
<div id="app">
<!-- Top bar -->
<div class="topbar">
<span class="topbar-title">Evaluation Dashboard</span>
<span class="topbar-sep">·</span>
<span class="topbar-file" id="file-label">—</span>
<div class="spacer"></div>
<div class="chip" id="stat-avg">Avg —</div>
<span class="dirty-pill" id="dirty-pill">● Unsaved edits</span>
<button class="btn btn-sm" onclick="document.getElementById('file-input').click()">Load CSV</button>
<button class="btn btn-sm btn-primary" id="download-btn">Download CSV</button>
<button class="btn btn-sm" id="toggle-filters" onclick="toggleFilters()" title="Hide / show filter rows" aria-label="Toggle filters">☰</button>
</div>
<!-- Review progress strip -->
<div class="review-progress" id="review-progress">
<div class="rp-fill" id="rp-fill"></div>
<div class="rp-content">
<span class="rp-label">Review progress</span>
<strong class="rp-count" id="rp-count">0 / 0</strong>
<span class="rp-pct" id="rp-pct">0%</span>
<span class="rp-done" id="rp-done">✓ Complete</span>
</div>
</div>
<!-- Controls -->
<div class="controls">
<div class="cg">
<span class="cl">Min</span>
<input type="range" id="sl-min" min="0" max="100" value="0">
<span class="rv" id="lbl-min">0</span>
</div>
<div class="cg">
<span class="cl">Max</span>
<input type="range" id="sl-max" min="0" max="100" value="100">
<span class="rv" id="lbl-max">100</span>
</div>
<div class="cg">
<span class="cl">Sort</span>
<select id="sort-sel">
<option value="orig">Original order</option>
<option value="desc">Highest first</option>
<option value="asc">Lowest first</option>
</select>
</div>
<div class="cg">
<span class="cl">Status</span>
<select id="status-sel">
<option value="all">All</option>
<option value="unreviewed">Unreviewed</option>
<option value="reviewed">Reviewed</option>
</select>
</div>
<div class="cg">
<input class="search" id="search" type="text" placeholder="Search notes…">
</div>
<div class="spacer"></div>
<span class="filter-stat" id="filter-stat"></span>
</div>
<!-- Validation warnings (shown only when CSV has issues) -->
<div class="warn-bar" id="warn-bar"></div>
<!-- Tag filter (only shown when CSV has tags) -->
<div class="tag-filter" id="tag-filter">
<span class="tag-filter-label">Tags</span>
<div class="match-toggle" id="match-toggle" role="group" aria-label="Match mode">
<button class="match-opt active" data-mode="any">Any</button>
<button class="match-opt" data-mode="all">All</button>
</div>
<div class="tag-chips" id="tag-chips"></div>
<button class="tag-toggle" id="tag-toggle" style="display:none;"></button>
<button class="tag-clear" id="tag-clear" style="display:none;">Clear</button>
</div>
<!-- Split view -->
<div class="split">
<!-- Left: list -->
<div class="left">
<div class="left-hdr">
<span class="left-hdr-label">Results</span>
<button class="left-toggle" id="toggle-sidebar" onclick="toggleSidebar()" title="Collapse / expand sidebar" aria-label="Toggle sidebar"></button>
</div>
<div class="list" id="list"></div>
</div>
<!-- Right: detail -->
<div class="right">
<div class="empty" id="empty">
<div class="empty-icon">←</div>
<div class="empty-title">Select an evaluation</div>
<div class="empty-sub">Click any item in the list to view it</div>
</div>
<div class="detail" id="detail" style="display:none;"></div>
</div>
</div>
</div>
<input type="file" id="file-input" accept=".csv">
<script>
// ── State ────────────────────────────────────────────────
let evals = [], filtered = [], selectedId = null;
let cols = null; // column descriptor from detectCols — used on export
let minScore = 0, maxScore = 100, sortOrder = 'orig', query = '', statusFilter = 'all';
let activeTags = new Set();
let tagMatchMode = 'any';
let tagsExpanded = false;
let collapsed = { note: false, reviewerNotes: false };
let dirty = false;
let currentFilename = '';
const annotationDebounce = new Map(); // evalId -> timeout
const MAX_DOTS = 5;
const MAX_ITEM_TAGS = 3;
const MAX_FILTER_CHIPS = 15;
// Reviewed/Unreviewed are auto-derived from dim engagement.
const STATUS_LABEL = { unreviewed: 'Unreviewed', reviewed: 'Reviewed' };
const NOTE_KEYWORDS = ['note', 'content', 'text', 'output', 'response', 'sample', 'input', 'article', 'summary', 'message', 'prompt'];
// ── CSV Parser ───────────────────────────────────────────
function parseCSV(txt) {
const rows = [];
let i = 0, n = txt.length;
while (i < n) {
while (i < n && (txt[i] === '\r' || txt[i] === '\n')) i++;
if (i >= n) break;
const row = [];
while (i < n) {
let f = '';
if (txt[i] === '"') {
i++;
while (i < n) {
if (txt[i] === '"' && txt[i+1] === '"') { f += '"'; i += 2; }
else if (txt[i] === '"') { i++; break; }
else f += txt[i++];
}
} else {
while (i < n && txt[i] !== ',' && txt[i] !== '\r' && txt[i] !== '\n') f += txt[i++];
}
row.push(f.trim());
if (i < n && txt[i] === ',') i++;
else { if (txt[i] === '\r') i++; if (txt[i] === '\n') i++; break; }
}
if (row.some(c => c)) rows.push(row);
}
return rows;
}
function detectCols(hdr) {
const norm = s => s.toLowerCase().replace(/[_\-]+/g, ' ').trim();
const h = hdr.map(norm);
const firstOf = (...preds) => { for (const p of preds) { const i = h.findIndex(p); if (i !== -1) return i; } return -1; };
const noteIdx = firstOf(
c => NOTE_KEYWORDS.includes(c),
c => NOTE_KEYWORDS.some(k => new RegExp(`\\b${k}\\b`).test(c)) && !c.includes('reviewer') && !/\s+score$/.test(c) && !/\s+feedback$/.test(c)
);
const avgIdx = firstOf(c => c === 'average score', c => c === 'average' || c === 'avg');
const tagsIdx = firstOf(c => c === 'tags' || c === 'labels' || c === 'categories');
const notesIdx = firstOf(c => c === 'reviewer notes' || c === 'reviewer note');
const statusIdx = firstOf(c => c === 'review status' || c === 'status');
const reserved = new Set([noteIdx, avgIdx, tagsIdx, notesIdx, statusIdx].filter(i => i !== -1));
const used = new Set(reserved);
// Primary dim score columns: end with " score" but aren't reviewer scores or the avg score.
// Track which dim names we've already locked in so duplicates don't double-add.
const dims = [];
const dimNames = new Set();
const duplicateDimNames = [];
const find = pred => h.findIndex((cc, j) => !used.has(j) && pred(cc));
const titleCase = s => s.split(/\s+/).map(w => w[0].toUpperCase() + w.slice(1)).join(' ');
for (let i = 0; i < h.length; i++) {
if (reserved.has(i) || used.has(i)) continue;
const c = h[i];
if (!/\s+score$/.test(c)) continue;
if (/reviewer\s+score$/.test(c)) continue;
const name = c.replace(/\s+score$/, '').trim();
if (!name || name === 'average' || name === 'avg') continue;
if (dimNames.has(name)) { duplicateDimNames.push(hdr[i]); continue; }
dimNames.add(name);
const fbIdx = find(cc => cc === `${name} feedback` || cc === `${name} comment`);
const revScIdx = find(cc => cc === `${name} reviewer score`);
const revFbIdx = find(cc => cc === `${name} reviewer feedback` || cc === `${name} reviewer comment`);
const revAgIdx = find(cc => cc === `${name} reviewer agreement` || cc === `${name} agreement`);
const key = name.replace(/\s+/g, '-');
const originalName = hdr[i].replace(/\s+[sS]core$/, '').trim(); // preserve original casing for export
dims.push({ key, label: titleCase(name), originalName, scIdx: i, fbIdx, revScIdx, revFbIdx, revAgIdx });
used.add(i);
for (const j of [fbIdx, revScIdx, revFbIdx, revAgIdx]) if (j !== -1) used.add(j);
}
// Second pass: orphan "<name> feedback" / "<name> comment" with no matching score
// → create a score-less dim so the reviewer can read the LLM feedback and score from scratch.
for (let i = 0; i < h.length; i++) {
if (reserved.has(i) || used.has(i)) continue;
const c = h[i];
const m = c.match(/\s+(feedback|comment)$/);
if (!m) continue;
if (/reviewer\s+(feedback|comment)$/.test(c)) continue;
const name = c.replace(/\s+(feedback|comment)$/, '').trim();
if (!name || name === 'average' || name === 'avg') continue;
if (dimNames.has(name)) continue;
dimNames.add(name);
const revScIdx = find(cc => cc === `${name} reviewer score`);
const revFbIdx = find(cc => cc === `${name} reviewer feedback` || cc === `${name} reviewer comment`);
const revAgIdx = find(cc => cc === `${name} reviewer agreement` || cc === `${name} agreement`);
const key = name.replace(/\s+/g, '-');
const originalName = hdr[i].replace(/\s+[fF]eedback$/, '').replace(/\s+[cC]omment$/, '').trim();
dims.push({ key, label: titleCase(name), originalName, scIdx: -1, fbIdx: i, revScIdx, revFbIdx, revAgIdx });
used.add(i);
for (const j of [revScIdx, revFbIdx, revAgIdx]) if (j !== -1) used.add(j);
}
// Order dims by their first column appearance for a stable, intuitive UI order.
dims.sort((a, b) => {
const ax = a.scIdx >= 0 ? a.scIdx : a.fbIdx;
const bx = b.scIdx >= 0 ? b.scIdx : b.fbIdx;
return ax - bx;
});
const resolvedNoteIdx = noteIdx !== -1 ? noteIdx : 0;
return {
noteIdx: resolvedNoteIdx,
noteName: hdr[resolvedNoteIdx] || 'Sample',
avgIdx, tagsIdx, notesIdx, statusIdx,
hasAvgCol: avgIdx !== -1,
hasTagsCol: tagsIdx !== -1,
hasNotesCol: notesIdx !== -1,
hasStatusCol: statusIdx !== -1,
dims,
duplicateDimNames,
};
}
function validateCols(cols) {
const warnings = [];
if (!cols) return warnings;
if (cols.dims.length === 0) {
warnings.push('No score or feedback columns detected. You can still annotate via Reviewer Notes and Status.');
}
if (cols.duplicateDimNames && cols.duplicateDimNames.length) {
const list = cols.duplicateDimNames.map(n => `"${n}"`).join(', ');
warnings.push(`Duplicate score column${cols.duplicateDimNames.length > 1 ? 's' : ''}: ${list}. Only the first occurrence is used.`);
}
return warnings;
}
function parseTags(raw) {
if (!raw) return [];
const seen = new Set();
const out = [];
for (const t of raw.split(/[,|;]/)) {
const v = t.trim();
if (v && !seen.has(v)) { seen.add(v); out.push(v); }
}
return out;
}
function buildEvals(rows, c) {
if (rows.length < 2 || !c) return [];
const g = (row, i) => (i >= 0 && i < row.length) ? row[i] : '';
const numOrNull = v => {
if (v == null || String(v).trim() === '') return null;
const x = parseFloat(v);
return isNaN(x) ? null : Math.round(x);
};
const normAgree = v => {
const s = String(v || '').toLowerCase().trim();
return s === 'agree' || s === 'disagree' ? s : null;
};
return rows.slice(1).map((row, i) => {
const dims = c.dims.map(d => ({
key: d.key,
label: d.label,
score: numOrNull(g(row, d.scIdx)),
feedback: g(row, d.fbIdx),
revScore: numOrNull(g(row, d.revScIdx)),
revFeedback: g(row, d.revFbIdx),
}));
const statusVal = String(g(row, c.statusIdx) || '').trim();
const e = {
id: i,
note: g(row, c.noteIdx),
dims,
avg: 0,
tags: parseTags(g(row, c.tagsIdx)),
reviewerNotes: g(row, c.notesIdx),
revReviewed: /^(reviewed|true|1|yes)$/i.test(statusVal),
};
e.avg = computeEvalAvg(e);
return e;
}).filter(e => e.note.trim());
}
// Effective score for a dim — reviewer override wins if set, otherwise LLM score.
function effectiveDimScore(d) {
if (d.revScore != null) return d.revScore;
return d.score; // may be null
}
// Reviewer's explicit "Mark Reviewed" gesture — no auto-derivation from dim
// engagement, since override / comment activity counts as work-in-progress, not done.
function effectiveStatus(e) {
return e.revReviewed ? 'reviewed' : 'unreviewed';
}
function computeEvalAvg(e) {
const vals = e.dims.map(effectiveDimScore).filter(v => v != null);
return vals.length ? Math.round(vals.reduce((s, x) => s + x, 0) / vals.length) : 0;
}
// ── Score helpers ────────────────────────────────────────
function sColor(s) {
if (s == null) return '#4a5270';
if (s >= 80) return '#22c55e';
if (s >= 50) return '#f59e0b';
return '#ef4444';
}
function sBg(s) {
if (s == null) return 'rgba(74,82,112,.15)';
if (s >= 80) return 'rgba(34,197,94,.15)';
if (s >= 50) return 'rgba(245,158,11,.15)';
return 'rgba(239,68,68,.15)';
}
// ── Note formatting ──────────────────────────────────────
function esc(s) {
return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
}
function escAttr(s) {
return esc(s).replace(/"/g,'"');
}
function notePreview(note) {
const line = note.split('\n')[0].trim().replace(/^\*{0,2}(title|note)[:\s*]*/i,'').replace(/\*+$/,'').trim();
return line.length > 72 ? line.slice(0, 70) + '…' : (line || note.slice(0, 72));
}
// Minimal markdown renderer — supports tables, paragraphs, headings, lists,
// inline bold/italic/code. Escapes HTML in content; structural tags are emitted raw.
function renderMarkdown(text) {
if (!text || !text.trim()) return '';
const escContent = (s) => s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
const inline = (s) => {
s = escContent(s);
// Extract inline code into placeholders so bold/italic regex doesn't run inside code
const codes = [];
s = s.replace(/`([^`]+)`/g, (_, c) => {
codes.push(c);
return `\x00CODE${codes.length - 1}\x00`;
});
s = s.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
s = s.replace(/\*([^*\n]+)\*/g, '<em>$1</em>');
s = s.replace(/\x00CODE(\d+)\x00/g, (_, i) => `<code>${codes[+i]}</code>`);
return s;
};
const parseTableRow = (line) => {
const cells = line.split('|');
if (cells.length && cells[0].trim() === '') cells.shift();
if (cells.length && cells[cells.length - 1].trim() === '') cells.pop();
return cells.map(c => c.trim());
};
const isSeparatorRow = (line) => /^\s*\|?[\s\-:|]+\|?\s*$/.test(line) && line.includes('-');
const lines = text.split('\n');
const blocks = [];
let i = 0;
while (i < lines.length) {
const line = lines[i];
if (!line.trim()) { i++; continue; }
// Heading: # / ## / ###
const hMatch = line.match(/^(#{1,6})\s+(.*)$/);
if (hMatch) {
const level = hMatch[1].length;
blocks.push(`<h${level}>${inline(hMatch[2])}</h${level}>`);
i++;
continue;
}
// Pipe table: header row + separator row + body rows
if (line.includes('|') && i + 1 < lines.length && isSeparatorRow(lines[i + 1])) {
const headerCells = parseTableRow(line);
const bodyRows = [];
let j = i + 2;
while (j < lines.length && lines[j].includes('|') && lines[j].trim()) {
bodyRows.push(parseTableRow(lines[j]));
j++;
}
let html = '<table><thead><tr>';
headerCells.forEach(c => { html += `<th>${inline(c)}</th>`; });
html += '</tr></thead><tbody>';
bodyRows.forEach(row => {
html += '<tr>';
row.forEach(c => { html += `<td>${inline(c)}</td>`; });
html += '</tr>';
});
html += '</tbody></table>';
blocks.push(html);
i = j;
continue;
}
// Unordered list
if (/^\s*[-*]\s+/.test(line)) {
const items = [];
while (i < lines.length && /^\s*[-*]\s+/.test(lines[i])) {
items.push(lines[i].replace(/^\s*[-*]\s+/, ''));
i++;
}
blocks.push('<ul>' + items.map(it => `<li>${inline(it)}</li>`).join('') + '</ul>');
continue;
}
// Ordered list
if (/^\s*\d+\.\s+/.test(line)) {
const items = [];
while (i < lines.length && /^\s*\d+\.\s+/.test(lines[i])) {
items.push(lines[i].replace(/^\s*\d+\.\s+/, ''));
i++;
}
blocks.push('<ol>' + items.map(it => `<li>${inline(it)}</li>`).join('') + '</ol>');
continue;
}
// Paragraph: collect consecutive non-empty, non-block lines
const paraLines = [];
while (i < lines.length && lines[i].trim()
&& !/^#{1,6}\s+/.test(lines[i])
&& !(lines[i].includes('|') && i + 1 < lines.length && isSeparatorRow(lines[i + 1]))
&& !/^\s*[-*]\s+/.test(lines[i])
&& !/^\s*\d+\.\s+/.test(lines[i])) {
paraLines.push(lines[i]);
i++;
}
if (paraLines.length) {
blocks.push(`<p>${inline(paraLines.join(' '))}</p>`);
}
}
return blocks.join('');
}
function noteHTML(note) {
if (!note || !note.trim()) return '<div class="md-rendered"></div>';
const parts = note.split(/\n\n+/).map(p => p.trim()).filter(Boolean);
if (!parts.length) return `<div class="md-rendered">${renderMarkdown(note)}</div>`;
const first = parts[0];
const isTitle = parts.length > 1 && first.length < 130 && !/\.\s*$/.test(first);
if (isTitle) {
const t = first.replace(/^\*{1,2}|\*{1,2}$/g,'').replace(/^(title|note|heading)[:\s]*/i,'').trim();
return `<div class="note-heading">${esc(t)}</div><div class="md-rendered">${renderMarkdown(parts.slice(1).join('\n\n'))}</div>`;
}
return `<div class="md-rendered">${renderMarkdown(note)}</div>`;
}
// ── Filter & sort ────────────────────────────────────────
function applyFilters() {
const q = query.toLowerCase();
filtered = evals.filter(e => {
if (e.avg < minScore || e.avg > maxScore) return false;
if (q && !e.note.toLowerCase().includes(q)) return false;
if (statusFilter !== 'all' && effectiveStatus(e) !== statusFilter) return false;
if (activeTags.size > 0) {
if (tagMatchMode === 'all') {
for (const t of activeTags) if (!e.tags.includes(t)) return false;
} else {
if (!e.tags.some(t => activeTags.has(t))) return false;
}
}
return true;
});
if (sortOrder === 'desc') filtered.sort((a, b) => b.avg - a.avg);
else if (sortOrder === 'asc') filtered.sort((a, b) => a.avg - b.avg);
renderList();
updateStats();
}
// ── Render list ──────────────────────────────────────────
function renderList() {
const el = document.getElementById('list');
if (!filtered.length) {
el.innerHTML = '<div class="no-results">No evaluations match the current filters.</div>';
return;
}
el.innerHTML = filtered.map((e, rank) => {
const active = e.id === selectedId ? 'active' : '';
const c = sColor(e.avg), bg = sBg(e.avg);
const shown = e.dims.slice(0, MAX_DOTS);
const extra = e.dims.length - shown.length;
const dotsHtml = shown.map(d => {
const s = effectiveDimScore(d);
return `<div class="dot" title="${escAttr(d.label)}: ${s == null ? '—' : s}" style="background:${sColor(s)}"></div>`;
}).join('') + (extra > 0 ? `<span class="dots-more">+${extra}</span>` : '');
const shownTags = e.tags.slice(0, MAX_ITEM_TAGS);
const extraTags = e.tags.length - shownTags.length;
const tagsHtml = e.tags.length ? `<div class="item-tags">${
shownTags.map(t => `<span class="item-tag${activeTags.has(t)?' on':''}" title="${escAttr(t)}">${esc(t)}</span>`).join('') +
(extraTags > 0 ? `<span class="item-tag more" title="${escAttr(e.tags.slice(MAX_ITEM_TAGS).join(', '))}">+${extraTags}</span>` : '')
}</div>` : '';
const eff = effectiveStatus(e);
return `<div class="item ${active}" id="item-${e.id}" onclick="select(${e.id})">
<div class="status-dot ${eff}" title="${STATUS_LABEL[eff]}"></div>
<div class="item-num">${rank+1}</div>
<div class="item-body">
<div class="item-top">
<div class="item-preview">${esc(notePreview(e.note))}</div>
<span class="badge" style="color:${c};background:${bg}">${e.avg}</span>
</div>
<div class="item-bottom">
<div class="dots">${dotsHtml}</div>
${tagsHtml}
</div>
</div>
</div>`;
}).join('');
}
// Surgical list-item update so annotation edits don't tear down DOM / lose focus.
function updateListMarker(e) {
const row = document.getElementById(`item-${e.id}`);
if (!row) return;
const eff = effectiveStatus(e);
const dot = row.querySelector('.status-dot');
if (dot) {
dot.className = `status-dot ${eff}`;
dot.title = STATUS_LABEL[eff];
}
const badge = row.querySelector('.badge');
if (badge) {
badge.textContent = e.avg;
badge.style.color = sColor(e.avg);
badge.style.background = sBg(e.avg);
}
}
function renderWarnings(warnings) {
const bar = document.getElementById('warn-bar');
if (!warnings.length) { bar.classList.remove('visible'); bar.innerHTML = ''; return; }
bar.innerHTML = `<span class="warn-icon">⚠</span>` +
warnings.map(w =>
`<span class="warn-item"><span class="warn-text">${esc(w)}</span><button class="warn-x" title="Dismiss">×</button></span>`
).join('');
bar.classList.add('visible');
}
function renderTagFilter() {
const bar = document.getElementById('tag-filter');
const chips = document.getElementById('tag-chips');
const clear = document.getElementById('tag-clear');
const toggle = document.getElementById('tag-toggle');
const counts = new Map();
for (const e of evals) for (const t of e.tags) counts.set(t, (counts.get(t) || 0) + 1);
if (counts.size === 0) { bar.classList.remove('visible'); chips.innerHTML = ''; return; }
// Active tags first (so they stay visible even when collapsed), then by frequency, then alpha
const sorted = [...counts.entries()].sort((a, b) => {
const aA = activeTags.has(a[0]) ? 1 : 0, bA = activeTags.has(b[0]) ? 1 : 0;
if (aA !== bA) return bA - aA;
return b[1] - a[1] || a[0].localeCompare(b[0]);
});
const visible = tagsExpanded ? sorted : sorted.slice(0, MAX_FILTER_CHIPS);
chips.innerHTML = visible.map(([t, n]) =>
`<span class="chip-filter${activeTags.has(t)?' active':''}" data-tag="${escAttr(t)}" title="${escAttr(t)}"><span class="label">${esc(t)}</span><span class="count">${n}</span></span>`
).join('');
if (sorted.length > MAX_FILTER_CHIPS) {
toggle.style.display = 'inline-block';
toggle.textContent = tagsExpanded ? 'Show less' : `Show all (${sorted.length})`;
} else {
toggle.style.display = 'none';
}
clear.style.display = activeTags.size > 0 ? 'inline-block' : 'none';
bar.classList.add('visible');
}
window.toggleTag = function(tag) {
if (activeTags.has(tag)) activeTags.delete(tag);
else activeTags.add(tag);
renderTagFilter();
applyFilters();
};
window.clearTags = function() {
activeTags.clear();
renderTagFilter();
applyFilters();
};
function updateStats() {
const n = evals.length;
const avg = n ? Math.round(evals.reduce((s,e)=>s+e.avg,0)/n) : 0;
const reviewed = evals.reduce((s, e) => s + (effectiveStatus(e) === 'reviewed' ? 1 : 0), 0);
const pct = n ? Math.round((reviewed / n) * 100) : 0;
document.getElementById('stat-avg').innerHTML = `Avg <strong style="color:${sColor(avg)}">${avg}</strong>`;
const rp = document.getElementById('review-progress');
document.getElementById('rp-fill').style.width = `${pct}%`;
document.getElementById('rp-count').textContent = `${reviewed} / ${n}`;
document.getElementById('rp-pct').textContent = n ? `${pct}%` : '';
rp.classList.toggle('complete', n > 0 && reviewed === n);
const fn = filtered.length;
document.getElementById('filter-stat').textContent = fn < n ? `Showing ${fn} of ${n}` : '';
}
// ── Select & detail ──────────────────────────────────────
window.select = function(id) {
selectedId = id;
renderList();
const e = evals.find(x => x.id === id);
if (!e) return;
document.getElementById('empty').style.display = 'none';
const det = document.getElementById('detail');
det.style.display = 'block';
const fi = filtered.findIndex(x => x.id === id);
const prevId = fi > 0 ? filtered[fi-1].id : null;
const nextId = fi < filtered.length-1 ? filtered[fi+1].id : null;
const dims = e.dims;
dims.forEach(d => { if (!(d.key in collapsed)) collapsed[d.key] = false; });
const ac = sColor(e.avg), ab = sBg(e.avg);
det.innerHTML = `
<div class="detail-hdr" data-eval-id="${e.id}">
<div class="detail-title">Evaluation <strong>#${id+1}</strong></div>
<div class="avg-badge" style="color:${ac};background:${ab}">
${e.avg} <span class="avg-label">avg</span>
</div>
<button class="mark-reviewed-btn${e.revReviewed?' reviewed':''}" data-rev-field="revReviewed">
${e.revReviewed ? '✓ Reviewed' : 'Mark Reviewed'}
</button>
<button class="nav" onclick="select(${prevId})" ${prevId===null?'disabled':''}>‹</button>
<button class="nav" onclick="select(${nextId})" ${nextId===null?'disabled':''}>›</button>
</div>
<div class="detail-body">
<!-- Primary text -->
<div class="note-box">
<div class="box-hdr" onclick="toggle('note',this)">
<span class="box-label">${esc(cols && cols.noteName || 'Sample')}</span>
<span class="chev ${collapsed.note?'':'open'}">▼</span>
</div>
<div class="note-body ${collapsed.note?'gone':''}">
${noteHTML(e.note)}
</div>
</div>
<!-- Scores overview -->
<div class="scores-grid">
${dims.map(d => {
const s = effectiveDimScore(d);
const c = sColor(s);
return `<div class="score-card" data-dim-key="${d.key}">
<div class="sc-label">${esc(d.label)}</div>
<div class="sc-value" style="color:${c}">${s == null ? '—' : s}</div>
<div class="sc-track"><div class="sc-fill" style="width:${s == null ? 0 : s}%;background:${c}"></div></div>
</div>`;
}).join('')}
</div>
<!-- Per-dim feedback + reviewer controls -->
${dims.map(d => renderDimSection(d, e, !collapsed[d.key])).join('')}
<!-- Reviewer Notes (overall) -->
<div class="note-box">
<div class="box-hdr" onclick="toggle('reviewerNotes',this)">
<span class="box-label">Reviewer Notes</span>
<span class="chev ${collapsed.reviewerNotes?'':'open'}">▼</span>
</div>
<div class="note-body ${collapsed.reviewerNotes?'gone':''}" data-eval-id="${e.id}">
<textarea class="rev-textarea" placeholder="Overall notes about this eval…" data-rev-field="reviewerNotes">${esc(e.reviewerNotes || '')}</textarea>
</div>
</div>
</div>
`;
document.getElementById(`item-${id}`)?.scrollIntoView({ block: 'nearest' });
};
function renderDimSection(d, e, open) {
const hasLLM = d.score != null;
const llmColor = sColor(d.score), llmBg = sBg(d.score);
const llmScoreStr = d.score == null ? '—' : `${d.score}/100`;
const revVal = d.revScore != null ? d.revScore : '';
const hasOverride = d.revScore != null;
const revFb = esc(d.revFeedback || '');
return `<div class="fb-section" data-eval-id="${e.id}" data-dim-key="${d.key}">
<div class="fb-hdr" onclick="toggle('${d.key}',this)">
<span class="fb-name">${esc(d.label)}</span>
<span class="fb-score" style="color:${llmColor};background:${llmBg}">${llmScoreStr}</span>
<input class="rev-score-inline${hasOverride ? ' has-override' : ''}" type="number" min="0" max="100" step="1"
placeholder="—" value="${revVal}" data-rev-field="revScore"
title="Reviewer override (leave blank to accept the LLM's score)"
onclick="event.stopPropagation()">
<span class="chev ${open?'open':''}">▼</span>
</div>
<div class="fb-collapsible ${open?'':'gone'}">
${(hasLLM || d.feedback) ? `<div class="fb-body md-rendered">${d.feedback ? renderMarkdown(d.feedback) : '<span class="fb-empty">No feedback provided.</span>'}</div>` : ''}
<div class="rev-strip" data-dim="${d.key}" data-eval-id="${e.id}">
<textarea class="rev-textarea" placeholder="Reviewer comment (optional)"
data-rev-field="revFeedback">${revFb}</textarea>
</div>
</div>
</div>`;
}
window.toggle = function(key, hdr) {
collapsed[key] = !collapsed[key];
hdr.querySelector('.chev').classList.toggle('open');
hdr.nextElementSibling.classList.toggle('gone');
};
window.toggleFilters = function() {
const ctrl = document.querySelector('.controls');
const tf = document.getElementById('tag-filter');
const btn = document.getElementById('toggle-filters');
const collapsed = !ctrl.classList.contains('collapsed');
ctrl.classList.toggle('collapsed', collapsed);
if (tf) tf.classList.toggle('collapsed', collapsed);
btn.classList.toggle('is-collapsed', collapsed);
};
window.toggleSidebar = function() {
const left = document.querySelector('.left');
const btn = document.getElementById('toggle-sidebar');
const collapsed = !left.classList.contains('collapsed');
left.classList.toggle('collapsed', collapsed);
btn.classList.toggle('is-collapsed', collapsed);
};
// ── Dirty tracking ───────────────────────────────────────
function setDirty(v) {
dirty = v;
document.getElementById('dirty-pill').classList.toggle('visible', v);
}
// ── Annotation handlers ──────────────────────────────────
function findEval(el) {
const host = el.closest('[data-eval-id]');
return host ? evals.find(e => e.id === +host.dataset.evalId) : null;
}
function findDim(el, e) {
const sec = el.closest('[data-dim-key]');
return sec && e ? e.dims.find(d => d.key === sec.dataset.dimKey) : null;
}
function updateDimDisplay(d) {
const det = document.getElementById('detail');
if (!det) return;
const s = effectiveDimScore(d);
const c = sColor(s);
const card = det.querySelector(`.score-card[data-dim-key="${d.key}"]`);
if (card) {
const val = card.querySelector('.sc-value');
const fill = card.querySelector('.sc-fill');
val.textContent = s == null ? '—' : s;
val.style.color = c;
fill.style.width = `${s == null ? 0 : s}%`;
fill.style.background = c;
}
// The fb-score badge in the dim header stays static — it shows the LLM's
// original score, which is immutable. Reviewer override has its own visual
// (the .has-override class on the inline input) and feeds the score-card.
}
function updateOverallAvg(e) {
const det = document.getElementById('detail');
if (!det) return;
const b = det.querySelector('.detail-hdr .avg-badge');
if (b && b.firstChild) {
b.firstChild.nodeValue = `${e.avg} `;
b.style.color = sColor(e.avg);
b.style.background = sBg(e.avg);
}
}
function onDetailInput(ev) {
const el = ev.target;
const field = el.dataset && el.dataset.revField;
if (!field) return;
const e = findEval(el);
if (!e) return;
const d = findDim(el, e);
if (d) {
if (field === 'revScore') {
const v = el.value.trim();
if (v === '') d.revScore = null;
else {
const n = Math.round(parseFloat(v));
d.revScore = isNaN(n) ? null : Math.max(0, Math.min(100, n));
}
el.classList.toggle('has-override', d.revScore != null);
} else if (field === 'revFeedback') {
d.revFeedback = el.value;
} else return;
e.avg = computeEvalAvg(e);
updateDimDisplay(d);
updateOverallAvg(e);
updateListMarker(e);
updateStats();
} else {
if (field === 'reviewerNotes') e.reviewerNotes = el.value;
else return;
}
setDirty(true);
}
function onDetailClick(ev) {
const btn = ev.target.closest('[data-rev-field]');
if (!btn) return;
const field = btn.dataset.revField;
const e = findEval(btn);
if (!e) return;
if (field === 'revReviewed') {
e.revReviewed = !e.revReviewed;
btn.classList.toggle('reviewed', e.revReviewed);
btn.textContent = e.revReviewed ? '✓ Reviewed' : 'Mark Reviewed';
updateListMarker(e);
updateStats();
setDirty(true);
if (statusFilter !== 'all') applyFilters();
}
}
// ── CSV export ───────────────────────────────────────────
function escapeCSVField(v) {
if (v == null) return '';
const s = String(v);
if (/[",\r\n]/.test(s)) return `"${s.replace(/"/g, '""')}"`;
return s;
}
function buildCSV() {
if (!cols) return '';
const header = [cols.noteName || 'Note'];
for (const d of cols.dims) {
header.push(`${d.originalName} Score`);
header.push(`${d.originalName} Feedback`);
header.push(`${d.originalName} Reviewer Score`);
header.push(`${d.originalName} Reviewer Feedback`);
}
if (cols.hasAvgCol) header.push('Average Score');
if (cols.hasTagsCol) header.push('Tags');
header.push('Reviewer Notes');
header.push('Review Status');
const lines = [header.map(escapeCSVField).join(',')];
for (const e of evals) {
const row = [e.note];
for (let i = 0; i < cols.dims.length; i++) {
const d = e.dims[i];
row.push(d.score == null ? '' : d.score);
row.push(d.feedback || '');
row.push(d.revScore == null ? '' : d.revScore);
row.push(d.revFeedback || '');
}
if (cols.hasAvgCol) row.push(e.avg);
if (cols.hasTagsCol) row.push(e.tags.join(', '));
row.push(e.reviewerNotes || '');
row.push(e.revReviewed ? 'reviewed' : '');
lines.push(row.map(escapeCSVField).join(','));
}
return lines.join('\r\n') + '\r\n';
}
function downloadCSV() {
if (!evals.length) return;
const csv = buildCSV();
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
const base = (currentFilename || 'evaluations.csv').replace(/\.csv$/i, '');
a.download = `${base}-reviewed.csv`;
document.body.appendChild(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 100);
setDirty(false);
}
// ── Load CSV ─────────────────────────────────────────────
function loadCSV(txt, filename) {
if (dirty && !confirm('You have unsaved edits. Discard them and load a new CSV?')) return;
const rows = parseCSV(txt);
cols = rows.length ? detectCols(rows[0]) : null;
evals = buildEvals(rows, cols);
selectedId = null;
collapsed = { note: false, reviewerNotes: false };
activeTags = new Set();
tagMatchMode = 'any';
tagsExpanded = false;
statusFilter = 'all';
sortOrder = 'orig';
currentFilename = filename || '';
setDirty(false);
document.querySelectorAll('#match-toggle .match-opt').forEach(b => b.classList.toggle('active', b.dataset.mode === 'any'));
document.getElementById('status-sel').value = 'all';
document.getElementById('sort-sel').value = 'orig';
document.getElementById('upload-screen').classList.add('hidden');
document.getElementById('app').classList.add('visible');
document.getElementById('file-label').textContent = filename || 'loaded';
document.getElementById('empty').style.display = 'flex';
document.getElementById('detail').style.display = 'none';
renderWarnings(validateCols(cols));
renderTagFilter();
applyFilters();
if (filtered.length) select(filtered[0].id);
}
// ── Events ───────────────────────────────────────────────
document.getElementById('file-input').addEventListener('change', e => {
const f = e.target.files[0]; if (!f) return;
const r = new FileReader();
r.onload = ev => loadCSV(ev.target.result, f.name);
r.readAsText(f);
e.target.value = '';
});
const dz = document.getElementById('drop-zone');
dz.addEventListener('dragover', e => { e.preventDefault(); dz.classList.add('drag'); });
dz.addEventListener('dragleave', () => dz.classList.remove('drag'));
dz.addEventListener('drop', e => {
e.preventDefault(); dz.classList.remove('drag');
const f = e.dataTransfer.files[0];
if (!f || !f.name.toLowerCase().endsWith('.csv')) return;
const r = new FileReader();
r.onload = ev => loadCSV(ev.target.result, f.name);
r.readAsText(f);
});
document.getElementById('sl-min').addEventListener('input', e => {
minScore = +e.target.value;
if (minScore > maxScore) { maxScore = minScore; document.getElementById('sl-max').value = maxScore; document.getElementById('lbl-max').textContent = maxScore; }
document.getElementById('lbl-min').textContent = minScore;
applyFilters();
});
document.getElementById('sl-max').addEventListener('input', e => {
maxScore = +e.target.value;
if (maxScore < minScore) { minScore = maxScore; document.getElementById('sl-min').value = minScore; document.getElementById('lbl-min').textContent = minScore; }
document.getElementById('lbl-max').textContent = maxScore;
applyFilters();
});
document.getElementById('sort-sel').addEventListener('change', e => { sortOrder = e.target.value; applyFilters(); });
document.getElementById('status-sel').addEventListener('change', e => { statusFilter = e.target.value; applyFilters(); });
document.getElementById('search').addEventListener('input', e => { query = e.target.value; applyFilters(); });
document.getElementById('download-btn').addEventListener('click', downloadCSV);
const detailEl = document.getElementById('detail');
detailEl.addEventListener('input', onDetailInput);
detailEl.addEventListener('click', onDetailClick);
window.addEventListener('beforeunload', e => {
if (dirty) { e.preventDefault(); e.returnValue = ''; }
});
document.getElementById('tag-chips').addEventListener('click', e => {
const chip = e.target.closest('.chip-filter');
if (chip && chip.dataset.tag) toggleTag(chip.dataset.tag);
});
document.getElementById('tag-clear').addEventListener('click', clearTags);
document.getElementById('tag-toggle').addEventListener('click', () => {
tagsExpanded = !tagsExpanded;
renderTagFilter();
});
document.getElementById('warn-bar').addEventListener('click', e => {
const x = e.target.closest('.warn-x');
if (!x) return;
x.closest('.warn-item')?.remove();
const bar = document.getElementById('warn-bar');
if (!bar.querySelector('.warn-item')) bar.classList.remove('visible');
});
document.getElementById('match-toggle').addEventListener('click', e => {
const btn = e.target.closest('.match-opt');
if (!btn || btn.classList.contains('active')) return;
tagMatchMode = btn.dataset.mode;
document.querySelectorAll('#match-toggle .match-opt').forEach(b => b.classList.toggle('active', b === btn));
applyFilters();
});
document.addEventListener('keydown', e => {
if (!filtered.length) return;
const t = e.target.tagName;
if (t === 'INPUT' || t === 'SELECT' || t === 'TEXTAREA') return;
const i = filtered.findIndex(x => x.id === selectedId);
if ((e.key === 'ArrowDown' || e.key === 'j') && i < filtered.length-1) { e.preventDefault(); select(filtered[i+1].id); }
if ((e.key === 'ArrowUp' || e.key === 'k') && i > 0) { e.preventDefault(); select(filtered[i-1].id); }
});
</script>
</body>
</html>
Last updated: April 22, 2026html