Chat Agent for Patients

Как работает мультиагентная система

Здесь объясняется, что такое намерение (intent) и ветка (branch), как реплика пациента доходит до ответа, и какие защитные механизмы стоят на пути «петель» и потерянного контекста. Английские термины оставлены в скобках — на них завязан код и логи.

Зачем это всё

На пилоте у нас был «один большой системный промпт» — модель пыталась одновременно записывать на приём, отвечать на вопросы по ценам и эскалировать острые случаи. По мере усложнения скрипта запись начинала «плыть»: модель забывала шаги, путала ветки, перескакивала через мини-интервью.

Мы перешли на графовую архитектуру (graph-based routing): реплика пациента сначала идёт в маршрутизатор (router), который определяет намерение (intent) — категорию темы. По коду намерения подбирается ветка (branch), отвечающая только за свой узкий сценарий. У сложных веток внутри есть собственный пошаговый сценарий (state machine).

Главные термины

Намерение (intent)
Категория темы, которую распознал маршрутизатор: new_booking, price_question, general_info, medical_question, reschedule, escalate_human. У нас намерение жёстко связано с веткой 1:1 — код намерения совпадает с кодом ветки.
Ветка (branch)

Изолированный «под-агент» с собственным системным промптом. Отвечает за одно намерение: запись, перенос, цены, медицинский вопрос, общая справка, перевод на оператора. У каждой ветки — свой код, активная версия настроек, опционально — пошаговый сценарий и защитные условия.

В коде и в БД ветка хранится в таблице intents — исторически. Концептуально это branch, но из-за связи 1:1 с намерением мы пока не разделяли их в коде.

Маршрутизатор (router)
Системная ветка _router: отдельный, дешёвый вызов модели, который по последней реплике пациента возвращает один из кодов намерений. Не отвечает пациенту напрямую — только классифицирует. Вызывается на КАЖДОЙ реплике, не один раз при входе.
Решение маршрутизатора (router decision)
Код намерения, который маршрутизатор вернул на конкретной реплике. Видно в Песочнице бейджем «Решение маршрутизатора: <код>» под ответом ассистента. Если решение совпало с активной веткой — мы остались в ней; если разошлось — сработало либо переключение, либо удержание в ветке.
Активная ветка (active branch)
Ветка, которая реально сформировала ответ ассистента на этой реплике. Может совпадать с решением маршрутизатора (норма), а может расходиться (если сработало удержание в ветке или возврат из отложенного сценария). Видно в Песочнице бейджем «Активная ветка: <код>».
Пошаговый сценарий (state machine)
Внутренний граф шагов внутри ветки. Сейчас есть только у new_booking: 6 шагов от приветствия до подтверждения записи. Модель на каждой реплике видит, на каком шаге сейчас, и какие слоты уже собраны.
Шаг (step)
Узел пошагового сценария. У каждого шага свой код (intro, qualify, present, offer_time, book, close), свой кусок промпта и список допустимых переходов.
Допустимые переходы (allowed_next)
Список кодов шагов, в которые можно перейти с текущего. Например, с qualify разрешено в qualify (остаться) или present (двигаться вперёд), но не в close. Если модель попытается перепрыгнуть через шаг — валидатор переходов (transition validator) отклонит запрос, мы останемся на шаге.
Слоты (slots)
JSON-словарь данных, которые ветка собирает по ходу разговора. Для записи это name, reason, specialist, preferred_time, confirmed. Модель на каждой реплике их видит и обновляет — старые не переспрашиваются.
Условия выхода (exit conditions)
Список ситуаций, когда ветка должна вместо обычного ответа выдать служебный сигнал [INTENT_CHANGE: <код_ветки>] и передать диалог другой ветке. Например, если пациент в записи упомянул хирургию — ветка new_booking сама вернёт [INTENT_CHANGE: escalate_human].
Переключение ветки (hard handoff)
Полная смена ветки внутри одного диалога с обнулением шага и слотов. Бывает в двух случаях: ветка сама выдала [INTENT_CHANGE] или маршрутизатор предложил другую ветку, которая не имеет пошагового сценария.
Удержание в ветке (sticky state machine)

