74befa484d
Промпты веток (по docs/BRANCH_MAP_AND_PROMPTS_v1.md):
- reschedule.md — полная замена. Одношаговый сценарий из 6 пунктов:
action (cancel/reschedule), patient_name, patient_phone, original_time,
preferred_new_time. Слоты хранит вызывающая система, STATE_JSON не используется.
- price_question.md — добавлены 3 пункта: эндоскопия 1000₽ при первичном
ЛОР-приёме, лечебные процедуры доплачиваются, ОМС только сурдолог
(последний пункт работает только при подтверждении в базе).
- medical_question.md — расширена карта жалоб → специалист (ЛОР / сурдолог /
аллерголог / иммунолог / пульмонолог); добавлен пункт про беременность,
онкологию, психиатрию — мягко сказать «специализированная клиника»,
не предлагать запись.
- general_info.md — добавлены разделы «Отзывы и социальное доказательство»,
«Преимущества клиники», «Сокращения». Условия выхода расширены до 5 интентов.
escalate_human и new_booking не трогаем (escalate — карта говорит «не менять»;
new_booking — отдельный Спринт 7.6 по docs/OPTIMIZATION_CONVERSION_v1.md).
Применение в БД — вручную через UI «Настройки» (вариант A): оператор копирует
текст из .md, сохраняет как новую версию + активирует. Файлы — только seed.
Eval-каркас (заготовка под Спринт 8):
- eval/router_cases_booking.jsonl (875 кейсов new_booking) и
eval/router_cases_other.jsonl (698 кейсов: general_info 295, price 165,
escalate 139, medical 59, reschedule 40). CSV-исходники рядом.
- eval/README.md — формат, глоссарий, что это и зачем.
- routers/eval.py: GET /eval/router-cases?intent_code=...&limit=...
Lazy-кэш, сортировка по count desc, фильтр по expected_intent.
UI Настроек — выбор готового кейса в тест-блоке:
- Полоса «Готовый кейс:» с datalist (поиск по началу строки) + кнопка
«🎲 Случайный» + счётчик кейсов для активной ветки.
- При выборе — текст подставляется в textarea вопроса.
- Загружается при выборе ветки. Если кейсов 0 (для _router, _debug) — скрыто.
- Полная подсистема прогона (run.py, отчёты, baseline) — Спринт 8.
SPRINTS.md:
- Спринт 7 (мульти-RAG, часть A) → ✅ Закрыт (коммит 52b46bc).
- Заведён Спринт 7.5 «Обновление промптов 4 веток» (этот спринт).
- Заведён Спринт 7.6 «Оптимизация воронки new_booking до 4 шагов»
по OPTIMIZATION_CONVERSION_v1.md.
- В идеи на потом: сквозные правила всех веток (BRANCH_MAP §2),
отложенная документация Спринта 7 (docs.html карточка термина,
GRAPH_ARCHITECTURE_v5, README про мульти-RAG).
Также: docs/COMPETITOR_ALEXANDRA_top100.md — рабочие материалы пользователя
по конкурентному боту (NEXTBOT/Александра), используется как baseline для
оптимизации воронки в Спринте 7.6.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
296 lines
32 KiB
Markdown
296 lines
32 KiB
Markdown
# 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`) — досинтезировать вручную или взять из второй сотни диалогов вне первой реплики.
|