# Пример 03 v2 · Запись ребёнка — защитное условие в шаге уточнения > **Версия v2 · 2026-04-27.** Переписано под реальный код. В коде есть **только одно защитное условие** — `require_legal_rep` на шаге `qualify` (см. `services/intent_step_service.py`, `SEED_INTENT_STEPS`). Никаких guard'ов на `present` (сурдолог) и `close` (юридическая оговорка) в коде нет — в v1 они были как иллюстрация будущего, в реальности это просто инструкции в промпте `qualify.md`. Список изменений — внизу в Changelog. > > Связано с [`../architecture/GRAPH_ARCHITECTURE_v4.md`](../architecture/GRAPH_ARCHITECTURE_v4.md), разделы 3.2, 3.3. Демонстрирует **защитное условие** (guard) внутри шага `qualify` ветки `new_booking`. Когда модель определяет, что пациент — ребёнок (`is_child=true`), переход в `present` блокируется, пока не заполнены `legal_rep_name` и `legal_rep_phone`. Также показано, как валидатор переходов отклоняет ход модели, если она попыталась двинуться без выполнения защитного условия. ## О чём этот пример Мама записывает 8-летнего сына к ЛОР-врачу. Скрипт записи в вики клиники (см. `prompts/intents/new_booking/steps/qualify.md`) формализует это как обязательный сбор ФИО и контактного телефона законного представителя. В терминах архитектуры это означает: на шаге `qualify` стоит **защитное условие** `require_legal_rep` со следующей семантикой: ```json { "require_legal_rep": { "description": "Для записи ребёнка нужны ФИО и телефон законного представителя", "trigger_slot": "is_child", "trigger_value": true, "required_slots": ["legal_rep_name", "legal_rep_phone"] } } ``` Пока `is_child == true` и в `slots` нет `legal_rep_name` или `legal_rep_phone`, валидатор отклоняет любой переход с шага `qualify` (в том числе на `present`). Модель остаётся на `qualify`. ## Начальное состояние ```json { "thread_id": 9003, "current_intent_code": null, "current_step": 0, "current_step_code": null, "slots": {}, "handoff_count": 0, "soft_insertion_count": 0, "suspended_intent": null, "resumable_step_code": null, "resumable_slots": null } ``` --- ## Реплика 1 · «Здравствуйте, хочу записать ребёнка к лору» **Решение маршрутизатора:** `new_booking`. Упоминание «ребёнка» само по себе не сигнал эскалации — это норма для записи. **Активная ветка / шаг:** `new_booking` / `intro`. **Структурированный ответ модели:** ``` Здравствуйте! Помогу записать ребёнка. Подскажите, как могу к вам обращаться? STATE_JSON: {"state_after": "intro", "slots_updated": {}} ``` Шаг не меняется — ждём имя того, с кем разговариваем. **`thread_state` после хода:** ```json { "current_intent_code": "new_booking", "current_step": 0, "current_step_code": "intro", "slots": {}, "handoff_count": 0, "soft_insertion_count": 0 } ``` --- ## Реплика 2 · «Меня Мария зовут» **Решение маршрутизатора:** `new_booking`. **Активная ветка / шаг:** `new_booking` / `intro`. Имя получено → переход в `qualify`. **Структурированный ответ модели:** ``` Очень приятно, Мария. Что беспокоит ребёнка? И сколько ему лет? STATE_JSON: {"state_after": "qualify", "slots_updated": {"name": "Мария"}} ``` Шаг переходит в `qualify`. Слот `name` — это обращение к собеседнику в чате (к маме). Имя ребёнка отдельным слотом не сохраняется — оно нужно только в финальном проговаривании, и модель использует его прямо из истории сообщений. **`thread_state` после хода:** ```json { "current_intent_code": "new_booking", "current_step": 1, "current_step_code": "qualify", "slots": { "name": "Мария" }, "handoff_count": 0, "soft_insertion_count": 0 } ``` --- ## Реплика 3 · «Сыну 8 лет, болят уши» **Решение маршрутизатора:** `new_booking`. Жалоба упомянута, но в контексте записи — не самостоятельный медицинский вопрос. Маршрутизатор не уходит в `medical_question`. **Активная ветка / шаг:** `new_booking` / `qualify`. **Промпт `qualify.md`** прямо предусматривает «особую ситуацию 1: запись ребёнка»: при упоминании «сын/дочка», «ребёнок», возраст — модель фиксирует `is_child: true` и переходит к сбору данных представителя. **Структурированный ответ модели:** ``` Понимаю, боль в ушах у ребёнка — это всегда тревожно. С такой жалобой обычно записывают к ЛОР-врачу — подходит? И, чтобы оформить запись, нужны ФИО и контактный телефон родителя или опекуна — подскажете? STATE_JSON: {"state_after": "qualify", "slots_updated": {"is_child": true, "reason": "боль в ушах у ребёнка"}} ``` Шаг **не меняется** — `specialist` ещё не подтверждён, и данные представителя не собраны. Слот `is_child: true` теперь активирует защитное условие `require_legal_rep`: с этого момента переход `qualify → present` запрещён, пока в `slots` не появятся `legal_rep_name` и `legal_rep_phone`. **`thread_state` после хода:** ```json { "current_intent_code": "new_booking", "current_step": 1, "current_step_code": "qualify", "slots": { "name": "Мария", "is_child": true, "reason": "боль в ушах у ребёнка" }, "handoff_count": 0, "soft_insertion_count": 0 } ``` В Песочнице на этом ответе в правой панели «Состояние диалога» появится индикатор: **«🔒 защитное условие активно: `require_legal_rep` — ждём заполнения: `legal_rep_name`, `legal_rep_phone`»** (жёлтый блок). Пациент его не видит — это сигнал оператору. --- ## Реплика 4 · «А зачем вам ФИО и телефон родителя?» Возражение мамы. Из `qualify.md` модель должна спокойно объяснить. **Решение маршрутизатора:** `new_booking`. **Активная ветка / шаг:** `new_booking` / `qualify`. Защитное условие всё ещё активно. **Структурированный ответ модели:** ``` Это стандартная практика: при записи ребёнка нам важно иметь возможность связаться с родителем — например, если на приёме потребуется согласие на процедуру. Это требование клиники для безопасности ребёнка. Подскажете ФИО и телефон? STATE_JSON: {"state_after": "qualify", "slots_updated": {}} ``` Шаг и слоты не меняются — защитное условие всё ещё блокирует переход. **`thread_state` после хода:** идентично состоянию до хода. --- ## Реплика 5 · «Хорошо. Иванова Мария Сергеевна, +79991234567» **Решение маршрутизатора:** `new_booking`. **Активная ветка / шаг:** `new_booking` / `qualify`. Получены данные представителя → защитное условие выполнено. **Структурированный ответ модели:** ``` Спасибо, записываю. Подходит ЛОР-врач? STATE_JSON: {"state_after": "qualify", "slots_updated": {"legal_rep_name": "Иванова Мария Сергеевна", "legal_rep_phone": "+79991234567"}} ``` Слоты заполнены, защитное условие выполнено, но модель ещё не закрыла вопрос со специалистом — поэтому остаётся на `qualify`. Это корректное поведение: даже когда guard сейчас выполнен, **переход в `present` логически нужен только когда есть и `specialist`** (промпт ветки требует все три: `name`, `reason`, `specialist`). **`thread_state` после хода:** ```json { "current_intent_code": "new_booking", "current_step": 1, "current_step_code": "qualify", "slots": { "name": "Мария", "is_child": true, "reason": "боль в ушах у ребёнка", "legal_rep_name": "Иванова Мария Сергеевна", "legal_rep_phone": "+79991234567" }, "handoff_count": 0, "soft_insertion_count": 0 } ``` В Песочнице индикатор «🔒 защитное условие активно» **исчезает** — guard выполнен. --- ## Реплика 6 · «Да, ЛОР подходит» **Решение маршрутизатора:** `new_booking`. **Активная ветка / шаг:** `new_booking` / `qualify`. Защитное условие выполнено + `specialist` заполнен → переход в `present` разрешён. **Структурированный ответ модели:** ``` Хорошо. Записываю Матвея к ЛОР-врачу — на приёме доктор осмотрит и обратит внимание на боль в ушах. STATE_JSON: {"state_after": "present", "slots_updated": {"specialist": "ЛОР"}} ``` Шаг переходит в `present` — это легально (`allowed_next: ["qualify", "present"]`), и теперь валидатор `check_guards()` тоже пропускает: `is_child: true` + оба `legal_rep_*` заполнены. **`thread_state` после хода:** ```json { "current_intent_code": "new_booking", "current_step": 2, "current_step_code": "present", "slots": { "name": "Мария", "is_child": true, "reason": "боль в ушах у ребёнка", "legal_rep_name": "Иванова Мария Сергеевна", "legal_rep_phone": "+79991234567", "specialist": "ЛОР" }, "handoff_count": 0, "soft_insertion_count": 0 } ``` Дальше всё как в `01_basic_booking_v2.md`, начиная с реплики 5: согласие → `offer_time`, время → `book`, подтверждение → `close`. Никаких особенностей `is_child` на этих шагах в коде сейчас нет. --- ## Что было бы, если модель попыталась перейти без слотов представителя Допустим, после реплики 4 («А зачем?») модель проигнорирует инструкцию и вернёт: ``` Хорошо, к ЛОР-врачу. Подбираю удобное время. STATE_JSON: {"state_after": "present", "slots_updated": {"specialist": "ЛОР"}} ``` `state_after: present` сам по себе легален (`allowed_next` его допускает). Но `check_guards()` запускается **после** `validate_transition()` и видит: - активна `is_child: true` (триггер защитного условия `require_legal_rep`), - `legal_rep_name` и `legal_rep_phone` отсутствуют. Валидатор отклоняет переход. Поведение оркестратора: - `current_step_code` остаётся `qualify`. - Пациенту всё равно показывается ответ модели (нельзя «съесть» реплику бота). - В Песочнице на этом ответе появится событие **«валидатор: переход отклонён»** (красный бейдж `validation_blocked`), а в правой панели — детализация: **«🔒 защитное условие `require_legal_rep` не пройдено — ждём: `legal_rep_name`, `legal_rep_phone`»**. Так что даже если модель «забыла» — состояние не разъезжается. --- ## Что показал этот пример - **Защитное условие как страховка от регрессии.** Промпт `qualify.md` сам предписывает не переходить, но модель иногда забывает; `check_guards()` ловит это механически. - **Активация только при триггере.** Поле `trigger_slot: "is_child"` + `trigger_value: true` означает, что защитное условие **бездействует**, пока `is_child != true`. Для взрослых пациентов (как в `01_basic_booking_v2.md`) этого блока вообще нет в индикаторах — так и должно быть. - **Защитное условие проверяется после `allowed_next`.** Сначала валидатор смотрит, легален ли вообще переход (есть ли в `allowed_next` следующего шага); затем — выполнено ли активное защитное условие. - **Один guard в коде сейчас.** Сурдолог при жалобе на слух и юридическая оговорка в `close` — это **не** защитные условия в коде, это инструкции в промптах (`qualify.md`, `present.md`). Если они начнут пробуксовывать, можно будет дотянуть как guards (см. идеи на потом в `SPRINTS.md`). ## Что важно проверять в eval-наборе на этом примере - **Без `legal_rep_*` переход `qualify → present` отклоняется.** Тест: подать в ветку модельный ответ с `state_after: "present"` при `is_child: true` и пустых `legal_rep_*` → валидатор должен отклонить, состояние остаётся `qualify`, в логе и в Песочнице — событие `validation_blocked` с деталями guard'а. - **`is_child: true` устанавливается рано.** Тест: фраза «запишите ребёнка», без других слов → проверить, что `is_child: true` появляется в `slots_updated` уже на 2-3 реплике (как только пациент явно упомянул ребёнка). - **После заполнения `legal_rep_*` индикатор `pending_guard` исчезает.** Тест: дойти до реплики «Иванова Мария Сергеевна, +79991234567» → проверить, что в `state.pending_guard` стало `null` и в Песочнице нет жёлтого блока. - **Защитное условие не активно для взрослых.** Регрессионный тест: сценарий из `01_basic_booking_v2.md` (без `is_child`) → проверить, что переход `qualify → present` разрешён без `legal_rep_*` и в логах нет упоминаний `require_legal_rep`. --- ## Changelog ### v2 → 2026-04-27 **Имена полей `thread_state`** приведены к реальной БД (как в `01_basic_booking_v2.md`). **Слоты приведены к реальной таксономии** из `qualify.md`: - Удалены вымышленные `patient_name`, `patient_age`, `parent_first_name`, `complaint`, `service`, `time_chosen`, `branch`, `booking_id`, `guard_surdologist_suggested`. - Используются только реальные: `name` (обращение к собеседнику в чате — у нас это родитель), `is_child`, `reason`, `specialist`, `legal_rep_name`, `legal_rep_phone`. Отсутствуют слоты для имени и возраста ребёнка — этих слотов в коде нет, имя ребёнка модель использует прямо из истории сообщений. **Один guard в коде, не три:** - Удалены guard'ы про сурдолога (на `present`) и юридическую оговорку (на `close`) — в `services/intent_step_service.py` `SEED_INTENT_STEPS` их нет. Сурдолог при жалобе на слух — упомянут как инструкция в `qualify.md` (особая ситуация 3), но не как защитное условие. Юридический текст для записи ребёнка в `close.md` сейчас отсутствует вообще. - Сценарий укорочен с 9 до 6 реплик с подробным разбором (плюс отдельный раздел «что было бы, если…»). **Структурированный ответ модели** — формат `STATE_JSON:` в хвосте текста. **Поведение защитного условия** уточнено под реальный механизм (`services/state_machine.py:check_guards()`): - Защитное условие проверяется **после** `validate_transition()` (т.е. после `allowed_next`). - Срабатывает только при `slots[trigger_slot] == trigger_value`. - Если не пройдено — `current_step_code` не меняется, ответ модели всё равно показывается пациенту, событие `validation_blocked` уходит в Песочницу. **Удалены sub-states** (`qualify.legal_rep`, `qualify.base`) — их в коде нет; v3 архитектуры сама рекомендует не плодить sub-states, и реальная реализация идёт через condition-based guards. См. также идею на потом в `SPRINTS.md` про возможные sub-states при росте числа guards. **Удалены вымышленные UI-теги** про реакцию валидатора на сурдолог-флаг — таких событий в коде нет. **Терминология:** «guard» → «защитное условие», «роутер» → «маршрутизатор». **Содержательно** (что показывает пример) — то же: блокировка перехода защитным условием при записи ребёнка, разблокировка после заполнения слотов представителя.