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:
AR 15 M4
2026-05-03 01:20:59 +05:00
parent a8f7e68795
commit bb5e3f5eb3
15 changed files with 1228 additions and 109 deletions
+248 -52
View File
@@ -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;