Параллель к 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>
Eval-сеты для маршрутизатора
Тест-набор фраз пациентов для проверки RouterClient.classify() (формат из Спринта 8).
Что такое eval
Eval (от англ. evaluation) — оценочный прогон. На русский часто переводят как «оценочный набор», «оценочный прогон», «эталонный набор» или «контрольный прогон»; в индустрии слово «eval» обычно оставляют как термин-кальку (по аналогии с «коммит», «релиз»). В этом репозитории мы используем оба варианта взаимозаменяемо: «eval», «оценочный прогон», «оценочный набор».
В отличие от обычного юнит-теста, eval не сравнивает результат с одним «правильным» ответом — модель вероятностна, и при одной и той же входной реплике разные прогоны могут дать слегка разный текст. Eval работает иначе: берётся набор размеченных кейсов (фраза пациента + ожидаемое намерение), модель отрабатывает на каждом, и считается доля попаданий и характер ошибок. Это позволяет:
- зафиксировать отправной замер (baseline) — точку отсчёта качества маршрутизатора в текущей версии;
- при правке промпта
_router.mdили смене модели — прогнать eval и сравнить с отправным замером, чтобы поймать ухудшения до выкатки; - понять, на каких классах маршрутизатор путается чаще (например, путает
medical_questionсnew_bookingна жалобах вроде «болит ухо»).
Eval-набор в этой папке покрывает только маршрутизатор — то есть проверку, в правильную ли ветку попадёт реплика. Он не проверяет содержимое ответа ассистента, корректность выдачи RAG, прохождение пошагового сценария и т.п. — для этих проверок в Спринте 8 запланированы отдельные наборы (handoff_cases.jsonl, resumable_cases.jsonl, rag_cases.jsonl).
Глоссарий
Термины, которые встречаются ниже. Английские варианты оставлены в скобках — на них завязаны имена файлов, тегов и поля JSON, поэтому совсем выкинуть нельзя.
| русский | англ. | смысл |
|---|---|---|
| оценочный набор / оценочный прогон | eval (evaluation) | тест-набор размеченных кейсов и сам процесс прогона по нему. |
| эталонная разметка | ground truth | «правильные» ответы, с которыми сравниваем то, что выдала модель. У нас это поле expected_* в JSONL. |
| отправной замер / точка отсчёта | baseline | зафиксированный результат прогона, относительно которого сравниваем все следующие. Хранится в eval/reports/baseline.md. |
| ухудшение / регрессия | regression | новый прогон выдал хуже, чем отправной замер. То, что мы ловим до выкатки. |
| короткий прогон | smoke | быстрая проверка ядра, в минуты. У нас — кейсы с count >= 2 плюс несколько ключевых многошаговых. Гоняется при каждой правке промпта. |
| полный прогон | full | весь набор. Дольше и дороже, гоняется реже (раз в неделю и перед релизом). |
| доля попаданий | accuracy | correct / total. Самая общая метрика, обманчива при перекосе классов. |
| доля попаданий по классу | per-class accuracy | то же, но внутри одного намерения. |
| точность по классу | precision | когда маршрутизатор сказал X, насколько он был прав. Важна, чтобы не дёргать оператора на пустяках. |
| полнота по классу | recall | насколько маршрутизатор поймал все реальные X. Важна, чтобы не пропустить острую боль. |
| F1-мера | F1 | гармоническое среднее точности и полноты, общий показатель класса. |
| матрица ошибок | confusion matrix | таблица «ожидалось X — получили Y». Самое полезное для отладки. |
| передача управления | handoff | ситуация, когда внутри одной ветки реплика уходит в другую (с возвратом или без). |
| возобновляемый сценарий | resumable | ветка, которую можно приостановить, сходить в другую и вернуться. |
| петля маршрутизатора | routing loop | маршрутизатор перепрыгивает между ветками туда-сюда. После порога переключений уходим в эскалацию с причиной routing_loop. |
| защитное условие | guard | правило, которое блокирует переход в ветку при определённом контексте (ребёнок, очередь и т.п.). |
Источник
Извлечено из 2691 реальных диалогов конкурентного бота «Александра» (ЛОР-клиника, Пермь, файл dialogs-export-2026-04-27-09-45-07.xlsx). Контекст — docs/COMPETITOR_ALEXANDRA_top100.md.
Берётся первая реплика пациента в каждом диалоге (где первая реплика классифицируема) — это самый честный тест маршрутизатора, потому что нет ещё активной ветки/state machine, которая могла бы подсказать решение.
Файлы
Каждый набор — в двух форматах. Основной — JSONL (читается глазами, безопасен с запятыми/кавычками в репликах). CSV оставлен для совместимости со Спринтом 8 и Excel.
router_cases_booking.jsonl/router_cases_booking.csv— кейсы сexpected_intent = new_booking.router_cases_other.jsonl/router_cases_other.csv— все остальные интенты.
Внутри каждого файла строки отсортированы по count desc — частотные кейсы сверху, длинный хвост уникальных снизу.
Статистика частотности
Snapshot текущего состава (на момент последней пересборки).
router_cases_booking.jsonl — 875 уникальных текстов, 934 реальных обращения:
| expected_intent | uniq | total |
|---|---|---|
new_booking |
875 | 934 |
router_cases_other.jsonl — 698 уникальных текстов, 769 реальных обращений:
| expected_intent | uniq | total |
|---|---|---|
general_info |
295 | 359 |
price_question |
165 | 167 |
escalate_human | surgery |
120 | 121 |
medical_question |
59 | 59 |
reschedule |
40 | 44 |
escalate_human | acute_pain |
11 | 11 |
escalate_human | explicit_request |
6 | 6 |
escalate_human | angry |
2 | 2 |
Распределение по count (показывает, сколько в наборе действительно частотных vs. одиночных кейсов):
| набор | count >= 5 |
count = 2..4 |
count = 1 (длинный хвост) |
|---|---|---|---|
| booking | 4 | 12 | 859 |
| other | 8 | 15 | 675 |
Для короткого прогона (smoke) разумно брать count >= 2: это 16 кейсов в booking и 23 в other — все они встречались у конкурента минимум дважды и проверяют ядро воронки. Для регрессионного прогона — весь набор.
Формат
JSONL (одна строка = один JSON-объект, UTF-8). Поля:
| поле | обяз. | значение |
|---|---|---|
text |
да | реплика пациента дословно (одна строка, переводы строк убраны) |
expected_intent |
да | один из new_booking / reschedule / price_question / medical_question / general_info / escalate_human |
expected_reason |
нет | только для escalate_human: acute_pain / surgery / angry / explicit_request |
count |
да | сколько раз эта нормализованная реплика встретилась в исходных данных. Первое сито для приоритизации. |
note |
нет | автокомментарий: пограничный случай, длинная реплика, false-positive разметки и т.п. |
Парсить так:
import json
with open('eval/router_cases_booking.jsonl', encoding='utf-8') as fp:
cases = [json.loads(line) for line in fp]
CSV-версия содержит те же поля колонками, но expected_reason и note всегда присутствуют (могут быть пустыми строками). Запятые и кавычки в text экранированы по RFC 4180 — корректно открывается в Excel/Numbers/pandas, но в cat/обычном текстовом просмотре кавычки видны.
Формат многошагового кейса
Используется в наборах handoff_cases.jsonl, resumable_cases.jsonl, loop_cases.jsonl, guard_cases.jsonl, rag_cases.jsonl. Один кейс = одна строка JSON, в которой массив реплик и для каждой — ожидаемое состояние диалога.
Поля верхнего уровня:
| поле | обяз. | значение |
|---|---|---|
name |
да | короткий идентификатор кейса (snake_case), уникален в пределах файла. Используется в отчётах. |
description |
нет | человекочитаемое описание сценария — что проверяем. |
steps |
да | массив шагов; каждый шаг — реплика пациента + ожидания. |
tags |
нет | массив тегов для фильтрации (["smoke","handoff","child"]). |
Поля шага в steps[]:
| поле | обяз. | значение |
|---|---|---|
text |
да | реплика пациента дословно. |
expected |
да | объект ожиданий — все поля внутри необязательны, проверяется только то, что указано (см. ниже). |
Поля внутри expected (любые из них, что не указаны — не проверяются на этом шаге):
| поле | значение |
|---|---|
router |
код намерения, который должен вернуть маршрутизатор. |
router_reason |
для escalate_human — acute_pain / surgery / angry / explicit_request / routing_loop. |
active_branch |
код активной ветки после применения решения маршрутизатора (может расходиться с router из-за удержания в ветке или возврата из приостановленной). |
current_step |
текущий шаг внутри пошаговой ветки (например, qualify, book, close). |
slots |
объект {slot_name: value} — какие слоты должны быть собраны (значения сравниваются на равенство; для проверки только наличия — поставить true). |
suspended_branch |
код приостановленной ветки (после soft-insertion). |
resumed |
true, если на этом шаге сработал возврат из приостановленной ветки. |
handoff_count |
значение счётчика переключений после шага. |
retrieved_documents |
массив document_id, которые должны оказаться в retrieved-чанках. Для rag_cases.jsonl. |
retrieved_phrases |
массив подстрок, любая из которых должна встретиться в retrieved-контексте. Для rag_cases.jsonl, когда document_id неудобно фиксировать. |
Пример: передача управления (запись → вопрос про цену → возврат)
{"name":"booking_then_price_then_resume","description":"мягкая вставка (soft-insertion): внутри записи спрашиваем цену, после ответа возвращаемся в запись","tags":["handoff","smoke"],"steps":[{"text":"хочу записаться к лору","expected":{"router":"new_booking","active_branch":"new_booking","current_step":"qualify"}},{"text":"а сколько стоит первичный приём?","expected":{"router":"price_question","active_branch":"price_question","suspended_branch":"new_booking","handoff_count":1}},{"text":"ок, продолжим запись","expected":{"router":"new_booking","active_branch":"new_booking","resumed":true,"current_step":"qualify"}}]}
Пример: возобновляемый сценарий (запись → перенос другой записи → возврат к новой записи)
{"name":"booking_then_reschedule_then_back","description":"жёсткая передача (hard-handoff) на перенос старой записи, после завершения возвращаемся к незавершённой новой","tags":["resumable"],"steps":[{"text":"запишите меня на четверг к лору","expected":{"router":"new_booking","active_branch":"new_booking","current_step":"qualify","slots":{"target_day":"четверг"}}},{"text":"а отмените мою старую запись на завтра","expected":{"router":"reschedule","active_branch":"reschedule","suspended_branch":"new_booking"}},{"text":"всё, отменили, спасибо","expected":{"active_branch":"reschedule"}},{"text":"теперь по новой записи — на 14 часов","expected":{"router":"new_booking","active_branch":"new_booking","resumed":true,"slots":{"target_day":"четверг","target_time":"14:00"}}}]}
Пример: правильные чанки в правильной ветке (RAG)
{"name":"price_question_uses_price_doc","description":"в price_question ретривал тянет чанки именно из прайса, не из скрипта записи","tags":["rag","smoke"],"steps":[{"text":"сколько стоит эндоскопия лор-органов?","expected":{"router":"price_question","retrieved_phrases":["эндоскопия","1000"]}}]}
Пример: петля маршрутизатора
{"name":"router_loop_after_cap","description":"искусственно гоняем маршрутизатор между ветками, после порога должен сработать routing_loop","tags":["loop"],"steps":[{"text":"запишите","expected":{"router":"new_booking","handoff_count":0}},{"text":"а сколько стоит","expected":{"router":"price_question","handoff_count":1}},{"text":"запишите","expected":{"router":"new_booking","handoff_count":2}},{"text":"а сколько стоит","expected":{"router":"price_question","handoff_count":3}},{"text":"запишите","expected":{"router_reason":"routing_loop","active_branch":"escalate_human"}}]}
Принципы схемы
- Проверяется только то, что явно указано в
expected. Если на шаге не указанslots— слоты не сверяются. Это позволяет писать минимальные кейсы для одной проверки и подробные для интеграционной. - Шаги выполняются последовательно в одном треде: каждая следующая реплика идёт на состояние, оставшееся после предыдущей.
- JSONL — одна строка на кейс. Для редактирования удобно открывать в IDE, который умеет JSON-prettify по hotkey (VS Code:
Format Documentна выделенной строке черезEdit > Sort Lines > ...или плагин JSON Lines). При необходимости можно вручную перенести в*.json(массив объектов с pretty-print) для чтения, но в репозиторий идёт JSONL.
Как запускать
Полный прогонщик eval/run.py — задача Спринта 8 (см. docs/SPRINTS.md). Пока его нет, минимальный прогон делается коротким скриптом, который зовёт RouterClient.classify() напрямую без поднятия HTTP-сервиса:
# eval/run_router_quick.py — минимальный прогонщик, заменится на run.py в Спринте 8
import asyncio, json
from collections import Counter
from db.session import async_session_maker
from services.router_client import RouterClient
async def main(path: str):
cases = [json.loads(l) for l in open(path, encoding="utf-8")]
router = RouterClient()
correct = 0
confusion = Counter()
errors = []
async with async_session_maker() as session:
for c in cases:
result = await router.classify(session, history=[], text=c["text"])
actual = result["code"]
expected = c["expected_intent"]
confusion[(expected, actual)] += 1
if actual == expected:
correct += 1
else:
errors.append((c["text"], expected, actual))
print(f"accuracy: {correct}/{len(cases)} = {correct/len(cases):.1%}")
print("\nconfusion matrix (expected → actual):")
for (exp, act), n in confusion.most_common():
marker = "✓" if exp == act else "✗"
print(f" {marker} {exp:>20} → {act:<20} {n}")
print(f"\nerrors ({len(errors)}):")
for text, exp, act in errors[:30]:
print(f" expected={exp:<18} actual={act:<18} text={text[:90]!r}")
if __name__ == "__main__":
import sys
asyncio.run(main(sys.argv[1] if len(sys.argv) > 1 else "eval/router_cases_booking.jsonl"))
Запуск:
# Активный конфиг роутера должен быть включён в БД, переменные .env подгружены.
python -m eval.run_router_quick eval/router_cases_booking.jsonl
python -m eval.run_router_quick eval/router_cases_other.jsonl
Что нужно держать в уме при прогоне:
- Маршрутизатор зовёт LLM (DeepSeek по умолчанию) — каждый кейс это сетевой запрос. На полном наборе ~1500 кейсов это около 1500 запросов и заметные минуты ожидания. Для короткого прогона фильтруйте по
count >= 2(см. ниже про метрики). - Прогон не статичен: при
temperature=0ответы должны быть стабильны, но изредка модель отдаёт пограничный класс — раз в N прогонов один-два кейса могут «дрожать». Считается нормой; чтобы поймать дрейф, прогоняем eval два раза подряд и смотрим разницу. expected_reasonдляescalate_humanмаршрутизатор возвращает в полеresult["escalation_reason"]— снапшот выше упрощён, при необходимости добавьте сравнение reason отдельно.- Не запускайте против продакшен-БД — eval создаёт нагрузку на API-ключ DeepSeek и не должен учитываться в метриках реальных диалогов (логи маршрутизатора в коде есть, но это отдельная история).
На какие метрики смотреть
Минимальный набор метрик, по которому имеет смысл судить о качестве маршрутизатора:
1. Общая доля попаданий (accuracy) — correct / total. Доля кейсов, где маршрутизатор вернул ровно тот класс, что в expected_intent. Это первая цифра, на которую смотрим, но она обманчива: при перекосе классов (у нас 875 кейсов в new_booking против 2 в escalate_human|angry) маршрутизатор может показать высокую общую долю попаданий, всегда отвечая new_booking. Поэтому общая цифра — только сигнал, дальше смотрим разрез.
2. Доля попаданий по классу (per-class accuracy) — отдельно по каждому намерению: correct_in_class / total_in_class. Здесь видно, что, например, new_booking распознаётся в 92%, а medical_question — только в 60%. Слабые классы — кандидаты на правку промпта или добавление кейсов в обучающие примеры внутри _router.md.
3. Матрица ошибок (confusion matrix) — таблица «ожидалось X — получили Y». Самое полезное для отладки. Например, если строка medical_question → new_booking имеет 20 кейсов, значит маршрутизатор регулярно классифицирует медицинские вопросы как запись на приём — нужно усилить разделяющие правила в промпте.
4. Точность / полнота / F1-мера по классу (precision / recall / F1) — для каждого класса:
- точность (precision) = «когда маршрутизатор сказал X, насколько он был прав» (важна для
escalate_human|surgery: ложные срабатывания дороги, бессмысленно гонять пациента к оператору на простой вопрос). - полнота (recall) = «насколько мы поймали все реальные X» (важна для
escalate_human|acute_pain: пропустить острую боль — провал). - F1 — гармоническое среднее точности и полноты, общий показатель класса.
Считаются стандартной формулой; в python — через sklearn.metrics.classification_report, если хочется без зависимостей — руками из матрицы ошибок.
5. Список ошибок — конкретные тексты, где разметка разошлась с ответом маршрутизатора. Прежде чем чинить промпт, прогоняем глазами: примерно треть «ошибок» обычно оказываются спорными случаями нашей разметки (см. раздел «Известные ограничения»). Только после этого считаем «настоящие» ошибки маршрутизатора.
Целевые цифры (ориентир, не обещание):
| метрика | приемлемо | хорошо |
|---|---|---|
| общая доля попаданий | ≥ 80% | ≥ 90% |
доля попаданий по частотным классам (new_booking, general_info, price_question) |
≥ 85% | ≥ 92% |
| полнота для `escalate_human | acute_pain` | ≥ 90% |
| точность для `escalate_human | surgery` | ≥ 70% |
Цифры взяты «по ощущениям» с учётом особенностей домена (медицинский колл-центр, цена ошибки на эскалации) — после первого отправного замера стоит откалибровать.
Базовая методика работы с eval:
- Перед правкой промпта
_router.md(или сменой модели) — прогнать eval, сохранить отчёт какeval/reports/{дата}_baseline.md(отправной замер). - Внести правку.
- Прогнать eval ещё раз.
- Сравнить матрицы ошибок и списки ошибок — поймать ухудшения (стало хуже там, где было хорошо) и улучшения (стало лучше там, где было плохо).
- Если ухудшений нет, а улучшения есть — коммитим правку. Если ухудшения перевешивают — откатываем.
Как разметка получена
Rule-based классификатор по ключевым словам, согласован с правилами из prompts/intents/_router.md:
- Сначала проверяются «жёсткие» правила: явная просьба оператора →
escalate_human|explicit_request; упоминание операции/наркоза →escalate_human|surgery; острая боль/кровотечение/инородное тело →escalate_human|acute_pain; ругань →escalate_human|angry. - Затем
reschedule(перенос/отмена существующей записи). - Затем
general_infoдля справок/доверенностей (FAQ-документы) — раньшеprice_question, чтобы «справка для налогового вычета» не уехала в цены. - Затем
price_question(стоимость, ОМС/ДМС, оплата) если нет явного намерения записаться. - Затем
medical_question(вопрос про лекарство/диагноз/«что делать» с симптомом). - Затем
new_booking(явные глаголы записи или симптомы как стартер записи). - В конце — оставшийся
general_info.
~750 первых реплик не удалось классифицировать regex'ом (короткие междометия, очень длинные жалобы без ключевых слов, спам, эмодзи-only) — они в eval не попали. Если хочется наращивать сет — стоит прогнать unknown через LLM-разметку отдельно.
Известные ограничения и пограничные кейсы
- «Сколько стоит операция …» размечено как
escalate_human|surgery, не какprice_question— следуем правилу из_router.md: «любое упоминание операции/наркоза/стационара/хирургии → escalate_human|surgery». Помечено вnote. - «Налоговый вычет / справка для налоговой» размечено как
general_info, неprice_question. Это FAQ о выдаче документа, а не вопрос о стоимости/оплате услуг клиники. Александра отвечает на это шаблонным длинным ответом — кандидат на отдельный intent «справка» в нашем боте. - «Болит ухо», «насморк не проходит» и подобные жалобы без слова «записаться» размечены как
new_booking(по правилу_router.md: жалоба на симптом — этоnew_booking, если в диалоге идёт запись). Помечено вnoteкак «симптом без явного «записать»» — это самые ценные кейсы для проверки роутера, потому что легко спутать сmedical_question. escalate_human|angry— всего 2 реальных кейса. Класс острого недостатка. Аналогичноacute_pain(11) иexplicit_request(6, причём один — спам с hh.ru). Эти подклассы стоит досинтезировать вручную — реальные данные у конкурента почти не содержат таких реплик первыми (видимо, агрессия/срочность приходят дальше по воронке, а не в первой реплике).reschedule— 40 уникальных, в основном «отменить запись на …». Качество разметки приемлемое, но один false-positive («хотела спросить актуальна ли вакансия администратора») попал из-за регекса по «администратор». Помечен вnote.- Длинные реплики (>200 символов) помечены в
note— их полезно тестировать отдельно, потому что роутер видит обрезанные реплики в_format_history.
Что делать дальше
- Для короткого прогона: взять
count >= 2(надёжно частотные) — 16 кейсов в booking и 23 в other. - Для регрессионного прогона: брать весь набор, ожидать высокий процент совпадений с этой разметкой (она не идеальная, но согласована с маршрутизатором).
- Перед коммитом отправного замера стоит пройтись глазами по
note != ''— там сосредоточены спорные случаи, часть из них может быть переразмечена иначе после ручной проверки. - Слабые подклассы (
angry,explicit_request,acute_pain) — досинтезировать вручную или взять из второй сотни диалогов вне первой реплики.