Files
RAG_helper/static/regression.html
T
AR 15 M4 bb5e3f5eb3 feat(sprint8b): регрессия ответов веток · general_info + фикс PRAGMA foreign_keys
Параллель к 8a, но проверяем не код intent от роутера, а содержимое ответа
конкретной ветки на одиночную реплику. Старт — general_info, 46 кейсов.

Логика pass/fail (для одного кейса):
- A — RAG-секция: среди retrieved-чанков есть кусок с
  section == expected_doc_section (точное совпадение). Если поле не задано —
  пропускаем.
- B — keywords: обязательные expected_keywords встречаются в predicted_answer
  (case-insensitive). По умолчанию все; поддерживаются keywords_min: N
  и keywords_any: true. Запрещённые expected_must_not — ни одного.
- Pass = A ∧ B. Незаданные поля не проверяются.
- Кэш: (text_hash, branch_config_id) → {answer_text, retrieved_sections}.
  Привязан к версии промпта ветки. Смена версии = пустой кэш = свежий прогон.
  Правка JSONL без изменения text → pass/fail пересчитывается без LLM.

Backend:
- Таблицы eval_branch_runs / eval_branch_run_cases / eval_branch_predictions.
  Миграция m9g1f7e89j56.
- services/eval_branch_run_service.py: загрузка JSONL, фоновый прогон через
  asyncio.create_task, кэш, оценка A+B с поддержкой keywords_min/keywords_any.
- chat_service.run_branch_single_turn — изолированный single-turn без
  роутера и треда (использует существующий config_service + vectorstore + llm).
- API: POST /eval/branch-runs, GET /eval/branch-runs?intent_code=,
  GET /eval/branch-runs/{id}, GET /eval/branch-cases-with-status?intent_code=.

UI (static/regression.html):
- Селектор режима «Роутер / Ветка · general_info». Логика пикера переиспользуется
  (фильтры, диапазон, массовый выбор, счётчик «новые / в кэше»).
- Для режима «Ветка»: фильтр по coverage, колонки секция/coverage, keywords,
  частота, кэш. Drill-down прогона: ожидание, retrieved-секции, причины fail,
  полный ответ ветки.

База кейсов (eval/branch_cases_general_info.jsonl) — от пользователя, 46 кейсов
по схеме {text, intent, coverage, expected_doc_section?, expected_keywords?,
expected_must_not?, keywords_min?, keywords_any?, count?, note?}.

Связанная правка SQLite (нашли при удалении документа в этом спринте):
- db/session.py: connect-listener PRAGMA foreign_keys=ON на каждое подключение.
  Без этого ondelete=CASCADE в SQLite не enforced, и удаление документа
  оставляло подписки в intent_documents висячими (что давало пустой RAG
  и fail регрессии).
- Миграция n0h2g8f9a0k67 — одноразовая чистка существующих висячих подписок.

docs/SPRINTS.md: Спринт 8b →  Закрыт. Diff vs предыдущий прогон для веток
и кнопка «Сбросить кэш регрессии» вынесены в docs/BACKLOG.md.

Также включены обновлённые data/datasets/general_info.md и price_question.md
(рабочий материал оператора), и черновик eval/branch_cases_price_question.jsonl
для следующего захода (8b на price_question).

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

