Files
RAG_helper/static/regression.html
T
AR 15 M4 a8f7e68795 feat(sprint8a): регрессия роутера в UI с выбором кейсов и кэшем
Оператор-настройщик после правки промпта _router нажимает «Прогнать выбранное»
на странице «Регрессия» и видит, что сломалось. Не CLI, не в обход
интерфейса — встроено в верхнюю навигацию рядом с Настройками.

Backend:
- Таблицы eval_runs / eval_run_cases (с is_pass) / eval_router_predictions
  (кэш text_hash + router_config_id → predicted_intent). Миграции
  k7e9d5c67h34 и l8f0e6d78i45.
- services/eval_run_service.py: start_router_run(text_hashes) запускает
  фоновую корутину через asyncio.create_task, фиксирует активную версию
  _router. Кэш привязан к версии: повторный прогон на той же версии —
  мгновенный, на новой — пересчитывается. compute_diff_vs_previous
  сравнивает с предыдущим прогоном на той же версии (новые fail / pass).
- API: POST /eval/runs (фон, body text_hashes), GET /eval/runs,
  GET /eval/runs/{id}, GET /eval/router-cases-with-status (все 1573 кейса
  + кэш на активной версии).

Frontend (static/regression.html — новая страница, ссылка добавлена в
шапки index/sandbox/settings/docs):
- Сворачиваемый блок «Выбор кейсов»: фильтр по intent, ввод диапазона
  (1-50, 200-300), кнопки «Все видимые», «Снять все», «Только без кэша»,
  «Только FAIL в кэше», «Снять кэшированные». Чекбокс в шапке.
- Таблица 1573 кейсов отсортирована по count desc: #, чекбокс, запрос,
  intent, частота, кэш (PASS / FAIL → predicted / —). Цветной фон строки
  по статусу кэша.
- Счётчик «выбрано N (новых: X, в кэше: Y)»; кнопка
  «Прогнать выбранное (X новых + Y из кэша)» — сразу видно реальный
  объём LLM-работы.
- Polling /eval/runs/{id} раз в 2 секунды, прогресс-бар, drill-down:
  все кейсы прогона + фильтр pass/fail + поиск + diff vs предыдущий
  (новые fail / новые pass).

docs/SPRINTS.md: Спринт 8 разбит на 8a ( закрыт), 8b (регрессия ответов
веток, ждёт базу кейсов от пользователя), 8c (handoff/resumable/loop/
guard/rag — позже).

docs/BACKLOG.md: новый файл для идей на потом. Записаны: просмотр
архивного графа без активации (из 7.7), варианты C (LLM-judge) и D
(эталон + embeddings) для регрессии веток в 8b.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-02 20:39:22 +05:00

804 lines
31 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="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 => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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>