# Пример 01 v2 · Базовая запись к ЛОР-врачу (happy path) > **Версия v2 · 2026-04-27.** Переписано под реальный код: имена полей `thread_state` соответствуют `db/models/thread_state.py`, набор слотов — реальной таксономии из `prompts/intents/new_booking/steps/*.md`. Изменения относительно v1 — внизу в Changelog. > > Связано с [`../architecture/GRAPH_ARCHITECTURE_v4.md`](../architecture/GRAPH_ARCHITECTURE_v4.md), разделы 1–3. Демонстрирует **линейный** проход машины состояний ветки `new_booking`: `intro → qualify → present → offer_time → book → close`. Никаких защитных условий, никаких боковых вопросов, никаких переключений в другие ветки. ## О чём этот пример Взрослый пациент пишет в чат, хочет записаться к ЛОР-врачу с жалобой на горло. Не уточняет конкретного врача, согласен на любое удобное время. Семь реплик, после которых ветка `new_booking` доходит до шага `close` и передаёт результат администратору клиники. На каждой реплике показано: - **решение маршрутизатора** (router) — какое намерение распознано; - **активная ветка / шаг** (`current_intent_code` / `current_step_code`) — где мы сейчас; - **структурированный ответ модели** (хвостовой блок `STATE_JSON:`) — `state_after` и `slots_updated`; - **итоговое состояние треда** (`thread_state`) — что после хода легло в БД. CRM-инструменты (`crm.get_slots`, `crm.create_booking`) и реальный календарь врачей **в этом коде ещё не подключены** — на шаге `book` модель только проговаривает собранные данные и получает явное «да», а на `close` сообщает, что администратор свяжется в течение дня. Реальная интеграция — в идеях на потом (см. `docs/SPRINTS.md`). ## Начальное состояние Тред только что создан. Записи в `thread_state` ещё нет — она появится после первого ответа ассистента. Логически это эквивалентно: ```json { "thread_id": 9001, "current_intent_code": null, "current_step": 0, "current_step_code": null, "slots": {}, "handoff_count": 0, "soft_insertion_count": 0, "suspended_intent": null, "resumable_step_code": null, "resumable_slots": null } ``` > Примечание: `slots` и `resumable_slots` хранятся в БД как текстовые колонки `slots_json` / `resumable_slots_json`. В API распакованы в объекты — так и показываем во всех `thread_state` ниже. --- ## Реплика 1 · «Здравствуйте, хочу записаться к лору» **Решение маршрутизатора:** `new_booking`. Однозначное упоминание записи + специальности. **Активная ветка / шаг:** `new_booking` / `intro`. Шаг по умолчанию для свежей пошаговой ветки. **Промпт шага `intro`** (`prompts/intents/new_booking/steps/intro.md`): представься, узнай имя пациента; не уточняй сразу повод, специальность, время. Слоты этого шага: `name`. **Структурированный ответ модели:** ``` Здравствуйте! Я виртуальный ассистент клиники, помогу вам записаться. Подскажите, как могу к вам обращаться? STATE_JSON: {"state_after": "intro", "slots_updated": {}} ``` Шаг **не меняется** — мы остаёмся в `intro`, пока не получили имя. Слоты пустые: специальность «лор» пациент назвал, но `intro.md` явно запрещает её фиксировать на этом шаге — это работа `qualify`. **`thread_state` после хода:** ```json { "current_intent_code": "new_booking", "current_step": 0, "current_step_code": "intro", "slots": {}, "handoff_count": 0, "soft_insertion_count": 0 } ``` --- ## Реплика 2 · «Анна Петровна» **Решение маршрутизатора:** `new_booking`. Намерение не сменилось. **Активная ветка / шаг:** `new_booking` / `intro`. Имя получено — переход в `qualify` разрешён (`allowed_next: ["intro", "qualify"]`). **Структурированный ответ модели:** ``` Очень приятно, Анна Петровна. Подскажите, что вас беспокоит — чтобы я подобрал подходящего специалиста? STATE_JSON: {"state_after": "qualify", "slots_updated": {"name": "Анна Петровна"}} ``` Шаг переходит в `qualify`. Числовой счётчик `current_step` инкрементируется (это монотонный счётчик хода, нужен для совместимости с историей версий схемы; для логики используется `current_step_code`). **`thread_state` после хода:** ```json { "current_intent_code": "new_booking", "current_step": 1, "current_step_code": "qualify", "slots": { "name": "Анна Петровна" }, "handoff_count": 0, "soft_insertion_count": 0 } ``` --- ## Реплика 3 · «Болит горло уже неделю» **Решение маршрутизатора:** `new_booking`. Жалоба упомянута, но в контексте записи — не самостоятельный медицинский вопрос. По промпту `qualify.md` модель **не уходит** в `medical_question` по одному факту жалобы — это повод для записи, а не для обсуждения симптомов. **Активная ветка / шаг:** `new_booking` / `qualify`. **Промпт шага `qualify`:** записать жалобу в `reason`, выразить короткое сочувствие, при отсутствии специалиста — мягко предложить направление по поводу. **Структурированный ответ модели:** ``` Понимаю, боль в горле — это неприятно. С такой жалобой обычно записывают к ЛОР-врачу. Подходит? STATE_JSON: {"state_after": "qualify", "slots_updated": {"reason": "боль в горле, неделю"}} ``` Шаг **не меняется** — `specialist` ещё не зафиксирован, ждём подтверждения. Слот `is_child` модель не выставляет (пациент назвал ФИО, говорит от первого лица — взрослый). **`thread_state` после хода:** ```json { "current_intent_code": "new_booking", "current_step": 1, "current_step_code": "qualify", "slots": { "name": "Анна Петровна", "reason": "боль в горле, неделю" }, "handoff_count": 0, "soft_insertion_count": 0 } ``` --- ## Реплика 4 · «Да, к ЛОРу» **Решение маршрутизатора:** `new_booking`. **Активная ветка / шаг:** `new_booking` / `qualify`. Известны `name`, `reason`, `specialist` — можно переходить в `present` (`allowed_next: ["qualify", "present"]`). **Структурированный ответ модели:** ``` Хорошо. Записываю вас к ЛОР-врачу — на приёме доктор осмотрит вас и обратит внимание на боль в горле. STATE_JSON: {"state_after": "present", "slots_updated": {"specialist": "ЛОР"}} ``` Шаг переходит в `present`. На самом шаге `present` (`prompts/intents/new_booking/steps/present.md`) новые слоты не собираются — модель использует уже известные `name`, `specialist`, `reason`, чтобы пациент почувствовал, что его услышали. **`thread_state` после хода:** ```json { "current_intent_code": "new_booking", "current_step": 2, "current_step_code": "present", "slots": { "name": "Анна Петровна", "reason": "боль в горле, неделю", "specialist": "ЛОР" }, "handoff_count": 0, "soft_insertion_count": 0 } ``` --- ## Реплика 5 · «Хорошо, давайте» **Решение маршрутизатора:** `new_booking`. **Активная ветка / шаг:** `new_booking` / `present`. Согласие пациента → переход в `offer_time` (`allowed_next: ["present", "qualify", "offer_time"]`). **Промпт шага `offer_time`:** спросить про удобные дни и часы, зафиксировать в `preferred_time`. Реального календаря нет — конкретные слоты не предлагаем. **Структурированный ответ модели:** ``` Отлично. Подскажите, в какое время вам удобнее — утро, день или вечер? Будни или выходные? STATE_JSON: {"state_after": "offer_time", "slots_updated": {}} ``` Шаг переходит в `offer_time`. Слотов на этом ходе не добавляется — ждём ответа пациента. **`thread_state` после хода:** ```json { "current_intent_code": "new_booking", "current_step": 3, "current_step_code": "offer_time", "slots": { "name": "Анна Петровна", "reason": "боль в горле, неделю", "specialist": "ЛОР" }, "handoff_count": 0, "soft_insertion_count": 0 } ``` --- ## Реплика 6 · «Удобнее утром в будни» **Решение маршрутизатора:** `new_booking`. **Активная ветка / шаг:** `new_booking` / `offer_time`. Получили `preferred_time` → переход в `book` (`allowed_next: ["offer_time", "book"]`). **Промпт шага `book`:** проговорить пациенту собранные данные (3–4 поля) и получить явное «да». **Структурированный ответ модели:** ``` Записываю: Анна Петровна, к ЛОР-врачу, повод — боль в горле, удобное время — утром в будни. Передам администратору клиники, он подберёт конкретное окно и подтвердит. Всё верно? STATE_JSON: {"state_after": "book", "slots_updated": {"preferred_time": "утром в будни"}} ``` Шаг переходит в `book`. Никаких CRM tool calls на этом шаге **сейчас не делается** — реальная интеграция с расписанием врачей пока не подключена. **`thread_state` после хода:** ```json { "current_intent_code": "new_booking", "current_step": 4, "current_step_code": "book", "slots": { "name": "Анна Петровна", "reason": "боль в горле, неделю", "specialist": "ЛОР", "preferred_time": "утром в будни" }, "handoff_count": 0, "soft_insertion_count": 0 } ``` --- ## Реплика 7 · «Да, всё верно» **Решение маршрутизатора:** `new_booking`. **Активная ветка / шаг:** `new_booking` / `book`. Явное «да» → можно переходить в `close` (`allowed_next: ["book", "qualify", "offer_time", "close"]`). **Промпт шага `close`:** короткое подтверждение, не задавать новых вопросов. **Структурированный ответ модели:** ``` Готово, Анна Петровна. Передаю администратору, он свяжется с вами в течение дня и подтвердит точное время. Спасибо за обращение! STATE_JSON: {"state_after": "close", "slots_updated": {"confirmed": true}} ``` Шаг переходит в `close`. Это финальный шаг ветки — `allowed_next: ["close"]`, дальше идти некуда. Тред физически не закрывается; если пациент напишет что-то ещё, маршрутизатор разберёт реплику и при необходимости откроет новую ветку (`general_info`, `reschedule` и т. п.). **`thread_state` после хода:** ```json { "current_intent_code": "new_booking", "current_step": 5, "current_step_code": "close", "slots": { "name": "Анна Петровна", "reason": "боль в горле, неделю", "specialist": "ЛОР", "preferred_time": "утром в будни", "confirmed": true }, "handoff_count": 0, "soft_insertion_count": 0 } ``` --- ## Что показал этот пример - **Линейный проход машины состояний.** Шаги шли строго в порядке `intro → qualify → present → offer_time → book → close`. Каждая реплика пациента = ровно один переход (или удержание на текущем шаге). Двойных переходов в одном ответе нет — `allowed_next` каждого шага это запрещает. - **Слоты накапливаются.** На каждом ходе `slots_updated` содержит только новые/изменённые поля, и они мерджатся с предыдущим состоянием в `thread_state.slots`. Старые значения не теряются. - **Маршрутизатор подтверждает ту же ветку.** На каждой реплике решение маршрутизатора совпадало с активной веткой `new_booking` — в Песочнице бейдж «решение маршрутизатора» был зелёным. - **Защитные условия не сработали.** Слот `is_child` отсутствует (пациент — взрослый) → guard `require_legal_rep` неактивен. Случай со срабатыванием — в `03_child_patient_guard_v2.md`. - **Никаких реальных tool calls.** На шагах `book` и `close` модель только проговаривает собранные данные, никаких записей в CRM или календарь — этой интеграции в коде ещё нет. ## Что важно проверять в eval-наборе на этом примере - Все шаги машины состояний пройдены в правильном порядке (логи `state_after` на каждом ходе). - К моменту шага `book` в `slots` заполнены `name`, `reason`, `specialist`, `preferred_time`. Без любого из этих полей переход в `book` не должен случаться (валидатор `allowed_next` его пропустит, но модель не должна стремиться туда без данных). - Слот `confirmed: true` появляется только на шаге `close` — это маркер успешно завершённой записи. - На реплике 3 («Болит горло уже неделю») маршрутизатор НЕ уходит в `medical_question` — жалоба в контексте записи это не самостоятельный медицинский вопрос. Это типовая ловушка для роутера. --- ## Changelog ### v2 → 2026-04-27 **Имена полей `thread_state`** приведены к реальной БД (`db/models/thread_state.py`): - `current_intent` → `current_intent_code` - `current_step` (как строка) → `current_step_code`. Числовое `current_step` (счётчик хода) показано отдельно, как оно реально хранится в БД. - Добавлен `soft_insertion_count` (был и в v1 неявно, теперь показан явно во всех `thread_state`). - `slots` и `resumable_slots` показаны как объекты с пометкой, что в БД они хранятся текстовыми колонками `slots_json` / `resumable_slots_json`. - `thread_id` показан как `int` (было `str` «T-9001»). **Слоты приведены к реальной таксономии** из `prompts/intents/new_booking/steps/*.md`: - Удалены вымышленные `service_mention`, `patient_name`, `service`, `complaint`, `doctor_preference`, `time_candidates`, `time_chosen`, `branch`, `booking_id`. - Используются только реальные: `name`, `reason`, `specialist`, `is_child` (опционально), `preferred_time`, `confirmed`. **Структурированный ответ модели** показан в реальном формате (`STATE_JSON:` в хвосте текста, который парсер вырезает), а не как отдельный JSON-объект. **Сценарий перестроен под реальные `allowed_next`:** - В v1 на реплике 6 была двойная склейка `offer_time → book → close` в одном ответе — это невозможно, модель выбирает один `state_after`. Теперь шаги идут по одному за реплику. - Реплик стало 7 (вместо 7), но сценарий другой: добавлена отдельная реплика про согласие с планом (`present → offer_time`). **Удалены CRM tool calls** (`crm.get_slots`, `crm.create_booking`) и связанные с ними `branch`, `booking_id` — этой интеграции в коде нет, в v1 они были как иллюстрация будущего. Переехало в идеи на потом в `SPRINTS.md`. **Терминология:** «роутер» → «маршрутизатор», «решение роутера» → «решение маршрутизатора» — выровнено со словарём в `static/docs.html`. **Содержательно** (что показывает пример) — то же: линейный happy path записи без защитных условий и переключений.