1000 lines
40 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 id="page-title">Регрессия роутера</h2>
<p class="sub" id="page-sub">Прогон одношаговых кейсов классификатора на активной версии промпта <code>_router</code>. Pass/fail сравниваются с ожидаемой веткой. Кэш ответов привязан к версии роутера: повторный прогон на той же версии — мгновенный.</p>
<div class="panel" style="display:flex; align-items:center; gap:14px;">
<span style="font-weight:600;">Режим:</span>
<select id="mode-select" onchange="setMode(this.value)" style="padding:6px 10px; font-size:13px; border:1px solid var(--border); border-radius:4px;">
<option value="router">Роутер (1573 кейса · все ветки)</option>
<option value="branch:general_info">Ветка · general_info</option>
</select>
<span class="sub" id="mode-hint"></span>
</div>
<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" id="picker-intent-wrap">
<span>Ветка (intent)</span>
<select id="picker-intent">
<option value="">все ветки</option>
</select>
</label>
<label class="field" id="picker-coverage-wrap" style="display:none;">
<span>Coverage</span>
<select id="picker-coverage" onchange="renderPickerTable()">
<option value="">все</option>
<option value="covered">covered</option>
<option value="partial">partial</option>
<option value="not_covered">not_covered</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 id="picker-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 = []; // последние полученные кейсы выбранного прогона
// Режим страницы. "router" = классификатор; "branch:<intent_code>" = ответы ветки.
let currentMode = "router";
function isBranchMode() { return currentMode.startsWith("branch:"); }
function currentBranchIntent() {
return isBranchMode() ? currentMode.split(":", 2)[1] : null;
}
async function setMode(mode) {
currentMode = mode;
selectedRunId = null;
stopPolling();
pickerSelected.clear();
// Заголовок и подсказка.
if (isBranchMode()) {
const code = currentBranchIntent();
$("page-title").textContent = `Регрессия ветки · ${code}`;
$("page-sub").innerHTML = `Single-turn запрос к ветке <code>${esc(code)}</code> на её активной версии. Pass: ожидаемая секция найдена в RAG (если задана) И ключевые слова присутствуют, запрещённые отсутствуют.`;
$("picker-intent-wrap").style.display = "none";
$("picker-coverage-wrap").style.display = "";
} else {
$("page-title").textContent = "Регрессия роутера";
$("page-sub").innerHTML = `Прогон одношаговых кейсов классификатора (1573 фразы из реальных диалогов) на активной версии промпта <code>_router</code>. Pass/fail сравниваются с ожидаемой веткой. Кэш ответов привязан к версии роутера: повторный прогон на той же версии — мгновенный.`;
$("picker-intent-wrap").style.display = "";
$("picker-coverage-wrap").style.display = "none";
}
$("run-detail-panel").style.display = "none";
await loadPicker();
await refreshRuns();
}
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 url = isBranchMode()
? `/eval/branch-runs?intent_code=${encodeURIComponent(currentBranchIntent())}`
: "/eval/runs";
const d = await api(url);
renderRunsTable(d.runs || []);
} catch (e) {
$("runs-tbody").innerHTML = `<tr><td colspan="8" 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 ver = isBranchMode() ? r.branch_config_version : r.router_config_version;
const versionStr = ver ? `v${ver}` : "—";
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 {
if (isBranchMode()) {
const code = currentBranchIntent();
const d = await api(`/eval/branch-cases-with-status?intent_code=${encodeURIComponent(code)}`);
pickerCases = d.cases || [];
pickerVersionLabel = d.branch_config_version ? `v${d.branch_config_version}` : "—";
renderPickerInfo({ ...d, branch: true });
} else {
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="7" 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 => isCaseCached(c)).length;
const label = isBranchMode()
? `активная версия ветки ${pickerVersionLabel}`
: `активная версия роутера ${pickerVersionLabel}`;
$("picker-summary-info").textContent =
`${label} · ${d.total} кейсов · в кэше ${cached}`;
}
function isCaseCached(c) {
if (isBranchMode()) return c.cached_is_pass !== null && c.cached_is_pass !== undefined;
return c.cached_predicted !== null && c.cached_predicted !== undefined;
}
function caseIsPass(c) {
// Унифицированный pass-флаг для текущего mode.
if (isBranchMode()) return c.cached_is_pass === true;
return c.cached_is_pass === true; // роутер также имеет cached_is_pass
}
function pickerVisibleCases() {
if (isBranchMode()) {
const cov = $("picker-coverage").value;
if (!cov) return pickerCases;
return pickerCases.filter(c => c.coverage === cov);
}
const intent = $("picker-intent").value;
if (!intent) return pickerCases;
return pickerCases.filter(c => c.expected_intent === intent);
}
function renderPickerTable() {
const visible = pickerVisibleCases();
// Шапка зависит от режима.
const thead = $("picker-thead");
if (isBranchMode()) {
thead.innerHTML = `
<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">секция / coverage</th>
<th class="col-count">keywords</th>
<th class="col-cache">частота</th>
<th class="col-cache">кэш</th>
</tr>`;
} else {
thead.innerHTML = `
<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>`;
}
const tbody = $("picker-tbody");
const cols = isBranchMode() ? 7 : 6;
if (!visible.length) {
tbody.innerHTML = `<tr><td colspan="${cols}" class="empty">— нет кейсов под фильтр —</td></tr>`;
refreshPickerCounter();
return;
}
tbody.innerHTML = visible.map(c => renderPickerRow(c)).join("");
refreshPickerCounter();
syncPickerHeaderCheckbox();
}
function renderPickerRow(c) {
const checked = pickerSelected.has(c.text_hash) ? "checked" : "";
const cacheCell = renderCacheCell(c);
const cached = isCaseCached(c);
const rowCls = !cached ? "" : (caseIsPass(c) ? "cached-pass" : "cached-fail");
if (isBranchMode()) {
const sectionStr = c.expected_doc_section || "—";
const kwBrief = (c.expected_keywords || []).slice(0, 3).join(", ") +
((c.expected_keywords || []).length > 3 ? "…" : "");
const covBadge = c.coverage ? `<div class="sub" style="font-size:10px;">${esc(c.coverage)}</div>` : "";
return `
<tr class="${rowCls}">
<td class="col-idx">${c.idx}</td>
<td class="col-check"><input type="checkbox" data-hash="${esc(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(sectionStr)}${covBadge}</td>
<td class="col-count" style="text-align:left;" title="${esc((c.expected_keywords || []).join(', '))}">${esc(kwBrief || "—")}</td>
<td class="col-cache" style="text-align:right;color:var(--muted);">×${c.count}</td>
<td class="col-cache ${cacheCellClass(c)}">${cacheCell}</td>
</tr>
`;
}
return `
<tr class="${rowCls}">
<td class="col-idx">${c.idx}</td>
<td class="col-check"><input type="checkbox" data-hash="${esc(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>
`;
}
function renderCacheCell(c) {
if (!isCaseCached(c)) return "—";
if (caseIsPass(c)) return "PASS";
if (isBranchMode()) {
const reasons = c.cached_fail_reasons || [];
const hint = reasons.length ? `<div class="sub" style="font-size:10px;">${esc(reasons[0].slice(0, 30))}</div>` : "";
return `FAIL${hint}`;
}
return `FAIL<div class="sub" style="font-size:10px;">→ ${esc(c.cached_predicted)}</div>`;
}
function cacheCellClass(c) {
if (!isCaseCached(c)) return "empty-c";
return caseIsPass(c) ? "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" && !isCaseCached(c)) pickerSelected.add(c.text_hash);
else if (mode === "fail" && isCaseCached(c) && !caseIsPass(c)) 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() {
let cached = 0;
for (const c of pickerCases) {
if (!pickerSelected.has(c.text_hash)) continue;
if (isCaseCached(c)) 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 (isCaseCached(c)) 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 url = isBranchMode() ? "/eval/branch-runs" : "/eval/runs";
const body = isBranchMode()
? { intent_code: currentBranchIntent(), text_hashes: hashes }
: { suite: "router", text_hashes: hashes };
const r = await api(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
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 url = isBranchMode() ? `/eval/branch-runs/${runId}` : `/eval/runs/${runId}`;
const d = await api(url);
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;
}
if (isBranchMode()) {
root.innerHTML = renderBranchCaseList(cases);
} else {
root.innerHTML = renderRouterCaseList(cases);
}
}
function renderRouterCaseList(cases) {
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("");
return `<div class="case-list">${header}${rows}</div>`;
}
function renderBranchCaseList(cases) {
// Для веток показываем кейс блоком: запрос + статус + ожидаемое + фактическое + причины.
const rows = cases.map(c => {
const cls = c.is_pass ? "pass" : "fail";
const status = c.is_pass ? "PASS" : "FAIL";
const sections = (c.predicted_sections || [])
.map(s => s.section)
.filter(Boolean)
.map(s => `<code style="font-size:11px;">${esc(s)}</code>`)
.join(", ");
const expectedSection = c.expected_doc_section
? `<code>${esc(c.expected_doc_section)}</code>`
: '<span style="color:var(--muted);">—</span>';
const kw = (c.expected_keywords || []).map(k => `<code>${esc(k)}</code>`).join(" · ");
const mustNot = (c.expected_must_not || []).map(k => `<code>${esc(k)}</code>`).join(" · ");
const reasons = (c.fail_reasons || []).map(r => `<li>${esc(r)}</li>`).join("");
return `
<details class="branch-case ${cls}" style="border:1px solid var(--border); border-radius:6px; margin-bottom:10px; background:#fff;">
<summary style="padding:10px 14px; cursor:pointer; display:flex; gap:10px; align-items:center;">
<span class="case-status ${cls}" style="min-width:50px;">${status}</span>
<span style="flex:1; font-weight:500;">${esc(c.text)}</span>
<span class="sub" style="font-size:11px;">${esc(c.coverage)} · ×${c.count_weight}</span>
</summary>
<div style="padding:10px 14px 14px; border-top:1px solid var(--border); font-size:13px;">
<div style="margin-bottom:8px;"><b>Ожидание:</b></div>
<div class="sub" style="margin-left:14px; font-size:12px;">
секция: ${expectedSection}<br>
keywords (${c.keywords_min ?? 'все'}): ${kw || '—'}<br>
must_not: ${mustNot || '—'}
</div>
<div style="margin-top:10px;"><b>RAG-секции в retrieved:</b> ${sections || '<span class="sub">—</span>'}</div>
${reasons ? `<div style="margin-top:10px;color:var(--err);"><b>Причины fail:</b><ul style="margin:4px 0;padding-left:20px;">${reasons}</ul></div>` : ''}
<div style="margin-top:10px;"><b>Ответ ветки:</b></div>
<pre style="background:#f9fafb; padding:10px; border-radius:4px; white-space:pre-wrap; word-wrap:break-word; font-size:12px; margin:4px 0 0;">${esc(c.predicted_answer || '(пусто)')}</pre>
</div>
</details>
`;
}).join("");
return rows;
}
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 listUrl = isBranchMode()
? `/eval/branch-runs?intent_code=${encodeURIComponent(currentBranchIntent())}`
: "/eval/runs";
const d = await api(listUrl);
const runs = d.runs || [];
renderRunsTable(runs);
if (selectedRunId) {
const cur = runs.find(r => r.id === selectedRunId);
if (cur) {
const detailUrl = isBranchMode()
? `/eval/branch-runs/${selectedRunId}`
: `/eval/runs/${selectedRunId}`;
const detail = await api(detailUrl);
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 () => {
// Изначально открываем в режиме роутера; если в URL ?mode=branch:general_info — переключаем.
const params = new URLSearchParams(location.search);
const initMode = params.get("mode") || "router";
if ($("mode-select").querySelector(`option[value="${initMode}"]`)) {
$("mode-select").value = initMode;
}
await setMode($("mode-select").value);
// Если в текущем режиме есть «running» прогон — подсветить и начать polling.
try {
const url = isBranchMode()
? `/eval/branch-runs?intent_code=${encodeURIComponent(currentBranchIntent())}`
: "/eval/runs";
const d = await api(url);
const running = (d.runs || []).find(r => r.status === "running");
if (running) {
selectedRunId = running.id;
await selectRun(running.id);
startPolling();
}
} catch (_) {}
})();
</script>
</body>
</html>