Evaluation Results Dashboard
Standalone HTML page for reviewing evaluation results from any CSV with a Note column and one or more `<name> Score` / `<name> Feedback` column pairs. Supports score filtering, sort order, full-text search, multi-tag filtering (optional `Tags` column), and expandable per-dimension feedback sections.
GET
/v2/utils/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;
}
.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);
}
.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;
white-space: pre-wrap; overflow-wrap: anywhere;
}
.fb-body.gone { display: none; }
.fb-empty { font-style: italic; opacity: .5; }
/* 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 <code>Note</code> column, 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-count">— evals</div>
<div class="chip" id="stat-avg">Avg —</div>
<button class="btn btn-sm" onclick="document.getElementById('file-input').click()">Load CSV</button>
</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="desc">Highest first</option>
<option value="asc">Lowest first</option>
<option value="orig">Original order</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>
<!-- 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" id="list-hdr">Results</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 minScore = 0, maxScore = 100, sortOrder = 'desc', query = '';
let activeTags = new Set();
let tagMatchMode = 'any';
let tagsExpanded = false;
let collapsed = { note: false };
const MAX_DOTS = 5;
const MAX_ITEM_TAGS = 3;
const MAX_FILTER_CHIPS = 15;
// ── 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 findAll = pred => h.map((c, i) => pred(c, i) ? i : -1).filter(i => i !== -1);
const firstOf = (...preds) => { for (const p of preds) { const i = h.findIndex(p); if (i !== -1) return i; } return -1; };
const noteIdx = firstOf(c => c === 'note', c => c.includes('note'));
const avgIdx = firstOf(c => c === 'average score', c => c === 'average' || c === 'avg', c => c.includes('average'));
const tagsIdx = firstOf(c => c === 'tags' || c === 'labels' || c === 'categories', c => /\btags?\b/.test(c) || /\blabels?\b/.test(c));
const reserved = new Set([noteIdx, avgIdx, tagsIdx].filter(i => i !== -1));
const scoreIdxs = findAll((c, i) => !reserved.has(i) && /\s+score$/.test(c));
const dims = [];
const used = new Set(reserved);
for (const i of scoreIdxs) {
const name = h[i].replace(/\s+score$/, '').trim();
if (!name || name === 'average' || name === 'avg') continue;
const fbIdx = h.findIndex((c, j) => !used.has(j) && (c === `${name} feedback` || c === `${name} comment`));
const key = name.replace(/\s+/g, '-');
const label = name.split(/\s+/).map(w => w[0].toUpperCase() + w.slice(1)).join(' ');
dims.push({ key, label, scIdx: i, fbIdx });
used.add(i);
if (fbIdx !== -1) used.add(fbIdx);
}
return { noteIdx: noteIdx !== -1 ? noteIdx : 0, avgIdx, tagsIdx, dims };
}
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) {
if (rows.length < 2) return [];
const c = detectCols(rows[0]);
const g = (row, i) => (i >= 0 && i < row.length) ? row[i] : '';
const n = v => { const x = parseFloat(v); return isNaN(x) ? 0 : Math.round(x); };
return rows.slice(1).map((row, i) => {
const dims = c.dims.map(d => ({
key: d.key,
label: d.label,
score: n(g(row, d.scIdx)),
feedback: g(row, d.fbIdx),
}));
let avg = n(g(row, c.avgIdx));
if (!avg && dims.some(d => d.score))
avg = Math.round(dims.reduce((s, d) => s + d.score, 0) / dims.length);
const tags = parseTags(g(row, c.tagsIdx));
return { id: i, note: g(row, c.noteIdx), dims, avg, tags };
}).filter(e => e.note.trim());
}
// ── Score helpers ────────────────────────────────────────
function sColor(s) {
if (s >= 80) return '#22c55e';
if (s >= 50) return '#f59e0b';
return '#ef4444';
}
function sBg(s) {
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));
}
function noteHTML(note) {
const parts = note.split(/\n\n+/).map(p => p.trim()).filter(Boolean);
if (!parts.length) return `<p class="note-p">${esc(note)}</p>`;
const first = parts[0];
const isTitle = parts.length > 1 && first.length < 130 && !/\.\s*$/.test(first);
let out = '';
if (isTitle) {
const t = first.replace(/^\*{1,2}|\*{1,2}$/g,'').replace(/^(title|note|heading)[:\s]*/i,'').trim();
out += `<div class="note-heading">${esc(t)}</div>`;
out += parts.slice(1).map(p => `<p class="note-p">${esc(p)}</p>`).join('');
} else {
out += parts.map(p => `<p class="note-p">${esc(p)}</p>`).join('');
}
return out;
}
// ── 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 (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 =>
`<div class="dot" title="${escAttr(d.label)}: ${d.score}" style="background:${sColor(d.score)}"></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>` : '';
return `<div class="item ${active}" id="item-${e.id}" onclick="select(${e.id})">
<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('');
}
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;
document.getElementById('stat-count').innerHTML = `<strong>${n}</strong> evals`;
document.getElementById('stat-avg').innerHTML = `Avg <strong style="color:${sColor(avg)}">${avg}</strong>`;
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">
<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="nav" onclick="select(${prevId})" ${prevId===null?'disabled':''}>‹</button>
<button class="nav" onclick="select(${nextId})" ${nextId===null?'disabled':''}>›</button>
</div>
<div class="detail-body">
<!-- Note text -->
<div class="note-box">
<div class="box-hdr" onclick="toggle('note',this)">
<span class="box-label">Generated Note</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 c = sColor(d.score);
return `<div class="score-card">
<div class="sc-label">${d.label}</div>
<div class="sc-value" style="color:${c}">${d.score}</div>
<div class="sc-track"><div class="sc-fill" style="width:${d.score}%;background:${c}"></div></div>
</div>`;
}).join('')}
</div>
<!-- Feedback sections -->
${dims.map(d => {
const c = sColor(d.score), bg = sBg(d.score);
const open = !collapsed[d.key];
return `<div class="fb-section">
<div class="fb-hdr" onclick="toggle('${d.key}',this)">
<span class="fb-name">${d.label} Feedback</span>
<span class="fb-score" style="color:${c};background:${bg}">${d.score}/100</span>
<span class="chev ${open?'open':''}">▼</span>
</div>
<div class="fb-body ${open?'':'gone'}">${d.feedback ? esc(d.feedback) : '<span class="fb-empty">No feedback provided.</span>'}</div>
</div>`;
}).join('')}
</div>
`;
document.getElementById(`item-${id}`)?.scrollIntoView({ block: 'nearest' });
};
window.toggle = function(key, hdr) {
collapsed[key] = !collapsed[key];
hdr.querySelector('.chev').classList.toggle('open');
hdr.nextElementSibling.classList.toggle('gone');
};
// ── Load CSV ─────────────────────────────────────────────
function loadCSV(txt, filename) {
const rows = parseCSV(txt);
evals = buildEvals(rows);
selectedId = null;
collapsed = { note: false };
activeTags = new Set();
tagMatchMode = 'any';
tagsExpanded = false;
document.querySelectorAll('.match-opt').forEach(b => b.classList.toggle('active', b.dataset.mode === 'any'));
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';
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('search').addEventListener('input', e => { query = e.target.value; applyFilters(); });
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('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-opt').forEach(b => b.classList.toggle('active', b === btn));
applyFilters();
});
document.addEventListener('keydown', e => {
if (!filtered.length || e.target.tagName === 'INPUT' || e.target.tagName === 'SELECT') 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