feat(sprint8a): регрессия роутера в UI с выбором кейсов и кэшем

Оператор-настройщик после правки промпта _router нажимает «Прогнать выбранное»
на странице «Регрессия» и видит, что сломалось. Не CLI, не в обход
интерфейса — встроено в верхнюю навигацию рядом с Настройками.

Backend:
- Таблицы eval_runs / eval_run_cases (с is_pass) / eval_router_predictions
  (кэш text_hash + router_config_id → predicted_intent). Миграции
  k7e9d5c67h34 и l8f0e6d78i45.
- services/eval_run_service.py: start_router_run(text_hashes) запускает
  фоновую корутину через asyncio.create_task, фиксирует активную версию
  _router. Кэш привязан к версии: повторный прогон на той же версии —
  мгновенный, на новой — пересчитывается. compute_diff_vs_previous
  сравнивает с предыдущим прогоном на той же версии (новые fail / pass).
- API: POST /eval/runs (фон, body text_hashes), GET /eval/runs,
  GET /eval/runs/{id}, GET /eval/router-cases-with-status (все 1573 кейса
  + кэш на активной версии).

Frontend (static/regression.html — новая страница, ссылка добавлена в
шапки index/sandbox/settings/docs):
- Сворачиваемый блок «Выбор кейсов»: фильтр по intent, ввод диапазона
  (1-50, 200-300), кнопки «Все видимые», «Снять все», «Только без кэша»,
  «Только FAIL в кэше», «Снять кэшированные». Чекбокс в шапке.
- Таблица 1573 кейсов отсортирована по count desc: #, чекбокс, запрос,
  intent, частота, кэш (PASS / FAIL → predicted / —). Цветной фон строки
  по статусу кэша.
- Счётчик «выбрано N (новых: X, в кэше: Y)»; кнопка
  «Прогнать выбранное (X новых + Y из кэша)» — сразу видно реальный
  объём LLM-работы.
- Polling /eval/runs/{id} раз в 2 секунды, прогресс-бар, drill-down:
  все кейсы прогона + фильтр pass/fail + поиск + diff vs предыдущий
  (новые fail / новые pass).

docs/SPRINTS.md: Спринт 8 разбит на 8a ( закрыт), 8b (регрессия ответов
веток, ждёт базу кейсов от пользователя), 8c (handoff/resumable/loop/
guard/rag — позже).

