# Пример 02 v2 · Вопрос про цену в середине записи > **Версия v2 · 2026-04-27.** Переписано под реальный код: имена полей `thread_state` соответствуют `db/models/thread_state.py`, набор слотов — реальной таксономии из `prompts/intents/new_booking/steps/*.md`. У ветки `price_question` нет ни шагов, ни слотов (`prompts/intents/price_question.md` — обычный отвечающий промпт), поэтому в варианте B после переключения `current_step_code = null`, `slots = {}` — никакого «маппинга `dms_provider`» из v1 здесь нет, этот механизм в коде не реализован. Изменения относительно v1 — внизу в Changelog. > > Связано с [`../architecture/GRAPH_ARCHITECTURE_v4.md`](../architecture/GRAPH_ARCHITECTURE_v4.md), разделы 4.1, 4.2, 4.4. Демонстрирует разницу между **боковым вопросом** (soft insertion) и **переключением ветки** (hard handoff) на одном и том же сценарии. Также показывает работу полей `suspended_intent`, `resumable_step_code`, `resumable_slots` при возврате в исходную ветку. ## О чём этот пример Пациент находится посреди записи (шаг `offer_time`, мы только что спросили про удобное время). Прямо перед ответом он задаёт вопрос про деньги. Вариантов поведения системы — два, и **они оба корректны**, в зависимости от того, как сформулирован вопрос: - **Вариант A.** «А сколько стоит приём?» — короткий уточняющий вопрос. Ветка `new_booking` отвечает сама, **не покидая шаг `offer_time`**. Это **боковой вопрос** (soft insertion), счётчик `soft_insertion_count` инкрементируется. - **Вариант B.** «А что у вас по ДМС? У меня ВТБ страхование» — это уже не короткое уточнение, а развёрнутый вопрос с подтемой. Ветка `new_booking` выдаёт служебный сигнал `[INTENT_CHANGE: price_question]`, оркестратор сохраняет состояние записи в `suspended_*` и запускает ветку `price_question`. После короткого ответа пациент возвращается к записи — состояние `new_booking` восстанавливается. Ниже разбор обоих вариантов. ## Общая отправная точка После пяти реплик из `01_basic_booking_v2.md` пациент стоит на шаге `offer_time` (мы только что спросили про удобное время). Состояние треда: ```json { "thread_id": 9002, "current_intent_code": "new_booking", "current_step": 3, "current_step_code": "offer_time", "slots": { "name": "Анна Петровна", "reason": "боль в горле, неделю", "specialist": "ЛОР" }, "handoff_count": 0, "soft_insertion_count": 0, "suspended_intent": null, "resumable_step_code": null, "resumable_slots": null } ``` Последняя реплика бота (реплика 5 из примера 01 v2): «*Отлично. Подскажите, в какое время вам удобнее — утро, день или вечер? Будни или выходные?*» --- # Вариант A · Боковой вопрос (soft insertion) ## Реплика 6A · «А сколько стоит приём ЛОРа?» **Решение маршрутизатора:** `new_booking`. Это важный момент — маршрутизатор видит короткий вопрос про цену, но контекст разговора (5 реплик про запись) и формулировка («приём ЛОРа» — отсылка к обсуждаемому приёму) позволяют ему остаться в `new_booking`. В Песочнице бейдж «решение маршрутизатора» — зелёный, совпадает с активной веткой. **Активная ветка / шаг:** `new_booking` / `offer_time`. **Поведение модели:** ветка `new_booking` видит, что вопрос вне сценария, но короткий и относится к текущему обсуждаемому приёму. По правилам ветки — отвечает в-line, ставит флаг `soft_insertion: true` и **остаётся на текущем шаге**. **Структурированный ответ модели:** ``` Первичный приём ЛОР-врача — 2 300 рублей. Возвращаемся к удобному времени: утро или вечер вам ближе? STATE_JSON: {"state_after": "offer_time", "slots_updated": {}, "soft_insertion": true} ``` Ключевое: - **Шаг не меняется** (`state_after == current_step_code`). - **Слоты не обновляются** (`slots_updated: {}`). - **Флаг `soft_insertion: true`** — оркестратор по этому признаку (плюс отсутствие изменений) увеличивает `soft_insertion_count` на 1. - **`handoff_count` не меняется**. В Песочнице на этом ответе появится бейдж **«тип ответа: боковой вопрос»** (жёлтый). **`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": 1 } ``` ## Реплика 7A · «Утром в будни» Дальше всё как в `01_basic_booking_v2.md`, реплика 6: переход в `book`, проговаривание, подтверждение, `close`. На переходе `offer_time → book` счётчик `soft_insertion_count` сбрасывается в 0 (он считает только подряд идущие боковые вопросы без движения шага). ## Что показал вариант A - Боковой вопрос — это **лёгкий механизм без побочных эффектов**: ни шаг, ни слоты, ни `handoff_count` не меняются. Сдвигается только `soft_insertion_count`. - Защита от «бесконечных боковых»: если `soft_insertion_count` дойдёт до 3, в следующий системный промпт ветки добавляется указание вернуть пациента к шагу скрипта (`SOFT_INSERTION_NUDGE` в `services/chat_service.py`). - В промпте ветки `new_booking` явно прописано правило отвечать на короткие вопросы про цену/адрес/длительность в-line, не уходя в другую ветку. --- # Вариант B · Переключение ветки и возврат ## Реплика 6B · «А что у вас по ДМС? У меня ВТБ страхование. Они покрывают?» **Решение маршрутизатора:** `price_question`. Здесь: - Конкретный страховщик упомянут → нужен поиск по партнёрам ДМС, это не короткое уточнение. - Вопрос с подтемой («покрывают ли они»), требует развёрнутого ответа. - Это уже не «вставка в скрипт записи», а самостоятельная подтема. Оркестратор сравнивает: маршрутизатор сказал `price_question`, активная ветка — `new_booking` (пошаговая, на шаге `offer_time`). Это случай **удержания в ветке** (sticky): по умолчанию оркестратор не переключает пошаговую ветку по решению маршрутизатора, а добавляет в её системный промпт блок `[ПОДСКАЗКА РОУТЕРА]` и даёт ветке самой решить. **Поведение модели ветки `new_booking`:** видит подсказку, что маршрутизатор счёл тему сменившейся. Сама оценивает: вопрос явно вне сценария записи, не короткий, требует другого контекста (база знаний по ДМС). Возвращает служебный сигнал: ``` [INTENT_CHANGE: price_question] ``` **Шаги оркестратора в одном ходе:** 1. Парсер видит `[INTENT_CHANGE: price_question]` → ответ ветки `new_booking` пациенту не показывается. 2. **Сохраняет состояние ветки-донора** в `thread_state`: - `suspended_intent` ← `"new_booking"` - `resumable_step_code` ← `"offer_time"` - `resumable_slots` ← копия текущих `slots` 3. **Инкрементит счётчик переключений:** `handoff_count: 0 → 1`. Кап — 3; следующее переключение тоже допустимо, но при достижении 3 произойдёт авто-эскалация в `escalate_human` с причиной `routing_loop`. 4. **Сбрасывает `slots` и `current_step_code`** — у `price_question` нет своей машины состояний, она линейная. 5. **Запускает ветку `price_question`** с полной историей и пустым шагом, в обработке этой же реплики (так называемый bounce — модель вызывается дважды в одном ходе). **`thread_state` после переключения, перед ответом `price_question`:** ```json { "current_intent_code": "price_question", "current_step": 0, "current_step_code": null, "slots": {}, "handoff_count": 1, "soft_insertion_count": 0, "suspended_intent": "new_booking", "resumable_step_code": "offer_time", "resumable_slots": { "name": "Анна Петровна", "reason": "боль в горле, неделю", "specialist": "ЛОР" } } ``` Заметим: `slots` ветки `price_question` начинается с пустого объекта — у этой ветки **нет своих слотов** (`prompts/intents/price_question.md` — обычный отвечающий промпт без машины состояний). Слоты записи **полностью изолированы в `resumable_slots`** и не «смешиваются». **Ответ модели `price_question`:** ``` Да, мы работаем с ДМС «ВТБ Страхование». Точный список покрываемых услуг и условия согласования есть у нашего администратора — он подтвердит покрытие конкретно на вашу программу при оформлении записи. Если хотите — продолжим запись, а администратор по ДМС свяжется с вами отдельно? ``` (Эта ветка не возвращает `STATE_JSON:` — у неё нет машины состояний.) **`thread_state` после хода не меняется** (никаких слотов и шагов у `price_question` нет). В Песочнице на этом ответе: - Бейдж **«активная ветка: price_question»** (без отметки «пошаговая»). - Бейдж **«решение маршрутизатора: price_question»** (зелёный — совпадает). - В правой панели «Состояние диалога»: видно **«📌 отложен сценарий: `new_booking` (шаг `offer_time`)»**, слотов отложенного — 3. - Счётчик переключений — **«1 из 3»** (синий бейдж, не красный). ## Реплика 7B · «Хорошо, давайте записываться. Утром в будни» **Решение маршрутизатора:** `new_booking`. Чёткий сигнал возврата к записи + конкретное предпочтение по времени. **Шаги оркестратора:** 1. Видит, что `current_intent_code == "price_question"`, новое решение маршрутизатора = `"new_booking"`. 2. **Проверяет `suspended_intent`** — он равен `"new_booking"`. **Это и есть путь возврата**, не новое переключение. 3. Восстанавливает: - `current_intent_code` ← `suspended_intent` - `current_step_code` ← `resumable_step_code` (`"offer_time"`) - `slots` ← `resumable_slots` 4. Очищает поля сохранения: `suspended_intent = null`, `resumable_step_code = null`, `resumable_slots = null`. 5. **Сбрасывает `handoff_count` в 0** — это возврат, а не новое переключение. Если бы счётчик не сбрасывался, диалог из 4 переключений и 4 возвратов засчитал бы 8 переходов и упёрся в кап. 6. Запускает ветку `new_booking` на восстановленном шаге `offer_time` для обработки этой же реплики. **`thread_state` после восстановления, перед ответом `new_booking`:** ```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, "suspended_intent": null, "resumable_step_code": null, "resumable_slots": null } ``` **Ответ модели `new_booking`** (теперь на шаге `offer_time`, видит `preferred_time` в реплике пациента): ``` Хорошо. Записываю: Анна Петровна, к ЛОР-врачу, повод — боль в горле, удобное время — утром в будни. Передам администратору, он подберёт конкретное окно. Всё верно? STATE_JSON: {"state_after": "book", "slots_updated": {"preferred_time": "утром в будни"}} ``` Шаг переходит в `book`, слот `preferred_time` заполнен. Дальше — реплика 7 из примера 01 v2 (явное «да» → `close`). В Песочнице на этом ответе: - Бейдж **«активная ветка: new_booking»** + **«пошаговая»** + бейдж **«шаг ветки: book»**. - Бейдж **«решение маршрутизатора: new_booking»** (зелёный). - В правой панели — событие **«решение: восстановили сценарий»** (зелёный бейдж `resumed`). - В «Состоянии диалога» больше нет блока «📌 отложен сценарий». - Счётчик переключений — **«0 из 3»** (сбросился). ## Что показал вариант B - **Переключение ветки — это полноценный hard handoff**, со всем что к нему прилагается: служебный сигнал из ветки, сохранение состояния в `suspended_*`, инкремент `handoff_count`, изоляция слотов новой ветки. - **Возврат — не «новое переключение»**, а особый путь оркестратора: он сравнивает решение маршрутизатора с `suspended_intent` и при совпадении восстанавливает состояние, **сбрасывая `handoff_count` в 0**. Это критично для защиты от петли — иначе чередование «запись ↔ цены» съело бы кап за один-два цикла. - **У `price_question` нет своих слотов** — это простая отвечающая ветка. Никакого «маппинга `dms_provider`» при возврате нет (этого механизма в коде нет; в v1 он был как иллюстрация). - **Pendant возврата — `slots` записи восстанавливается полностью**, ничего не теряется. --- ## Когда боковой вопрос, а когда переключение Решение принимает **маршрутизатор плюс ветка-донор** (двойная защита). На практике различение работает по таким признакам: | Признак | Боковой вопрос | Переключение ветки | |---------|----------------|---------------------| | Длина вопроса | Короткий, точечный | Развёрнутый, с подвопросами | | Контекст | Уточнение к текущему шагу | Запрос самостоятельной темы | | Маркеры в реплике | «а сколько», «а где», «и как долго» | «стоп», «подождите», «расскажите про», «у меня …, что насчёт» | | Можно ли ответить одной репликой | Да | Нет, минимум 2-3 обмена | | Меняет `slots` / `current_step_code` | Нет | Да (полное переключение) | | Меняет `handoff_count` | Нет | Да (+1) | | Меняет `soft_insertion_count` | Да (+1) | Нет (сбрасывается) | Ни одна модель и ни один маршрутизатор не сделают это безошибочно с первого захода. Двойная защита: ветка имеет в промпте правило «короткие боковые отвечай сам», маршрутизатор на каждой реплике независимо классифицирует. Если оба согласны — остаёмся; если ветка пропустила сигнал — маршрутизатор на следующей реплике увидит и переключит. ## Что важно проверять в eval-наборе на этом примере - **Soft insertion не меняет `current_step_code` и `slots`.** Тест: на шаге `offer_time` подать «а сколько стоит» → проверить `state_after == "offer_time"`, `slots_updated == {}`, `soft_insertion: true` в ответе, `handoff_count == 0`, `soft_insertion_count` увеличился на 1. - **Hard handoff корректно сохраняет состояние.** Тест: на шаге `offer_time` подать «расскажите про ДМС» → проверить, что `suspended_intent == "new_booking"`, `resumable_step_code == "offer_time"`, `resumable_slots` содержит все три слота записи. - **Возврат сбрасывает `handoff_count`.** Тест: hard handoff, потом «давайте записываться» → проверить, что `handoff_count` стал **0**, не **2**. - **При возврате `slots` не дополнились ничем «случайным».** В v1 был ожидаем `dms_provider` после возврата — этого механизма нет, проверять нечего; но если вдруг в `slots` после возврата появятся поля, которых не было в `resumable_slots`, — это регрессия. --- ## Changelog ### v2 → 2026-04-27 **Имена полей `thread_state`** приведены к реальной БД (как в `01_basic_booking_v2.md`). **Слоты приведены к реальной таксономии:** - В отправной точке (после 5 реплик примера 01) теперь только `name`, `reason`, `specialist` — никаких `service`, `complaint`, `doctor_preference`, `time_candidates`, `branch`. После возврата в варианте B на шаге `book` появляется `preferred_time` и `confirmed: true` на `close`. **`price_question` показана как ветка без слотов и без шагов** — реально она именно такая (`prompts/intents/price_question.md`). Удалён вымышленный mapping `dms_provider` из `price_question` в `new_booking` после возврата — этого механизма в коде нет (нет ни слота `dms_provider`, ни логики mapping'а). **Структурированный ответ модели** — формат `STATE_JSON:` в хвосте текста (а не отдельный JSON). Для `price_question` показано, что ветка не возвращает `STATE_JSON:` (нет машины состояний). **Описание hard handoff** уточнено под реальную механику оркестратора: - Сначала маршрутизатор предлагает `price_question`, оркестратор применяет sticky (передаёт ветке `new_booking` подсказку), и **только если ветка сама выдаёт `[INTENT_CHANGE]`**, происходит переключение. Прямое переключение по решению маршрутизатора — только для веток без машины состояний. - В одном ходе модель может быть вызвана дважды (bounce): сначала ветка-донор выдаёт сигнал, потом запускается ветка-приёмник. **Удалён CRM-вызов** `crm.create_booking(... dms_provider=...)` — таких tool calls в коде нет. **Добавлены признаки UI Песочницы** на каждом шаге (бейджи активной ветки, решения маршрутизатора, событий, счётчика переключений) — чтобы пример читался как сверка с тем, что оператор реально увидит. **Терминология:** «роутер» → «маршрутизатор», «жёсткий переход / hard handoff» → «переключение ветки». **Содержательно** (что показывает пример) — то же: разница между боковым вопросом и переключением ветки + механика возврата через `suspended_intent`.