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>
This commit is contained in:
+248
-52
@@ -215,8 +215,17 @@
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<h2>Регрессия роутера</h2>
|
||||
<p class="sub">Прогон одношаговых кейсов классификатора (1573 фразы из реальных диалогов) на активной версии промпта <code>_router</code>. Pass/fail сравниваются с ожидаемой веткой. Кэш ответов привязан к версии роутера: повторный прогон на той же версии — мгновенный.</p>
|
||||
<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">
|
||||
@@ -224,12 +233,21 @@
|
||||
</summary>
|
||||
<div class="picker-body">
|
||||
<div class="picker-tools">
|
||||
<label class="field">
|
||||
<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">
|
||||
@@ -244,7 +262,7 @@
|
||||
</div>
|
||||
<div class="picker-list-wrap">
|
||||
<table class="picker-table">
|
||||
<thead>
|
||||
<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>
|
||||
@@ -305,6 +323,37 @@ 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;
|
||||
@@ -333,10 +382,13 @@ function fmtDate(iso) {
|
||||
|
||||
async function refreshRuns() {
|
||||
try {
|
||||
const d = await api("/eval/runs");
|
||||
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="9" class="empty">Ошибка: ${esc(e.message)}</td></tr>`;
|
||||
$("runs-tbody").innerHTML = `<tr><td colspan="8" class="empty">Ошибка: ${esc(e.message)}</td></tr>`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -348,7 +400,8 @@ function renderRunsTable(runs) {
|
||||
}
|
||||
body.innerHTML = runs.map(r => {
|
||||
const cls = r.id === selectedRunId ? "selected" : "";
|
||||
const versionStr = r.router_config_version ? `v${r.router_config_version}` : "—";
|
||||
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>
|
||||
@@ -384,15 +437,23 @@ 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);
|
||||
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="6" class="empty">Ошибка: ${esc(e.message)}</td></tr>`;
|
||||
$("picker-tbody").innerHTML = `<tr><td colspan="7" class="empty">Ошибка: ${esc(e.message)}</td></tr>`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -405,12 +466,31 @@ function fillPickerIntentSelect() {
|
||||
}
|
||||
|
||||
function renderPickerInfo(d) {
|
||||
const cached = pickerCases.filter(c => c.cached_predicted !== null).length;
|
||||
const cached = pickerCases.filter(c => isCaseCached(c)).length;
|
||||
const label = isBranchMode()
|
||||
? `активная версия ветки ${pickerVersionLabel}`
|
||||
: `активная версия роутера ${pickerVersionLabel}`;
|
||||
$("picker-summary-info").textContent =
|
||||
`— активная версия роутера ${pickerVersionLabel} · ${d.total} кейсов всего · в кэше ${cached}`;
|
||||
`— ${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);
|
||||
@@ -418,40 +498,91 @@ function pickerVisibleCases() {
|
||||
|
||||
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="6" class="empty">— нет кейсов под фильтр —</td></tr>';
|
||||
tbody.innerHTML = `<tr><td colspan="${cols}" 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("");
|
||||
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 (c.cached_predicted === null) return "—";
|
||||
if (c.cached_is_pass) return "PASS";
|
||||
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 (c.cached_predicted === null) return "empty-c";
|
||||
return c.cached_is_pass ? "pass" : "fail";
|
||||
if (!isCaseCached(c)) return "empty-c";
|
||||
return caseIsPass(c) ? "pass" : "fail";
|
||||
}
|
||||
|
||||
function pickerToggleOne(cb) {
|
||||
@@ -480,8 +611,8 @@ 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);
|
||||
if (mode === "none" && !isCaseCached(c)) pickerSelected.add(c.text_hash);
|
||||
else if (mode === "fail" && isCaseCached(c) && !caseIsPass(c)) pickerSelected.add(c.text_hash);
|
||||
}
|
||||
renderPickerTable();
|
||||
}
|
||||
@@ -522,11 +653,10 @@ function parseRanges(s) {
|
||||
}
|
||||
|
||||
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++;
|
||||
if (isCaseCached(c)) cached++;
|
||||
}
|
||||
return { total: pickerSelected.size, cached, fresh: pickerSelected.size - cached };
|
||||
}
|
||||
@@ -564,7 +694,7 @@ function refreshPickerCounter() {
|
||||
|
||||
function pickerDropCached() {
|
||||
for (const c of pickerCases) {
|
||||
if (c.cached_predicted !== null) pickerSelected.delete(c.text_hash);
|
||||
if (isCaseCached(c)) pickerSelected.delete(c.text_hash);
|
||||
}
|
||||
renderPickerTable();
|
||||
}
|
||||
@@ -580,14 +710,17 @@ async function startRun() {
|
||||
if (!hashes.length) { toast("Выберите хотя бы один кейс", "err"); return; }
|
||||
$("start-btn").disabled = true;
|
||||
try {
|
||||
const r = await api("/eval/runs", {
|
||||
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({ suite: "router", text_hashes: hashes }),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
toast(`Прогон #${r.id} запущен (${r.total} кейсов)`);
|
||||
selectedRunId = r.id;
|
||||
// Свернуть пикер, чтобы показать прогресс прогона.
|
||||
$("picker-block").open = false;
|
||||
await refreshRuns();
|
||||
await selectRun(r.id);
|
||||
@@ -603,7 +736,8 @@ async function selectRun(runId) {
|
||||
selectedRunId = runId;
|
||||
await refreshRuns();
|
||||
try {
|
||||
const d = await api(`/eval/runs/${runId}`);
|
||||
const url = isBranchMode() ? `/eval/branch-runs/${runId}` : `/eval/runs/${runId}`;
|
||||
const d = await api(url);
|
||||
renderRunDetail(d);
|
||||
} catch (e) {
|
||||
toast("Ошибка: " + e.message, "err");
|
||||
@@ -702,7 +836,14 @@ function renderCaseList() {
|
||||
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>
|
||||
@@ -726,7 +867,48 @@ function renderCaseList() {
|
||||
</div>
|
||||
`;
|
||||
}).join("");
|
||||
root.innerHTML = `<div class="case-list">${header}${rows}</div>`;
|
||||
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) {
|
||||
@@ -752,13 +934,19 @@ function startPolling() {
|
||||
stopPolling();
|
||||
pollHandle = setInterval(async () => {
|
||||
try {
|
||||
const d = await api("/eval/runs");
|
||||
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 detail = await api(`/eval/runs/${selectedRunId}`);
|
||||
const detailUrl = isBranchMode()
|
||||
? `/eval/branch-runs/${selectedRunId}`
|
||||
: `/eval/runs/${selectedRunId}`;
|
||||
const detail = await api(detailUrl);
|
||||
renderRunDetail(detail);
|
||||
if (cur.status !== "running") {
|
||||
stopPolling();
|
||||
@@ -784,11 +972,19 @@ function stopPolling() {
|
||||
}
|
||||
|
||||
(async () => {
|
||||
await loadPicker();
|
||||
await refreshRuns();
|
||||
// Если есть «running» прогон — сразу подсветить и начать polling.
|
||||
// Изначально открываем в режиме роутера; если в 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 d = await api("/eval/runs");
|
||||
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;
|
||||
|
||||
Reference in New Issue
Block a user