# Пример 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` содержит ровно тот набор данных из скрипта вики: дата, врач, адрес, стоимость, что принести.