- 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 <noreply@anthropic.com>
19 KiB
Пример 02 · Вопрос про цену в середине записи
Связано с
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. Состояние треда:
{
"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) — описание услуги (уже в контексте).
Вызовы инструментов: нет.
Структурированный ответ модели:
{
"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) и видит расхождение → инициирует жёсткий переход.
Шаги оркестратора в одном ходе:
-
Получает структурированный ответ от текущей ветки. Ветка
new_bookingтоже видит смену темы (двойная защита из раздела 1 v3) и возвращает:{ "reply": "", "signal": "[INTENT_CHANGE: price_question]", "state_after": "offer_time" } -
Сохраняет состояние ветки-донора в
thread_state:{ "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" } } -
Инкрементит счётчик переключений:
handoff_count: 0 → 1. (Кап — 2; на следующем переключении ещё ок, на третьем — автоматическая эскалация вescalate_humanсreason=routing_loop, см. v3 раздел 4.3.) -
Запускает ветку
price_questionс полной историей и пустымcurrent_step(у этой ветки нет машины состояний — она линейная, отвечает сразу).
thread_state после шагов оркестратора, до ответа модели:
{
"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) — общие правила работы по ДМС.
Вызовы инструментов: нет.
Структурированный ответ модели:
{
"reply": "Да, ДМС «ВТБ Страхование» мы принимаем. По вашему полису первичный приём ЛОР покрывается полностью, без доплаты. Эндоскопия — по медицинским показаниям, согласовывается со страховой через нашего администратора. Лечебные процедуры — частично, зависит от программы (уточнит координатор перед приёмом). При себе нужно иметь паспорт и полис ДМС. Если хотите — продолжим оформление записи?",
"state_after": null,
"slots_updated": {
"dms_provider": "ВТБ Страхование"
}
}
thread_state после хода:
{
"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. Чёткий сигнал возврата к записи + конкретное время.
Шаги оркестратора:
- Видит, что
current_intent=price_question, новое решение роутера =new_booking. - Проверяет
suspended_intent— оно равноnew_booking. Это и есть путь возврата, не новый переход. - Восстанавливает
current_intent ← suspended_intent,current_step ← resumable_step,slots ← resumable_slots. - Очищает поля сохранения:
suspended_intent=null,resumable_step=null,resumable_slots=null. - Не инкрементит
handoff_count— это возврат, а не новое переключение. - Дополнительно: переносит из
price_question.slotsв восстановленныеnew_booking.slotsте поля, которые могут быть полезны (в нашем случаеdms_provider). Это делается явным mapping'ом в коде, не автоматически.
thread_state после восстановления, перед вызовом ветки:
{
"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 вне боковых вопросов).
Вызовы инструментов:
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"}
Структурированный ответ модели:
{
"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был передан. Это проверка маппинга при возврате — частая точка регрессий.