Защита от ложных переключений внутри пошагового сценария. Это не второй вызов маршрутизатора — один вызов, но с расширенным промптом. Работает пошагово:

  1. Пациент пишет что-то вроде «а сколько стоит приём?» внутри записи.
  2. Маршрутизатор анализирует реплику и возвращает: general_info (в Песочнице — бейдж «Решение маршрутизатора: general_info»).
  3. Система видит: тред уже идёт по пошаговой ветке new_booking, шаг qualify. Переключать опасно — потеряем контекст записи.
  4. Вместо переключения: effective_code остаётся new_booking, в системный промпт ветки добавляется блок:
    [ПОДСКАЗКА РОУТЕРА]
    Маршрутизатор счёл, что тема — `general_info`.
    Ты ведёшь сценарий `new_booking`.
    Если пациент сменил тему — выдай [INTENT_CHANGE: general_info].
    Если реплика в сценарии — зафиксируй в слот и продолжай.
  5. Модель new_booking получает весь этот контекст и сама решает:
    • Ответить на вопрос и остаться → вернёт {"state_after": "qualify", "soft_insertion": true} → бейдж «удержались в ветке» + «боковой вопрос».
    • Решить, что тема реально сменилась → вернёт [INTENT_CHANGE: general_info] → переключение ветки.

Ключевое: решение принимает модель ветки за один вызов, а не отдельный маршрутизатор. Подсказка маршрутизатора — просто контекст в промпте.

Боковой вопрос (soft insertion)

Ситуация, когда модель пошаговой ветки отвечает на вопрос вне сценария, не продвигая шаг и не меняя ветку. Типичные примеры внутри new_booking: «а сколько стоит приём?», «у вас есть парковка?», «как долго идёт приём?».

Откуда система знает, что это боковой вопрос? Модель сама сообщает об этом в структурированном ответе:

STATE_JSON: {"state_after": "qualify", "slots_updated": {}, "soft_insertion": true}
Флаг soft_insertion: true + текущий шаг в state_after + пустые slots_updated = «ответил на отвлечение, сценарий не двинулся».

Счётчик soft_insertion_count в состоянии диалога инкрементируется на каждом таком ответе и сбрасывается при смене шага или ветки. При трёх подряд — в системный промпт ветки добавляется жёсткое указание: «верни пациента к вопросу текущего шага».

В Песочнице: бейдж «тип ответа: боковой вопрос» (жёлтый) на ответе ассистента.

Структурированный ответ ветки (structured output)
Каждая пошаговая ветка возвращает не только текст пациенту, но и служебный JSON-блок в хвосте ответа:
STATE_JSON: {"state_after": "qualify", "slots_updated": {"name": "Алексей"}}
Парсер вырезает этот блок (пациент его не видит), валидатор проверяет легальность state_after, обновляет состояние диалога. Необязательный флаг soft_insertion: true сигнализирует, что это был боковой ответ без продвижения сценария.
Отложенный сценарий (suspended intent / resume)
Если из пошаговой ветки произошёл переход в другую (например, посреди записи спросили про цены), её состояние (текущий шаг и собранные слоты) запоминается в полях suspended_intent, resumable_step_code, resumable_slots. Когда маршрутизатор увидит, что пациент возвращается к исходной теме («ладно, продолжаем запись»), мы автоматически восстановим шаг и слоты.
Защитное условие (guard)

Правило, которое блокирует переход шага вперёд, пока не заполнены нужные слоты. Хранится в поле guards каждого шага (Настройки → Шаги → поле «Защитные условия»). Проверяется после того, как модель вернула корректный state_after — то есть даже если модель «захотела» перейти, валидатор не пустит без нужных данных.

Формат — JSON-объект, где каждый ключ — имя защитного условия:

{
  "require_legal_rep": {
    "description": "Для записи ребёнка нужны ФИО и телефон законного представителя",
    "trigger_slot": "is_child",
    "trigger_value": true,
    "required_slots": ["legal_rep_name", "legal_rep_phone"]
  }
}

Поля:

  • trigger_slot — слот, при значении которого защитное условие активируется. Если опущен — условие активно всегда.
  • trigger_value — значение, которое должен иметь trigger_slot для активации (например, true).
  • required_slots — список слотов, которые должны быть заполнены для разрешения перехода.
  • description — пояснение для операторов; показывается в «Состоянии диалога» в Песочнице при срабатывании.

Сейчас защитное условие задано на шаге qualify ветки new_booking: при is_child: true нельзя перейти в present, пока не заполнены legal_rep_name и legal_rep_phone. В Песочнице при срабатывании появляется красный блок: «🔒 защитное условие require_legal_rep не пройдено — ждём: legal_rep_name, legal_rep_phone».

Счётчик переключений (handoff_count)
Сколько раз в этом диалоге произошло переключение ветки. Растёт при каждом переключении и сбрасывается в 0 при возврате из отложенного сценария. В Песочнице показан как «Переключений: N из 3» в карточке состояния диалога. По достижении кап-значения срабатывает защита от петли.
Защита от петли (routing loop guard)
При превышении 3 переключений за диалог следующее переключение блокируется: диалог автоматически уходит в escalate_human с причиной routing_loop и шаблонным ответом «Уточню детали с администратором клиники, свяжемся с вами в течение ближайшего часа». Это страховка от циклов вроде «запись ↔ цены ↔ запись ↔ цены».
Причина передачи оператору (escalation_reason)

