a8f7e68795
Оператор-настройщик после правки промпта _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>
804 lines
31 KiB
HTML
804 lines
31 KiB
HTML
<!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>
|