Files
RAG_helper/docs/examples/02_price_during_booking.md
T
AR 15 M4 f348570b1b docs: переезд в docs/ — SPRINTS, architecture (v1/v2/v3), examples
- 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>
2026-04-26 21:50:16 +05:00

19 KiB
Raw Blame History

Пример 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) и видит расхождение → инициирует жёсткий переход.

Шаги оркестратора в одном ходе:

  1. Получает структурированный ответ от текущей ветки. Ветка new_booking тоже видит смену темы (двойная защита из раздела 1 v3) и возвращает:

    {
      "reply": "",
      "signal": "[INTENT_CHANGE: price_question]",
      "state_after": "offer_time"
    }
    
  2. Сохраняет состояние ветки-донора в 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"
      }
    }
    
  3. Инкрементит счётчик переключений: handoff_count: 0 → 1. (Кап — 2; на следующем переключении ещё ок, на третьем — автоматическая эскалация в escalate_human с reason=routing_loop, см. v3 раздел 4.3.)

  4. Запускает ветку 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. Чёткий сигнал возврата к записи + конкретное время.

Шаги оркестратора:

  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 после восстановления, перед вызовом ветки:

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