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>
This commit is contained in:
AR 15 M4
2026-05-02 20:39:22 +05:00
parent d5eccfc342
commit a8f7e68795
14 changed files with 1567 additions and 32 deletions
+1
View File
@@ -193,6 +193,7 @@
<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">Регрессия</a>
<a href="/docs.html" class="nav-link active">Документация</a>
</nav>
</header>
+1
View File
@@ -417,6 +417,7 @@
<a href="/" class="nav-link active">Отладка</a>
<a href="/sandbox.html" class="nav-link">Песочница</a>
<a href="/settings.html" class="nav-link">Настройки</a>
<a href="/regression.html" class="nav-link">Регрессия</a>
<a href="/docs.html" class="nav-link">Документация</a>
</nav>
<span class="status"><span class="dot" id="dot"></span><span id="status-text">проверяю…</span></span>
+803
View File
@@ -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 => ({'&':'&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>
+1
View File
@@ -543,6 +543,7 @@
<a href="/" class="nav-link">Отладка</a>
<a href="/sandbox.html" class="nav-link active">Песочница</a>
<a href="/settings.html" class="nav-link">Настройки</a>
<a href="/regression.html" class="nav-link">Регрессия</a>
<a href="/docs.html" class="nav-link">Документация</a>
</nav>
<span class="status" style="margin-left:auto;"><span class="dot" id="dot"></span><span id="status-text">проверяю…</span></span>
+1
View File
@@ -865,6 +865,7 @@
<a href="/" class="nav-link">Отладка</a>
<a href="/sandbox.html" class="nav-link">Песочница</a>
<a href="/settings.html" class="nav-link active">Настройки</a>
<a href="/regression.html" class="nav-link">Регрессия</a>
<a href="/docs.html" class="nav-link">Документация</a>
</nav>
<span class="stats" id="stats"></span>