docs/BACKLOG.md: новый файл для идей на потом. Записаны: просмотр
архивного графа без активации (из 7.7), варианты C (LLM-judge) и D
(эталон + embeddings) для регрессии веток в 8b.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
AR 15 M4
2026-05-02 20:39:22 +05:00
parent d5eccfc342
commit a8f7e68795
14 changed files with 1567 additions and 32 deletions
+28
View File
@@ -0,0 +1,28 @@
# Идеи на потом (бэклог)
Это не план с дедлайнами — список идей и улучшений, которые всплыли в работе, но решено отложить, чтобы не раздувать текущий спринт. Перед стартом нового спринта — пройтись по списку, что-то взять в скоуп.
Формат записи: краткое название → **Зачем:****Откуда пришло** (контекст / коммит / Спринт). Если идея взята в работу — переносим в `docs/SPRINTS.md` и удаляем отсюда.
---
## UI
### Просмотр архивного графа шагов без активации
**Зачем.** Сейчас, чтобы увидеть содержимое архивного 6-шагового графа `new_booking`, его нужно сделать активным. Хочется кнопку «Открыть только для чтения» — посмотреть шаги архива, не переключая активный.
**Откуда.** Спринт 7.7 (коммит `a79b6f9`), обсуждение с пользователем 2026-05-02. Решено отложить до отдельного спринта.
---
## Регрессия
### LLM-judge для регрессии ответов веток (вариант C из 8b)
**Зачем.** Дополнительный способ pass/fail для свободно-текстовых ответов веток: отдельный LLM-вызов оценивает «вот вопрос, вот ответ — подходит ли по смыслу?» Прощает перефраз, в отличие от точного совпадения слов. Дороже (× ещё один LLM-вызов на кейс) и менее детерминирован.
**Откуда.** Обсуждение Спринта 8b 2026-05-02. На старт 8b берём только A (RAG) + B (keywords), C — на потом, если хрупкость keywords станет проблемой.
### Эталонный ответ + embeddings (вариант D из 8b)
**Зачем.** Альтернатива LLM-judge: оператор пишет «правильный ответ» в кейсе, при прогоне считаем cosine similarity между фактическим ответом и эталоном. Pass если ≥ порога (например, 0.75). Дешевле LLM-judge (один embedding-вызов вместо LLM), но требует сочинять эталоны и плохо ловит фактические ошибки в цифрах/адресах.
**Откуда.** Обсуждение Спринта 8b 2026-05-02. Кандидат на 8c вместе с C, если A+B окажется недостаточно.
+70 -26
View File
@@ -573,41 +573,85 @@
---
## Спринт 8. Мини-eval: роутер, handoff, resumable
## Спринт 8a. Регрессия роутера в UI
### Цель
После дотяжки v2 (Спринт 6) и мульти-RAG (Спринт 7) — зафиксировать автоматизированный тест-набор, чтобы следующие правки промптов и `wiki_sources` не ломали собранное. Формализует ручные сценарии из блока H Спринта 6.
Дать оператору-настройщику кнопку: «после правки промпта `_router` нажми и увидь, что сломалось». Не CLI, не для разработчика — встроено в страницу «Регрессия» рядом с Настройками. Кэш ответов привязан к версии роутера: повторный прогон на той же версии — мгновенный, на новой — пересчитывается.
### Статус: Запланирован
### Статус: Закрыт
### Задачи
**Eval-наборы (отдельные файлы в репозитории, без БД):**
**Backend:**
- [x] Таблицы `eval_runs`, `eval_run_cases` (с `is_pass`), `eval_router_predictions` (кэш `text_hash + router_config_id → predicted_intent`). Alembic-миграции `k7e9d5c67h34`, `l8f0e6d78i45`.
- [x] Сервис `services/eval_run_service.py`: `start_router_run(text_hashes)` запускает фоновую корутину, использует кэш, фиксирует активную версию `_router`. `compute_diff_vs_previous` — сравнение с предыдущим прогоном на той же версии (новые fail / новые pass).
- [x] API: `POST /eval/runs` (фон), `GET /eval/runs`, `GET /eval/runs/{id}`, `GET /eval/router-cases-with-status` (все 1573 кейса + кэш на активной версии).
Все наборы в **JSONL** (одна строка = один кейс). Унифицированный формат, единый парсер. Схема описана в `eval/README.md`. Историческое замечание: в первой версии плана одношаговые кейсы были в CSV, многошаговые в YAML — отказались от зоопарка форматов в пользу одного JSONL.
- [x] `eval/router_cases_booking.jsonl` + `eval/router_cases_other.jsonl` — одношаговые кейсы маршрутизатора (875 + 698, собраны из реальных диалогов конкурента, см. `eval/README.md`). Схема: `{text, expected_intent, expected_reason?, count, note?}`. CSV-версии сохранены рядом для совместимости.
- [ ] `eval/handoff_cases.jsonl` — 5–10 многошаговых мини-диалогов: реплики пациента по порядку + ожидаемая активная ветка / решение маршрутизатора / приостановленная ветка / счётчик переключений на каждом шаге.
- [ ] `eval/resumable_cases.jsonl` — 3–5 сценариев detour-и-возврат: реплики + ожидаемые `current_intent`, `current_step`, ключевые слоты на каждом шаге.
- [ ] `eval/loop_cases.jsonl` — 1–2 сценария искусственной петли с проверкой `reason=routing_loop`.
- [ ] `eval/guard_cases.jsonl` — сценарии на защитные условия (ребёнок, waitlist).
- [ ] `eval/rag_cases.jsonl` — сценарии на мульти-RAG: реплика внутри ветки → проверка, что в retrieved-чанках есть фразы из ожидаемого документа (или ожидаемые `document_id`).
**Запускалка (CLI, не часть сервиса):**
- [ ] `eval/run.py` — читает JSONL-наборы, прогоняет через живой сервис. Режимы:
- `router` — прямой вызов `RouterClient.classify()` на одношаговых кейсах (быстро).
- `dialog` — полный `/chat` на чистых тредах, сверка по каждому шагу: активная ветка + решение маршрутизатора + текущий шаг + слоты + счётчик переключений + причина эскалации + retrieved-источники.
- [ ] Вывод: per-ветка accuracy, confusion matrix, список расхождений с текстом реплики.
- [ ] Отчёт: stdout + `eval/reports/{timestamp}.md` (добавлять в git для сравнения во времени).
**Документация:**
- [ ] В `README.md` — раздел «Как прогнать eval» (одна команда).
- [ ] Договорённость: перед правкой промпта роутера / ветки / `wiki_sources` — прогнать eval, зафиксировать baseline; после — сравнить.
**UI (`static/regression.html` + новая вкладка «Регрессия» в шапках):**
- [x] Сворачиваемый блок «Выбор кейсов»: фильтр по intent, ввод диапазона (`1-50, 200-300`), кнопки массового выбора (Все / Снять / Только без кэша / Только FAIL в кэше / Снять кэшированные).
- [x] Таблица 1573 кейсов (отсортированы по count desc): #, чекбокс, запрос, intent, частота, кэш (PASS / FAIL → predicted / —). Цветной фон строки.
- [x] Счётчик «выбрано N (новых: X, в кэше: Y)»; кнопка «Прогнать выбранное (X новых + Y из кэша)».
- [x] История прогонов с polling раз в 2 секунды, прогресс-бар, drill-down: все кейсы прогона + фильтр pass/fail + поиск + diff vs предыдущий.
### Критерий готовности
- [ ] `eval/run.py` работает одной командой, режим `router` проходит за ≤ 30 секунд (на `count >= 2`), режим `dialog` — за ≤ 3 минуты.
- [ ] Отчёт покрывает все 8 сценариев из блока H Спринта 6 + одношаговые кейсы маршрутизатора + RAG-проверки Спринта 7.
- [ ] Baseline зафиксирован в `eval/reports/{date}_baseline.md` и добавлен в git.
- [x] На пустой версии роутера прогон 50 кейсов за ~1 минуту, повторный — мгновенный.
- [x] При активации новой версии `_router` — кэш пуст, прогон полный.
- [x] Diff показывает «новые fail / новые pass» при сравнении с предыдущим прогоном на той же версии.
---
## Спринт 8b. Регрессия ответов веток (RAG + keywords)
### Цель
По принципу 8a, но проверяем уже не код intent-а от роутера, а **содержимое ответа конкретной ветки** на одиночную реплику. Старт — только `general_info`: «вопрос про адрес / часы / маршрут → ответ должен ссылаться на нужный документ и содержать ключевые слова». Дальше расширим на остальные ветки.
### Статус: ⏳ Запланирован (ждём базу кейсов от пользователя)
### Скоуп MVP (что берём)
- **Ветка:** только `general_info`.
- **Способы pass/fail:**
- **A — RAG-проверка:** в retrieved-чанках есть все ожидаемые `document_id`. Детерминировано, без LLM в проверке.
- **B — keywords в ответе:** в тексте ответа бота встречаются все обязательные подстроки (`expected_keywords`) и нет запрещённых (`expected_must_not`).
- **Pass = A ∧ B** (если поле задано). Незаданные поля не проверяются.
- **Кэш:** `(text_hash, branch_config_id) → {answer_text, retrieved_doc_ids}`. При смене активной версии промпта `general_info` — кэш по новой версии пуст, прогон полный.
### Что осознанно вынесено в `docs/BACKLOG.md`
- **Вариант C — LLM-judge** (отдельный LLM-вызов оценивает «подходит ли ответ»).
- **Вариант D — эталон + embeddings** (cosine similarity с эталонным ответом).
Оба добавим, если A+B окажется хрупким (keywords ловят перефраз ненадёжно).
### Задачи
**База кейсов (за пользователем):**
- [ ] `eval/branch_cases_general_info.jsonl`. Схема: `{text, intent, expected_doc_ids?, expected_keywords?, expected_must_not?, count?, note?}`. Минимум для одного кейса — `text + intent + (хотя бы одно из expected_*)`.
**Backend:**
- [ ] Таблицы (или общая `eval_runs` с `suite="branch:<intent_code>"`): `eval_branch_runs` или универсальное расширение, `eval_branch_run_cases` с полями `answer_text`, `retrieved_doc_ids_json`, `is_pass`, `fail_reason`. Кэш `eval_branch_predictions(text_hash, branch_config_id) → {answer_text, retrieved_doc_ids}`.
- [ ] Сервис: запуск кейса = вызов того же flow, что в `chat_service.send_message`, но на чистом треде, с фиксацией активной версии branch-config и retrieved-чанков.
- [ ] API: `POST /eval/branch-runs`, `GET /eval/branch-runs`, `GET /eval/branch-runs/{id}`, `GET /eval/branch-cases-with-status?intent_code=general_info`.
**UI:**
- [ ] На странице «Регрессия» — переключатель режима: `Роутер` / `Ветка · general_info` (дальше другие ветки добавятся в этот же селектор).
- [ ] Для режима «Ветка»: те же фильтры/диапазон/массовый выбор, но в таблице вместо «expected_intent» — `ожидаемые документы` и `keywords`. В drill-down прогона — текст реплики, фактический ответ бота, retrieved-документы, причина fail.
### Критерий готовности
- [ ] На стартовом наборе general_info прогон без правок промпта даёт консистентный результат.
- [ ] После правки промпта `general_info` — diff показывает кейсы, где RAG-документы или keywords изменились.
---
## Спринт 8c. Дополнительные регрессионные сценарии
### Статус: ⏳ Запланирован (после 8b и накопления кейсов)
Темы: handoff между ветками (multi-turn), resumable detour-и-возврат, петли роутера, защитные условия (ребёнок, waitlist), мульти-RAG. Эти сценарии в SPRINTS.md изначально шли в одном Спринте 8 — разделили, чтобы 8a/8b закрыть быстрее.
Точечные наборы из исходного плана:
- [ ] `eval/handoff_cases.jsonl` — 5–10 многошаговых мини-диалогов.
- [ ] `eval/resumable_cases.jsonl` — 35 detour-и-возврат.
- [ ] `eval/loop_cases.jsonl` — 1–2 искусственная петля.
- [ ] `eval/guard_cases.jsonl``require_legal_rep`, `waitlist`.
- [ ] `eval/rag_cases.jsonl` — мульти-RAG.
---