# 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 разметки и т.п. | Парсить так: ```python 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` неудобно фиксировать. | ### Пример: передача управления (запись → вопрос про цену → возврат) ```json {"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"}}]} ``` ### Пример: возобновляемый сценарий (запись → перенос другой записи → возврат к новой записи) ```json {"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) ```json {"name":"price_question_uses_price_doc","description":"в price_question ретривал тянет чанки именно из прайса, не из скрипта записи","tags":["rag","smoke"],"steps":[{"text":"сколько стоит эндоскопия лор-органов?","expected":{"router":"price_question","retrieved_phrases":["эндоскопия","1000"]}}]} ``` ### Пример: петля маршрутизатора ```json {"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-сервиса: ```python # 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")) ``` Запуск: ```bash # Активный конфиг роутера должен быть включён в БД, переменные .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% | ≥ 95% | | точность для `escalate_human|surgery` | ≥ 70% | ≥ 85% | Цифры взяты «по ощущениям» с учётом особенностей домена (медицинский колл-центр, цена ошибки на эскалации) — после первого отправного замера стоит откалибровать. **Базовая методика работы с eval:** 1. Перед правкой промпта `_router.md` (или сменой модели) — прогнать eval, сохранить отчёт как `eval/reports/{дата}_baseline.md` (отправной замер). 2. Внести правку. 3. Прогнать eval ещё раз. 4. Сравнить матрицы ошибок и списки ошибок — поймать **ухудшения** (стало хуже там, где было хорошо) и **улучшения** (стало лучше там, где было плохо). 5. Если ухудшений нет, а улучшения есть — коммитим правку. Если ухудшения перевешивают — откатываем. ## Как разметка получена 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-разметку отдельно. ## Известные ограничения и пограничные кейсы 1. **«Сколько стоит операция …»** размечено как `escalate_human|surgery`, не как `price_question` — следуем правилу из `_router.md`: «любое упоминание операции/наркоза/стационара/хирургии → escalate_human|surgery». Помечено в `note`. 2. **«Налоговый вычет / справка для налоговой»** размечено как `general_info`, не `price_question`. Это FAQ о выдаче документа, а не вопрос о стоимости/оплате услуг клиники. Александра отвечает на это шаблонным длинным ответом — кандидат на отдельный intent «справка» в нашем боте. 3. **«Болит ухо», «насморк не проходит»** и подобные жалобы без слова «записаться» размечены как `new_booking` (по правилу `_router.md`: жалоба на симптом — это `new_booking`, если в диалоге идёт запись). Помечено в `note` как «симптом без явного «записать»» — это **самые ценные кейсы** для проверки роутера, потому что легко спутать с `medical_question`. 4. **`escalate_human|angry` — всего 2 реальных кейса**. Класс острого недостатка. Аналогично `acute_pain` (11) и `explicit_request` (6, причём один — спам с hh.ru). Эти подклассы стоит **досинтезировать вручную** — реальные данные у конкурента почти не содержат таких реплик первыми (видимо, агрессия/срочность приходят дальше по воронке, а не в первой реплике). 5. **`reschedule` — 40 уникальных**, в основном «отменить запись на …». Качество разметки приемлемое, но один false-positive (`«хотела спросить актуальна ли вакансия администратора»`) попал из-за регекса по «администратор». Помечен в `note`. 6. **Длинные реплики (>200 символов)** помечены в `note` — их полезно тестировать отдельно, потому что роутер видит обрезанные реплики в `_format_history`. ## Что делать дальше - Для короткого прогона: взять `count >= 2` (надёжно частотные) — 16 кейсов в booking и 23 в other. - Для регрессионного прогона: брать весь набор, ожидать высокий процент совпадений с этой разметкой (она не идеальная, но согласована с маршрутизатором). - Перед коммитом отправного замера стоит пройтись глазами по `note != ''` — там сосредоточены спорные случаи, часть из них может быть переразмечена иначе после ручной проверки. - Слабые подклассы (`angry`, `explicit_request`, `acute_pain`) — досинтезировать вручную или взять из второй сотни диалогов вне первой реплики.