# Пример 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` был передан. Это проверка маппинга при возврате — частая точка регрессий.