Когда диалог уходит в ветку escalate_human, фиксируется причина — для статистики и для оператора, который примет диалог. Возможные значения:

  • acute_pain — пациент описывает острое состояние.
  • surgery — упоминание операции, наркоза, стационара.
  • angry — агрессивный или раздражённый тон.
  • explicit_request — пациент явно просит оператора («дайте человека»).
  • routing_loop — сработала защита от петли (3+ переключений ветки).

В Песочнице видно в карточке ответа: «Передача оператору · причина: <reason>». Само значение модель возвращает в служебном сигнале [INTENT_CHANGE: escalate_human|reason] либо его подставляет код (например, routing_loop при срабатывании защиты).

Состояние диалога (thread state)
Запись в БД (одна на диалог), хранящая активную ветку, текущий шаг (если есть), собранные слоты, счётчик переключений и поля отложенного сценария. Видно в Песочнице справа, в блоке «Состояние диалога».

Что происходит на каждой реплике

1
Маршрутизатор классифицирует реплику.
Отдельный вызов модели с короткой системой и историей. Возвращает один код намерения.
2
Проверяем отложенный сценарий.
Если в состоянии диалога есть suspended_intent и маршрутизатор вернул именно его — восстанавливаем шаг и слоты, очищаем поля, обнуляем счётчик переключений.
3
Применяем удержание в ветке.
Если диалог уже идёт по пошаговой ветке и маршрутизатор предлагает другую — состояние не сбрасываем, в системный промпт ветки добавляется блок [ПОДСКАЗКА РОУТЕРА].
4
Если переключение всё-таки происходит — инкрементим счётчик.
При превышении 3 — авто-перевод на оператора, без вызова модели.
5
Собираем системный промпт ветки.
Базовый промпт + промпт текущего шага (если есть) + блок текущего состояния (шаг, слоты, подсказка). Зовём модель.
6
Парсим ответ.
Если есть [INTENT_CHANGE] — переключаемся в новую ветку (если из пошаговой ветки — запоминаем в отложенный сценарий) и зовём модель ещё раз. Если есть STATE_JSON: — валидируем переход, обновляем шаг и сливаем слоты.
7
Сохраняем сообщение пациента, ответ модели и обновлённое состояние диалога.
Всё одной транзакцией. Если что-то упадёт — откатываем целиком, «диалог-призрак» в списке не появится.
Почему бейджи — на ответах ассистента, а не на репликах пациента?
Маршрутизатор обрабатывает каждую реплику пациента, но результат его решения материализуется в следующем ответе ассистента: именно там формируется ветка, шаг, события (sticky, боковой вопрос и т.д.). Поэтому в Песочнице бейджи стоят под ответом бота, а не над вопросом пациента — они описывают обработку предшествующей реплики.

Защитные механизмы

Где что настраивается

Разобранные примеры

Четыре пошаговых разбора реальных сценариев: что делает маршрутизатор, какая ветка отвечает, на каком шаге, какие слоты заполняются и что происходит в состоянии диалога после каждой реплики. Полезно для понимания архитектуры в работе и как ориентир для будущего eval-набора.

Пример 01 · Базовая запись к ЛОР-врачу
Линейный happy path: intro → qualify → present → offer_time → book → close. Никаких защитных условий, никаких боковых вопросов, никаких переключений. Самый простой случай — на нём удобно увидеть базовое поведение системы.
Пример 02 · Вопрос про цену в середине записи
Один сценарий разобран в двух вариантах: боковой вопрос (без выхода из ветки) и переключение ветки с возвратом через отложенный сценарий. Лучший пример для понимания различий между этими двумя механизмами.
Пример 03 · Запись ребёнка — защитное условие
Срабатывание единственного реального защитного условия require_legal_rep на шаге qualify: при is_child=true диалог не уходит в present, пока не собраны legal_rep_name и legal_rep_phone.
Пример 04 · Простые информационные запросы
Короткие диалоги про часы, адрес, проезд, услуги, которых клиника не делает, и переход «справка → запись». Самый дешёвый путь: одна реплика → ветка general_info без машины состояний и без слотов → ответ.
Документ описывает текущее состояние после Спринта 6b (блоки D + F): удержание в ветке, боковые вопросы, структурированный ответ, защитные условия. Следующее: причина передачи оператору с reason (блок E), умный маршрутизатор, видящий состояние диалога (блок G).