From f348570b1b8ee82dd05536144139af5dbb5abb0a Mon Sep 17 00:00:00 2001 From: AR 15 M4 Date: Sun, 26 Apr 2026 21:50:16 +0500 Subject: [PATCH] =?UTF-8?q?docs:=20=D0=BF=D0=B5=D1=80=D0=B5=D0=B5=D0=B7?= =?UTF-8?q?=D0=B4=20=D0=B2=20docs/=20=E2=80=94=20SPRINTS,=20architecture?= =?UTF-8?q?=20(v1/v2/v3),=20examples?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SPRINTS.md → docs/SPRINTS.md - GRAPH_ARCHITECTURE.md → docs/architecture/GRAPH_ARCHITECTURE_v1.md - GRAPH_ARCHITECTURE_v2.md → docs/architecture/GRAPH_ARCHITECTURE_v2.md - Новый docs/architecture/GRAPH_ARCHITECTURE_v3.md (билингв. термины + ссылки на примеры) - Новые docs/examples/: 01 базовая запись, 02 цена во время записи (soft vs hard), 03 запись ребёнка (guard), 04 простой general_info - README обновлён: ссылки на новые пути + раздел «Документация» Co-Authored-By: Claude Sonnet 4.6 --- README.md | 19 +- SPRINTS.md => docs/SPRINTS.md | 4 +- .../architecture/GRAPH_ARCHITECTURE_v1.md | 0 .../architecture/GRAPH_ARCHITECTURE_v2.md | 0 docs/architecture/GRAPH_ARCHITECTURE_v3.md | 439 ++++++++ docs/examples/01_basic_booking.md | 386 +++++++ docs/examples/02_price_during_booking.md | 284 +++++ docs/examples/03_child_patient_guard.md | 332 ++++++ docs/examples/04_general_info_simple.md | 966 ++++++++++++++++++ 9 files changed, 2427 insertions(+), 3 deletions(-) rename SPRINTS.md => docs/SPRINTS.md (99%) rename GRAPH_ARCHITECTURE.md => docs/architecture/GRAPH_ARCHITECTURE_v1.md (100%) rename GRAPH_ARCHITECTURE_v2.md => docs/architecture/GRAPH_ARCHITECTURE_v2.md (100%) create mode 100644 docs/architecture/GRAPH_ARCHITECTURE_v3.md create mode 100644 docs/examples/01_basic_booking.md create mode 100644 docs/examples/02_price_during_booking.md create mode 100644 docs/examples/03_child_patient_guard.md create mode 100644 docs/examples/04_general_info_simple.md diff --git a/README.md b/README.md index 03f35e8..595766e 100644 --- a/README.md +++ b/README.md @@ -87,7 +87,7 @@ ## План (спринты) -См. [`SPRINTS.md`](./SPRINTS.md). +См. [`docs/SPRINTS.md`](./docs/SPRINTS.md). Архитектурные документы и разобранные примеры — в [`docs/architecture/`](./docs/architecture/) и [`docs/examples/`](./docs/examples/). --- @@ -165,6 +165,8 @@ embedding-модели E5-large занимает ~15–20 секунд. ``` . +├── README.md # этот файл +├── docs/ # проектная документация (см. ниже) ├── config.py # настройки (Pydantic BaseSettings) ├── main.py # FastAPI app, lifespan, авто-миграции ├── alembic.ini # конфиг Alembic @@ -204,6 +206,21 @@ embedding-модели E5-large занимает ~15–20 секунд. └── sqlite/ # реляционная БД (gitignored) ``` +### Документация (`docs/`) + +``` +docs/ +├── SPRINTS.md # план и статус спринтов +├── architecture/ +│ ├── GRAPH_ARCHITECTURE_v1.md # черновик при развороте 2026-04-23 +│ ├── GRAPH_ARCHITECTURE_v2.md # уточнения 2026-04-24 +│ └── GRAPH_ARCHITECTURE_v3.md # текущая (билингв. термины + ссылки на примеры) +└── examples/ + ├── 01_basic_booking.md # happy path записи (линейный) + ├── 02_price_during_booking.md # soft-insertion vs. hard-handoff + └── 03_child_patient_guard.md # guard в шаге qualify (запись ребёнка) +``` + --- ## Связанные проекты diff --git a/SPRINTS.md b/docs/SPRINTS.md similarity index 99% rename from SPRINTS.md rename to docs/SPRINTS.md index 42043c4..7d4954c 100644 --- a/SPRINTS.md +++ b/docs/SPRINTS.md @@ -144,7 +144,7 @@ ## Архитектурный разворот после Спринта 3 (2026-04-23) -После пилота Спринтов 1–3 решили уходить от одного «мега-промпта» ко графовой архитектуре: **роутер намерений + изолированные ветки + state machine + exit conditions**. Подробности — в `GRAPH_ARCHITECTURE.md`. +После пилота Спринтов 1–3 решили уходить от одного «мега-промпта» ко графовой архитектуре: **роутер намерений + изолированные ветки + state machine + exit conditions**. Подробности — в [`architecture/GRAPH_ARCHITECTURE_v3.md`](./architecture/GRAPH_ARCHITECTURE_v3.md) (последняя версия). Исторические снапшоты — `architecture/GRAPH_ARCHITECTURE_v1.md` (изначальный, на момент разворота) и `architecture/GRAPH_ARCHITECTURE_v2.md`. **Принятые решения по открытым вопросам:** - **Фреймворк оркестровки:** пишем вручную на Python. LangGraph/n8n не берём — проект компактный, свой стек работает, не тянем лишних зависимостей. @@ -160,7 +160,7 @@ ## Спринт 4. Фундамент графа — `intents` + роутер + переключение веток ### Цель -Заменить «один активный промпт на всё» на «свой промпт на каждую ветку + роутер выбирает ветку на каждой реплике». Это первый шаг к графовой архитектуре из `GRAPH_ARCHITECTURE.md`. +Заменить «один активный промпт на всё» на «свой промпт на каждую ветку + роутер выбирает ветку на каждой реплике». Это первый шаг к графовой архитектуре из [`architecture/GRAPH_ARCHITECTURE_v3.md`](./architecture/GRAPH_ARCHITECTURE_v3.md). ### Статус: ✅ Закрыт diff --git a/GRAPH_ARCHITECTURE.md b/docs/architecture/GRAPH_ARCHITECTURE_v1.md similarity index 100% rename from GRAPH_ARCHITECTURE.md rename to docs/architecture/GRAPH_ARCHITECTURE_v1.md diff --git a/GRAPH_ARCHITECTURE_v2.md b/docs/architecture/GRAPH_ARCHITECTURE_v2.md similarity index 100% rename from GRAPH_ARCHITECTURE_v2.md rename to docs/architecture/GRAPH_ARCHITECTURE_v2.md diff --git a/docs/architecture/GRAPH_ARCHITECTURE_v3.md b/docs/architecture/GRAPH_ARCHITECTURE_v3.md new file mode 100644 index 0000000..a217409 --- /dev/null +++ b/docs/architecture/GRAPH_ARCHITECTURE_v3.md @@ -0,0 +1,439 @@ +# Графовая архитектура: роутер намерений + изолированные ветки + +> **Версия 3 · 2026-04-26.** По содержанию — то же, что v2, но переписано в стиле «русское объяснение + английский термин в скобках» для всех понятий, которые будут встречаться в коде, в промптах или в названиях полей БД. В конце документа — разделы со ссылками на разобранные примеры (см. файлы в [`../examples/`](../examples/)). Изменения относительно v2 — в разделе **Changelog**. + +Документ фиксирует направление, в которое двигается проект после пилота Спринтов 1–3. Перепланировка спринтов сделана в `SPRINTS.md` — здесь только сама архитектура и почему она нам нужна. + +--- + +## Соглашение о терминах + +В документе встречаются понятия, которые одновременно: +- являются обычными русскими словами в обиходе, +- и одновременно — идентификаторами полей БД, переменных в коде, ключей в JSON-структурах. + +Чтобы не было путаницы, при первом упоминании в разделе мы пишем русский термин и сразу даём английский эквивалент (в том виде, в котором он живёт в коде). Например: **намерение** (intent), **машина состояний** (state machine), **счётчик переключений** (handoff_count). Ниже по тексту того же раздела используется тот вариант, который удобнее по контексту. + +--- + +## Проблема, с которой сталкиваемся + +Текущая реализация — это «мега-промпт»: в один системный промпт положен весь скрипт поведения агента, плюс правила, плюс инструкции по всем возможным темам (запись, перенос, цены, подготовка к приёму, ДМС, детский приём и т. д.). + +На MVP это работает. Но как только добавим реальные бизнес-процессы с несколькими этапами (например, запись с перехватом инициативы в 6 шагов) — модель начнёт «плыть»: + +- **Забывать начало инструкций** в конце длинного промпта. +- **Перескакивать этапы** мини-интервью. +- **Пытаться применять правила не к месту** — например, запустить скрипт записи, когда пациент просто спросил, как доехать. +- **Путать ветки** между собой, потому что они все лежат в одном контексте. + +Это классическая ловушка production-ready ассистентов. Дело не в мощности модели (DeepSeek более чем достаточно), а в архитектуре: **один промпт не должен знать про всё одновременно**. + +--- + +## Архитектура, к которой идём + +Паттерн называется **маршрутизация на основе графа** (graph-based routing) или **мультиагентная система** (multi-agent system). Идея проста: + +1. Входная реплика пациента идёт не сразу в отвечающего агента, а в **роутер** (router). +2. Роутер определяет **намерение** (intent) пациента и передаёт диалог в конкретную изолированную **ветку** (branch). +3. Каждая ветка — это отдельный узкий промпт, который умеет делать одну вещь хорошо. +4. Ветки не замкнуты: в любой момент агент может вернуть управление роутеру, если контекст изменился. + +``` +┌─────────────┐ +│ Пациент │ +└──────┬──────┘ + │ +┌──────▼──────────────────────────┐ +│ Роутер (LLM-классификатор) │ +│ определяет намерение │ +└──────┬──────────────────────────┘ + │ + ├──→ Ветка «Новая запись» (new_booking, машина состояний, 6 шагов + guard'ы) + ├──→ Ветка «Перенос / отмена» (reschedule) + ├──→ Ветка «Цены и ДМС» (price_question) + ├──→ Ветка «Медицинский вопрос» (medical_question, канонический ответ) + ├──→ Ветка «Общая справка» (general_info, адрес, часы, проезд) + └──→ Ветка «Эскалация» (escalate_human, reason: surgery | + acute_pain | + angry | + explicit_request | + routing_loop) +``` + +Шесть веток — то же количество, что сидится при первом запуске Спринта 4. Хирургия и острая боль не отдельные ветки, а значение поля **причина эскалации** (reason) внутри `escalate_human` — так решили на развороте 2026-04-23. + +--- + +## 1. Роутер — входной узел + +Отдельный, быстрый и дешёвый вызов **языковой модели** (LLM, large language model). Сам пациенту не отвечает — только классифицирует. + +Задача роутера: + +- Проанализировать последнюю реплику пациента + краткую историю диалога. +- Вернуть **код намерения** (intent code) — одну из заранее заданных категорий. +- Если детектирован острый случай (боль, кровотечение, упоминание операции) — маршрутизировать в `escalate_human` с соответствующим `reason`. + +Пример промпта роутера: + +> Определи намерение пользователя. Варианты: +> 1. `new_booking` — новая запись. +> 2. `reschedule` — перенос или отмена существующей записи. +> 3. `price_question` — цены, ДМС, оплата. +> 4. `medical_question` — симптомы, диагноз, лечение (немедленная эскалация не требуется). +> 5. `general_info` — как доехать, часы работы, контакты. +> 6. `escalate_human` — пациент явно просит оператора, злится, описывает острое состояние, упоминает операцию. +> +> Верни только код намерения. Для `escalate_human` дополнительно верни `reason` из списка: `acute_pain`, `surgery`, `angry`, `explicit_request`. + +Роутер продолжает **незримо присутствовать** в диалоге — его вызывают на каждой реплике, не один раз при входе. Это двойная защита: если ветка не поймала **условие выхода** (exit condition) сама, роутер увидит изменение намерения (intent'а) и инициирует **переход в другую ветвь** (handoff). + +--- + +## 2. Узкоспециализированные ветки (sub-agents) + +Каждая ветка — отдельный промпт, который ничего не знает про другие ветки. Он видит: + +- Свой системный промпт (узкий, под одну задачу). +- Свой срез базы знаний (см. раздел 6). +- Историю диалога (чтобы не переспрашивать имя/симптомы). +- Текущий шаг машины состояний — если она в этой ветке есть. + +Примеры: + +**Ветка «Новая запись» (`new_booking`).** 6-этапный промпт-продавец с условными ветвлениями. Перехват инициативы, мини-интервью по услуге и врачу, презентация приёма, два слота + «настоять на записи», бронирование, закрытие с проговариванием даты/врача/адреса/стоимости. Подробно — в разделе 3 и в `01_basic_booking.md`. + +**Ветка «Перенос / отмена» (`reschedule`).** Другой промпт: извиниться, уточнить текущую запись, сверить с календарём, предложить варианты. RAG не используется — работа через **вызовы инструментов** (tool calls) к CRM. + +**Ветка «Медицинский вопрос» (`medical_question`).** Канонический ответ: «не могу консультировать, это к врачу. Записать вас к профильному специалисту?» — с мягким переходом в `new_booking`. Никакого RAG по медицинским темам намеренно (юридический риск). + +**Ветка «Эскалация» (`escalate_human`).** Короткая: извиниться, передать оператору. Перед передачей формируется саммари с `reason`, историей и собранными слотами. + +--- + +## 3. Машина состояний внутри ветки + +Для сложных скриптов (вроде записи) недостаточно иметь один промпт — нужна ещё память о том, **на каком шаге мы сейчас находимся** (current_step) и **какие данные мы уже собрали** (slots). + +### 3.1 Базовая линейная цепочка + +Пример **состояния треда** (thread state) для `new_booking`: + +```json +{ + "intent": "new_booking", + "step": "offer_time", + "slots": { + "patient_name": "Анна", + "is_child": false, + "service": "первичный ЛОР", + "doctor": "Сушков М. Г.", + "time_candidates": ["2026-04-24 10:00", "2026-04-24 15:00"], + "time_chosen": null + } +} +``` + +Модель на каждом ходе видит: *«Я на шаге `offer_time`, слот `time_candidates` заполнен, значит следующим сообщением я должна получить выбор времени, а не представляться заново»*. Это убирает «перескоки» и «забывания». + +Состояние треда хранится в отдельной таблице `thread_state` с JSON-колонкой под слоты (см. раздел «Что это меняет в данных»). Полный пример заполнения слотов реплика за репликой — в `01_basic_booking.md`. + +### 3.2 Защитные условия (guards) и ветвления внутри скрипта + +Линейная цепочка из шести шагов — идеальный случай. В реальном скрипте записи (см. вики клиники) есть как минимум три **защитных условия** (guards), которые ломают линейность: + +- **Пациент — ребёнок.** На шаге `qualify` обязательно собрать ФИО и телефон законного представителя. Блокирует переход в `present`, пока слоты не заполнены. Юридическое требование, не косметика. +- **Запрос конкретного врача (например, Ворончихиной).** Вместо шага `offer_time` диалог уходит в рукав «лист ожидания» (waitlist): запись в очередь вместо предложения слотов. +- **Жалоба на слух без обследования у сурдолога.** На шаге `present` модель должна предложить записаться сначала к сурдологу, и только потом — к отоневрологу. + +Моделировать guard'ы можно двумя способами: + +— **Условные переходы** (conditional transitions). Шаг `qualify` имеет два возможных next-step'а: `present` (обычно) или `collect_legal_rep` (если `is_child=true`), и только после заполнения переходит дальше. + +— **Под-состояния** (sub-states). Внутри `qualify` есть `qualify.base` и `qualify.legal_rep`, последнее активируется при `is_child=true`. + +Рекомендуем первый вариант — он проще и легче тестируется. Разбор guard'а с ребёнком на конкретном диалоге — в `03_child_patient_guard.md`. + +### 3.3 Структурированный выход модели + валидатор переходов + +Чисто **управляемые моделью** (LLM-driven) переходы — где в промпте написано «если слот заполнен, переходи к следующему шагу» — фрагильны. Модель периодически «не замечает» заполнение слота и застревает или, наоборот, прыгает через шаг. + +Гибридный подход надёжнее. Модель возвращает **структурированный ответ** (structured output): + +```json +{ + "reply": "Записала вас на четверг, 10:00...", + "state_after": "close", + "slots_updated": { + "time_chosen": "2026-04-24 10:00" + } +} +``` + +Код: + +1. **Валидирует легальность перехода** — `offer_time → close` допустим, `intro → book` нет. +2. **Сохраняет слоты строго** — что модель обновила, то и попало в `thread_state`. +3. **Логирует несоответствия** — если модель вернула несуществующее `state_after`, состояние остаётся прежним, в лог пишется предупреждение. + +Модель рассуждает содержательно, код защищает механически. Прибавка — около 50 строк валидатора, снижение нестабильности — заметное. + +### 3.4 RAG-срез на уровне шага, а не только ветки + +Разным шагам одной ветки нужны разные куски вики. Для `new_booking`: + +| Шаг (`step`) | Срез базы знаний (`wiki_sources`) | Инструмент (`tool`) | +|--------------|-----------------------------------|---------------------| +| `intro` | — | — | +| `qualify` | `/wiki/services/**`, `/wiki/doctors/**` | — | +| `present` | `/wiki/services/**`, `/wiki/doctors/**`, `/wiki/preparation/**` | — | +| `offer_time` | `/wiki/services/**` (для боковых вопросов) | `crm.get_slots` | +| `book` | — | `crm.create_booking` | +| `close` | `/wiki/contacts/**`, `/wiki/preparation/**` | — | + +Поле «источники базы знаний» (`wiki_sources`) имеет смысл определять на уровне шага, а не только ветки. Ветка задаёт значения по умолчанию, шаг может их сузить или расширить. + +--- + +## 4. Условия выхода: динамическая маршрутизация + +### 4.1 Жёсткий переход в другую ветвь (hard handoff) + +Каждая ветка знает не только **как вести разговор**, но и **когда из него выйти**. В системный промпт ветки зашивается блок «условий выхода» (exit conditions): + +> Если в любой момент пациент упоминает операцию, наркоз, стационар, удаление гланд, септопластику, стапедопластику — прекрати скрипт записи и выдай служебный сигнал: `[INTENT_CHANGE: escalate_human]` с `reason=surgery`. + +Когда оркестратор видит такой сигнал в ответе модели: + +1. **Останавливает текущую ветку.** +2. **Сохраняет текущее состояние** как `suspended_intent` + `resumable_step` + `resumable_slots` (см. 4.4). +3. **Передаёт всю историю** в роутер. +4. **Запускает новую ветку** — бесшовно для пользователя. + +Полный разбор жёсткого перехода с возвратом — в `02_price_during_booking.md`. + +### 4.2 Мягкая вставка (soft insertion) — боковой вопрос без выхода из ветки + +Не каждое отклонение от темы — это переход в другую ветвь. Частый случай: пациент посреди записи спрашивает «а сколько это стоит?» или «где вы находитесь?». Это не смена темы, это короткий параллельный вопрос, после которого нужно продолжить скрипт записи с того же шага. + +Различение: + +- **Мягкая вставка** (soft insertion) — на вопрос можно ответить *одной репликой* без запуска собственной машины состояний. Цена услуги, адрес, длительность приёма, требования к документам. Ветка отвечает сама, поле `current_step` не меняется. +- **Жёсткий переход** (hard handoff) — вопрос сам по себе требует процесса (перенос существующей записи, запись другого человека, хирургия). Полный выход к роутеру. + +Практически: ветка `new_booking` имеет *read-only* доступ к RAG-срезам `price` и `info`, и в её промпте прописано правило: «короткие боковые вопросы отвечай сам, не покидая шаг». Модели этого обычно достаточно; если правило проскакивает — двойной прогон роутера на следующей реплике поймает ошибку. + +Сравнение мягкой вставки и жёсткого перехода на одном и том же сценарии — в `02_price_during_booking.md`. + +### 4.3 Защита от петель: `handoff_count` + +Без ограничения легко получить **цикл маршрутизации** (routing loop) — «`booking → price → booking → price → ...`» на несогласованных промптах. Поэтому в `thread_state` заводится счётчик: + +- `handoff_count` инкрементится при каждом жёстком переходе. +- Кап — 2–3 переключения за сессию. +- При превышении — автоматическая маршрутизация в `escalate_human` с `reason=routing_loop`. + +Это дешёвая страховка, которая окупается на первом же багованном промпте. + +### 4.4 Возобновление после перехода: `suspended_intent` + `resumable_state` + +Если ветка вышла по soft-handoff'у для короткого ответа — ок, через мгновение продолжает. Если произошёл жёсткий переход и боковая (detour) ветка закрылась — пациент часто возвращается к исходной задаче. Пример: + +- Пациент в `new_booking` на шаге `offer_time`. +- Переспросил про цену — ушли в `price_question`. +- Получил ответ, говорит «ок, тогда бронируем на четверг». +- Должен вернуться в `new_booking` на шаг `offer_time`, не в `intro`. + +Для этого при выходе из ветки в `thread_state` сохраняются: + +```json +{ + "current_intent": "price_question", + "current_step": null, + "suspended_intent": "new_booking", + "resumable_step": "offer_time", + "resumable_slots": { "...копия слотов new_booking..." } +} +``` + +Роутер, приняв решение о возврате, восстанавливает `current_intent` из `suspended_intent`, `current_step` из `resumable_step`, слоты — из `resumable_slots`. Полный диалог с разбором изменений `thread_state` на каждом ходе — в `02_price_during_booking.md`. + +--- + +## 5. Передача человеку (escalation) + +Часть сценариев не заканчивается в боте — агент **маршрутизирует пациента в контакт-центр**. Важное отличие от «просто сбросить диалог» — система отдаёт оператору **полный контекст** (full handoff context): + +- Полную историю переписки. +- Распознанное намерение + причину эскалации (`reason` из списка `acute_pain` / `surgery` / `angry` / `explicit_request` / `routing_loop`). +- Собранные слоты, если они уже есть (ФИО, телефон, услуга, предпочитаемый врач). +- Флаг `suspended_intent`, если эскалация прервала другую ветку. + +Это превращает ассистента не в «фильтр перед оператором», а в инструмент **квалификации лида** (lead qualification). Дальнейшая маршрутизация (какому именно оператору, в какую очередь) — задача смежного разработчика при подключении каналов. + +--- + +## 6. RAG: коллекции на ветку или подписка ветки на разделы вики? + +Здесь два технически рабочих подхода с очень разными эксплуатационными свойствами. + +### Вариант А — отдельная коллекция на ветку + +(как описано в v1 и как было запланировано в Спринте 6.) + +- Каждая ветка имеет собственную **векторную коллекцию** (vector collection) в Chroma. +- Загрузка документа требует выбора ветки. +- Поле `collection_name` в `intents`. +- **Плюсы:** жёсткая изоляция по умолчанию, простой query-путь. +- **Минусы:** дублирование (одна статья вики часто нужна нескольким веткам); лишнее решение на каждый upload; сложнее поддерживать при росте вики. + +### Вариант Б — одна коллекция + подписка ветки на разделы + +- Одна общая Chroma-коллекция `clinic_wiki`. +- В таблице `intents` поле «источники» (`wiki_sources: list[str]`) — список префиксов путей или набор идентификаторов документов (document ids). +- **Поисковик-ретривер** (retriever) применяет **фильтр по метаданным** (metadata filter, where-filter): `doc_path STARTS WITH any(...)`. +- Один документ, нужный нескольким веткам, перечисляется в `wiki_sources` нескольких веток — физического дублирования нет. +- **Плюсы:** структура вики = единый источник истины (single source of truth); новая страница в `/wiki/pricing/` автоматически попадает в `price_question` без правок конфига; операторы и так ведут вики — не добавляется отдельный процесс тегирования. +- **Минусы:** требует дисциплины в структуре папок вики. + +**Рекомендация для проекта — Вариант Б.** Причина: вики у клиники уже атомарная, регулярно обновляемая, с осмысленной структурой. Добавлять поверх неё тегирование чанков или физическую фрагментацию по коллекциям — это второй слой, который будет расходиться с первым. При Варианте Б «источник правды» один — сама вика. + +### Дополнительно: `wiki_sources` на уровне шага + +Внутри ветки `new_booking` разным шагам нужны разные срезы (см. 3.4). Это решается тем, что поле `wiki_sources` существует на двух уровнях: + +- на `intents` — значения по умолчанию для ветки; +- на шаге машины состояний — уточнение/сужение для конкретного состояния. + +--- + +## Что это меняет в данных + +Сейчас в БД: + +- `threads`, `messages` — диалоги (Спринт 2). +- `agent_configs` — один активный системный промпт на всё (Спринт 3). +- `intents` — справочник веток (Спринт 4). + +После полного перехода на графовую архитектуру понадобится: + +- **`intents`** — добавить поле `wiki_sources: list[str]` для Варианта Б мульти-RAG. +- **`agent_configs`** — привязан к `intent_id`, у каждой ветки свой активный промпт и свои условия выхода (уже заложено в Спринте 4). +- **`thread_state`** — текущее состояние треда: + - `thread_id` (PK, FK) + - `current_intent` + - `current_step` + - `slots` (JSON) + - `handoff_count` (int, default 0) — счётчик переключений, защита от петель. + - `suspended_intent` (nullable) — отложенное намерение, ветка, из которой вышли по жёсткому переходу. + - `resumable_step` (nullable) — шаг в `suspended_intent`, куда возвращаться. + - `resumable_slots` (JSON, nullable) — слоты той ветки. + - `updated_at`. +- **Машина состояний на ветке** — для `new_booking` справочник шагов + допустимых переходов (может быть в коде или в БД, на старте достаточно в коде). +- **`routing_log`** (опционально) — лог решений роутера: намерение, срабатывание условия выхода, инкремент `handoff_count`. Нужен для отладки и тюнинга. + +--- + +## Что это меняет в UI + +- «Настройки агента» — настройки веток: слева список веток, справа редактор промпта и условий выхода для выбранной ветки. Для веток с машиной состояний — дополнительная вкладка со списком шагов и их промптами. +- В «Песочнице» отладочная панель показывает: **текущее намерение** (current_intent), **шаг машины состояний** (current_step), **собранные слоты** (slots), **счётчик переключений** (handoff_count), **отложенное намерение** (suspended_intent), если есть, и **историю переходов между ветками** (handoff history) в рамках треда. +- «Сценарии» (Спринт 7) прогоняют не только диалог, но и проверяют: правильно ли роутер классифицировал намерение на каждой реплике, корректно ли сработали условия выхода, восстановилось ли состояние после боковой ветки. + +--- + +## 7. Eval-набор нужен до Спринта 5 + +В плане Спринт 7 — полноценная подсистема сценариев. Это правильная цель, но реализация bouncing'а в Спринте 5 требует минимального **набора оценочных кейсов** (eval set, evaluation set) уже на входе. Иначе реализуем переход «на глазок», без способа понять, стало лучше или хуже после правки промпта. + +Минимум: + +- **Eval роутера.** 20–30 фраз на каждую ветку: типичные, пограничные (ловушечные), злые (опечатки, короткие, эмоциональные). Формат: CSV `фраза, ожидаемый_intent`. +- **Eval перехода.** 5–10 многошаговых мини-диалогов: намерение на реплике 1 → пациент сменил тему на реплике 2 → на реплике 3 проверяем, что ветка ушла на роутер и роутер правильно переключил. +- **Eval возобновления.** 3–5 сценариев: detour → возврат. Проверяем, что `current_step` восстановился из `resumable_step`. + +Реализация — короткий скрипт, прогоняющий набор через `/chat` и сравнивающий решения. Будет заменён полноценной подсистемой Спринта 7, но до этого закроет ~80% регрессий. + +--- + +## Открытые вопросы + +Часть вопросов из v1 закрылась на развороте 2026-04-23. Актуальный список: + +1. **Фреймворк оркестровки** — решено: пишем вручную на Python. LangGraph/n8n не берём. +2. **Роутер — отдельная модель** — отложено: пока DeepSeek через отдельный `RouterClient`, чтобы сменить модель в одном месте. Пересмотрим, когда вызовов станет много. +3. **Формат условий выхода** — текстом в промпте ветки + независимый прогон роутера на каждой реплике. Если реальный прогон покажет, что свободный текст пропускает случаи смены темы — добавим **структурированный список триггеров** (trigger list, keyword-match). +4. **Уверенность роутера (confidence score)** — не на первом спринте. После живого прогона посмотрим на реальные ошибки, и если их много — добавим **уточняющий вопрос** (clarifying question) при низкой уверенности. + +Новые вопросы после v2: + +5. **Момент обновления `current_step`.** Сразу после парсинга `state_after` из ответа модели, или после того как ответ успешно показан пациенту? При ошибке доставки состояние может разъехаться с тем, что пациент видел. +6. **Кап на мягкие вставки.** Если пациент крутит ассистенту пять побочных вопросов подряд, не продвигаясь по записи — это нормально или это сигнал «пациент не хочет записываться, эскалировать»? Нужен ли кап на число инлайн-ответов до возврата к шагу скрипта. +7. **Шаги записи — из вики или из головы.** Шесть шагов `new_booking` формализованы нами, но скрипт в вике формулирует их слегка иначе («контакт → уточнение → презентация приёма → 2 слота → запись → закрытие»). До реализации Спринта 5 — свериться с вики по конкретной первой целевой специальности (ЛОР?) и принять официальный список шагов. + +Вопрос из v1 про границу «бот vs. оператор по хирургии» — исключён из архитектурных открытых: это продуктовое решение (как у клиники устроен контакт-центр), не архитектурное, и на код не влияет — хирургия просто эскалируется с `reason=surgery`, а дальше смежный разработчик маршрутизирует в нужную очередь. + +--- + +## Ориентир на следующие спринты + +Логичный порядок (согласован с `SPRINTS.md`, Спринты 4–7): + +1. **Разделить «один промпт» на несколько** → сделано (Спринт 4). +2. **Добавить роутер** → сделано (Спринт 4). +3. **Машина состояний + условия выхода** → Спринт 5. +4. **Мульти-RAG** → Спринт 6. С учётом v3: дизайн пересмотреть в сторону Варианта Б (подписка на разделы вики). +5. **Сценарии и экспорт** → Спринт 7. С учётом v3: минимальный eval-набор сделать до Спринта 5, полный Спринт 7 реализовать позже. + +**Рекомендация v3 по Спринту 5:** разделить на 5a (переходы между ветками: условия выхода, двойной прогон роутера, `handoff_count`, `suspended_intent`) и 5b (машина состояний внутри `new_booking` с guard'ами, структурированный ответ модели, валидатор переходов, `wiki_sources` на уровне шага). Объём работ неравномерный: 5a — несколько дней, 5b — неделя+. Если слить, велик шанс получить «наполовину сделано, протестировать нечем». + +--- + +## Разобранные примеры + +Эти документы показывают архитектуру в работе на конкретных диалогах — реплика за репликой, с фиксацией того, что в этот момент происходит в `thread_state`, какое решение принял роутер, какой шаг машины состояний активен, что вернула модель в `state_after` и `slots_updated`. + +- [`01_basic_booking.md`](../examples/01_basic_booking.md) — happy path записи к ЛОР-врачу. Базовый случай, в котором всё идёт по линейному скрипту: контакт → уточнение → презентация → слоты → запись → закрытие. Показывает, как заполняются слоты, как меняется `current_step`, что видит модель на каждой реплике. + +- [`02_price_during_booking.md`](../examples/02_price_during_booking.md) — пациент в середине записи спрашивает про цену. Один и тот же сценарий разобран в двух вариантах: мягкая вставка (без выхода из ветки) и жёсткий переход с возвратом (через `suspended_intent` + `resumable_state`). Лучший пример для понимания различий между этими двумя механизмами. + +- [`03_child_patient_guard.md`](../examples/03_child_patient_guard.md) — запись ребёнка к врачу. Показывает срабатывание guard'а в шаге `qualify`, нелинейный путь по машине состояний, сбор данных законного представителя, юридические оговорки в шаге `close`. + +- [`04_general_info_simple.md`](../examples/04_general_info_simple.md) — простые информационные запросы (часы, адрес, проезд, контакты, документы, не-предоставляемые услуги). Самый дешёвый путь в системе: одна реплика, одна ветка `general_info`, один шаг `answer`, прямой ретривер → ответ → `done`. Логичная стартовая точка для запуска первой версии бота. + +--- + +## Changelog + +### v3 → 2026-04-26 + +**Стиль:** +- Все технические понятия, которые встречаются в коде, в промптах или в названиях полей БД, оформлены по схеме «русское объяснение + английский термин в скобках» при первом упоминании в разделе. Это позволяет читать документ без догадок: что русское слово, что код, что переменная. +- Добавлен раздел «Соглашение о терминах» в начале. + +**Ссылки на примеры:** +- В разделах 3, 4 и в новом разделе «Разобранные примеры» добавлены ссылки на четыре документа с пошаговыми разборами диалогов: `01_basic_booking.md`, `02_price_during_booking.md`, `03_child_patient_guard.md`, `04_general_info_simple.md` (последний — для простых одношаговых запросов общей информации, добавлен 2026-04-26 как стартовая точка для запуска). + +**Содержательно:** +- Без изменений. Все архитектурные решения, открытые вопросы, рекомендации по спринтам — те же, что в v2. + +### v2 → 2026-04-24 + +**Добавлено:** +- Раздел 3.2: guards внутри ветки `new_booking` (ребёнок, Ворончихина, сурдолог) — из анализа скрипта записи в вике. +- Раздел 3.3: структурированный выход модели `{reply, state_after, slots_updated}` и валидатор переходов в коде. +- Раздел 3.4: `wiki_sources` на уровне шага, а не только ветки. +- Раздел 4.2: мягкая вставка (soft-insertion) для боковых вопросов без выхода из ветки. +- Раздел 4.3: `handoff_count` с капом и автоматическим уходом в `escalate_human` с `reason=routing_loop`. +- Раздел 4.4: `suspended_intent` + `resumable_step` + `resumable_slots` для возврата в исходную ветку после detour'а. +- Раздел 6 (новый): две схемы мульти-RAG. Рекомендация — Вариант Б (одна коллекция + подписка ветки на разделы вики через `wiki_sources`). +- Раздел 7 (новый): eval-набор (20–30 фраз на ветку + handoff-сценарии) нужен до Спринта 5, а не в Спринте 7. +- Рекомендация разделить Спринт 5 на 5a/5b. + +**Исправлено:** +- Убрана «Хирургия» как отдельная ветка (была в v1). Актуальная модель (решение разворота 2026-04-23) — одна ветка `escalate_human` с полем `reason`: `acute_pain | surgery | angry | explicit_request | routing_loop`. +- Пример условия выхода переписан с `[INTENT_CHANGE: surgery]` на `[INTENT_CHANGE: escalate_human]` + `reason=surgery`. +- Список веток в разделе 1 приведён к шести (как в сиде Спринта 4). +- Открытый вопрос #4 из v1 (граница бота и оператора по хирургии) исключён из архитектурных — это продуктовый вопрос, на код не влияет. diff --git a/docs/examples/01_basic_booking.md b/docs/examples/01_basic_booking.md new file mode 100644 index 0000000..e9f94a2 --- /dev/null +++ b/docs/examples/01_basic_booking.md @@ -0,0 +1,386 @@ +# Пример 01 · Базовая запись к ЛОР-врачу (happy path) + +> Связано с [`GRAPH_ARCHITECTURE_v3.md`](../architecture/GRAPH_ARCHITECTURE_v3.md), разделы 1, 2, 3. Демонстрирует **линейный** проход машины состояний (state machine) ветки `new_booking`: контакт → уточнение → презентация → слоты → запись → закрытие. Никаких guard'ов, никаких боковых вопросов, никаких переходов в другие ветки. Самый простой случай, на котором удобно увидеть базовое поведение системы. + +## О чём этот пример + +Взрослый пациент звонит/пишет в чат, хочет записаться к ЛОР-врачу с жалобой на горло. Не уточняет конкретного врача, согласен на ближайший слот. Семь реплик, после которых запись создана в CRM. + +Цель — показать на каждой реплике: +- **решение роутера** (router) — какое намерение распознано; +- **активный шаг** (current_step) — где мы сейчас в скрипте; +- **работу поисковика-ретривера** (retriever) — какие чанки достаются и из каких разделов вики; +- **вызовы инструментов** (tool calls) к CRM, если они есть; +- **структурированный ответ модели** (structured output) — `reply`, `state_after`, `slots_updated`; +- **итоговое состояние треда** (thread_state) — что записалось в БД. + +## Начальное состояние + +Тред только что создан. В таблице `thread_state` записи ещё нет (или есть пустая): + +```json +{ + "thread_id": "T-9001", + "current_intent": null, + "current_step": null, + "slots": {}, + "handoff_count": 0, + "suspended_intent": null, + "resumable_step": null, + "resumable_slots": null +} +``` + +--- + +## Реплика 1 · «Здравствуйте, хочу записаться к лору» + +**Решение роутера:** `new_booking`. Однозначное упоминание записи + специальности. + +**Активная ветка / шаг:** `new_booking` / `intro`. Шаг по умолчанию для свежей ветки. + +**Ретривер:** не вызывается. Шаг `intro` имеет пустой `wiki_sources` — на приветствии модели нечего искать. + +**Вызовы инструментов:** нет. + +**Структурированный ответ модели:** + +```json +{ + "reply": "Здравствуйте! С удовольствием помогу вам записаться. Подскажите, пожалуйста, как могу к вам обращаться?", + "state_after": "intro", + "slots_updated": { + "service_mention": "ЛОР" + } +} +``` + +Замечание: шаг **не меняется** — мы остаёмся в `intro`, пока не получим имя пациента. Но один слот заполнили заранее: модель распарсила «к лору» и положила это в `service_mention`, чтобы потом не переспрашивать. + +**`thread_state` после хода:** + +```json +{ + "thread_id": "T-9001", + "current_intent": "new_booking", + "current_step": "intro", + "slots": { + "service_mention": "ЛОР" + }, + "handoff_count": 0 +} +``` + +--- + +## Реплика 2 · «Анна Петровна» + +**Решение роутера:** `new_booking`. Намерение не сменилось. + +**Активная ветка / шаг:** `new_booking` / `intro`. Всё ещё ждём имя — и вот оно. + +**Ретривер:** не вызывается. + +**Вызовы инструментов:** нет. + +**Структурированный ответ модели:** + +```json +{ + "reply": "Очень приятно, Анна Петровна. Записать вас на первичный приём к ЛОР-врачу? Что именно беспокоит — есть конкретные жалобы?", + "state_after": "qualify", + "slots_updated": { + "patient_name": "Анна Петровна" + } +} +``` + +Шаг переходит в `qualify` — имя получено, далее нужно уточнить услугу, врача, детали. + +**`thread_state` после хода:** + +```json +{ + "current_intent": "new_booking", + "current_step": "qualify", + "slots": { + "service_mention": "ЛОР", + "patient_name": "Анна Петровна" + } +} +``` + +--- + +## Реплика 3 · «Да, болит горло уже неделю» + +**Решение роутера:** `new_booking`. Жалоба упомянута, но в контексте записи — не самостоятельный медицинский вопрос. Роутер не сворачивает в `medical_question`, потому что пациент уже на маршруте записи. + +**Активная ветка / шаг:** `new_booking` / `qualify`. + +**Ретривер:** срез на уровне шага — `wiki_sources`: `['/wiki/services/**', '/wiki/doctors/**']`. + +- top-k: 3. +- Достались чанки: + - `services/первичный-приём-лор.md` (score 0.87) — описание услуги, длительность, что входит. + - `doctors/сушков-михаил-германович.md` (score 0.71) — карточка врача. + - `doctors/иванов-сергей-петрович.md` (score 0.69) — карточка второго ЛОР. + +**Вызовы инструментов:** нет (на этом шаге слоты ещё собираем, в CRM не лезем). + +**Структурированный ответ модели:** + +```json +{ + "reply": "Понимаю. На первичный приём ЛОР могу записать. Принимают двое: Сушков Михаил Германович и Иванов Сергей Петрович. Есть предпочтение, или подобрать ближайшее свободное время к любому?", + "state_after": "present", + "slots_updated": { + "service": "первичный_ЛОР", + "complaint": "боль в горле, неделя", + "is_child": false + } +} +``` + +Шаг переходит в `present` — услуга, жалоба, факт «не ребёнок» зафиксированы. Заметим: `is_child=false` модель определила сама из контекста (пациент назвался ФИО + жаловался от первого лица — взрослый), это **снимает срабатывание guard'а** для детской записи (см. `03_child_patient_guard.md`, где наоборот). + +**`thread_state` после хода:** + +```json +{ + "current_intent": "new_booking", + "current_step": "present", + "slots": { + "service_mention": "ЛОР", + "patient_name": "Анна Петровна", + "service": "первичный_ЛОР", + "complaint": "боль в горле, неделя", + "is_child": false + } +} +``` + +--- + +## Реплика 4 · «Без разницы, что есть» + +**Решение роутера:** `new_booking`. + +**Активная ветка / шаг:** `new_booking` / `present`. + +**Ретривер:** срез шага — `['/wiki/services/**', '/wiki/doctors/**', '/wiki/preparation/**']`. + +- top-k: 3. +- Достались чанки: + - `services/первичный-приём-лор.md` — длительность, что входит, что оплачивается отдельно. + - `preparation/лор-первичный.md` (score 0.74) — что принести, как подготовиться. + - `services/эндоскопия-лор.md` (score 0.63) — упоминание дополнительных процедур. + +**Вызовы инструментов:** нет. + +**Структурированный ответ модели:** + +```json +{ + "reply": "Хорошо. Первичный приём ЛОР занимает около 30 минут. Врач осмотрит, при необходимости проведёт эндоскопию (оплачивается отдельно). Готова посмотреть свободные слоты?", + "state_after": "offer_time", + "slots_updated": { + "doctor_preference": null + } +} +``` + +Шаг переходит в `offer_time`. Поле `doctor_preference: null` явно фиксируем — это «без разницы», а не «не уточнили». + +**`thread_state` после хода:** + +```json +{ + "current_intent": "new_booking", + "current_step": "offer_time", + "slots": { + "patient_name": "Анна Петровна", + "service": "первичный_ЛОР", + "complaint": "боль в горле, неделя", + "is_child": false, + "doctor_preference": null + } +} +``` + +--- + +## Реплика 5 · «Да, посмотрите» + +**Решение роутера:** `new_booking`. + +**Активная ветка / шаг:** `new_booking` / `offer_time`. + +**Ретривер:** на этом шаге используется только для боковых вопросов, в основном потоке не вызывается. + +**Вызовы инструментов:** + +```python +crm.get_slots( + service_code="primary_lor", + doctor=None, + days_ahead=7 +) +# → [ +# {"datetime": "2026-04-28 10:00", "doctor": "Сушков М. Г.", "branch": "ул. Клары Цеткин, 9"}, +# {"datetime": "2026-04-28 15:00", "doctor": "Сушков М. Г.", "branch": "ул. Клары Цеткин, 9"} +# ] +``` + +Согласно скрипту записи в вике — предлагаем **ровно два слота**, не больше. Это правило зашито в промпт шага `offer_time`. + +**Структурированный ответ модели:** + +```json +{ + "reply": "Есть свободно вторник 28 апреля: 10:00 или 15:00 — оба слота к Сушкову Михаилу Германовичу, ул. Клары Цеткин, 9. Какое удобнее?", + "state_after": "offer_time", + "slots_updated": { + "time_candidates": ["2026-04-28 10:00", "2026-04-28 15:00"], + "doctor": "Сушков М. Г.", + "branch": "ул. Клары Цеткин, 9" + } +} +``` + +Шаг **не меняется** — слоты предложили, но выбора пока нет, ждём. + +**`thread_state` после хода:** + +```json +{ + "current_intent": "new_booking", + "current_step": "offer_time", + "slots": { + "patient_name": "Анна Петровна", + "service": "первичный_ЛОР", + "complaint": "боль в горле, неделя", + "is_child": false, + "doctor_preference": null, + "time_candidates": ["2026-04-28 10:00", "2026-04-28 15:00"], + "doctor": "Сушков М. Г.", + "branch": "ул. Клары Цеткин, 9" + } +} +``` + +--- + +## Реплика 6 · «10 утра, давайте» + +**Решение роутера:** `new_booking`. + +**Активная ветка / шаг:** `new_booking` / `offer_time`. Получили выбор времени → допустим переход `offer_time → book`. + +**Ретривер:** не вызывается. + +**Вызовы инструментов:** + +```python +crm.create_booking( + patient_name="Анна Петровна", + service_code="primary_lor", + doctor="Сушков М. Г.", + datetime="2026-04-28 10:00", + branch="ул. Клары Цеткин, 9" +) +# → {"booking_id": "B-1234", "status": "confirmed"} +``` + +Модель в одном ходе проходит `book`, и сразу за ним — `close` (это легальный двойной переход, потому что `book` — это «вызови инструмент» без отдельной реплики пациенту, а `close` — финальное проговаривание). + +**Структурированный ответ модели:** + +```json +{ + "reply": "Записала вас на вторник 28 апреля, 10:00, к ЛОР-врачу Сушкову Михаилу Германовичу, ул. Клары Цеткин, 9. Стоимость первичного приёма — 2 300 рублей. Эндоскопия, лечебные процедуры, забор анализов оплачиваются дополнительно. Подходите за 5 минут до приёма, при себе иметь паспорт. Подтверждаете?", + "state_after": "close", + "slots_updated": { + "time_chosen": "2026-04-28 10:00", + "booking_id": "B-1234" + } +} +``` + +Шаг переходит в `close`. На этом шаге ретривер уже доставал бы `wiki_sources` `['/wiki/contacts/**', '/wiki/preparation/**']`, но в этом конкретном ответе модель использовала чанки, оставшиеся в контексте с предыдущих шагов (стоимость уже была в карточке услуги, требования к документам — в `preparation`). + +**`thread_state` после хода:** + +```json +{ + "current_intent": "new_booking", + "current_step": "close", + "slots": { + "patient_name": "Анна Петровна", + "service": "первичный_ЛОР", + "complaint": "боль в горле, неделя", + "is_child": false, + "doctor_preference": null, + "time_candidates": ["2026-04-28 10:00", "2026-04-28 15:00"], + "doctor": "Сушков М. Г.", + "branch": "ул. Клары Цеткин, 9", + "time_chosen": "2026-04-28 10:00", + "booking_id": "B-1234" + } +} +``` + +--- + +## Реплика 7 · «Да, подтверждаю. Спасибо!» + +**Решение роутера:** `new_booking` (или `general_info` — реплика короткая, но контекст ясен). + +**Активная ветка / шаг:** `new_booking` / `close`. + +**Ретривер:** не вызывается. + +**Вызовы инструментов:** нет. + +**Структурированный ответ модели:** + +```json +{ + "reply": "Отлично! Жду вас 28 апреля в 10:00. Если что-то изменится — напишите, поможем перенести. Хорошего дня!", + "state_after": "done", + "slots_updated": { + "confirmed": true + } +} +``` + +`done` — терминальное состояние. Тред не закрывается физически (пациент может вернуться), но активная машина состояний `new_booking` отработала. + +**`thread_state` после хода:** + +```json +{ + "current_intent": "new_booking", + "current_step": "done", + "slots": { + "...все поля выше...": "...", + "confirmed": true + } +} +``` + +--- + +## Что показал этот пример + +- **Линейный проход машины состояний.** Шаги шли строго в порядке `intro → qualify → present → offer_time → book → close → done`. Двойной переход в реплике 6 (`offer_time → book → close`) — допустимый случай, потому что `book` — технический шаг с вызовом инструмента, не требующий отдельной реплики пациенту. +- **Слоты накапливаются.** На каждом ходе `slots_updated` содержит только новые/изменённые поля, а в `thread_state.slots` они мерджатся с предыдущим состоянием. Старые значения не теряются. +- **Ретривер использует срез шага.** Один и тот же тред на разных шагах достаёт разные документы вики: на `qualify` — каталог услуг и врачей, на `close` — контакты и подготовку. +- **Намерение остаётся одним.** Роутер на каждой реплике подтверждал `new_booking`. В этом примере смены намерения нет — для случаев, где она происходит, см. `02_price_during_booking.md`. +- **Guard'ы не сработали.** Пациент — взрослый (`is_child=false`), врача не выбирал — ушли по основному пути. Случай со срабатыванием guard'а — в `03_child_patient_guard.md`. + +## Что важно проверять в eval-наборе на этом примере + +- Все шаги машины состояний были пройдены в правильном порядке (логи `state_after` на каждом ходе). +- В `slots` к моменту шага `book` были заполнены: `patient_name`, `service`, `doctor`, `time_chosen`. Без любого из этих полей `crm.create_booking` не должен вызываться (валидатор перехода). +- Ответ на шаге `close` содержит ровно тот набор данных из скрипта вики: дата, врач, адрес, стоимость, что принести. diff --git a/docs/examples/02_price_during_booking.md b/docs/examples/02_price_during_booking.md new file mode 100644 index 0000000..daa23f8 --- /dev/null +++ b/docs/examples/02_price_during_booking.md @@ -0,0 +1,284 @@ +# Пример 02 · Вопрос про цену в середине записи + +> Связано с [`GRAPH_ARCHITECTURE_v3.md`](../architecture/GRAPH_ARCHITECTURE_v3.md), разделы 4.1, 4.2, 4.4. Демонстрирует разницу между **мягкой вставкой** (soft insertion) и **жёстким переходом в другую ветвь** (hard handoff) на одном и том же сценарии. Также показывает работу полей `suspended_intent` и `resumable_step` при возврате в исходную ветку после боковой. + +## О чём этот пример + +Пациент находится посреди записи (шаг `offer_time`, два слота уже предложены). Прямо перед выбором времени он задаёт вопрос про деньги. Вариантов поведения системы здесь — два, и **они оба корректны**, в зависимости от того, как сформулирован вопрос: + +- **Вариант A.** «А сколько это стоит?» — короткий уточняющий вопрос. Ветка отвечает сама, не покидая шаг записи. Это **мягкая вставка** (soft insertion). +- **Вариант B.** «Стоп, я хочу разобраться с ценами и ДМС, у меня страховка ВТБ, что у вас по покрытию?» — это уже не короткий вопрос, а отдельный процесс с возможными подвопросами. Ветка отдаёт управление роутеру, тред уходит в `price_question`, потом возвращается в `new_booking` на тот же шаг. + +Ниже разбор обоих вариантов. + +## Общая отправная точка для обоих вариантов + +После пяти реплик из `01_basic_booking.md` пациент стоит на шаге `offer_time`. Состояние треда: + +```json +{ + "thread_id": "T-9002", + "current_intent": "new_booking", + "current_step": "offer_time", + "slots": { + "patient_name": "Анна Петровна", + "service": "первичный_ЛОР", + "complaint": "боль в горле, неделя", + "is_child": false, + "doctor_preference": null, + "time_candidates": ["2026-04-28 10:00", "2026-04-28 15:00"], + "doctor": "Сушков М. Г.", + "branch": "ул. Клары Цеткин, 9" + }, + "handoff_count": 0, + "suspended_intent": null, + "resumable_step": null, + "resumable_slots": null +} +``` + +Последняя реплика бота (реплика 5 из примера 01): «*Есть свободно вторник 28 апреля: 10:00 или 15:00 — оба слота к Сушкову Михаилу Германовичу, ул. Клары Цеткин, 9. Какое удобнее?*» + +--- + +# Вариант A · Мягкая вставка + +## Реплика 6A · «А сколько это стоит?» + +**Решение роутера:** `new_booking`. Это важный момент — роутер видит короткий вопрос про цену, но контекст разговора (5 реплик про запись) и формулировка («это стоит» — отсылка к обсуждаемому приёму) позволяют ему **остаться** в `new_booking`. Если бы пациент написал «расскажите про ваши цены» без контекста — роутер ушёл бы в `price_question`. + +**Активная ветка / шаг:** `new_booking` / `offer_time`. + +**Ретривер:** срез шага плюс **разрешённый боковой срез** (read-only sideload) — конфигурация шага `offer_time` разрешает подмешивать чанки из `/wiki/pricing/**` для коротких уточнений. + +- Фильтр: `doc_path STARTS WITH any('/wiki/services/**', '/wiki/pricing/**')`. +- top-k: 3. +- Достались чанки: + - `pricing/first-visit-lor.md` (score 0.91) — стоимость первичного ЛОР. + - `pricing/dms-coverage.md` (score 0.62) — общие условия ДМС. + - `services/первичный-приём-лор.md` (score 0.74) — описание услуги (уже в контексте). + +**Вызовы инструментов:** нет. + +**Структурированный ответ модели:** + +```json +{ + "reply": "Первичный приём ЛОР — 2 300 рублей. Эндоскопия, лечебные процедуры, забор анализов оплачиваются дополнительно. Возвращаемся к слотам — 10:00 или 15:00 удобнее?", + "state_after": "offer_time", + "slots_updated": {} +} +``` + +Ключевое: **шаг не меняется**, **слоты не обновляются**, **`handoff_count` не инкрементится**. Ветка ответила в-line и в той же реплике вернула пациента к выбору времени. + +**`thread_state` после хода:** идентично состоянию до хода. Изменилась только история сообщений (`messages`), но `thread_state` — нет. + +## Реплика 7A · «10 утра» + +Дальше всё как в `01_basic_booking.md`, реплика 6: вызов `crm.create_booking`, переход в `close`, проговаривание итогов. + +## Что показал вариант A + +- Мягкая вставка — это **лёгкий механизм без побочных эффектов**: ни тред-стейт, ни счётчики не меняются, кроме истории сообщений. +- За счёт того, что шаг `offer_time` имеет настроенный «разрешённый боковой срез» по pricing, ретривер находит нужный чанк, не покидая основной поиск. +- В промпте ветки `new_booking` явно прописано правило: «короткие вопросы про цену, адрес, длительность — отвечай в-line и сразу возвращай к текущему шагу скрипта». Без этого правила модель скорее всего попыталась бы написать целый монолог про цены и ушла от слотов. + +--- + +# Вариант B · Жёсткий переход в другую ветвь и возврат + +## Реплика 6B · «Стоп, у меня вопросы по стоимости. У меня ДМС "ВТБ", вы их принимаете? Что покрывает?» + +**Решение роутера:** `price_question`. Здесь: +- Явный сигнал «стоп, у меня вопросы» — это сознательная пауза в записи. +- Конкретный страховщик упоминается → нужен поиск по партнёрам ДМС, это не короткое уточнение. +- Тон — переключение, не побочный вопрос. + +Роутер возвращает: `intent_code = price_question`. Оркестратор сравнивает с `current_intent` (`new_booking`) и видит расхождение → инициирует жёсткий переход. + +**Шаги оркестратора в одном ходе:** + +1. **Получает структурированный ответ от текущей ветки.** Ветка `new_booking` тоже видит смену темы (двойная защита из раздела 1 v3) и возвращает: + + ```json + { + "reply": "", + "signal": "[INTENT_CHANGE: price_question]", + "state_after": "offer_time" + } + ``` + +2. **Сохраняет состояние ветки-донора** в `thread_state`: + + ```json + { + "suspended_intent": "new_booking", + "resumable_step": "offer_time", + "resumable_slots": { + "patient_name": "Анна Петровна", + "service": "первичный_ЛОР", + "complaint": "боль в горле, неделя", + "is_child": false, + "doctor_preference": null, + "time_candidates": ["2026-04-28 10:00", "2026-04-28 15:00"], + "doctor": "Сушков М. Г.", + "branch": "ул. Клары Цеткин, 9" + } + } + ``` + +3. **Инкрементит счётчик переключений:** `handoff_count: 0 → 1`. (Кап — 2; на следующем переключении ещё ок, на третьем — автоматическая эскалация в `escalate_human` с `reason=routing_loop`, см. v3 раздел 4.3.) + +4. **Запускает ветку `price_question`** с полной историей и пустым `current_step` (у этой ветки нет машины состояний — она линейная, отвечает сразу). + +**`thread_state` после шагов оркестратора, до ответа модели:** + +```json +{ + "current_intent": "price_question", + "current_step": null, + "slots": {}, + "handoff_count": 1, + "suspended_intent": "new_booking", + "resumable_step": "offer_time", + "resumable_slots": { "...сохранённые слоты new_booking..." } +} +``` + +Заметим, что `slots` ветки `price_question` начинается с нуля — слоты записи изолированы в `resumable_slots`. Это важно: если пациент в `price_question` назовёт ФИО, оно должно положиться в *её* слоты, не смешиваясь со слотами записи. + +**Ретривер:** +- Срез ветки — `wiki_sources: ['/wiki/pricing/**', '/wiki/dms-partners/**', '/wiki/promotions/**']`. +- top-k: 5 (для ценовых ответов настроено больше). +- Достались чанки: + - `dms-partners/vtb.md` (score 0.94) — что покрывает ДМС ВТБ, какие услуги, лимиты. + - `pricing/first-visit-lor.md` (score 0.71) — стоимость для самооплаты. + - `dms-partners/general-rules.md` (score 0.65) — общие правила работы по ДМС. + +**Вызовы инструментов:** нет. + +**Структурированный ответ модели:** + +```json +{ + "reply": "Да, ДМС «ВТБ Страхование» мы принимаем. По вашему полису первичный приём ЛОР покрывается полностью, без доплаты. Эндоскопия — по медицинским показаниям, согласовывается со страховой через нашего администратора. Лечебные процедуры — частично, зависит от программы (уточнит координатор перед приёмом). При себе нужно иметь паспорт и полис ДМС. Если хотите — продолжим оформление записи?", + "state_after": null, + "slots_updated": { + "dms_provider": "ВТБ Страхование" + } +} +``` + +**`thread_state` после хода:** + +```json +{ + "current_intent": "price_question", + "current_step": null, + "slots": { + "dms_provider": "ВТБ Страхование" + }, + "handoff_count": 1, + "suspended_intent": "new_booking", + "resumable_step": "offer_time", + "resumable_slots": { "...сохранённые слоты new_booking..." } +} +``` + +## Реплика 7B · «Понятно, спасибо. Тогда давайте всё-таки запишемся, на 10 утра» + +**Решение роутера:** `new_booking`. Чёткий сигнал возврата к записи + конкретное время. + +**Шаги оркестратора:** + +1. Видит, что `current_intent=price_question`, новое решение роутера = `new_booking`. +2. Проверяет `suspended_intent` — оно равно `new_booking`. **Это и есть путь возврата**, не новый переход. +3. Восстанавливает `current_intent ← suspended_intent`, `current_step ← resumable_step`, `slots ← resumable_slots`. +4. Очищает поля сохранения: `suspended_intent=null`, `resumable_step=null`, `resumable_slots=null`. +5. **Не инкрементит** `handoff_count` — это возврат, а не новое переключение. +6. Дополнительно: переносит из `price_question.slots` в восстановленные `new_booking.slots` те поля, которые могут быть полезны (в нашем случае `dms_provider`). Это делается явным mapping'ом в коде, не автоматически. + +**`thread_state` после восстановления, перед вызовом ветки:** + +```json +{ + "current_intent": "new_booking", + "current_step": "offer_time", + "slots": { + "patient_name": "Анна Петровна", + "service": "первичный_ЛОР", + "complaint": "боль в горле, неделя", + "is_child": false, + "doctor_preference": null, + "time_candidates": ["2026-04-28 10:00", "2026-04-28 15:00"], + "doctor": "Сушков М. Г.", + "branch": "ул. Клары Цеткин, 9", + "dms_provider": "ВТБ Страхование" + }, + "handoff_count": 1, + "suspended_intent": null, + "resumable_step": null, + "resumable_slots": null +} +``` + +**Ретривер:** не вызывается (на шаге `offer_time` вне боковых вопросов). + +**Вызовы инструментов:** + +```python +crm.create_booking( + patient_name="Анна Петровна", + service_code="primary_lor", + doctor="Сушков М. Г.", + datetime="2026-04-28 10:00", + branch="ул. Клары Цеткин, 9", + dms_provider="ВТБ Страхование" # передаём, чтобы координатор знал +) +# → {"booking_id": "B-1235", "status": "confirmed"} +``` + +**Структурированный ответ модели:** + +```json +{ + "reply": "Записала вас на вторник 28 апреля, 10:00, к ЛОР-врачу Сушкову Михаилу Германовичу, ул. Клары Цеткин, 9. По вашему полису ДМС «ВТБ» приём покрыт. При себе паспорт и полис, подходите за 5 минут. Подтверждаете?", + "state_after": "close", + "slots_updated": { + "time_chosen": "2026-04-28 10:00", + "booking_id": "B-1235" + } +} +``` + +Шаг переходит в `close` — пациент вернулся ровно туда, где его ждали, без потери собранных слотов. Дополнительно ответ обогащён информацией про ДМС, потому что `dms_provider` теперь в слотах. + +## Что показал вариант B + +- **Жёсткий переход — это полноценное переключение ветки**, со всем что к этому прилагается: служебный сигнал из ветки, сохранение состояния, инкремент счётчика, изоляция слотов новой ветки. +- **Возврат — не «новый переход»**, а особый путь оркестратора: он сравнивает решение роутера с `suspended_intent` и, если они совпадают, восстанавливает состояние, не трогая `handoff_count`. Это критично — иначе диалог из 4 переходов и 4 возвратов засчитал бы 8 переключений и попал в кап. +- **Слоты могут обогащаться при возврате.** Если в боковой ветке выяснилась релевантная для исходной ветки информация (как `dms_provider` в этом примере), маппинг прописывается явно в коде. Не «автоматически слить всё», а контролируемое перетекание. + +--- + +## Когда — мягкая вставка, когда — жёсткий переход + +Решение принимает **роутер плюс ветка-донор** (двойная защита). На практике различение работает по таким признакам: + +| Признак | Мягкая вставка | Жёсткий переход | +|---------|----------------|-----------------| +| Длина вопроса | Короткий, точечный | Развёрнутый, с подвопросами | +| Контекст | Уточнение к текущему шагу | Запрос самостоятельного процесса | +| Маркеры в реплике | «а ещё», «а сколько», «и где» | «стоп», «подождите», «расскажите про» | +| Можно ли ответить одной репликой | Да | Нет, минимум 2-3 обмена | +| Меняет ли это `slots`/`current_step` | Нет | Да (полное переключение) | + +Ни одна модель и ни один роутер не сделают это безошибочно с первого захода. Для этого и нужна двойная защита: ветка имеет в промпте правило «короткие боковые отвечай сам», роутер на каждой реплике независимо классифицирует — если оба согласны, что переключения нет, остаёмся в ветке. Если ветка пропустила сигнал — роутер на следующей реплике увидит и переключит. + +## Что важно проверять в eval-наборе на этом примере + +- **Soft-insertion не должен менять `current_step`**. Тест: на шаге `offer_time` подать «а сколько стоит» → проверить, что `state_after == "offer_time"`, `slots_updated` пустой, `handoff_count == 0`. +- **Hard-handoff корректно сохраняет состояние**. Тест: на шаге `offer_time` подать «расскажите про ДМС» → проверить, что `suspended_intent == "new_booking"`, `resumable_step == "offer_time"`, `resumable_slots` содержит все слоты записи. +- **Возврат не инкрементит счётчик**. Тест: hard-handoff, потом «давайте записываться» → проверить, что `handoff_count` остался **1**, не стал **2**. +- **`dms_provider` при возврате попадает в слоты записи**. Тест: после возврата вызвать `crm.create_booking` и проверить, что параметр `dms_provider` был передан. Это проверка маппинга при возврате — частая точка регрессий. diff --git a/docs/examples/03_child_patient_guard.md b/docs/examples/03_child_patient_guard.md new file mode 100644 index 0000000..41edcc9 --- /dev/null +++ b/docs/examples/03_child_patient_guard.md @@ -0,0 +1,332 @@ +# Пример 03 · Запись ребёнка — защитное условие в шаге уточнения + +> Связано с [`GRAPH_ARCHITECTURE_v3.md`](../architecture/GRAPH_ARCHITECTURE_v3.md), разделы 3.2, 3.3. Демонстрирует **защитное условие** (guard) внутри шага `qualify` ветки `new_booking`. Когда модель определяет, что пациент — ребёнок (`is_child=true`), линейный путь машины состояний прерывается: переход `qualify → present` блокируется до тех пор, пока не будут собраны данные **законного представителя** (legal representative). Также показано, как эта особенность отражается на финальном шаге `close` — там добавляются юридические оговорки из вики. + +## О чём этот пример + +Мама записывает 8-летнего сына к ЛОР-врачу. Ситуация юридически регулируется: согласно ФЗ № 323-ФЗ «Об основах охраны здоровья граждан в РФ» приём ребёнка возможен только с участием законного представителя, и клиника обязана зафиксировать его данные на этапе записи. Скрипт записи в вике формулирует это как обязательный сбор ФИО и контактного телефона законного представителя. + +В терминах архитектуры это означает: внутри шага `qualify` есть **под-состояние** (sub-state) `qualify.legal_rep`, активирующееся при `is_child=true`. Модель не может вернуть `state_after: "present"`, пока в слотах не заполнены `legal_rep_full_name` и `legal_rep_phone` — валидатор перехода в коде это проверит и отклонит ход, если переход неправильный. + +## Начальное состояние + +```json +{ + "thread_id": "T-9003", + "current_intent": null, + "current_step": null, + "slots": {}, + "handoff_count": 0 +} +``` + +--- + +## Реплика 1 · «Здравствуйте, хочу записать ребёнка к лору» + +**Решение роутера:** `new_booking`. Упоминание «ребёнка» само по себе не сигнал эскалации — это норма для записи. + +**Активная ветка / шаг:** `new_booking` / `intro`. + +**Ретривер:** не вызывается. + +**Вызовы инструментов:** нет. + +**Структурированный ответ модели:** + +```json +{ + "reply": "Здравствуйте! Помогу записать ребёнка. Подскажите, как могу к вам обращаться, и как зовут ребёнка?", + "state_after": "intro", + "slots_updated": { + "service_mention": "ЛОР", + "is_child": true + } +} +``` + +Заметим: `is_child=true` модель устанавливает уже здесь, на основании слова «ребёнок». Это **раннее срабатывание флага** — и оно правильное, потому что определяет всю дальнейшую логику. Шаг при этом не меняется — ждём имена. + +**`thread_state` после хода:** + +```json +{ + "current_intent": "new_booking", + "current_step": "intro", + "slots": { + "service_mention": "ЛОР", + "is_child": true + } +} +``` + +--- + +## Реплика 2 · «Меня зовут Мария, ребёнка зовут Матвей, ему 8 лет» + +**Решение роутера:** `new_booking`. + +**Активная ветка / шаг:** `new_booking` / `intro`. + +**Ретривер:** не вызывается. + +**Структурированный ответ модели:** + +```json +{ + "reply": "Очень приятно, Мария. Записать Матвея на первичный приём к ЛОР-врачу? Что именно беспокоит?", + "state_after": "qualify", + "slots_updated": { + "patient_name": "Матвей", + "patient_age": 8, + "parent_first_name": "Мария" + } +} +``` + +Шаг переходит в `qualify`. Имя ребёнка — `patient_name` (это пациент, на чьё имя пойдёт запись), имя мамы временно фиксируем как `parent_first_name` — на следующем под-шаге его дополним до полного ФИО. + +**`thread_state` после хода:** + +```json +{ + "current_intent": "new_booking", + "current_step": "qualify", + "slots": { + "service_mention": "ЛОР", + "is_child": true, + "patient_name": "Матвей", + "patient_age": 8, + "parent_first_name": "Мария" + } +} +``` + +--- + +## Реплика 3 · «У него уши болят и плохо слышит на одно ухо» + +**Решение роутера:** `new_booking`. Жалоба упомянута, но в контексте записи — не самостоятельный медицинский вопрос. + +**Активная ветка / шаг:** `new_booking` / `qualify`. + +**Срабатывает guard** — при `is_child=true` обычный переход `qualify → present` заблокирован. Машина состояний ждёт под-состояние `qualify.legal_rep`. В промпте шага явно написано: «если `is_child=true` и не заполнены `legal_rep_full_name` + `legal_rep_phone` — соблюдай скрипт сбора данных представителя из вики». + +**Дополнительно срабатывает второй guard** — упоминание «плохо слышит на одно ухо» это сигнал на сурдолога. Из v3 раздел 3.2: «жалоба на слух без обследования у сурдолога → на шаге `present` предложить сурдолога». Этот guard сработает позже, когда дойдём до `present`. + +**Ретривер:** срез шага `qualify` — `wiki_sources: ['/wiki/services/**', '/wiki/doctors/**', '/wiki/legal/**']` (последний раздел добавлен для шага под-состояния `qualify.legal_rep`). + +- top-k: 3. +- Достались чанки: + - `legal/child-patient-rep-script.md` (score 0.93) — скрипт сбора данных законного представителя из вики. + - `legal/fz-323-rep.md` (score 0.78) — выдержка из ФЗ-323 для случаев «зачем вам это нужно». + - `services/первичный-приём-лор.md` (score 0.62). + +**Вызовы инструментов:** нет. + +**Структурированный ответ модели:** + +```json +{ + "reply": "Поняла, посмотрим. Прежде чем подобрать слот — для записи ребёнка нам по правилам клиники нужно зафиксировать данные законного представителя. Уточните, пожалуйста, ваше полное ФИО и контактный телефон.", + "state_after": "qualify.legal_rep", + "slots_updated": { + "complaint": "боль в ушах, ухудшение слуха на одно ухо" + } +} +``` + +Шаг переходит в под-состояние `qualify.legal_rep` — guard активен. Жалоба зафиксирована в слотах, но переход в `present` пока заблокирован. + +**`thread_state` после хода:** + +```json +{ + "current_intent": "new_booking", + "current_step": "qualify.legal_rep", + "slots": { + "service_mention": "ЛОР", + "is_child": true, + "patient_name": "Матвей", + "patient_age": 8, + "parent_first_name": "Мария", + "complaint": "боль в ушах, ухудшение слуха на одно ухо" + } +} +``` + +--- + +## Реплика 4 · «А зачем вам это нужно?» + +Возражение пациентки. Из вики: «Если возникает вопрос — А зачем вам это нужно? — вы можете объяснить, что это стандартная практика для обеспечения безопасности и контроля доступа к медицинским услугам ребенка...». + +**Решение роутера:** `new_booking`. + +**Активная ветка / шаг:** `new_booking` / `qualify.legal_rep`. + +**Ретривер:** тот же срез, чанк `legal/fz-323-rep.md` уже в контексте. + +**Структурированный ответ модели:** + +```json +{ + "reply": "Это стандартная практика — нужна для безопасности ребёнка и для того, чтобы мы могли с вами связаться, если в ходе приёма потребуется ваше согласие на процедуру. Согласно ФЗ № 323-ФЗ мы должны иметь возможность связаться с законным представителем в любой момент. Подскажите, пожалуйста, ваше полное ФИО и телефон.", + "state_after": "qualify.legal_rep", + "slots_updated": {} +} +``` + +Шаг и слоты не меняются — guard всё ещё блокирует выход. + +--- + +## Реплика 5 · «Хорошо. Иванова Мария Сергеевна, +79991234567» + +**Решение роутера:** `new_booking`. + +**Активная ветка / шаг:** `new_booking` / `qualify.legal_rep`. + +**Структурированный ответ модели:** + +```json +{ + "reply": "Спасибо, Мария Сергеевна. Записываю Матвея на первичный приём к ЛОР-врачу. Подберу ближайший свободный слот?", + "state_after": "present", + "slots_updated": { + "legal_rep_full_name": "Иванова Мария Сергеевна", + "legal_rep_phone": "+79991234567" + } +} +``` + +Guard выполнен — оба обязательных слота заполнены. Валидатор переходов в коде проверяет: переход `qualify.legal_rep → present` допустим **только если** `legal_rep_full_name` и `legal_rep_phone` заполнены. Условие выполняется, переход разрешён. + +**`thread_state` после хода:** + +```json +{ + "current_intent": "new_booking", + "current_step": "present", + "slots": { + "service_mention": "ЛОР", + "is_child": true, + "patient_name": "Матвей", + "patient_age": 8, + "parent_first_name": "Мария", + "complaint": "боль в ушах, ухудшение слуха на одно ухо", + "legal_rep_full_name": "Иванова Мария Сергеевна", + "legal_rep_phone": "+79991234567" + } +} +``` + +--- + +## Реплика 6 · «Да, давайте посмотрим, что есть» + +**Решение роутера:** `new_booking`. + +**Активная ветка / шаг:** `new_booking` / `present`. + +**Срабатывает второй guard** — на основании слота `complaint` (упоминание ухудшения слуха) промпт шага `present` должен предложить **сурдолога перед ЛОРом**. Это правило из вики и v3 раздел 3.2. + +**Ретривер:** срез шага — `wiki_sources: ['/wiki/services/**', '/wiki/doctors/**', '/wiki/preparation/**']`. + +- top-k: 3. +- Достались чанки: + - `services/приём-сурдолога.md` (score 0.88) — описание услуги. + - `services/первичный-приём-лор.md` (score 0.74). + - `doctors/общая-информация-сурдолог.md` (score 0.65). + +**Структурированный ответ модели:** + +```json +{ + "reply": "С учётом того, что есть жалоба на ухудшение слуха на одно ухо, я бы предложила сначала записать Матвея к нашему сурдологу — он проведёт обследование слуха, и уже с его заключением ЛОР сможет дать более точное лечение. Если вы согласны — подберу слот к сурдологу. Или, если хотите, всё равно идём сразу к ЛОРу?", + "state_after": "present", + "slots_updated": { + "guard_surdologist_suggested": true + } +} +``` + +Шаг не меняется — ждём решение мамы. Флаг `guard_surdologist_suggested` нужен, чтобы при следующей реплике не предлагать сурдолога повторно, если она откажется. + +--- + +## Реплика 7 · «Да, давайте сначала к сурдологу» + +**Решение роутера:** `new_booking`. + +**Активная ветка / шаг:** `new_booking` / `present` → `offer_time`. + +**Структурированный ответ модели:** + +```json +{ + "reply": "Хорошо. Подбираю свободные слоты к сурдологу.", + "state_after": "offer_time", + "slots_updated": { + "service": "приём_сурдолога", + "doctor_preference": null + } +} +``` + +Шаг переходит в `offer_time`. Услуга в слотах теперь — приём сурдолога, не ЛОР. + +--- + +## Реплика 8 · `crm.get_slots()` + предложение слотов + +(Аналогично `01_basic_booking.md` репликам 5-6, без особенностей. Получаем 2 слота, мама выбирает один, идём в `book` → `close`.) + +--- + +## Реплика 9 · Закрытие — финальная сводка с детскими оговорками + +**Активная ветка / шаг:** `new_booking` / `close`. + +**Срабатывает guard на шаге `close`:** при `is_child=true` промпт обязан включить специальный блок с юридическими оговорками из вики. Берётся из `legal/child-patient-final-script.md`. + +**Ретривер:** срез шага — `wiki_sources: ['/wiki/contacts/**', '/wiki/preparation/**', '/wiki/legal/**']`. + +- Достались чанки: + - `legal/child-patient-final-script.md` (score 0.95) — обязательный текст про сопровождение, паспорт + свидетельство о рождении, доверенность для других сопровождающих. + - `contacts/branch-klary-tsetkin-9.md` (score 0.81) — адрес, как доехать. + - `preparation/ребёнок-первичный.md` (score 0.71) — что взять с собой. + +**Структурированный ответ модели:** + +```json +{ + "reply": "Записала Матвея на четверг 30 апреля, 11:00, к сурдологу Кузнецовой Елене Игоревне, ул. Газеты Звезда, 31-А. Стоимость приёма — 2 100 рублей. Если в ходе приёма понадобится дополнительная диагностика, она оплачивается отдельно. Подходите за 5 минут до назначенного времени, при себе паспорт одного из родителей и свидетельство о рождении ребёнка. \n\nОбращаю внимание: на приём вместе с ребёнком должен прийти его законный представитель. Если в качестве сопровождающего лица будет кто-то другой (бабушка, дедушка), у него обязательно должен быть паспорт, доверенность от законного представителя, копия свидетельства о рождении и копия паспорта представителя. Форма доверенности — на сайте клиники.\n\nПодтверждаете?", + "state_after": "close", + "slots_updated": { + "time_chosen": "2026-04-30 11:00", + "doctor": "Кузнецова Е. И.", + "branch": "ул. Газеты Звезда, 31-А", + "booking_id": "B-1236" + } +} +``` + +Длинная финальная реплика с юридической оговоркой из ФЗ-323 — ровно так, как написано в скрипте записи в вике для детских пациентов. + +--- + +## Что показал этот пример + +- **Guard как изменение допустимых переходов.** Шаг `qualify` имеет два пути: обычный `qualify → present` (для взрослых, `is_child=false`) и через под-шаг `qualify.legal_rep → present` (для детей). Валидатор переходов в коде должен это учитывать, иначе модель сможет «прыгнуть» через `legal_rep` и переход состоится с пустыми слотами представителя. +- **Раннее срабатывание флага `is_child`.** Модель установила его уже на реплике 1, до получения имени. Это важно: если бы `is_child` появился только на реплике 2, сбор данных пошёл бы в неправильном порядке (сначала жалоба, потом данные представителя — пациентка успела бы рассказать симптомы, прежде чем мы попросили бы документы). +- **Несколько guard'ов в одной ветке.** На шаге `present` сработал второй guard (предложить сурдолога), на шаге `close` — третий (юридический текст для детей). Они независимы и могут срабатывать в одном диалоге. +- **Ретривер на каждом шаге достаёт релевантный для guard'а контент.** На `qualify.legal_rep` — раздел `/wiki/legal/`, на `close` — финальные юридические оговорки. Без правильного `wiki_sources` на уровне шага модель должна была бы «помнить» юридический текст из системного промпта — это плохо масштабируется. +- **Возражение «а зачем вам это нужно?» обрабатывается в-line, без выхода из guard'а.** На реплике 4 модель пояснила, оставаясь в `qualify.legal_rep`. Это похоже на мягкую вставку из примера 02, но внутри одного и того же шага, не между ветками. + +## Что важно проверять в eval-наборе на этом примере + +- **Без legal_rep слотов переход `qualify.legal_rep → present` не должен срабатывать.** Тест: подать в ветку модельный ответ с `state_after: "present"` при пустых `legal_rep_full_name` или `legal_rep_phone` → валидатор должен отклонить переход, состояние остаётся `qualify.legal_rep`, в логе предупреждение. +- **`is_child=true` устанавливается рано.** Тест: фраза «запишите ребёнка», без других слов → проверить, что `is_child=true` появляется в `slots_updated` уже на первой реплике. +- **На шаге `close` для ребёнка ответ обязан содержать упоминание свидетельства о рождении и доверенности.** Тест: прогнать сценарий с `is_child=true` → проверить, что финальный `reply` содержит подстроки «свидетельство о рождении» и «доверенность». Это простая проверка подстрокой, не нужен LLM-as-judge. +- **Guard сурдолога не срабатывает повторно.** Тест: после реплики, в которой бот предложил сурдолога, мама отказалась → проверить, что на следующей реплике бот не предлагает сурдолога снова (флаг `guard_surdologist_suggested` сделал своё дело). diff --git a/docs/examples/04_general_info_simple.md b/docs/examples/04_general_info_simple.md new file mode 100644 index 0000000..435a595 --- /dev/null +++ b/docs/examples/04_general_info_simple.md @@ -0,0 +1,966 @@ +# Пример 04 · Простые информационные запросы (general_info) + +> Связано с [`GRAPH_ARCHITECTURE_v3.md`](../architecture/GRAPH_ARCHITECTURE_v3.md), разделы 1, 2, 6. Демонстрирует **простейший случай**: ветка `general_info` без машины состояний — одна реплика пациента, ретривер, ответ, `done`. Никаких слотов, никаких tool calls, никаких guard'ов. Это та точка, с которой реально удобно запускать систему: классификатор + RAG + одна шаблонная ветка. + +## О чём этот пример + +Это сборник коротких самостоятельных диалогов по одному типу запросов — общая информация (часы, адрес, проезд, телефоны, что брать с собой, чего клиника не делает). У всех у них одна и та же траектория: + +1. **Роутер** классифицирует реплику как `general_info`. +2. Запускается ветка `general_info`, шаг `answer` (он же единственный). +3. **Ретривер** достаёт чанки из соответствующего раздела вики (`/wiki/contacts/**`, `/wiki/branches/**`, `/wiki/hours/**`, `/wiki/preparation/**`, `/wiki/services/_general/**`). +4. Модель формирует ответ строго по найденным чанкам. +5. `state_after = done`. + +Цель — на каждой реплике показать: +- **решение роутера** (router) — какое намерение распознано; +- **работу ретривера** (retriever) — какие чанки достаются и из каких разделов вики; +- **структурированный ответ модели** — `reply`, `state_after`, `slots_updated`; +- **итоговое состояние треда** (thread_state). + +Слоты у `general_info` практически не используются — одно поле `info_topic` модель проставляет, чтобы ретривер при последующих репликах в том же треде не лез в посторонние разделы. Если в ходе ответа возникает запрос на запись («а можно тогда записаться?»), это уже **жёсткий переход** в `new_booking` (см. примеры 05+). + +## Конфигурация ветки general_info + +```yaml +intent: general_info +steps: + answer: + wiki_sources: + - /wiki/contacts/** + - /wiki/branches/** + - /wiki/hours/** + - /wiki/preparation/** + - /wiki/services/_general/** + tools: [] + next: done + done: + wiki_sources: [] + tools: [] +``` + +## Начальное состояние (общее для всех примеров ниже) + +Перед каждым диалогом в `thread_state` пусто — это первый ход свежего треда: + +```json +{ + "thread_id": "T-9XXX", + "current_intent": null, + "current_step": null, + "slots": {}, + "handoff_count": 0, + "suspended_intent": null, + "resumable_step": null, + "resumable_slots": null +} +``` + +После ответа `current_intent = "general_info"`, `current_step = "done"`, и тред физически не закрывается — пациент может задать следующий вопрос. Если он задаст ещё один информационный вопрос, тот пройдёт по той же траектории заново (роутер его классифицирует, ветка отработает и снова уйдёт в `done`). + +--- + +# A · Часы работы + +## A.1 · «Во сколько вы открываетесь?» + +**Решение роутера:** `general_info`. Чёткий вопрос про режим работы, никаких других намерений. + +**Активная ветка / шаг:** `general_info` / `answer`. + +**Ретривер:** + +- Фильтр: `doc_path STARTS WITH any('/wiki/hours/**')`. +- top-k: 2. +- Достались чанки: + - `hours/clinic-czetkina-9.md` (score 0.93) — режим работы филиала на Клары Цеткин 9. + - `hours/clinic-pirogova.md` (score 0.71) — режим работы филиала «Пирогова». + +Модели достаточно первого чанка. Второй пригодится, если пациент уточнит про другой адрес. + +**Вызовы инструментов:** нет. + +**Структурированный ответ модели:** + +```json +{ + "reply": "Здравствуйте! Клиника на Клары Цеткин 9 открывается в 8:00 в будни и в 9:00 в выходные. Закрываемся в 21:00 в будни и в 18:00 в выходные. Если интересует другой филиал — подскажу отдельно.", + "state_after": "done", + "slots_updated": { + "info_topic": "hours" + } +} +``` + +**`thread_state` после хода:** + +```json +{ + "current_intent": "general_info", + "current_step": "done", + "slots": { + "info_topic": "hours" + } +} +``` + +--- + +## A.2 · «До скольки работает клиника на Цеткина?» + +**Решение роутера:** `general_info`. Конкретика про филиал — это всё ещё информационный вопрос, не запись. + +**Активная ветка / шаг:** `general_info` / `answer`. + +**Ретривер:** + +- Фильтр: `doc_path STARTS WITH any('/wiki/hours/**', '/wiki/branches/**')`. +- top-k: 2. +- Достались чанки: + - `hours/clinic-czetkina-9.md` (score 0.94) — режим работы. + - `branches/czetkina-9.md` (score 0.78) — карточка филиала с адресом. + +Реплика содержит «Цеткина» — в индексе совпадает по названию филиала, поэтому ретривер уверенно вытаскивает нужную карточку. + +**Вызовы инструментов:** нет. + +**Структурированный ответ модели:** + +```json +{ + "reply": "Клиника на Клары Цеткин 9 работает до 21:00 в будни и до 18:00 в выходные.", + "state_after": "done", + "slots_updated": { + "info_topic": "hours", + "branch_mention": "Клары Цеткин 9" + } +} +``` + +Заметим: `branch_mention` модель кладёт в слоты, чтобы при возможном следующем вопросе («а проезд как?») ретривер мог сразу дать чанк именно про этот филиал, а не про другой. + +**`thread_state` после хода:** + +```json +{ + "current_intent": "general_info", + "current_step": "done", + "slots": { + "info_topic": "hours", + "branch_mention": "Клары Цеткин 9" + } +} +``` + +--- + +## A.3 · «У вас по выходным открыто?» + +**Решение роутера:** `general_info`. + +**Активная ветка / шаг:** `general_info` / `answer`. + +**Ретривер:** + +- Фильтр: `doc_path STARTS WITH any('/wiki/hours/**')`. +- top-k: 2. +- Достались чанки: + - `hours/clinic-czetkina-9.md` (score 0.86). + - `hours/clinic-pirogova.md` (score 0.81). + +Здесь пациент не уточнил филиал, поэтому в ответе разумно перечислить оба. + +**Вызовы инструментов:** нет. + +**Структурированный ответ модели:** + +```json +{ + "reply": "Да, мы работаем и в субботу, и в воскресенье. На Клары Цеткин 9 — с 9:00 до 18:00, в Клинике Пирогова — с 9:00 до 17:00. Расписание конкретного врача может отличаться, при записи подскажу.", + "state_after": "done", + "slots_updated": { + "info_topic": "hours" + } +} +``` + +Замечание: фраза «расписание конкретного врача может отличаться» — это **зашитая в промпт ветки оговорка** (canned disclaimer), не результат RAG. Часы работы клиники и слоты конкретного врача — разные вещи, и модель должна на этом не зависать. + +**`thread_state` после хода:** + +```json +{ + "current_intent": "general_info", + "current_step": "done", + "slots": { + "info_topic": "hours" + } +} +``` + +--- + +## A.4 · «Завтра праздник, вы работаете?» + +**Решение роутера:** `general_info`. + +**Активная ветка / шаг:** `general_info` / `answer`. + +**Ретривер:** + +- Фильтр: `doc_path STARTS WITH any('/wiki/hours/**')`. +- top-k: 3. +- Достались чанки: + - `hours/clinic-czetkina-9.md` (score 0.74) — обычный режим. + - `hours/holidays-2026.md` (score 0.42) — праздничный календарь, низкий score (документ короткий, неточное совпадение по датам). + - `hours/clinic-pirogova.md` (score 0.71). + +Score праздничного чанка — ниже порога уверенности (0.50). По правилу шага: **если ни один чанк не покрывает запрос полностью — отвечаем шаблоном «уточним и перезвоним»**, а не выдумываем расписание праздников. + +**Вызовы инструментов:** нет. + +**Структурированный ответ модели:** + +```json +{ + "reply": "В обычные дни мы работаем с 8:00 до 21:00. В праздничные дни режим может меняться. Подскажите, пожалуйста, на какую дату вы планируете визит — я уточню у регистратуры и сообщу вам в течение дня.", + "state_after": "done", + "slots_updated": { + "info_topic": "hours", + "needs_followup": true + } +} +``` + +Слот `needs_followup: true` — сигнал для оператора в инбоксе, что тред нужно дозакрыть человеком. На дашборде такие треды попадают в очередь «требуется уточнение». + +**`thread_state` после хода:** + +```json +{ + "current_intent": "general_info", + "current_step": "done", + "slots": { + "info_topic": "hours", + "needs_followup": true + } +} +``` + +--- + +# B · Адрес и филиалы + +## B.1 · «Где вы находитесь?» + +**Решение роутера:** `general_info`. + +**Активная ветка / шаг:** `general_info` / `answer`. + +**Ретривер:** + +- Фильтр: `doc_path STARTS WITH any('/wiki/branches/**')`. +- top-k: 3. +- Достались чанки: + - `branches/_index.md` (score 0.88) — общий перечень филиалов. + - `branches/czetkina-9.md` (score 0.71). + - `branches/pirogova.md` (score 0.69). + +**Вызовы инструментов:** нет. + +**Структурированный ответ модели:** + +```json +{ + "reply": "У нас два адреса в Перми: ул. Клары Цеткин, 9 (основная клиника, ЛОР, сурдология, отоневрология) и Клиника Пирогова, ул. Героев Хасана 24 (терапия, аллергология, телемед). Подсказать, какой ближе к вам или какая специальность нужна?", + "state_after": "done", + "slots_updated": { + "info_topic": "branches" + } +} +``` + +Финальный встречный вопрос — лёгкий, без обязательного ответа. Если пациент проигнорирует, тред просто останется в `done`. Если ответит — пойдёт ещё один проход через роутера. + +**`thread_state` после хода:** + +```json +{ + "current_intent": "general_info", + "current_step": "done", + "slots": { + "info_topic": "branches" + } +} +``` + +--- + +## B.2 · «Какой у вас точный адрес на Цеткина?» + +**Решение роутера:** `general_info`. + +**Активная ветка / шаг:** `general_info` / `answer`. + +**Ретривер:** + +- Фильтр: `doc_path STARTS WITH any('/wiki/branches/**')`. +- top-k: 1. +- Достались чанки: + - `branches/czetkina-9.md` (score 0.96) — карточка филиала: адрес, ориентиры, вход. + +Высокий score, точный матч — достаточно одного чанка. + +**Вызовы инструментов:** нет. + +**Структурированный ответ модели:** + +```json +{ + "reply": "Адрес: г. Пермь, ул. Клары Цеткин, 9. Вход со стороны двора, ориентир — голубая вывеска «Клиника Ухо, Горло, Нос». Регистратура на первом этаже.", + "state_after": "done", + "slots_updated": { + "info_topic": "branches", + "branch_mention": "Клары Цеткин 9" + } +} +``` + +**`thread_state` после хода:** + +```json +{ + "current_intent": "general_info", + "current_step": "done", + "slots": { + "info_topic": "branches", + "branch_mention": "Клары Цеткин 9" + } +} +``` + +--- + +## B.3 · «Сколько у вас филиалов в городе?» + +**Решение роутера:** `general_info`. + +**Активная ветка / шаг:** `general_info` / `answer`. + +**Ретривер:** + +- Фильтр: `doc_path STARTS WITH any('/wiki/branches/**')`. +- top-k: 1. +- Достались чанки: + - `branches/_index.md` (score 0.90) — общий перечень. + +**Вызовы инструментов:** нет. + +**Структурированный ответ модели:** + +```json +{ + "reply": "В Перми у нас два филиала: основная клиника на ул. Клары Цеткин, 9, и Клиника Пирогова на ул. Героев Хасана, 24. Других филиалов и онлайн-клиник в других городах у нас нет — приём только в этих двух.", + "state_after": "done", + "slots_updated": { + "info_topic": "branches" + } +} +``` + +Замечание: пациенты иногда спрашивают «вы же есть в Москве?», путая клинику с одноимёнными. Ответ намеренно явно говорит «других филиалов нет» — чтобы не оставить надежду на не-существующее. + +**`thread_state` после хода:** + +```json +{ + "current_intent": "general_info", + "current_step": "done", + "slots": { + "info_topic": "branches" + } +} +``` + +--- + +## B.4 · «Как вас на Яндекс Картах искать?» + +**Решение роутера:** `general_info`. + +**Активная ветка / шаг:** `general_info` / `answer`. + +**Ретривер:** + +- Фильтр: `doc_path STARTS WITH any('/wiki/branches/**', '/wiki/contacts/**')`. +- top-k: 2. +- Достались чанки: + - `contacts/online-presence.md` (score 0.84) — карточка организации, ссылки на 2ГИС / Я.Карты. + - `branches/_index.md` (score 0.66). + +**Вызовы инструментов:** нет. + +**Структурированный ответ модели:** + +```json +{ + "reply": "На Яндекс Картах ищите по запросу «Клиника Ухо Горло Нос Пермь» — найдутся обе наших точки: на Клары Цеткин 9 и на Героев Хасана 24. Можно сразу проложить маршрут оттуда.", + "state_after": "done", + "slots_updated": { + "info_topic": "branches" + } +} +``` + +**`thread_state` после хода:** + +```json +{ + "current_intent": "general_info", + "current_step": "done", + "slots": { + "info_topic": "branches" + } +} +``` + +--- + +# C · Как добраться и парковка + +## C.1 · «На метро как до вас доехать?» + +**Решение роутера:** `general_info`. + +**Активная ветка / шаг:** `general_info` / `answer`. + +**Ретривер:** + +- Фильтр: `doc_path STARTS WITH any('/wiki/branches/**')`. +- top-k: 2. +- Достались чанки: + - `branches/czetkina-9.md` (score 0.62) — раздел «как добраться». + - `branches/pirogova.md` (score 0.59). + +Ни один чанк не упоминает метро — потому что **в Перми метро нет**. Ретривер вытащил то, что про транспорт ближе всего по теме. + +**Вызовы инструментов:** нет. + +**Структурированный ответ модели:** + +```json +{ + "reply": "В Перми метро нет, поэтому удобнее всего добираться на автобусе, троллейбусе или такси. До Клары Цеткин 9 — остановка «Площадь Восстания» (5 минут пешком), маршруты 14, 32, 36, 67. До Клиники Пирогова — остановка «Героев Хасана», маршруты 19, 27. Подсказать что-то конкретнее, если знаете, откуда поедете?", + "state_after": "done", + "slots_updated": { + "info_topic": "transit" + } +} +``` + +Это полезный пограничный кейс: вопрос содержит **ложное допущение** (метро). Промпт ветки требует мягко поправить пациента, не оставляя его в недоумении, и сразу предложить рабочую альтернативу. + +**`thread_state` после хода:** + +```json +{ + "current_intent": "general_info", + "current_step": "done", + "slots": { + "info_topic": "transit" + } +} +``` + +--- + +## C.2 · «Парковка у вас есть?» + +**Решение роутера:** `general_info`. + +**Активная ветка / шаг:** `general_info` / `answer`. + +**Ретривер:** + +- Фильтр: `doc_path STARTS WITH any('/wiki/branches/**')`. +- top-k: 2. +- Достались чанки: + - `branches/czetkina-9.md` (score 0.81) — раздел «парковка». + - `branches/pirogova.md` (score 0.78) — раздел «парковка». + +**Вызовы инструментов:** нет. + +**Структурированный ответ модели:** + +```json +{ + "reply": "Да, парковка есть на обоих филиалах. На Клары Цеткин 9 — небольшая бесплатная парковка во дворе клиники, обычно есть свободные места. У Клиники Пирогова — общая платная парковка торгового центра рядом, въезд с ул. Героев Хасана. Подсказать, какой филиал вам удобнее?", + "state_after": "done", + "slots_updated": { + "info_topic": "parking" + } +} +``` + +**`thread_state` после хода:** + +```json +{ + "current_intent": "general_info", + "current_step": "done", + "slots": { + "info_topic": "parking" + } +} +``` + +--- + +## C.3 · «Как пешком от центра дойти до Цеткина?» + +**Решение роутера:** `general_info`. + +**Активная ветка / шаг:** `general_info` / `answer`. + +**Ретривер:** + +- Фильтр: `doc_path STARTS WITH any('/wiki/branches/**')`. +- top-k: 1. +- Достались чанки: + - `branches/czetkina-9.md` (score 0.79) — раздел «как добраться» → подраздел «пешком». + +**Вызовы инструментов:** нет. + +**Структурированный ответ модели:** + +```json +{ + "reply": "От Комсомольского проспекта дойти можно за 12–15 минут: вниз по Сибирской до Клары Цеткин, направо до дома 9. Удобнее всего ориентироваться на Яндекс Карты — там же увидите маршрут.", + "state_after": "done", + "slots_updated": { + "info_topic": "transit", + "branch_mention": "Клары Цеткин 9" + } +} +``` + +**`thread_state` после хода:** + +```json +{ + "current_intent": "general_info", + "current_step": "done", + "slots": { + "info_topic": "transit", + "branch_mention": "Клары Цеткин 9" + } +} +``` + +--- + +# D · Контакты + +## D.1 · «Дайте телефон регистратуры» + +**Решение роутера:** `general_info`. + +**Активная ветка / шаг:** `general_info` / `answer`. + +**Ретривер:** + +- Фильтр: `doc_path STARTS WITH any('/wiki/contacts/**')`. +- top-k: 1. +- Достались чанки: + - `contacts/registratura.md` (score 0.95) — телефоны регистратуры по филиалам. + +**Вызовы инструментов:** нет. + +**Структурированный ответ модели:** + +```json +{ + "reply": "Регистратура на Клары Цеткин 9 — +7 (342) 204-00-00. Регистратура Клиники Пирогова — +7 (342) 204-67-16. Звонят с 8:00 до 21:00 в будни. Если удобнее, можно записаться прямо здесь — я помогу.", + "state_after": "done", + "slots_updated": { + "info_topic": "contacts" + } +} +``` + +Хвостовая фраза «можно записаться прямо здесь» — это soft-cross-sell, разрешённая в ветке `general_info`. Пациент свободен её проигнорировать; если откликнется — следующая реплика уйдёт в `new_booking` через роутера. + +**`thread_state` после хода:** + +```json +{ + "current_intent": "general_info", + "current_step": "done", + "slots": { + "info_topic": "contacts" + } +} +``` + +--- + +## D.2 · «У вас WhatsApp есть?» + +**Решение роутера:** `general_info`. + +**Активная ветка / шаг:** `general_info` / `answer`. + +**Ретривер:** + +- Фильтр: `doc_path STARTS WITH any('/wiki/contacts/**')`. +- top-k: 1. +- Достались чанки: + - `contacts/messengers.md` (score 0.93) — мессенджеры, в которых отвечает клиника. + +**Вызовы инструментов:** нет. + +**Структурированный ответ модели:** + +```json +{ + "reply": "Да, мы отвечаем в WhatsApp по номеру +7 (342) 204-00-00 — это тот же телефон регистратуры. Также есть Telegram: @ugn_clinic. Уведомления о приёме приходят на тот номер, который вы оставите при записи.", + "state_after": "done", + "slots_updated": { + "info_topic": "contacts" + } +} +``` + +**`thread_state` после хода:** + +```json +{ + "current_intent": "general_info", + "current_step": "done", + "slots": { + "info_topic": "contacts" + } +} +``` + +--- + +## D.3 · «А сайт у вас какой?» + +**Решение роутера:** `general_info`. + +**Активная ветка / шаг:** `general_info` / `answer`. + +**Ретривер:** + +- Фильтр: `doc_path STARTS WITH any('/wiki/contacts/**')`. +- top-k: 1. +- Достались чанки: + - `contacts/online-presence.md` (score 0.91). + +**Вызовы инструментов:** нет. + +**Структурированный ответ модели:** + +```json +{ + "reply": "Сайт клиники: ugn-clinic.ru. Там можно посмотреть прайс, расписание врачей и записаться. Если хотите — могу записать вас прямо сейчас, без перехода на сайт.", + "state_after": "done", + "slots_updated": { + "info_topic": "contacts" + } +} +``` + +**`thread_state` после хода:** + +```json +{ + "current_intent": "general_info", + "current_step": "done", + "slots": { + "info_topic": "contacts" + } +} +``` + +--- + +# E · Документы и подготовка к приёму + +## E.1 · «Что нужно с собой принести?» + +**Решение роутера:** `general_info`. + +**Активная ветка / шаг:** `general_info` / `answer`. + +**Ретривер:** + +- Фильтр: `doc_path STARTS WITH any('/wiki/preparation/**')`. +- top-k: 2. +- Достались чанки: + - `preparation/what-to-bring.md` (score 0.92) — общий список. + - `preparation/лор-первичный.md` (score 0.65) — для ЛОРа отдельно. + +**Вызовы инструментов:** нет. + +**Структурированный ответ модели:** + +```json +{ + "reply": "На приём возьмите паспорт (для пациентов с 14 лет) и, если есть, выписки или результаты предыдущих обследований по теме визита. Полис ОМС не нужен — клиника частная. Если есть конкретная специальность, к которой записываетесь, могу подсказать, что взять именно для неё.", + "state_after": "done", + "slots_updated": { + "info_topic": "preparation" + } +} +``` + +**`thread_state` после хода:** + +```json +{ + "current_intent": "general_info", + "current_step": "done", + "slots": { + "info_topic": "preparation" + } +} +``` + +--- + +## E.2 · «Полис ОМС у вас принимают?» + +**Решение роутера:** `general_info`. Это ещё не вопрос про цену в чистом виде — пациент уточняет принцип, не считает деньги. Но граница тонкая, см. замечание ниже. + +**Активная ветка / шаг:** `general_info` / `answer`. + +**Ретривер:** + +- Фильтр: `doc_path STARTS WITH any('/wiki/preparation/**', '/wiki/services/_general/**')`. +- top-k: 2. +- Достались чанки: + - `preparation/what-to-bring.md` (score 0.74) — там есть строка про ОМС. + - `services/_general/payment-options.md` (score 0.81) — формы оплаты, ДМС. + +**Вызовы инструментов:** нет. + +**Структурированный ответ модели:** + +```json +{ + "reply": "Нет, мы частная клиника и по полису ОМС не работаем. Принимаем оплату наличными, картой и переводом. По ДМС — работаем не со всеми страховыми, нужно уточнять по конкретной компании.", + "state_after": "done", + "slots_updated": { + "info_topic": "preparation" + } +} +``` + +Замечание: следующая реплика «а с какими страховыми работаете?» — это уже **жёсткий переход** в `price_question` (там есть отдельная ветвь про ДМС-покрытие). На текущей реплике мы аккуратно касаемся темы, но не углубляемся. + +**`thread_state` после хода:** + +```json +{ + "current_intent": "general_info", + "current_step": "done", + "slots": { + "info_topic": "preparation" + } +} +``` + +--- + +## E.3 · «Сыну 16, можно без меня прийти?» + +**Решение роутера:** `general_info`. Вопрос про порядок приёма несовершеннолетних — это организационный, не медицинский и не запись. + +**Активная ветка / шаг:** `general_info` / `answer`. + +**Ретривер:** + +- Фильтр: `doc_path STARTS WITH any('/wiki/preparation/**', '/wiki/_faq/**')`. +- top-k: 2. +- Достались чанки: + - `_faq/teenagers-without-parents.md` (score 0.94) — соответствует в точности. + - `preparation/what-to-bring.md` (score 0.61). + +Чанк `teenagers-without-parents.md` — это статья из раздела ЧаВо (см. источник `vrachi-kliniki-...md`, пункт «Если пациенту 16-17 лет»), индексированная в общий пул `general_info`. + +**Вызовы инструментов:** нет. + +**Структурированный ответ модели:** + +```json +{ + "reply": "С 15 лет подросток может прийти на приём один и сам подписать договор — нужен паспорт. Если родители смогут позже подъехать или прислать документы — это тоже подойдёт. Записать сына на удобное время?", + "state_after": "done", + "slots_updated": { + "info_topic": "preparation" + } +} +``` + +**`thread_state` после хода:** + +```json +{ + "current_intent": "general_info", + "current_step": "done", + "slots": { + "info_topic": "preparation" + } +} +``` + +--- + +# F · Что клиника не делает + +## F.1 · «У вас можно сделать бронхоскопию?» + +**Решение роутера:** `general_info`. По формальному признаку — пациент спрашивает про услугу. Но это не запись и не цена, это уточнение «делаете ли вы такое в принципе». Промпт роутера явно относит подобные формулировки к `general_info`, чтобы не запускать механизм записи на услугу, которой нет. + +**Активная ветка / шаг:** `general_info` / `answer`. + +**Ретривер:** + +- Фильтр: `doc_path STARTS WITH any('/wiki/services/_general/**')`. +- top-k: 2. +- Достались чанки: + - `services/_general/not-performed.md` (score 0.97) — список процедур, которые НЕ проводятся. + - `services/_general/scope.md` (score 0.62) — общий профиль клиники. + +Чанк `not-performed.md` собран из соответствующего раздела вики (см. источник «Процедуры, которые в клинике НЕ проводятся» в `skripty-...md`). Бронхоскопия там есть в явном виде. + +**Вызовы инструментов:** нет. + +**Структурированный ответ модели:** + +```json +{ + "reply": "Бронхоскопию у нас не делают — это исследование с эндоскопом по слизистым трахеи и бронхов, его нужно искать в специализированных пульмонологических центрах. Если вас беспокоит кашель или дыхание — мы можем записать к пульмонологу на консультацию, он уже подскажет, куда дальше.", + "state_after": "done", + "slots_updated": { + "info_topic": "scope_of_services" + } +} +``` + +Это **важный сценарий для honesty-eval'а**: модель не должна выдумывать «да, конечно, запишем» в надежде, что пациент придёт и разберётся на месте. Прямой отказ + полезная альтернатива. + +**`thread_state` после хода:** + +```json +{ + "current_intent": "general_info", + "current_step": "done", + "slots": { + "info_topic": "scope_of_services" + } +} +``` + +--- + +## F.2 · «Криодеструкцию миндалин делаете?» + +**Решение роутера:** `general_info`. + +**Активная ветка / шаг:** `general_info` / `answer`. + +**Ретривер:** + +- Фильтр: `doc_path STARTS WITH any('/wiki/services/_general/**')`. +- top-k: 1. +- Достались чанки: + - `services/_general/not-performed.md` (score 0.94). + +**Вызовы инструментов:** нет. + +**Структурированный ответ модели:** + +```json +{ + "reply": "Нет, криодеструкцию миндалин в нашей клинике не проводят. Если вас беспокоит хронический тонзиллит — можем записать на приём к ЛОР-врачу: он осмотрит, предложит варианты лечения из тех, что у нас доступны, и подскажет, куда обратиться, если понадобится именно эта процедура.", + "state_after": "done", + "slots_updated": { + "info_topic": "scope_of_services" + } +} +``` + +**`thread_state` после хода:** + +```json +{ + "current_intent": "general_info", + "current_step": "done", + "slots": { + "info_topic": "scope_of_services" + } +} +``` + +--- + +## F.3 · «Электрофорез носа можно у вас?» + +**Решение роутера:** `general_info`. + +**Активная ветка / шаг:** `general_info` / `answer`. + +**Ретривер:** + +- Фильтр: `doc_path STARTS WITH any('/wiki/services/_general/**')`. +- top-k: 1. +- Достались чанки: + - `services/_general/not-performed.md` (score 0.91) — электрофорез в списке «не проводится». + +**Вызовы инструментов:** нет. + +**Структурированный ответ модели:** + +```json +{ + "reply": "Нет, электрофорез у нас не делают. Из физиопроцедур по ЛОР-профилю мы предлагаем промывания, орошения, лечебные ингаляции — если интересно, могу подробнее рассказать или сразу записать на приём.", + "state_after": "done", + "slots_updated": { + "info_topic": "scope_of_services" + } +} +``` + +**`thread_state` после хода:** + +```json +{ + "current_intent": "general_info", + "current_step": "done", + "slots": { + "info_topic": "scope_of_services" + } +} +``` + +--- + +## Что показал этот пример + +- **Одна реплика — один проход.** Ветка `general_info` не имеет машины состояний: единственный «осмысленный» шаг `answer` сразу уводит в `done`. Это самый дешёвый путь в системе и логичная точка запуска первой версии бота. +- **Слоты почти не используются.** Поле `info_topic` нужно ровно для того, чтобы при следующем вопросе в том же треде ретривер знал, в какую сторону смотреть в первую очередь. Опционально — `branch_mention`, чтобы помнить, про какой филиал шла речь. +- **Ретривер делает основную работу.** Все факты в ответе должны быть из чанков, не из памяти модели. Если score лучшего чанка ниже порога — модель уходит в шаблон «уточним и перезвоним» с `needs_followup=true` (см. A.4). +- **Пограничные кейсы важнее, чем happy path.** Метро в Перми (которого нет — C.1), услуги, которых клиника не предоставляет (F), праздничные дни без чанка (A.4) — именно на них модель ломается чаще всего и именно их полезно держать в eval-наборе с самого начала. +- **Soft cross-sell разрешён, но мягкий.** Хвост «могу записать прямо сейчас» — нормальная практика для информационных ответов. Жёстко продавать запись — нет. + +## Что важно проверять в eval-наборе на этом примере + +- Роутер **не уводит** информационные вопросы в `new_booking` или `price_question` (классическая ошибка — на «сколько у вас стоит» в чистом виде это уже `price_question`, а на «какие у вас услуги» — `general_info`; границы должны быть чёткими). +- Все факты в `reply` находимы в одном из чанков, попавших в контекст. Хорошая метрика — `groundedness` (доля утверждений, для которых есть прямое подтверждение в источниках). +- При отсутствии релевантных чанков модель отвечает шаблоном «уточним», а не выдумывает (см. A.4 и логика `needs_followup`). +- Ответы не превышают 3–4 предложений. `general_info` — не место для лекций. +- Слот `info_topic` проставлен корректно (`hours`, `branches`, `transit`, `parking`, `contacts`, `preparation`, `scope_of_services`) — эта же таксономия используется в дашборде «о чём чаще всего спрашивают». +- На пограничных кейсах (метро в Перми, услуги, которых нет, праздничные дни) ответ не выдаёт ложных утверждений — это критичный безопасный минимум для запуска.