Files
RAG_helper/eval
AR 15 M4 60f8a7b398 feat(sprint7.6): оптимизация воронки new_booking до 4 шагов (вариант 2)
Воронка сжата с 6 шагов до 4: intro → qualify → book → close.
Спецификация: docs/OPTIMIZATION_CONVERSION_v1.md.
Цель: сравнимая с конкурентом (NEXTBOT/Александра) конверсия — ≤3 реплик
бота до запроса телефона, содержательный ответ на жалобу в первом
осмысленном сообщении.

Промпты шагов:
- intro.md — переписан. Приветствие + открытый вопрос «что беспокоит?».
  Имя НЕ спрашиваем (слот name со шага снят), оно собирается на book
  вместе с телефоном. Если пациент сразу написал жалобу — не зацикливаемся,
  переходим в qualify.
- qualify.md — переписан. Обязательный 5-пунктовый шаблон ответа на жалобу:
  эмпатия (одна фраза) → 2-3 ЛОР-гипотезы из RAG-выдержек («может быть
  связано с») → специалист → услуга/цена («при необходимости назначит») →
  бинарный CTA «записать?». Если в выдержках нет гипотез/цен — пункт
  пропускается, не сочиняем. Если жалоба не описана (пациент сразу
  «хочу записаться к ЛОРу») — пропускаем гипотезу/услугу, оставляем
  эмпатию-формальность + специалист + CTA.
  Три особые ситуации сохранены: ребёнок (require_legal_rep), конкретный
  врач (waitlist_flag), первичная жалоба на слух (needs_surgologist_first).
- book.md — переписан. Одной репликой: подтверждение плана с
  использованием {specialist}/{reason} + запрос телефона + имени (если
  ещё не было в истории). При is_child=true — обращение к родителю,
  legal_rep_phone используется, если уже собран.
- present.md — DEPRECATED. Файл оставлен в репо на случай отката
  (вариант 1 спецификации). Внутри — заглушка «попал по ошибке —
  выходи на book».
- close.md и offer_time.md не тронуты (offer_time станет актуален с
  реальным календарём).

allowed_next в SEED_INTENT_STEPS:
- intro: [intro, qualify] (без изменений)
- qualify: [qualify, book] (раньше: [qualify, present])
- present: [book] (изоляция; раньше: [present, qualify, offer_time])
- offer_time: [offer_time, book] (deprecated, без изменений)
- book: [book, qualify, close] (раньше: [book, qualify, offer_time, close])
- close: [close] (без изменений)

migrate_new_booking_allowed_next_v2(session) — одноразовая миграция в
services/intent_step_service.py. При старте для каждого шага
new_booking сравнивает текущий allowed_next_json с дореформенным
значением (_PRE_SPRINT_7_6_ALLOWED_NEXT). Если совпадает — обновляет
на новое из SEED. Если оператор правил вручную — пропускает,
warning в лог. Идемпотентна (на повторных запусках ничего не делает).
Подключена в main.py lifespan после ensure_seed_guards.

Защитное условие require_legal_rep на qualify сохранено. Теперь блокирует
переход qualify → book (раньше qualify → present). Логика та же:
при is_child=true и пустых legal_rep_name/legal_rep_phone валидатор
отклоняет переход.

eval/MANUAL_CASES.md — markdown-чеклист для ручных прогонов:
- §A: 5 конверсионных кейсов (храп+уши, боль в горле, тугоухость,
  насморк >месяца, звон в ушах) с чеклистом 5 пунктов на первый ответ
  и проверкой ≤3 реплик до телефона.
- §B: регрессия 8 ручных сценариев из блока H Спринта 6b со ссылками
  на docs/examples/*_v2.md.

SPRINTS.md: Спринт 7.6 →  Закрыт по коду. Применение промптов в БД
и ручная регрессия — за оператором (через UI «Настройки → Шаги»
для каждого из 4 шагов new_booking).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-28 21:04:09 +05:00
..

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_humanacute_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:

  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) — досинтезировать вручную или взять из второй сотни диалогов вне первой реплики.