4aac59313d
Sprint 8.5 — чанкер v2 (services/document_processor.py):
- markdown-it-py для md-входа: каждый H2 открывает свою секцию, H3 идёт в тело
- множественные H1 — штатный кейс (new_booking.md = 8 H1, шаги воронки + группы);
H1 без H2 → секция heading=H1; преамбула H1 (тело до первого H2) игнорируется
- YAML frontmatter (--- ... ---) отрезается, в индекс не попадает
- breadcrumb «## {H2}» как первая строка каждого subchunk'а
- merge коротких хвостов и sentence-overlap — только внутри одной H2-секции
- excluded_section_headings в config.py
- 17 unit-тестов на stdlib unittest (tests/test_document_processor_v2.py),
включая smoke по реальным general_info.md (тимпанометрия → правильная секция)
и new_booking.md (защита от регрессии множественных H1)
- ТЗ: docs/CHUNKER_v2_TZ.md
Sprint 8.6 — регрессия остальных 4 веток (static/regression.html):
- 4 опции в селекторе режима: branch:price_question (40 кейсов),
branch:medical_question (29), branch:escalate_human (14), branch:reschedule (16)
- бэкенд из 8b уже параметрический — правок в сервисе не потребовалось
- new_booking вне скоупа — state-machine, под него отдельный 8c (multi-turn)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1004 lines
40 KiB
HTML
1004 lines
40 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 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>
|
||
<option value="branch:price_question">Ветка · price_question</option>
|
||
<option value="branch:medical_question">Ветка · medical_question</option>
|
||
<option value="branch:escalate_human">Ветка · escalate_human</option>
|
||
<option value="branch:reschedule">Ветка · reschedule</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 => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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>
|