@@ -0,0 +1,803 @@
<!doctype html>
< html lang = "ru" >
< head >
< meta charset = "utf-8" >
< meta name = "viewport" content = "width=device-width,initial-scale=1" >
< title > Chat Agent for Patients — Регрессия< / title >
< style >
:root {
--bg: #f6f7fb;
--panel: #ffffff;
--border: #e5e7eb;
--muted: #6b7280;
--accent: #4f6df5;
--ok: #16a34a;
--err: #dc2626;
--warn: #d97706;
--mono: ui-monospace, SFMono-Regular, Menlo, Monaco, monospace;
}
* { box-sizing: border-box; }
html, body { margin: 0; background: var(--bg); font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; font-size: 14px; color: #111827; }
header {
display: flex;
align-items: center;
gap: 18px;
padding: 12px 20px;
background: var(--panel);
border-bottom: 1px solid var(--border);
}
header h1 { margin: 0; font-size: 16px; font-weight: 600; }
.nav { display: flex; gap: 14px; }
.nav-link { color: var(--muted); text-decoration: none; padding: 6px 10px; border-radius: 6px; font-size: 13px; }
.nav-link:hover { background: #f3f4f6; color: #111827; }
.nav-link.active { background: var(--accent); color: #fff; }
main { padding: 20px; max-width: 1400px; margin: 0 auto; }
h2 { margin: 0 0 10px; font-size: 18px; }
.sub { color: var(--muted); font-size: 13px; margin-bottom: 16px; }
.panel { background: var(--panel); border: 1px solid var(--border); border-radius: 8px; padding: 14px 16px; margin-bottom: 16px; }
.panel h3 { margin: 0 0 10px; font-size: 14px; font-weight: 600; }
.row { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
label.field { display: inline-flex; flex-direction: column; gap: 4px; font-size: 12px; color: var(--muted); }
input.num { width: 80px; padding: 5px 8px; border: 1px solid var(--border); border-radius: 4px; font-family: var(--mono); }
button.primary { padding: 7px 14px; background: var(--accent); color: #fff; border: 0; border-radius: 4px; cursor: pointer; font-size: 13px; }
button.primary:hover { background: #3f57c4; }
button.primary:disabled { background: #9ca3af; cursor: not-allowed; }
button.secondary { padding: 5px 10px; background: #fff; color: var(--accent); border: 1px solid var(--accent); border-radius: 4px; cursor: pointer; font-size: 12px; }
button.secondary:hover { background: var(--accent); color: #fff; }
table { width: 100%; border-collapse: collapse; font-size: 13px; }
table th, table td { padding: 8px 10px; text-align: left; border-bottom: 1px solid var(--border); }
table th { font-weight: 600; color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: 0.04em; }
table tr.run-row { cursor: pointer; }
table tr.run-row:hover { background: #f9fafb; }
table tr.run-row.selected { background: #eff6ff; }
.badge { display: inline-block; padding: 2px 8px; border-radius: 10px; font-size: 11px; font-weight: 600; }
.badge.running { background: #fef3c7; color: var(--warn); }
.badge.done { background: #dcfce7; color: var(--ok); }
.badge.error { background: #fee2e2; color: var(--err); }
.stat { font-family: var(--mono); }
.stat.pass { color: var(--ok); }
.stat.fail { color: var(--err); }
.progress {
height: 8px;
background: #f3f4f6;
border-radius: 4px;
overflow: hidden;
margin-top: 8px;
}
.progress-bar {
height: 100%;
background: var(--accent);
transition: width 0.3s;
}
.case-list { font-family: var(--mono); font-size: 12px; max-height: 600px; overflow-y: auto; border: 1px solid var(--border); border-radius: 6px; }
.case-row {
padding: 8px 10px;
border-bottom: 1px solid var(--border);
display: grid;
grid-template-columns: 50px 1fr 130px 130px 60px;
gap: 10px;
align-items: center;
}
.case-row:last-child { border-bottom: 0; }
.case-row.fail { background: #fef2f2; }
.case-row.pass { background: #f0fdf4; }
.case-status {
font-size: 11px;
font-weight: 700;
text-align: center;
padding: 2px 0;
border-radius: 3px;
}
.case-status.pass { color: var(--ok); background: #dcfce7; }
.case-status.fail { color: var(--err); background: #fee2e2; }
.case-text { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.case-expected { color: #4b5563; }
.case-predicted.match { color: var(--ok); }
.case-predicted.miss { color: var(--err); }
.case-weight { color: var(--muted); text-align: right; font-size: 11px; }
.case-controls { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; margin-bottom: 10px; }
.case-controls input[type=text] { padding: 5px 8px; border: 1px solid var(--border); border-radius: 4px; font-size: 12px; min-width: 240px; font-family: inherit; }
.case-controls .filter-group { display: inline-flex; gap: 4px; }
.case-controls .filter-btn {
padding: 4px 10px; border: 1px solid var(--border); background: #fff;
border-radius: 4px; cursor: pointer; font-size: 12px; color: var(--muted);
}
.case-controls .filter-btn.active { background: var(--accent); color: #fff; border-color: var(--accent); }
.case-list-header {
display: grid;
grid-template-columns: 50px 1fr 130px 130px 60px;
gap: 10px;
padding: 8px 10px;
background: #f9fafb;
font-weight: 600;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--muted);
border-bottom: 1px solid var(--border);
border-radius: 6px 6px 0 0;
position: sticky;
top: 0;
}
.diff-block { margin-top: 12px; }
.diff-header { font-weight: 600; font-size: 13px; margin-bottom: 6px; }
.empty { color: var(--muted); font-size: 13px; padding: 12px 0; }
/* Блок выбора кейсов перед прогоном */
.picker-block {
border: 1px solid var(--border);
border-radius: 8px;
background: var(--panel);
margin-bottom: 16px;
}
.picker-summary {
list-style: none;
cursor: pointer;
padding: 14px 16px;
font-weight: 600;
font-size: 14px;
position: relative;
padding-left: 36px;
}
.picker-summary::-webkit-details-marker { display: none; }
.picker-summary::before {
content: "▶";
position: absolute;
left: 16px;
transition: transform 0.15s;
font-size: 11px;
}
.picker-block[open] > .picker-summary::before { transform: rotate(90deg); }
.picker-block[open] > .picker-summary { border-bottom: 1px solid var(--border); }
.picker-summary .sub { font-weight: 400; }
.picker-body { padding: 14px 16px; }
.picker-tools { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; margin-bottom: 12px; }
.picker-tools select, .picker-tools input[type=text] {
padding: 5px 8px; border: 1px solid var(--border); border-radius: 4px; font-size: 12px;
}
.picker-tools input[type=text].range { min-width: 200px; font-family: var(--mono); }
.picker-tools .picker-btn {
padding: 4px 10px; border: 1px solid var(--border); background: #fff;
border-radius: 4px; cursor: pointer; font-size: 12px; color: #374151;
}
.picker-tools .picker-btn:hover { background: #f3f4f6; }
.picker-counter { color: var(--muted); font-size: 12px; }
.picker-list-wrap {
max-height: 480px;
overflow-y: auto;
border: 1px solid var(--border);
border-radius: 6px;
}
.picker-table {
width: 100%;
border-collapse: collapse;
font-size: 12px;
}
.picker-table thead th {
position: sticky; top: 0; background: #f9fafb;
padding: 6px 8px; text-align: left;
font-weight: 600; font-size: 11px;
text-transform: uppercase; letter-spacing: 0.04em;
color: var(--muted);
border-bottom: 1px solid var(--border);
}
.picker-table tbody td { padding: 5px 8px; border-bottom: 1px solid var(--border); font-family: var(--mono); }
.picker-table tbody tr:hover { background: #f9fafb; }
.picker-table tbody tr.cached-fail { background: #fef2f2; }
.picker-table tbody tr.cached-pass { background: #f0fdf4; }
.picker-table tbody tr.cached-fail:hover { background: #fee2e2; }
.picker-table tbody tr.cached-pass:hover { background: #dcfce7; }
.col-idx { width: 50px; text-align: right; color: var(--muted); }
.col-check { width: 36px; text-align: center; }
.col-text { max-width: 480px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.col-intent { width: 130px; }
.col-count { width: 70px; text-align: right; color: var(--muted); }
.col-cache { width: 90px; text-align: center; font-weight: 700; }
.col-cache.pass { color: var(--ok); }
.col-cache.fail { color: var(--err); }
.col-cache.empty-c { color: var(--muted); font-weight: 400; }
.toast { position: fixed; bottom: 20px; right: 20px; background: #111827; color: #fff; padding: 10px 14px; border-radius: 6px; font-size: 13px; opacity: 0; transition: opacity 0.2s; z-index: 100; }
.toast.show { opacity: 1; }
.toast.err { background: var(--err); }
< / style >
< / head >
< body >
< header >
< h1 > Chat Agent for Patients< / h1 >
< nav class = "nav" >
< a href = "/" class = "nav-link" > Отладка< / a >
< a href = "/sandbox.html" class = "nav-link" > Песочница< / a >
< a href = "/settings.html" class = "nav-link" > Настройки< / a >
< a href = "/regression.html" class = "nav-link active" > Регрессия< / a >
< a href = "/docs.html" class = "nav-link" > Документация< / a >
< / nav >
< / header >
< main >
< h2 > Регрессия роутера< / h2 >
< p class = "sub" > Прогон одношаговых кейсов классификатора (1573 фразы из реальных диалогов) на активной версии промпта < code > _router< / code > . Pass/fail сравниваются с ожидаемой веткой. Кэш ответов привязан к версии роутера: повторный прогон на той же версии — мгновенный.< / p >
< details class = "picker-block" id = "picker-block" >
< summary class = "picker-summary" >
Выбор кейсов < span class = "sub" id = "picker-summary-info" > — загружаю…< / span >
< / summary >
< div class = "picker-body" >
< div class = "picker-tools" >
< label class = "field" >
< span > Ветка (intent)< / span >
< select id = "picker-intent" >
< option value = "" > все ветки< / option >
< / select >
< / label >
< label class = "field" >
< span > Диапазон (по #)< / span >
< input type = "text" class = "range" id = "picker-range" placeholder = "например: 1-50, 200-300" >
< / label >
< button class = "picker-btn" onclick = "pickerApplyRange()" > Применить диапазон< / button >
< button class = "picker-btn" onclick = "pickerSelectAllVisible()" > Все (видимые)< / button >
< button class = "picker-btn" onclick = "pickerClearAll()" > Снять все< / button >
< button class = "picker-btn" onclick = "pickerSelectByCache('none')" > Только без кэша< / button >
< button class = "picker-btn" onclick = "pickerSelectByCache('fail')" > Только FAIL в кэше< / button >
< button class = "picker-btn" onclick = "pickerDropCached()" title = "Убрать галочки с тех, у которых уже есть результат в кэше" > Снять кэшированные< / button >
< span class = "picker-counter" id = "picker-counter" > выбрано 0< / span >
< / div >
< div class = "picker-list-wrap" >
< table class = "picker-table" >
< thead >
< tr >
< th class = "col-idx" > #< / th >
< th class = "col-check" > < input type = "checkbox" id = "picker-check-all" onchange = "pickerToggleAllVisible(this.checked)" > < / th >
< th class = "col-text" > запрос< / th >
< th class = "col-intent" > intent< / th >
< th class = "col-count" > частота< / th >
< th class = "col-cache" > кэш< / th >
< / tr >
< / thead >
< tbody id = "picker-tbody" >
< tr > < td colspan = "6" class = "empty" > — загружаю —< / td > < / tr >
< / tbody >
< / table >
< / div >
< div class = "row" style = "margin-top:14px;" >
< button class = "primary" id = "start-btn" onclick = "startRun()" disabled > Прогнать выбранное (0)< / button >
< span class = "sub" id = "start-hint" > Прогон идёт в фоне, можно свернуть и вернуться.< / span >
< / div >
< / div >
< / details >
< div class = "panel" >
< h3 > История прогонов< / h3 >
< table >
< thead >
< tr >
< th > #< / th >
< th > Стартовал< / th >
< th > Версия роутера< / th >
< th > Total< / th >
< th > Pass< / th >
< th > Fail< / th >
< th > Cache< / th >
< th > Статус< / th >
< / tr >
< / thead >
< tbody id = "runs-tbody" >
< tr > < td colspan = "8" class = "empty" > — загружаю —< / td > < / tr >
< / tbody >
< / table >
< / div >
< div class = "panel" id = "run-detail-panel" style = "display:none;" >
< h3 id = "run-detail-title" > Детали прогона< / h3 >
< div id = "run-detail-body" > < / div >
< / div >
< / main >
< div class = "toast" id = "toast" > < / div >
< script >
const $ = (id) => document.getElementById(id);
const esc = (s) => String(s ?? "").replace(/[& < >"']/g, c => ({'&':'& ','< ':'< ','>':'> ','"':'" ',"'":'' '}[c]));
let selectedRunId = null;
let pollHandle = null;
let caseFilter = "all"; // "all" | "pass" | "fail"
let caseSearch = "";
let currentCases = []; // последние полученные кейсы выбранного прогона
function toast(msg, kind = "ok") {
const t = $("toast");
t.textContent = msg;
t.className = "toast show" + (kind === "err" ? " err" : "");
setTimeout(() => t.className = "toast", 2500);
}
async function api(path, opts = {}) {
const res = await fetch(path, opts);
if (!res.ok) {
let msg = `${res.status}`;
try { const d = await res.json(); msg = d.detail || JSON.stringify(d); } catch (_) {}
throw new Error(msg);
}
if (res.status === 204) return null;
return res.json();
}
function fmtDate(iso) {
if (!iso) return "—";
try {
const d = new Date(iso);
return d.toLocaleString("ru-RU", { day: "2-digit", month: "2-digit", year: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit" });
} catch (_) { return iso; }
}
async function refreshRuns() {
try {
const d = await api("/eval/runs");
renderRunsTable(d.runs || []);
} catch (e) {
$("runs-tbody").innerHTML = `< tr > < td colspan = "9" class = "empty" > Ошибка: ${esc(e.message)}< / td > < / tr > `;
}
}
function renderRunsTable(runs) {
const body = $("runs-tbody");
if (!runs.length) {
body.innerHTML = '< tr > < td colspan = "8" class = "empty" > Прогонов ещё не было — выберите кейсы в блоке выше и нажмите «Прогнать выбранное».< / td > < / tr > ';
return;
}
body.innerHTML = runs.map(r => {
const cls = r.id === selectedRunId ? "selected" : "";
const versionStr = r.router_config_version ? `v${r.router_config_version}` : "—";
return `
< tr class = "run-row ${cls}" onclick = "selectRun(${r.id})" >
< td > #${r.id}< / td >
< td > ${fmtDate(r.started_at)}< / td >
< td > < code > ${versionStr}< / code > < / td >
< td class = "stat" > ${r.total}< / td >
< td class = "stat pass" > ${r.passed}< / td >
< td class = "stat fail" > ${r.failed}< / td >
< td class = "stat" title = "кэш-хитов из ${r.total}" > ${r.cache_hits}< / td >
< td > ${renderStatusBadge(r)}< / td >
< / tr >
`;
}).join("");
}
function renderStatusBadge(r) {
if (r.status === "running") {
const done = r.passed + r.failed;
const pct = r.total > 0 ? Math.round(100 * done / r.total) : 0;
return `< span class = "badge running" > ${pct}%< / span > `;
}
if (r.status === "done") return '< span class = "badge done" > готово< / span > ';
if (r.status === "error") return '< span class = "badge error" > ошибка< / span > ';
return `< span class = "badge" > ${esc(r.status)}< / span > `;
}
// ---------- Picker (выбор кейсов) ----------
let pickerCases = []; // полный список из /router-cases-with-status
let pickerSelected = new Set(); // text_hash выбранных
let pickerIntents = []; // уникальные intents для select
let pickerVersionLabel = "";
async function loadPicker() {
try {
const d = await api("/eval/router-cases-with-status");
pickerCases = d.cases || [];
pickerVersionLabel = d.router_config_version ? `v${d.router_config_version}` : "—";
pickerIntents = Array.from(new Set(pickerCases.map(c => c.expected_intent))).sort();
fillPickerIntentSelect();
renderPickerInfo(d);
renderPickerTable();
} catch (e) {
$("picker-tbody").innerHTML = `< tr > < td colspan = "6" class = "empty" > Ошибка: ${esc(e.message)}< / td > < / tr > `;
}
}
function fillPickerIntentSelect() {
const sel = $("picker-intent");
sel.innerHTML =
'< option value = "" > все ветки< / option > ' +
pickerIntents.map(i => `< option value = "${esc(i)}" > ${esc(i)}< / option > `).join("");
sel.onchange = () => renderPickerTable();
}
function renderPickerInfo(d) {
const cached = pickerCases.filter(c => c.cached_predicted !== null).length;
$("picker-summary-info").textContent =
`— активная версия роутера ${pickerVersionLabel} · ${d.total} кейсов всего · в кэше ${cached}`;
}
function pickerVisibleCases() {
const intent = $("picker-intent").value;
if (!intent) return pickerCases;
return pickerCases.filter(c => c.expected_intent === intent);
}
function renderPickerTable() {
const visible = pickerVisibleCases();
const tbody = $("picker-tbody");
if (!visible.length) {
tbody.innerHTML = '< tr > < td colspan = "6" class = "empty" > — нет кейсов под фильтр —< / td > < / tr > ';
refreshPickerCounter();
return;
}
tbody.innerHTML = visible.map(c => {
const checked = pickerSelected.has(c.text_hash) ? "checked" : "";
const cacheCell = renderCacheCell(c);
const rowCls = c.cached_predicted === null ? "" : (c.cached_is_pass ? "cached-pass" : "cached-fail");
return `
< tr class = "${rowCls}" >
< td class = "col-idx" > ${c.idx}< / td >
< td class = "col-check" > < input type = "checkbox" data-hash = "${c.text_hash}" $ { checked } onchange = "pickerToggleOne(this)" > < / td >
< td class = "col-text" title = "${esc(c.text)}" > ${esc(c.text)}< / td >
< td class = "col-intent" > ${esc(c.expected_intent)}< / td >
< td class = "col-count" > × ${c.count}< / td >
< td class = "col-cache ${cacheCellClass(c)}" > ${cacheCell}< / td >
< / tr >
`;
}).join("");
refreshPickerCounter();
syncPickerHeaderCheckbox();
}
function renderCacheCell(c) {
if (c.cached_predicted === null) return "—";
if (c.cached_is_pass) return "PASS";
return `FAIL< div class = "sub" style = "font-size:10px;" > → ${esc(c.cached_predicted)}< / div > `;
}
function cacheCellClass(c) {
if (c.cached_predicted === null) return "empty-c";
return c.cached_is_pass ? "pass" : "fail";
}
function pickerToggleOne(cb) {
const h = cb.dataset.hash;
if (cb.checked) pickerSelected.add(h); else pickerSelected.delete(h);
refreshPickerCounter();
syncPickerHeaderCheckbox();
}
function pickerToggleAllVisible(checked) {
const visible = pickerVisibleCases();
for (const c of visible) {
if (checked) pickerSelected.add(c.text_hash);
else pickerSelected.delete(c.text_hash);
}
renderPickerTable();
}
function pickerSelectAllVisible() { pickerToggleAllVisible(true); }
function pickerClearAll() {
pickerSelected.clear();
renderPickerTable();
}
function pickerSelectByCache(mode) {
const visible = pickerVisibleCases();
pickerSelected.clear();
for (const c of visible) {
if (mode === "none" & & c.cached_predicted === null) pickerSelected.add(c.text_hash);
else if (mode === "fail" & & c.cached_predicted !== null & & !c.cached_is_pass) pickerSelected.add(c.text_hash);
}
renderPickerTable();
}
function pickerApplyRange() {
const raw = $("picker-range").value.trim();
if (!raw) { toast("Введите диапазон, например: 1-50, 200-300", "err"); return; }
const ranges = parseRanges(raw);
if (!ranges.length) { toast("Не удалось разобрать диапазон", "err"); return; }
const visible = pickerVisibleCases();
pickerSelected.clear();
for (const c of visible) {
for (const [lo, hi] of ranges) {
if (c.idx >= lo & & c.idx < = hi) { pickerSelected.add(c.text_hash); break; }
}
}
renderPickerTable();
}
function parseRanges(s) {
// "1-50, 200-300, 5" → [[1,50],[200,300],[5,5]]
const out = [];
for (const part of s.split(",")) {
const p = part.trim();
if (!p) continue;
const m = p.match(/^(\d+)\s*-\s*(\d+)$/);
if (m) {
const a = parseInt(m[1], 10), b = parseInt(m[2], 10);
out.push([Math.min(a, b), Math.max(a, b)]);
} else if (/^\d+$/.test(p)) {
const n = parseInt(p, 10);
out.push([n, n]);
} else {
return [];
}
}
return out;
}
function pickerSelectionStats() {
// По cached_predicted делим выбранные на «новые» (LLM нужен) и «в кэше» (мгновенно).
let cached = 0;
for (const c of pickerCases) {
if (!pickerSelected.has(c.text_hash)) continue;
if (c.cached_predicted !== null) cached++;
}
return { total: pickerSelected.size, cached, fresh: pickerSelected.size - cached };
}
function refreshPickerCounter() {
const s = pickerSelectionStats();
$("picker-counter").textContent =
s.total === 0
? "выбрано 0"
: `выбрано ${s.total} (новых: ${s.fresh}, в кэше: ${s.cached})`;
const btn = $("start-btn");
btn.disabled = s.total === 0;
if (s.total === 0) {
btn.textContent = "Прогнать выбранное (0)";
} else if (s.cached === 0) {
btn.textContent = `Прогнать выбранное (${s.fresh})`;
} else if (s.fresh === 0) {
btn.textContent = `Прогнать выбранное (${s.total} из кэша)`;
} else {
btn.textContent = `Прогнать выбранное (${s.fresh} новых + ${s.cached} из кэша)`;
}
const hint = $("start-hint");
if (hint) {
if (s.fresh > 0) {
hint.textContent = `Через LLM пойдут только ${s.fresh} новых, остальные ${s.cached} возьмутся из кэша мгновенно.`;
} else if (s.cached > 0) {
hint.textContent = "Все выбранные уже в кэше на этой версии — прогон будет мгновенным.";
} else {
hint.textContent = "Прогон идёт в фоне, можно свернуть и вернуться.";
}
}
}
function pickerDropCached() {
for (const c of pickerCases) {
if (c.cached_predicted !== null) pickerSelected.delete(c.text_hash);
}
renderPickerTable();
}
function syncPickerHeaderCheckbox() {
const visible = pickerVisibleCases();
const checked = visible.length > 0 & & visible.every(c => pickerSelected.has(c.text_hash));
$("picker-check-all").checked = checked;
}
async function startRun() {
const hashes = Array.from(pickerSelected);
if (!hashes.length) { toast("Выберите хотя бы один кейс", "err"); return; }
$("start-btn").disabled = true;
try {
const r = await api("/eval/runs", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ suite: "router", text_hashes: hashes }),
});
toast(`Прогон #${r.id} запущен (${r.total} кейсов)`);
selectedRunId = r.id;
// Свернуть пикер, чтобы показать прогресс прогона.
$("picker-block").open = false;
await refreshRuns();
await selectRun(r.id);
startPolling();
} catch (e) {
toast("Ошибка: " + e.message, "err");
} finally {
refreshPickerCounter();
}
}
async function selectRun(runId) {
selectedRunId = runId;
await refreshRuns();
try {
const d = await api(`/eval/runs/${runId}`);
renderRunDetail(d);
} catch (e) {
toast("Ошибка: " + e.message, "err");
}
}
function renderRunDetail(d) {
const panel = $("run-detail-panel");
const title = $("run-detail-title");
const body = $("run-detail-body");
panel.style.display = "block";
const r = d.run;
currentCases = d.cases || [];
const versionStr = r.router_config_version ? `v${r.router_config_version}` : "—";
title.textContent = `Прогон #${r.id} · роутер ${versionStr} · ${r.status}`;
let progressHtml = "";
if (r.status === "running" & & r.total > 0) {
const done = r.passed + r.failed;
const pct = Math.round(100 * done / r.total);
progressHtml = `
< div class = "progress" > < div class = "progress-bar" style = "width:${pct}%" > < / div > < / div >
< div class = "sub" > Обработано ${done} / ${r.total}, кэш ${r.cache_hits}< / div >
`;
}
let errorHtml = "";
if (r.status === "error" & & r.error_text) {
errorHtml = `< div class = "empty" style = "color:var(--err)" > Ошибка: ${esc(r.error_text)}< / div > `;
}
// Контролы фильтра/поиска кейсов.
const total = currentCases.length;
const passes = currentCases.filter(c => c.is_pass).length;
const fails = total - passes;
const controlsHtml = `
< div class = "case-controls" >
< span class = "diff-header" style = "margin:0;" > Кейсы прогона< / span >
< span class = "filter-group" >
< button class = "filter-btn ${caseFilter==='all'?'active':''}" onclick = "setCaseFilter('all')" > все (${total})< / button >
< button class = "filter-btn ${caseFilter==='pass'?'active':''}" onclick = "setCaseFilter('pass')" > pass (${passes})< / button >
< button class = "filter-btn ${caseFilter==='fail'?'active':''}" onclick = "setCaseFilter('fail')" > fail (${fails})< / button >
< / span >
< input type = "text" id = "case-search" placeholder = "🔍 поиск по тексту…" value = "${esc(caseSearch)}" oninput = "onCaseSearch(this.value)" >
< / div >
< div id = "case-list-root" > < / div >
`;
let diffHtml = "";
if (d.diff & & d.diff.prev_run_id) {
const newFails = renderCasesSection(d.diff.new_fails, `🔴 Новые fail vs прогон #${d.diff.prev_run_id}`, "—");
const newPasses = renderCasesSection(d.diff.new_passes, `🟢 Новые pass vs прогон #${d.diff.prev_run_id}`, "—");
diffHtml = `< div class = "diff-block" > ${newFails}${newPasses}< / div > `;
} else if (d.diff & & r.status === "done") {
diffHtml = `< div class = "diff-block sub" > Это первый завершённый прогон на текущей версии роутера — сравнивать не с чем.< / div > `;
}
body.innerHTML = `
${progressHtml}
${errorHtml}
${controlsHtml}
${diffHtml}
`;
renderCaseList();
}
function setCaseFilter(f) {
caseFilter = f;
// Перерисовываем все контролы (счётчики не меняются, но кнопки — да) и список.
const detailBody = $("run-detail-body");
if (!detailBody) return;
// Простой путь: перерисовать кнопки руками, не трогая остальное.
detailBody.querySelectorAll(".filter-btn").forEach(b => b.classList.remove("active"));
const map = {all: 0, pass: 1, fail: 2};
const idx = map[f];
const btns = detailBody.querySelectorAll(".filter-btn");
if (btns[idx]) btns[idx].classList.add("active");
renderCaseList();
}
function onCaseSearch(value) {
caseSearch = value;
renderCaseList();
}
function renderCaseList() {
const root = $("case-list-root");
if (!root) return;
const q = caseSearch.trim().toLowerCase();
let cases = currentCases;
if (caseFilter === "pass") cases = cases.filter(c => c.is_pass);
else if (caseFilter === "fail") cases = cases.filter(c => !c.is_pass);
if (q) cases = cases.filter(c => c.text.toLowerCase().includes(q));
if (!cases.length) {
root.innerHTML = '< div class = "empty" > — ничего не найдено —< / div > ';
return;
}
const header = `
< div class = "case-list-header" >
< div > результат< / div >
< div > запрос (реплика пациента)< / div >
< div > ответ роутера< / div >
< div > правильный< / div >
< div style = "text-align:right;" > вес< / div >
< / div >
`;
const rows = cases.map(c => {
const cls = c.is_pass ? "pass" : "fail";
const predCls = c.is_pass ? "match" : "miss";
const status = c.is_pass ? "PASS" : "FAIL";
return `
< div class = "case-row ${cls}" >
< div class = "case-status ${cls}" > ${status}< / div >
< div class = "case-text" title = "${esc(c.text)}" > ${esc(c.text)}< / div >
< div class = "case-predicted ${predCls}" > ${esc(c.predicted_intent)}< / div >
< div class = "case-expected" > ${esc(c.expected_intent)}< / div >
< div class = "case-weight" > × ${c.count_weight}< / div >
< / div >
`;
}).join("");
root.innerHTML = `< div class = "case-list" > ${header}${rows}< / div > `;
}
function renderCasesSection(cases, title, emptyMsg) {
if (!cases || !cases.length) {
return `< div class = "diff-header" > ${esc(title)}< / div > < div class = "empty" > ${esc(emptyMsg)}< / div > `;
}
const rows = cases.map(c => `
< div class = "case-row fail" >
< div class = "case-status fail" > FAIL< / div >
< div class = "case-text" title = "${esc(c.text)}" > ${esc(c.text)}< / div >
< div class = "case-predicted miss" > ${esc(c.predicted_intent)}< / div >
< div class = "case-expected" > ${esc(c.expected_intent)}< / div >
< div class = "case-weight" > × ${c.count_weight}< / div >
< / div >
`).join("");
return `
< div class = "diff-header" style = "margin-top:14px;" > ${esc(title)} < span class = "sub" > (${cases.length})< / span > < / div >
< div class = "case-list" > ${rows}< / div >
`;
}
function startPolling() {
stopPolling();
pollHandle = setInterval(async () => {
try {
const d = await api("/eval/runs");
const runs = d.runs || [];
renderRunsTable(runs);
if (selectedRunId) {
const cur = runs.find(r => r.id === selectedRunId);
if (cur) {
const detail = await api(`/eval/runs/${selectedRunId}`);
renderRunDetail(detail);
if (cur.status !== "running") {
stopPolling();
if (cur.status === "done") {
toast(`Прогон #${cur.id} завершён: ${cur.passed}/${cur.total}`);
} else if (cur.status === "error") {
toast(`Прогон #${cur.id} упал с ошибкой`, "err");
}
}
}
}
} catch (e) {
console.warn("poll failed", e);
}
}, 2000);
}
function stopPolling() {
if (pollHandle) {
clearInterval(pollHandle);
pollHandle = null;
}
}
(async () => {
await loadPicker();
await refreshRuns();
// Если есть «running» прогон — сразу подсветить и начать polling.
try {
const d = await api("/eval/runs");
const running = (d.runs || []).find(r => r.status === "running");
if (running) {
selectedRunId = running.id;
await selectRun(running.id);
startPolling();
}
} catch (_) {}
})();
< / script >
< / body >
< / html >