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>
This commit is contained in:
AR 15 M4
2026-04-26 21:50:16 +05:00
parent 82bba34937
commit f348570b1b
9 changed files with 2427 additions and 3 deletions
+18 -1
View File
@@ -87,7 +87,7 @@
## План (спринты)
См. [`SPRINTS.md`](./SPRINTS.md).
См. [`docs/SPRINTS.md`](./docs/SPRINTS.md). Архитектурные документы и разобранные примеры — в [`docs/architecture/`](./docs/architecture/) и [`docs/examples/`](./docs/examples/).
---
@@ -165,6 +165,8 @@ embedding-модели E5-large занимает ~15–20 секунд.
```
.
├── README.md # этот файл
├── docs/ # проектная документация (см. ниже)
├── config.py # настройки (Pydantic BaseSettings)
├── main.py # FastAPI app, lifespan, авто-миграции
├── alembic.ini # конфиг Alembic
@@ -204,6 +206,21 @@ embedding-модели E5-large занимает ~15–20 секунд.
└── sqlite/ # реляционная БД (gitignored)
```
### Документация (`docs/`)
```
docs/
├── SPRINTS.md # план и статус спринтов
├── architecture/
│ ├── GRAPH_ARCHITECTURE_v1.md # черновик при развороте 2026-04-23
│ ├── GRAPH_ARCHITECTURE_v2.md # уточнения 2026-04-24
│ └── GRAPH_ARCHITECTURE_v3.md # текущая (билингв. термины + ссылки на примеры)
└── examples/
├── 01_basic_booking.md # happy path записи (линейный)
├── 02_price_during_booking.md # soft-insertion vs. hard-handoff
└── 03_child_patient_guard.md # guard в шаге qualify (запись ребёнка)
```
---
## Связанные проекты
+2 -2
View File
@@ -144,7 +144,7 @@
## Архитектурный разворот после Спринта 3 (2026-04-23)
После пилота Спринтов 1–3 решили уходить от одного «мега-промпта» ко графовой архитектуре: **роутер намерений + изолированные ветки + state machine + exit conditions**. Подробности — в `GRAPH_ARCHITECTURE.md`.
После пилота Спринтов 1–3 решили уходить от одного «мега-промпта» ко графовой архитектуре: **роутер намерений + изолированные ветки + state machine + exit conditions**. Подробности — в [`architecture/GRAPH_ARCHITECTURE_v3.md`](./architecture/GRAPH_ARCHITECTURE_v3.md) (последняя версия). Исторические снапшоты — `architecture/GRAPH_ARCHITECTURE_v1.md` (изначальный, на момент разворота) и `architecture/GRAPH_ARCHITECTURE_v2.md`.
**Принятые решения по открытым вопросам:**
- **Фреймворк оркестровки:** пишем вручную на Python. LangGraph/n8n не берём — проект компактный, свой стек работает, не тянем лишних зависимостей.
@@ -160,7 +160,7 @@
## Спринт 4. Фундамент графа — `intents` + роутер + переключение веток
### Цель
Заменить «один активный промпт на всё» на «свой промпт на каждую ветку + роутер выбирает ветку на каждой реплике». Это первый шаг к графовой архитектуре из `GRAPH_ARCHITECTURE.md`.
Заменить «один активный промпт на всё» на «свой промпт на каждую ветку + роутер выбирает ветку на каждой реплике». Это первый шаг к графовой архитектуре из [`architecture/GRAPH_ARCHITECTURE_v3.md`](./architecture/GRAPH_ARCHITECTURE_v3.md).
### Статус: ✅ Закрыт
+439
View File
@@ -0,0 +1,439 @@
# Графовая архитектура: роутер намерений + изолированные ветки
> **Версия 3 · 2026-04-26.** По содержанию — то же, что v2, но переписано в стиле «русское объяснение + английский термин в скобках» для всех понятий, которые будут встречаться в коде, в промптах или в названиях полей БД. В конце документа — разделы со ссылками на разобранные примеры (см. файлы в [`../examples/`](../examples/)). Изменения относительно v2 — в разделе **Changelog**.
Документ фиксирует направление, в которое двигается проект после пилота Спринтов 1–3. Перепланировка спринтов сделана в `SPRINTS.md` — здесь только сама архитектура и почему она нам нужна.
---
## Соглашение о терминах
В документе встречаются понятия, которые одновременно:
- являются обычными русскими словами в обиходе,
- и одновременно — идентификаторами полей БД, переменных в коде, ключей в JSON-структурах.
Чтобы не было путаницы, при первом упоминании в разделе мы пишем русский термин и сразу даём английский эквивалент (в том виде, в котором он живёт в коде). Например: **намерение** (intent), **машина состояний** (state machine), **счётчик переключений** (handoff_count). Ниже по тексту того же раздела используется тот вариант, который удобнее по контексту.
---
## Проблема, с которой сталкиваемся
Текущая реализация — это «мега-промпт»: в один системный промпт положен весь скрипт поведения агента, плюс правила, плюс инструкции по всем возможным темам (запись, перенос, цены, подготовка к приёму, ДМС, детский приём и т. д.).
На MVP это работает. Но как только добавим реальные бизнес-процессы с несколькими этапами (например, запись с перехватом инициативы в 6 шагов) — модель начнёт «плыть»:
- **Забывать начало инструкций** в конце длинного промпта.
- **Перескакивать этапы** мини-интервью.
- **Пытаться применять правила не к месту** — например, запустить скрипт записи, когда пациент просто спросил, как доехать.
- **Путать ветки** между собой, потому что они все лежат в одном контексте.
Это классическая ловушка production-ready ассистентов. Дело не в мощности модели (DeepSeek более чем достаточно), а в архитектуре: **один промпт не должен знать про всё одновременно**.
---
## Архитектура, к которой идём
Паттерн называется **маршрутизация на основе графа** (graph-based routing) или **мультиагентная система** (multi-agent system). Идея проста:
1. Входная реплика пациента идёт не сразу в отвечающего агента, а в **роутер** (router).
2. Роутер определяет **намерение** (intent) пациента и передаёт диалог в конкретную изолированную **ветку** (branch).
3. Каждая ветка — это отдельный узкий промпт, который умеет делать одну вещь хорошо.
4. Ветки не замкнуты: в любой момент агент может вернуть управление роутеру, если контекст изменился.
```
┌─────────────┐
│ Пациент │
└──────┬──────┘
┌──────▼──────────────────────────┐
│ Роутер (LLM-классификатор) │
│ определяет намерение │
└──────┬──────────────────────────┘
├──→ Ветка «Новая запись» (new_booking, машина состояний, 6 шагов + guard'ы)
├──→ Ветка «Перенос / отмена» (reschedule)
├──→ Ветка «Цены и ДМС» (price_question)
├──→ Ветка «Медицинский вопрос» (medical_question, канонический ответ)
├──→ Ветка «Общая справка» (general_info, адрес, часы, проезд)
└──→ Ветка «Эскалация» (escalate_human, reason: surgery |
acute_pain |
angry |
explicit_request |
routing_loop)
```
Шесть веток — то же количество, что сидится при первом запуске Спринта 4. Хирургия и острая боль не отдельные ветки, а значение поля **причина эскалации** (reason) внутри `escalate_human` — так решили на развороте 2026-04-23.
---
## 1. Роутер — входной узел
Отдельный, быстрый и дешёвый вызов **языковой модели** (LLM, large language model). Сам пациенту не отвечает — только классифицирует.
Задача роутера:
- Проанализировать последнюю реплику пациента + краткую историю диалога.
- Вернуть **код намерения** (intent code) — одну из заранее заданных категорий.
- Если детектирован острый случай (боль, кровотечение, упоминание операции) — маршрутизировать в `escalate_human` с соответствующим `reason`.
Пример промпта роутера:
> Определи намерение пользователя. Варианты:
> 1. `new_booking` — новая запись.
> 2. `reschedule` — перенос или отмена существующей записи.
> 3. `price_question` — цены, ДМС, оплата.
> 4. `medical_question` — симптомы, диагноз, лечение (немедленная эскалация не требуется).
> 5. `general_info` — как доехать, часы работы, контакты.
> 6. `escalate_human` — пациент явно просит оператора, злится, описывает острое состояние, упоминает операцию.
>
> Верни только код намерения. Для `escalate_human` дополнительно верни `reason` из списка: `acute_pain`, `surgery`, `angry`, `explicit_request`.
Роутер продолжает **незримо присутствовать** в диалоге — его вызывают на каждой реплике, не один раз при входе. Это двойная защита: если ветка не поймала **условие выхода** (exit condition) сама, роутер увидит изменение намерения (intent'а) и инициирует **переход в другую ветвь** (handoff).
---
## 2. Узкоспециализированные ветки (sub-agents)
Каждая ветка — отдельный промпт, который ничего не знает про другие ветки. Он видит:
- Свой системный промпт (узкий, под одну задачу).
- Свой срез базы знаний (см. раздел 6).
- Историю диалога (чтобы не переспрашивать имя/симптомы).
- Текущий шаг машины состояний — если она в этой ветке есть.
Примеры:
**Ветка «Новая запись» (`new_booking`).** 6-этапный промпт-продавец с условными ветвлениями. Перехват инициативы, мини-интервью по услуге и врачу, презентация приёма, два слота + «настоять на записи», бронирование, закрытие с проговариванием даты/врача/адреса/стоимости. Подробно — в разделе 3 и в `01_basic_booking.md`.
**Ветка «Перенос / отмена» (`reschedule`).** Другой промпт: извиниться, уточнить текущую запись, сверить с календарём, предложить варианты. RAG не используется — работа через **вызовы инструментов** (tool calls) к CRM.
**Ветка «Медицинский вопрос» (`medical_question`).** Канонический ответ: «не могу консультировать, это к врачу. Записать вас к профильному специалисту?» — с мягким переходом в `new_booking`. Никакого RAG по медицинским темам намеренно (юридический риск).
**Ветка «Эскалация» (`escalate_human`).** Короткая: извиниться, передать оператору. Перед передачей формируется саммари с `reason`, историей и собранными слотами.
---
## 3. Машина состояний внутри ветки
Для сложных скриптов (вроде записи) недостаточно иметь один промпт — нужна ещё память о том, **на каком шаге мы сейчас находимся** (current_step) и **какие данные мы уже собрали** (slots).
### 3.1 Базовая линейная цепочка
Пример **состояния треда** (thread state) для `new_booking`:
```json
{
"intent": "new_booking",
"step": "offer_time",
"slots": {
"patient_name": "Анна",
"is_child": false,
"service": "первичный ЛОР",
"doctor": "Сушков М. Г.",
"time_candidates": ["2026-04-24 10:00", "2026-04-24 15:00"],
"time_chosen": null
}
}
```
Модель на каждом ходе видит: *«Я на шаге `offer_time`, слот `time_candidates` заполнен, значит следующим сообщением я должна получить выбор времени, а не представляться заново»*. Это убирает «перескоки» и «забывания».
Состояние треда хранится в отдельной таблице `thread_state` с JSON-колонкой под слоты (см. раздел «Что это меняет в данных»). Полный пример заполнения слотов реплика за репликой — в `01_basic_booking.md`.
### 3.2 Защитные условия (guards) и ветвления внутри скрипта
Линейная цепочка из шести шагов — идеальный случай. В реальном скрипте записи (см. вики клиники) есть как минимум три **защитных условия** (guards), которые ломают линейность:
- **Пациент — ребёнок.** На шаге `qualify` обязательно собрать ФИО и телефон законного представителя. Блокирует переход в `present`, пока слоты не заполнены. Юридическое требование, не косметика.
- **Запрос конкретного врача (например, Ворончихиной).** Вместо шага `offer_time` диалог уходит в рукав «лист ожидания» (waitlist): запись в очередь вместо предложения слотов.
- **Жалоба на слух без обследования у сурдолога.** На шаге `present` модель должна предложить записаться сначала к сурдологу, и только потом — к отоневрологу.
Моделировать guard'ы можно двумя способами:
**Условные переходы** (conditional transitions). Шаг `qualify` имеет два возможных next-step'а: `present` (обычно) или `collect_legal_rep` (если `is_child=true`), и только после заполнения переходит дальше.
**Под-состояния** (sub-states). Внутри `qualify` есть `qualify.base` и `qualify.legal_rep`, последнее активируется при `is_child=true`.
Рекомендуем первый вариант — он проще и легче тестируется. Разбор guard'а с ребёнком на конкретном диалоге — в `03_child_patient_guard.md`.
### 3.3 Структурированный выход модели + валидатор переходов
Чисто **управляемые моделью** (LLM-driven) переходы — где в промпте написано «если слот заполнен, переходи к следующему шагу» — фрагильны. Модель периодически «не замечает» заполнение слота и застревает или, наоборот, прыгает через шаг.
Гибридный подход надёжнее. Модель возвращает **структурированный ответ** (structured output):
```json
{
"reply": "Записала вас на четверг, 10:00...",
"state_after": "close",
"slots_updated": {
"time_chosen": "2026-04-24 10:00"
}
}
```
Код:
1. **Валидирует легальность перехода**`offer_time → close` допустим, `intro → book` нет.
2. **Сохраняет слоты строго** — что модель обновила, то и попало в `thread_state`.
3. **Логирует несоответствия** — если модель вернула несуществующее `state_after`, состояние остаётся прежним, в лог пишется предупреждение.
Модель рассуждает содержательно, код защищает механически. Прибавка — около 50 строк валидатора, снижение нестабильности — заметное.
### 3.4 RAG-срез на уровне шага, а не только ветки
Разным шагам одной ветки нужны разные куски вики. Для `new_booking`:
| Шаг (`step`) | Срез базы знаний (`wiki_sources`) | Инструмент (`tool`) |
|--------------|-----------------------------------|---------------------|
| `intro` | — | — |
| `qualify` | `/wiki/services/**`, `/wiki/doctors/**` | — |
| `present` | `/wiki/services/**`, `/wiki/doctors/**`, `/wiki/preparation/**` | — |
| `offer_time` | `/wiki/services/**` (для боковых вопросов) | `crm.get_slots` |
| `book` | — | `crm.create_booking` |
| `close` | `/wiki/contacts/**`, `/wiki/preparation/**` | — |
Поле «источники базы знаний» (`wiki_sources`) имеет смысл определять на уровне шага, а не только ветки. Ветка задаёт значения по умолчанию, шаг может их сузить или расширить.
---
## 4. Условия выхода: динамическая маршрутизация
### 4.1 Жёсткий переход в другую ветвь (hard handoff)
Каждая ветка знает не только **как вести разговор**, но и **когда из него выйти**. В системный промпт ветки зашивается блок «условий выхода» (exit conditions):
> Если в любой момент пациент упоминает операцию, наркоз, стационар, удаление гланд, септопластику, стапедопластику — прекрати скрипт записи и выдай служебный сигнал: `[INTENT_CHANGE: escalate_human]` с `reason=surgery`.
Когда оркестратор видит такой сигнал в ответе модели:
1. **Останавливает текущую ветку.**
2. **Сохраняет текущее состояние** как `suspended_intent` + `resumable_step` + `resumable_slots` (см. 4.4).
3. **Передаёт всю историю** в роутер.
4. **Запускает новую ветку** — бесшовно для пользователя.
Полный разбор жёсткого перехода с возвратом — в `02_price_during_booking.md`.
### 4.2 Мягкая вставка (soft insertion) — боковой вопрос без выхода из ветки
Не каждое отклонение от темы — это переход в другую ветвь. Частый случай: пациент посреди записи спрашивает «а сколько это стоит?» или «где вы находитесь?». Это не смена темы, это короткий параллельный вопрос, после которого нужно продолжить скрипт записи с того же шага.
Различение:
- **Мягкая вставка** (soft insertion) — на вопрос можно ответить *одной репликой* без запуска собственной машины состояний. Цена услуги, адрес, длительность приёма, требования к документам. Ветка отвечает сама, поле `current_step` не меняется.
- **Жёсткий переход** (hard handoff) — вопрос сам по себе требует процесса (перенос существующей записи, запись другого человека, хирургия). Полный выход к роутеру.
Практически: ветка `new_booking` имеет *read-only* доступ к RAG-срезам `price` и `info`, и в её промпте прописано правило: «короткие боковые вопросы отвечай сам, не покидая шаг». Модели этого обычно достаточно; если правило проскакивает — двойной прогон роутера на следующей реплике поймает ошибку.
Сравнение мягкой вставки и жёсткого перехода на одном и том же сценарии — в `02_price_during_booking.md`.
### 4.3 Защита от петель: `handoff_count`
Без ограничения легко получить **цикл маршрутизации** (routing loop) — «`booking → price → booking → price → ...`» на несогласованных промптах. Поэтому в `thread_state` заводится счётчик:
- `handoff_count` инкрементится при каждом жёстком переходе.
- Кап — 2–3 переключения за сессию.
- При превышении — автоматическая маршрутизация в `escalate_human` с `reason=routing_loop`.
Это дешёвая страховка, которая окупается на первом же багованном промпте.
### 4.4 Возобновление после перехода: `suspended_intent` + `resumable_state`
Если ветка вышла по soft-handoff'у для короткого ответа — ок, через мгновение продолжает. Если произошёл жёсткий переход и боковая (detour) ветка закрылась — пациент часто возвращается к исходной задаче. Пример:
- Пациент в `new_booking` на шаге `offer_time`.
- Переспросил про цену — ушли в `price_question`.
- Получил ответ, говорит «ок, тогда бронируем на четверг».
- Должен вернуться в `new_booking` на шаг `offer_time`, не в `intro`.
Для этого при выходе из ветки в `thread_state` сохраняются:
```json
{
"current_intent": "price_question",
"current_step": null,
"suspended_intent": "new_booking",
"resumable_step": "offer_time",
"resumable_slots": { "...копия слотов new_booking..." }
}
```
Роутер, приняв решение о возврате, восстанавливает `current_intent` из `suspended_intent`, `current_step` из `resumable_step`, слоты — из `resumable_slots`. Полный диалог с разбором изменений `thread_state` на каждом ходе — в `02_price_during_booking.md`.
---
## 5. Передача человеку (escalation)
Часть сценариев не заканчивается в боте — агент **маршрутизирует пациента в контакт-центр**. Важное отличие от «просто сбросить диалог» — система отдаёт оператору **полный контекст** (full handoff context):
- Полную историю переписки.
- Распознанное намерение + причину эскалации (`reason` из списка `acute_pain` / `surgery` / `angry` / `explicit_request` / `routing_loop`).
- Собранные слоты, если они уже есть (ФИО, телефон, услуга, предпочитаемый врач).
- Флаг `suspended_intent`, если эскалация прервала другую ветку.
Это превращает ассистента не в «фильтр перед оператором», а в инструмент **квалификации лида** (lead qualification). Дальнейшая маршрутизация (какому именно оператору, в какую очередь) — задача смежного разработчика при подключении каналов.
---
## 6. RAG: коллекции на ветку или подписка ветки на разделы вики?
Здесь два технически рабочих подхода с очень разными эксплуатационными свойствами.
### Вариант А — отдельная коллекция на ветку
(как описано в v1 и как было запланировано в Спринте 6.)
- Каждая ветка имеет собственную **векторную коллекцию** (vector collection) в Chroma.
- Загрузка документа требует выбора ветки.
- Поле `collection_name` в `intents`.
- **Плюсы:** жёсткая изоляция по умолчанию, простой query-путь.
- **Минусы:** дублирование (одна статья вики часто нужна нескольким веткам); лишнее решение на каждый upload; сложнее поддерживать при росте вики.
### Вариант Б — одна коллекция + подписка ветки на разделы
- Одна общая Chroma-коллекция `clinic_wiki`.
- В таблице `intents` поле «источники» (`wiki_sources: list[str]`) — список префиксов путей или набор идентификаторов документов (document ids).
- **Поисковик-ретривер** (retriever) применяет **фильтр по метаданным** (metadata filter, where-filter): `doc_path STARTS WITH any(...)`.
- Один документ, нужный нескольким веткам, перечисляется в `wiki_sources` нескольких веток — физического дублирования нет.
- **Плюсы:** структура вики = единый источник истины (single source of truth); новая страница в `/wiki/pricing/` автоматически попадает в `price_question` без правок конфига; операторы и так ведут вики — не добавляется отдельный процесс тегирования.
- **Минусы:** требует дисциплины в структуре папок вики.
**Рекомендация для проекта — Вариант Б.** Причина: вики у клиники уже атомарная, регулярно обновляемая, с осмысленной структурой. Добавлять поверх неё тегирование чанков или физическую фрагментацию по коллекциям — это второй слой, который будет расходиться с первым. При Варианте Б «источник правды» один — сама вика.
### Дополнительно: `wiki_sources` на уровне шага
Внутри ветки `new_booking` разным шагам нужны разные срезы (см. 3.4). Это решается тем, что поле `wiki_sources` существует на двух уровнях:
- на `intents` — значения по умолчанию для ветки;
- на шаге машины состояний — уточнение/сужение для конкретного состояния.
---
## Что это меняет в данных
Сейчас в БД:
- `threads`, `messages` — диалоги (Спринт 2).
- `agent_configs` — один активный системный промпт на всё (Спринт 3).
- `intents` — справочник веток (Спринт 4).
После полного перехода на графовую архитектуру понадобится:
- **`intents`** — добавить поле `wiki_sources: list[str]` для Варианта Б мульти-RAG.
- **`agent_configs`** — привязан к `intent_id`, у каждой ветки свой активный промпт и свои условия выхода (уже заложено в Спринте 4).
- **`thread_state`** — текущее состояние треда:
- `thread_id` (PK, FK)
- `current_intent`
- `current_step`
- `slots` (JSON)
- `handoff_count` (int, default 0) — счётчик переключений, защита от петель.
- `suspended_intent` (nullable) — отложенное намерение, ветка, из которой вышли по жёсткому переходу.
- `resumable_step` (nullable) — шаг в `suspended_intent`, куда возвращаться.
- `resumable_slots` (JSON, nullable) — слоты той ветки.
- `updated_at`.
- **Машина состояний на ветке** — для `new_booking` справочник шагов + допустимых переходов (может быть в коде или в БД, на старте достаточно в коде).
- **`routing_log`** (опционально) — лог решений роутера: намерение, срабатывание условия выхода, инкремент `handoff_count`. Нужен для отладки и тюнинга.
---
## Что это меняет в UI
- «Настройки агента» — настройки веток: слева список веток, справа редактор промпта и условий выхода для выбранной ветки. Для веток с машиной состояний — дополнительная вкладка со списком шагов и их промптами.
- В «Песочнице» отладочная панель показывает: **текущее намерение** (current_intent), **шаг машины состояний** (current_step), **собранные слоты** (slots), **счётчик переключений** (handoff_count), **отложенное намерение** (suspended_intent), если есть, и **историю переходов между ветками** (handoff history) в рамках треда.
- «Сценарии» (Спринт 7) прогоняют не только диалог, но и проверяют: правильно ли роутер классифицировал намерение на каждой реплике, корректно ли сработали условия выхода, восстановилось ли состояние после боковой ветки.
---
## 7. Eval-набор нужен до Спринта 5
В плане Спринт 7 — полноценная подсистема сценариев. Это правильная цель, но реализация bouncing'а в Спринте 5 требует минимального **набора оценочных кейсов** (eval set, evaluation set) уже на входе. Иначе реализуем переход «на глазок», без способа понять, стало лучше или хуже после правки промпта.
Минимум:
- **Eval роутера.** 20–30 фраз на каждую ветку: типичные, пограничные (ловушечные), злые (опечатки, короткие, эмоциональные). Формат: CSV `фраза, ожидаемый_intent`.
- **Eval перехода.** 5–10 многошаговых мини-диалогов: намерение на реплике 1 → пациент сменил тему на реплике 2 → на реплике 3 проверяем, что ветка ушла на роутер и роутер правильно переключил.
- **Eval возобновления.** 3–5 сценариев: detour → возврат. Проверяем, что `current_step` восстановился из `resumable_step`.
Реализация — короткий скрипт, прогоняющий набор через `/chat` и сравнивающий решения. Будет заменён полноценной подсистемой Спринта 7, но до этого закроет ~80% регрессий.
---
## Открытые вопросы
Часть вопросов из v1 закрылась на развороте 2026-04-23. Актуальный список:
1. **Фреймворк оркестровки** — решено: пишем вручную на Python. LangGraph/n8n не берём.
2. **Роутер — отдельная модель** — отложено: пока DeepSeek через отдельный `RouterClient`, чтобы сменить модель в одном месте. Пересмотрим, когда вызовов станет много.
3. **Формат условий выхода** — текстом в промпте ветки + независимый прогон роутера на каждой реплике. Если реальный прогон покажет, что свободный текст пропускает случаи смены темы — добавим **структурированный список триггеров** (trigger list, keyword-match).
4. **Уверенность роутера (confidence score)** — не на первом спринте. После живого прогона посмотрим на реальные ошибки, и если их много — добавим **уточняющий вопрос** (clarifying question) при низкой уверенности.
Новые вопросы после v2:
5. **Момент обновления `current_step`.** Сразу после парсинга `state_after` из ответа модели, или после того как ответ успешно показан пациенту? При ошибке доставки состояние может разъехаться с тем, что пациент видел.
6. **Кап на мягкие вставки.** Если пациент крутит ассистенту пять побочных вопросов подряд, не продвигаясь по записи — это нормально или это сигнал «пациент не хочет записываться, эскалировать»? Нужен ли кап на число инлайн-ответов до возврата к шагу скрипта.
7. **Шаги записи — из вики или из головы.** Шесть шагов `new_booking` формализованы нами, но скрипт в вике формулирует их слегка иначе («контакт → уточнение → презентация приёма → 2 слота → запись → закрытие»). До реализации Спринта 5 — свериться с вики по конкретной первой целевой специальности (ЛОР?) и принять официальный список шагов.
Вопрос из v1 про границу «бот vs. оператор по хирургии» — исключён из архитектурных открытых: это продуктовое решение (как у клиники устроен контакт-центр), не архитектурное, и на код не влияет — хирургия просто эскалируется с `reason=surgery`, а дальше смежный разработчик маршрутизирует в нужную очередь.
---
## Ориентир на следующие спринты
Логичный порядок (согласован с `SPRINTS.md`, Спринты 47):
1. **Разделить «один промпт» на несколько** → сделано (Спринт 4).
2. **Добавить роутер** → сделано (Спринт 4).
3. **Машина состояний + условия выхода** → Спринт 5.
4. **Мульти-RAG** → Спринт 6. С учётом v3: дизайн пересмотреть в сторону Варианта Б (подписка на разделы вики).
5. **Сценарии и экспорт** → Спринт 7. С учётом v3: минимальный eval-набор сделать до Спринта 5, полный Спринт 7 реализовать позже.
**Рекомендация v3 по Спринту 5:** разделить на 5a (переходы между ветками: условия выхода, двойной прогон роутера, `handoff_count`, `suspended_intent`) и 5b (машина состояний внутри `new_booking` с guard'ами, структурированный ответ модели, валидатор переходов, `wiki_sources` на уровне шага). Объём работ неравномерный: 5a — несколько дней, 5b — неделя+. Если слить, велик шанс получить «наполовину сделано, протестировать нечем».
---
## Разобранные примеры
Эти документы показывают архитектуру в работе на конкретных диалогах — реплика за репликой, с фиксацией того, что в этот момент происходит в `thread_state`, какое решение принял роутер, какой шаг машины состояний активен, что вернула модель в `state_after` и `slots_updated`.
- [`01_basic_booking.md`](../examples/01_basic_booking.md) — happy path записи к ЛОР-врачу. Базовый случай, в котором всё идёт по линейному скрипту: контакт → уточнение → презентация → слоты → запись → закрытие. Показывает, как заполняются слоты, как меняется `current_step`, что видит модель на каждой реплике.
- [`02_price_during_booking.md`](../examples/02_price_during_booking.md) — пациент в середине записи спрашивает про цену. Один и тот же сценарий разобран в двух вариантах: мягкая вставка (без выхода из ветки) и жёсткий переход с возвратом (через `suspended_intent` + `resumable_state`). Лучший пример для понимания различий между этими двумя механизмами.
- [`03_child_patient_guard.md`](../examples/03_child_patient_guard.md) — запись ребёнка к врачу. Показывает срабатывание guard'а в шаге `qualify`, нелинейный путь по машине состояний, сбор данных законного представителя, юридические оговорки в шаге `close`.
- [`04_general_info_simple.md`](../examples/04_general_info_simple.md) — простые информационные запросы (часы, адрес, проезд, контакты, документы, не-предоставляемые услуги). Самый дешёвый путь в системе: одна реплика, одна ветка `general_info`, один шаг `answer`, прямой ретривер → ответ → `done`. Логичная стартовая точка для запуска первой версии бота.
---
## Changelog
### v3 → 2026-04-26
**Стиль:**
- Все технические понятия, которые встречаются в коде, в промптах или в названиях полей БД, оформлены по схеме «русское объяснение + английский термин в скобках» при первом упоминании в разделе. Это позволяет читать документ без догадок: что русское слово, что код, что переменная.
- Добавлен раздел «Соглашение о терминах» в начале.
**Ссылки на примеры:**
- В разделах 3, 4 и в новом разделе «Разобранные примеры» добавлены ссылки на четыре документа с пошаговыми разборами диалогов: `01_basic_booking.md`, `02_price_during_booking.md`, `03_child_patient_guard.md`, `04_general_info_simple.md` (последний — для простых одношаговых запросов общей информации, добавлен 2026-04-26 как стартовая точка для запуска).
**Содержательно:**
- Без изменений. Все архитектурные решения, открытые вопросы, рекомендации по спринтам — те же, что в v2.
### v2 → 2026-04-24
**Добавлено:**
- Раздел 3.2: guards внутри ветки `new_booking` (ребёнок, Ворончихина, сурдолог) — из анализа скрипта записи в вике.
- Раздел 3.3: структурированный выход модели `{reply, state_after, slots_updated}` и валидатор переходов в коде.
- Раздел 3.4: `wiki_sources` на уровне шага, а не только ветки.
- Раздел 4.2: мягкая вставка (soft-insertion) для боковых вопросов без выхода из ветки.
- Раздел 4.3: `handoff_count` с капом и автоматическим уходом в `escalate_human` с `reason=routing_loop`.
- Раздел 4.4: `suspended_intent` + `resumable_step` + `resumable_slots` для возврата в исходную ветку после detour'а.
- Раздел 6 (новый): две схемы мульти-RAG. Рекомендация — Вариант Б (одна коллекция + подписка ветки на разделы вики через `wiki_sources`).
- Раздел 7 (новый): eval-набор (20–30 фраз на ветку + handoff-сценарии) нужен до Спринта 5, а не в Спринте 7.
- Рекомендация разделить Спринт 5 на 5a/5b.
**Исправлено:**
- Убрана «Хирургия» как отдельная ветка (была в v1). Актуальная модель (решение разворота 2026-04-23) — одна ветка `escalate_human` с полем `reason`: `acute_pain | surgery | angry | explicit_request | routing_loop`.
- Пример условия выхода переписан с `[INTENT_CHANGE: surgery]` на `[INTENT_CHANGE: escalate_human]` + `reason=surgery`.
- Список веток в разделе 1 приведён к шести (как в сиде Спринта 4).
- Открытый вопрос #4 из v1 (граница бота и оператора по хирургии) исключён из архитектурных — это продуктовый вопрос, на код не влияет.
+386
View File
@@ -0,0 +1,386 @@
# Пример 01 · Базовая запись к ЛОР-врачу (happy path)
> Связано с [`GRAPH_ARCHITECTURE_v3.md`](../architecture/GRAPH_ARCHITECTURE_v3.md), разделы 1, 2, 3. Демонстрирует **линейный** проход машины состояний (state machine) ветки `new_booking`: контакт → уточнение → презентация → слоты → запись → закрытие. Никаких guard'ов, никаких боковых вопросов, никаких переходов в другие ветки. Самый простой случай, на котором удобно увидеть базовое поведение системы.
## О чём этот пример
Взрослый пациент звонит/пишет в чат, хочет записаться к ЛОР-врачу с жалобой на горло. Не уточняет конкретного врача, согласен на ближайший слот. Семь реплик, после которых запись создана в CRM.
Цель — показать на каждой реплике:
- **решение роутера** (router) — какое намерение распознано;
- **активный шаг** (current_step) — где мы сейчас в скрипте;
- **работу поисковика-ретривера** (retriever) — какие чанки достаются и из каких разделов вики;
- **вызовы инструментов** (tool calls) к CRM, если они есть;
- **структурированный ответ модели** (structured output) — `reply`, `state_after`, `slots_updated`;
- **итоговое состояние треда** (thread_state) — что записалось в БД.
## Начальное состояние
Тред только что создан. В таблице `thread_state` записи ещё нет (или есть пустая):
```json
{
"thread_id": "T-9001",
"current_intent": null,
"current_step": null,
"slots": {},
"handoff_count": 0,
"suspended_intent": null,
"resumable_step": null,
"resumable_slots": null
}
```
---
## Реплика 1 · «Здравствуйте, хочу записаться к лору»
**Решение роутера:** `new_booking`. Однозначное упоминание записи + специальности.
**Активная ветка / шаг:** `new_booking` / `intro`. Шаг по умолчанию для свежей ветки.
**Ретривер:** не вызывается. Шаг `intro` имеет пустой `wiki_sources` — на приветствии модели нечего искать.
**Вызовы инструментов:** нет.
**Структурированный ответ модели:**
```json
{
"reply": "Здравствуйте! С удовольствием помогу вам записаться. Подскажите, пожалуйста, как могу к вам обращаться?",
"state_after": "intro",
"slots_updated": {
"service_mention": "ЛОР"
}
}
```
Замечание: шаг **не меняется** — мы остаёмся в `intro`, пока не получим имя пациента. Но один слот заполнили заранее: модель распарсила «к лору» и положила это в `service_mention`, чтобы потом не переспрашивать.
**`thread_state` после хода:**
```json
{
"thread_id": "T-9001",
"current_intent": "new_booking",
"current_step": "intro",
"slots": {
"service_mention": "ЛОР"
},
"handoff_count": 0
}
```
---
## Реплика 2 · «Анна Петровна»
**Решение роутера:** `new_booking`. Намерение не сменилось.
**Активная ветка / шаг:** `new_booking` / `intro`. Всё ещё ждём имя — и вот оно.
**Ретривер:** не вызывается.
**Вызовы инструментов:** нет.
**Структурированный ответ модели:**
```json
{
"reply": "Очень приятно, Анна Петровна. Записать вас на первичный приём к ЛОР-врачу? Что именно беспокоит — есть конкретные жалобы?",
"state_after": "qualify",
"slots_updated": {
"patient_name": "Анна Петровна"
}
}
```
Шаг переходит в `qualify` — имя получено, далее нужно уточнить услугу, врача, детали.
**`thread_state` после хода:**
```json
{
"current_intent": "new_booking",
"current_step": "qualify",
"slots": {
"service_mention": "ЛОР",
"patient_name": "Анна Петровна"
}
}
```
---
## Реплика 3 · «Да, болит горло уже неделю»
**Решение роутера:** `new_booking`. Жалоба упомянута, но в контексте записи — не самостоятельный медицинский вопрос. Роутер не сворачивает в `medical_question`, потому что пациент уже на маршруте записи.
**Активная ветка / шаг:** `new_booking` / `qualify`.
**Ретривер:** срез на уровне шага — `wiki_sources`: `['/wiki/services/**', '/wiki/doctors/**']`.
- top-k: 3.
- Достались чанки:
- `services/первичный-приём-лор.md` (score 0.87) — описание услуги, длительность, что входит.
- `doctors/сушков-михаил-германович.md` (score 0.71) — карточка врача.
- `doctors/иванов-сергей-петрович.md` (score 0.69) — карточка второго ЛОР.
**Вызовы инструментов:** нет (на этом шаге слоты ещё собираем, в CRM не лезем).
**Структурированный ответ модели:**
```json
{
"reply": "Понимаю. На первичный приём ЛОР могу записать. Принимают двое: Сушков Михаил Германович и Иванов Сергей Петрович. Есть предпочтение, или подобрать ближайшее свободное время к любому?",
"state_after": "present",
"slots_updated": {
"service": "первичный_ЛОР",
"complaint": "боль в горле, неделя",
"is_child": false
}
}
```
Шаг переходит в `present` — услуга, жалоба, факт «не ребёнок» зафиксированы. Заметим: `is_child=false` модель определила сама из контекста (пациент назвался ФИО + жаловался от первого лица — взрослый), это **снимает срабатывание guard'а** для детской записи (см. `03_child_patient_guard.md`, где наоборот).
**`thread_state` после хода:**
```json
{
"current_intent": "new_booking",
"current_step": "present",
"slots": {
"service_mention": "ЛОР",
"patient_name": "Анна Петровна",
"service": "первичный_ЛОР",
"complaint": "боль в горле, неделя",
"is_child": false
}
}
```
---
## Реплика 4 · «Без разницы, что есть»
**Решение роутера:** `new_booking`.
**Активная ветка / шаг:** `new_booking` / `present`.
**Ретривер:** срез шага — `['/wiki/services/**', '/wiki/doctors/**', '/wiki/preparation/**']`.
- top-k: 3.
- Достались чанки:
- `services/первичный-приём-лор.md` — длительность, что входит, что оплачивается отдельно.
- `preparation/лор-первичный.md` (score 0.74) — что принести, как подготовиться.
- `services/эндоскопия-лор.md` (score 0.63) — упоминание дополнительных процедур.
**Вызовы инструментов:** нет.
**Структурированный ответ модели:**
```json
{
"reply": "Хорошо. Первичный приём ЛОР занимает около 30 минут. Врач осмотрит, при необходимости проведёт эндоскопию (оплачивается отдельно). Готова посмотреть свободные слоты?",
"state_after": "offer_time",
"slots_updated": {
"doctor_preference": null
}
}
```
Шаг переходит в `offer_time`. Поле `doctor_preference: null` явно фиксируем — это «без разницы», а не «не уточнили».
**`thread_state` после хода:**
```json
{
"current_intent": "new_booking",
"current_step": "offer_time",
"slots": {
"patient_name": "Анна Петровна",
"service": "первичный_ЛОР",
"complaint": "боль в горле, неделя",
"is_child": false,
"doctor_preference": null
}
}
```
---
## Реплика 5 · «Да, посмотрите»
**Решение роутера:** `new_booking`.
**Активная ветка / шаг:** `new_booking` / `offer_time`.
**Ретривер:** на этом шаге используется только для боковых вопросов, в основном потоке не вызывается.
**Вызовы инструментов:**
```python
crm.get_slots(
service_code="primary_lor",
doctor=None,
days_ahead=7
)
# → [
# {"datetime": "2026-04-28 10:00", "doctor": "Сушков М. Г.", "branch": "ул. Клары Цеткин, 9"},
# {"datetime": "2026-04-28 15:00", "doctor": "Сушков М. Г.", "branch": "ул. Клары Цеткин, 9"}
# ]
```
Согласно скрипту записи в вике — предлагаем **ровно два слота**, не больше. Это правило зашито в промпт шага `offer_time`.
**Структурированный ответ модели:**
```json
{
"reply": "Есть свободно вторник 28 апреля: 10:00 или 15:00 — оба слота к Сушкову Михаилу Германовичу, ул. Клары Цеткин, 9. Какое удобнее?",
"state_after": "offer_time",
"slots_updated": {
"time_candidates": ["2026-04-28 10:00", "2026-04-28 15:00"],
"doctor": "Сушков М. Г.",
"branch": "ул. Клары Цеткин, 9"
}
}
```
Шаг **не меняется** — слоты предложили, но выбора пока нет, ждём.
**`thread_state` после хода:**
```json
{
"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"
}
}
```
---
## Реплика 6 · «10 утра, давайте»
**Решение роутера:** `new_booking`.
**Активная ветка / шаг:** `new_booking` / `offer_time`. Получили выбор времени → допустим переход `offer_time → book`.
**Ретривер:** не вызывается.
**Вызовы инструментов:**
```python
crm.create_booking(
patient_name="Анна Петровна",
service_code="primary_lor",
doctor="Сушков М. Г.",
datetime="2026-04-28 10:00",
branch="ул. Клары Цеткин, 9"
)
# → {"booking_id": "B-1234", "status": "confirmed"}
```
Модель в одном ходе проходит `book`, и сразу за ним — `close` (это легальный двойной переход, потому что `book` — это «вызови инструмент» без отдельной реплики пациенту, а `close` — финальное проговаривание).
**Структурированный ответ модели:**
```json
{
"reply": "Записала вас на вторник 28 апреля, 10:00, к ЛОР-врачу Сушкову Михаилу Германовичу, ул. Клары Цеткин, 9. Стоимость первичного приёма — 2 300 рублей. Эндоскопия, лечебные процедуры, забор анализов оплачиваются дополнительно. Подходите за 5 минут до приёма, при себе иметь паспорт. Подтверждаете?",
"state_after": "close",
"slots_updated": {
"time_chosen": "2026-04-28 10:00",
"booking_id": "B-1234"
}
}
```
Шаг переходит в `close`. На этом шаге ретривер уже доставал бы `wiki_sources` `['/wiki/contacts/**', '/wiki/preparation/**']`, но в этом конкретном ответе модель использовала чанки, оставшиеся в контексте с предыдущих шагов (стоимость уже была в карточке услуги, требования к документам — в `preparation`).
**`thread_state` после хода:**
```json
{
"current_intent": "new_booking",
"current_step": "close",
"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",
"time_chosen": "2026-04-28 10:00",
"booking_id": "B-1234"
}
}
```
---
## Реплика 7 · «Да, подтверждаю. Спасибо!»
**Решение роутера:** `new_booking` (или `general_info` — реплика короткая, но контекст ясен).
**Активная ветка / шаг:** `new_booking` / `close`.
**Ретривер:** не вызывается.
**Вызовы инструментов:** нет.
**Структурированный ответ модели:**
```json
{
"reply": "Отлично! Жду вас 28 апреля в 10:00. Если что-то изменится — напишите, поможем перенести. Хорошего дня!",
"state_after": "done",
"slots_updated": {
"confirmed": true
}
}
```
`done` — терминальное состояние. Тред не закрывается физически (пациент может вернуться), но активная машина состояний `new_booking` отработала.
**`thread_state` после хода:**
```json
{
"current_intent": "new_booking",
"current_step": "done",
"slots": {
"...все поля выше...": "...",
"confirmed": true
}
}
```
---
## Что показал этот пример
- **Линейный проход машины состояний.** Шаги шли строго в порядке `intro → qualify → present → offer_time → book → close → done`. Двойной переход в реплике 6 (`offer_time → book → close`) — допустимый случай, потому что `book` — технический шаг с вызовом инструмента, не требующий отдельной реплики пациенту.
- **Слоты накапливаются.** На каждом ходе `slots_updated` содержит только новые/изменённые поля, а в `thread_state.slots` они мерджатся с предыдущим состоянием. Старые значения не теряются.
- **Ретривер использует срез шага.** Один и тот же тред на разных шагах достаёт разные документы вики: на `qualify` — каталог услуг и врачей, на `close` — контакты и подготовку.
- **Намерение остаётся одним.** Роутер на каждой реплике подтверждал `new_booking`. В этом примере смены намерения нет — для случаев, где она происходит, см. `02_price_during_booking.md`.
- **Guard'ы не сработали.** Пациент — взрослый (`is_child=false`), врача не выбирал — ушли по основному пути. Случай со срабатыванием guard'а — в `03_child_patient_guard.md`.
## Что важно проверять в eval-наборе на этом примере
- Все шаги машины состояний были пройдены в правильном порядке (логи `state_after` на каждом ходе).
- В `slots` к моменту шага `book` были заполнены: `patient_name`, `service`, `doctor`, `time_chosen`. Без любого из этих полей `crm.create_booking` не должен вызываться (валидатор перехода).
- Ответ на шаге `close` содержит ровно тот набор данных из скрипта вики: дата, врач, адрес, стоимость, что принести.
+284
View File
@@ -0,0 +1,284 @@
# Пример 02 · Вопрос про цену в середине записи
> Связано с [`GRAPH_ARCHITECTURE_v3.md`](../architecture/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`. Состояние треда:
```json
{
"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) — описание услуги (уже в контексте).
**Вызовы инструментов:** нет.
**Структурированный ответ модели:**
```json
{
"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) и возвращает:
```json
{
"reply": "",
"signal": "[INTENT_CHANGE: price_question]",
"state_after": "offer_time"
}
```
2. **Сохраняет состояние ветки-донора** в `thread_state`:
```json
{
"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` после шагов оркестратора, до ответа модели:**
```json
{
"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) — общие правила работы по ДМС.
**Вызовы инструментов:** нет.
**Структурированный ответ модели:**
```json
{
"reply": "Да, ДМС «ВТБ Страхование» мы принимаем. По вашему полису первичный приём ЛОР покрывается полностью, без доплаты. Эндоскопия — по медицинским показаниям, согласовывается со страховой через нашего администратора. Лечебные процедуры — частично, зависит от программы (уточнит координатор перед приёмом). При себе нужно иметь паспорт и полис ДМС. Если хотите — продолжим оформление записи?",
"state_after": null,
"slots_updated": {
"dms_provider": "ВТБ Страхование"
}
}
```
**`thread_state` после хода:**
```json
{
"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` после восстановления, перед вызовом ветки:**
```json
{
"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` вне боковых вопросов).
**Вызовы инструментов:**
```python
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"}
```
**Структурированный ответ модели:**
```json
{
"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` был передан. Это проверка маппинга при возврате — частая точка регрессий.
+332
View File
@@ -0,0 +1,332 @@
# Пример 03 · Запись ребёнка — защитное условие в шаге уточнения
> Связано с [`GRAPH_ARCHITECTURE_v3.md`](../architecture/GRAPH_ARCHITECTURE_v3.md), разделы 3.2, 3.3. Демонстрирует **защитное условие** (guard) внутри шага `qualify` ветки `new_booking`. Когда модель определяет, что пациент — ребёнок (`is_child=true`), линейный путь машины состояний прерывается: переход `qualify → present` блокируется до тех пор, пока не будут собраны данные **законного представителя** (legal representative). Также показано, как эта особенность отражается на финальном шаге `close` — там добавляются юридические оговорки из вики.
## О чём этот пример
Мама записывает 8-летнего сына к ЛОР-врачу. Ситуация юридически регулируется: согласно ФЗ № 323-ФЗ «Об основах охраны здоровья граждан в РФ» приём ребёнка возможен только с участием законного представителя, и клиника обязана зафиксировать его данные на этапе записи. Скрипт записи в вике формулирует это как обязательный сбор ФИО и контактного телефона законного представителя.
В терминах архитектуры это означает: внутри шага `qualify` есть **под-состояние** (sub-state) `qualify.legal_rep`, активирующееся при `is_child=true`. Модель не может вернуть `state_after: "present"`, пока в слотах не заполнены `legal_rep_full_name` и `legal_rep_phone` — валидатор перехода в коде это проверит и отклонит ход, если переход неправильный.
## Начальное состояние
```json
{
"thread_id": "T-9003",
"current_intent": null,
"current_step": null,
"slots": {},
"handoff_count": 0
}
```
---
## Реплика 1 · «Здравствуйте, хочу записать ребёнка к лору»
**Решение роутера:** `new_booking`. Упоминание «ребёнка» само по себе не сигнал эскалации — это норма для записи.
**Активная ветка / шаг:** `new_booking` / `intro`.
**Ретривер:** не вызывается.
**Вызовы инструментов:** нет.
**Структурированный ответ модели:**
```json
{
"reply": "Здравствуйте! Помогу записать ребёнка. Подскажите, как могу к вам обращаться, и как зовут ребёнка?",
"state_after": "intro",
"slots_updated": {
"service_mention": "ЛОР",
"is_child": true
}
}
```
Заметим: `is_child=true` модель устанавливает уже здесь, на основании слова «ребёнок». Это **раннее срабатывание флага** — и оно правильное, потому что определяет всю дальнейшую логику. Шаг при этом не меняется — ждём имена.
**`thread_state` после хода:**
```json
{
"current_intent": "new_booking",
"current_step": "intro",
"slots": {
"service_mention": "ЛОР",
"is_child": true
}
}
```
---
## Реплика 2 · «Меня зовут Мария, ребёнка зовут Матвей, ему 8 лет»
**Решение роутера:** `new_booking`.
**Активная ветка / шаг:** `new_booking` / `intro`.
**Ретривер:** не вызывается.
**Структурированный ответ модели:**
```json
{
"reply": "Очень приятно, Мария. Записать Матвея на первичный приём к ЛОР-врачу? Что именно беспокоит?",
"state_after": "qualify",
"slots_updated": {
"patient_name": "Матвей",
"patient_age": 8,
"parent_first_name": "Мария"
}
}
```
Шаг переходит в `qualify`. Имя ребёнка — `patient_name` (это пациент, на чьё имя пойдёт запись), имя мамы временно фиксируем как `parent_first_name` — на следующем под-шаге его дополним до полного ФИО.
**`thread_state` после хода:**
```json
{
"current_intent": "new_booking",
"current_step": "qualify",
"slots": {
"service_mention": "ЛОР",
"is_child": true,
"patient_name": "Матвей",
"patient_age": 8,
"parent_first_name": "Мария"
}
}
```
---
## Реплика 3 · «У него уши болят и плохо слышит на одно ухо»
**Решение роутера:** `new_booking`. Жалоба упомянута, но в контексте записи — не самостоятельный медицинский вопрос.
**Активная ветка / шаг:** `new_booking` / `qualify`.
**Срабатывает guard** — при `is_child=true` обычный переход `qualify → present` заблокирован. Машина состояний ждёт под-состояние `qualify.legal_rep`. В промпте шага явно написано: «если `is_child=true` и не заполнены `legal_rep_full_name` + `legal_rep_phone` — соблюдай скрипт сбора данных представителя из вики».
**Дополнительно срабатывает второй guard** — упоминание «плохо слышит на одно ухо» это сигнал на сурдолога. Из v3 раздел 3.2: «жалоба на слух без обследования у сурдолога → на шаге `present` предложить сурдолога». Этот guard сработает позже, когда дойдём до `present`.
**Ретривер:** срез шага `qualify``wiki_sources: ['/wiki/services/**', '/wiki/doctors/**', '/wiki/legal/**']` (последний раздел добавлен для шага под-состояния `qualify.legal_rep`).
- top-k: 3.
- Достались чанки:
- `legal/child-patient-rep-script.md` (score 0.93) — скрипт сбора данных законного представителя из вики.
- `legal/fz-323-rep.md` (score 0.78) — выдержка из ФЗ-323 для случаев «зачем вам это нужно».
- `services/первичный-приём-лор.md` (score 0.62).
**Вызовы инструментов:** нет.
**Структурированный ответ модели:**
```json
{
"reply": "Поняла, посмотрим. Прежде чем подобрать слот — для записи ребёнка нам по правилам клиники нужно зафиксировать данные законного представителя. Уточните, пожалуйста, ваше полное ФИО и контактный телефон.",
"state_after": "qualify.legal_rep",
"slots_updated": {
"complaint": "боль в ушах, ухудшение слуха на одно ухо"
}
}
```
Шаг переходит в под-состояние `qualify.legal_rep` — guard активен. Жалоба зафиксирована в слотах, но переход в `present` пока заблокирован.
**`thread_state` после хода:**
```json
{
"current_intent": "new_booking",
"current_step": "qualify.legal_rep",
"slots": {
"service_mention": "ЛОР",
"is_child": true,
"patient_name": "Матвей",
"patient_age": 8,
"parent_first_name": "Мария",
"complaint": "боль в ушах, ухудшение слуха на одно ухо"
}
}
```
---
## Реплика 4 · «А зачем вам это нужно?»
Возражение пациентки. Из вики: «Если возникает вопрос — А зачем вам это нужно? — вы можете объяснить, что это стандартная практика для обеспечения безопасности и контроля доступа к медицинским услугам ребенка...».
**Решение роутера:** `new_booking`.
**Активная ветка / шаг:** `new_booking` / `qualify.legal_rep`.
**Ретривер:** тот же срез, чанк `legal/fz-323-rep.md` уже в контексте.
**Структурированный ответ модели:**
```json
{
"reply": "Это стандартная практика — нужна для безопасности ребёнка и для того, чтобы мы могли с вами связаться, если в ходе приёма потребуется ваше согласие на процедуру. Согласно ФЗ № 323-ФЗ мы должны иметь возможность связаться с законным представителем в любой момент. Подскажите, пожалуйста, ваше полное ФИО и телефон.",
"state_after": "qualify.legal_rep",
"slots_updated": {}
}
```
Шаг и слоты не меняются — guard всё ещё блокирует выход.
---
## Реплика 5 · «Хорошо. Иванова Мария Сергеевна, +79991234567»
**Решение роутера:** `new_booking`.
**Активная ветка / шаг:** `new_booking` / `qualify.legal_rep`.
**Структурированный ответ модели:**
```json
{
"reply": "Спасибо, Мария Сергеевна. Записываю Матвея на первичный приём к ЛОР-врачу. Подберу ближайший свободный слот?",
"state_after": "present",
"slots_updated": {
"legal_rep_full_name": "Иванова Мария Сергеевна",
"legal_rep_phone": "+79991234567"
}
}
```
Guard выполнен — оба обязательных слота заполнены. Валидатор переходов в коде проверяет: переход `qualify.legal_rep → present` допустим **только если** `legal_rep_full_name` и `legal_rep_phone` заполнены. Условие выполняется, переход разрешён.
**`thread_state` после хода:**
```json
{
"current_intent": "new_booking",
"current_step": "present",
"slots": {
"service_mention": "ЛОР",
"is_child": true,
"patient_name": "Матвей",
"patient_age": 8,
"parent_first_name": "Мария",
"complaint": "боль в ушах, ухудшение слуха на одно ухо",
"legal_rep_full_name": "Иванова Мария Сергеевна",
"legal_rep_phone": "+79991234567"
}
}
```
---
## Реплика 6 · «Да, давайте посмотрим, что есть»
**Решение роутера:** `new_booking`.
**Активная ветка / шаг:** `new_booking` / `present`.
**Срабатывает второй guard** — на основании слота `complaint` (упоминание ухудшения слуха) промпт шага `present` должен предложить **сурдолога перед ЛОРом**. Это правило из вики и v3 раздел 3.2.
**Ретривер:** срез шага — `wiki_sources: ['/wiki/services/**', '/wiki/doctors/**', '/wiki/preparation/**']`.
- top-k: 3.
- Достались чанки:
- `services/приём-сурдолога.md` (score 0.88) — описание услуги.
- `services/первичный-приём-лор.md` (score 0.74).
- `doctors/общая-информация-сурдолог.md` (score 0.65).
**Структурированный ответ модели:**
```json
{
"reply": "С учётом того, что есть жалоба на ухудшение слуха на одно ухо, я бы предложила сначала записать Матвея к нашему сурдологу — он проведёт обследование слуха, и уже с его заключением ЛОР сможет дать более точное лечение. Если вы согласны — подберу слот к сурдологу. Или, если хотите, всё равно идём сразу к ЛОРу?",
"state_after": "present",
"slots_updated": {
"guard_surdologist_suggested": true
}
}
```
Шаг не меняется — ждём решение мамы. Флаг `guard_surdologist_suggested` нужен, чтобы при следующей реплике не предлагать сурдолога повторно, если она откажется.
---
## Реплика 7 · «Да, давайте сначала к сурдологу»
**Решение роутера:** `new_booking`.
**Активная ветка / шаг:** `new_booking` / `present``offer_time`.
**Структурированный ответ модели:**
```json
{
"reply": "Хорошо. Подбираю свободные слоты к сурдологу.",
"state_after": "offer_time",
"slots_updated": {
"service": "приём_сурдолога",
"doctor_preference": null
}
}
```
Шаг переходит в `offer_time`. Услуга в слотах теперь — приём сурдолога, не ЛОР.
---
## Реплика 8 · `crm.get_slots()` + предложение слотов
(Аналогично `01_basic_booking.md` репликам 5-6, без особенностей. Получаем 2 слота, мама выбирает один, идём в `book``close`.)
---
## Реплика 9 · Закрытие — финальная сводка с детскими оговорками
**Активная ветка / шаг:** `new_booking` / `close`.
**Срабатывает guard на шаге `close`:** при `is_child=true` промпт обязан включить специальный блок с юридическими оговорками из вики. Берётся из `legal/child-patient-final-script.md`.
**Ретривер:** срез шага — `wiki_sources: ['/wiki/contacts/**', '/wiki/preparation/**', '/wiki/legal/**']`.
- Достались чанки:
- `legal/child-patient-final-script.md` (score 0.95) — обязательный текст про сопровождение, паспорт + свидетельство о рождении, доверенность для других сопровождающих.
- `contacts/branch-klary-tsetkin-9.md` (score 0.81) — адрес, как доехать.
- `preparation/ребёнок-первичный.md` (score 0.71) — что взять с собой.
**Структурированный ответ модели:**
```json
{
"reply": "Записала Матвея на четверг 30 апреля, 11:00, к сурдологу Кузнецовой Елене Игоревне, ул. Газеты Звезда, 31-А. Стоимость приёма — 2 100 рублей. Если в ходе приёма понадобится дополнительная диагностика, она оплачивается отдельно. Подходите за 5 минут до назначенного времени, при себе паспорт одного из родителей и свидетельство о рождении ребёнка. \n\nОбращаю внимание: на приём вместе с ребёнком должен прийти его законный представитель. Если в качестве сопровождающего лица будет кто-то другой (бабушка, дедушка), у него обязательно должен быть паспорт, доверенность от законного представителя, копия свидетельства о рождении и копия паспорта представителя. Форма доверенности — на сайте клиники.\n\nПодтверждаете?",
"state_after": "close",
"slots_updated": {
"time_chosen": "2026-04-30 11:00",
"doctor": "Кузнецова Е. И.",
"branch": "ул. Газеты Звезда, 31-А",
"booking_id": "B-1236"
}
}
```
Длинная финальная реплика с юридической оговоркой из ФЗ-323 — ровно так, как написано в скрипте записи в вике для детских пациентов.
---
## Что показал этот пример
- **Guard как изменение допустимых переходов.** Шаг `qualify` имеет два пути: обычный `qualify → present` (для взрослых, `is_child=false`) и через под-шаг `qualify.legal_rep → present` (для детей). Валидатор переходов в коде должен это учитывать, иначе модель сможет «прыгнуть» через `legal_rep` и переход состоится с пустыми слотами представителя.
- **Раннее срабатывание флага `is_child`.** Модель установила его уже на реплике 1, до получения имени. Это важно: если бы `is_child` появился только на реплике 2, сбор данных пошёл бы в неправильном порядке (сначала жалоба, потом данные представителя — пациентка успела бы рассказать симптомы, прежде чем мы попросили бы документы).
- **Несколько guard'ов в одной ветке.** На шаге `present` сработал второй guard (предложить сурдолога), на шаге `close` — третий (юридический текст для детей). Они независимы и могут срабатывать в одном диалоге.
- **Ретривер на каждом шаге достаёт релевантный для guard'а контент.** На `qualify.legal_rep` — раздел `/wiki/legal/`, на `close` — финальные юридические оговорки. Без правильного `wiki_sources` на уровне шага модель должна была бы «помнить» юридический текст из системного промпта — это плохо масштабируется.
- **Возражение «а зачем вам это нужно?» обрабатывается в-line, без выхода из guard'а.** На реплике 4 модель пояснила, оставаясь в `qualify.legal_rep`. Это похоже на мягкую вставку из примера 02, но внутри одного и того же шага, не между ветками.
## Что важно проверять в eval-наборе на этом примере
- **Без legal_rep слотов переход `qualify.legal_rep → present` не должен срабатывать.** Тест: подать в ветку модельный ответ с `state_after: "present"` при пустых `legal_rep_full_name` или `legal_rep_phone` → валидатор должен отклонить переход, состояние остаётся `qualify.legal_rep`, в логе предупреждение.
- **`is_child=true` устанавливается рано.** Тест: фраза «запишите ребёнка», без других слов → проверить, что `is_child=true` появляется в `slots_updated` уже на первой реплике.
- **На шаге `close` для ребёнка ответ обязан содержать упоминание свидетельства о рождении и доверенности.** Тест: прогнать сценарий с `is_child=true` → проверить, что финальный `reply` содержит подстроки «свидетельство о рождении» и «доверенность». Это простая проверка подстрокой, не нужен LLM-as-judge.
- **Guard сурдолога не срабатывает повторно.** Тест: после реплики, в которой бот предложил сурдолога, мама отказалась → проверить, что на следующей реплике бот не предлагает сурдолога снова (флаг `guard_surdologist_suggested` сделал своё дело).
+966
View File
@@ -0,0 +1,966 @@
# Пример 04 · Простые информационные запросы (general_info)
> Связано с [`GRAPH_ARCHITECTURE_v3.md`](../architecture/GRAPH_ARCHITECTURE_v3.md), разделы 1, 2, 6. Демонстрирует **простейший случай**: ветка `general_info` без машины состояний — одна реплика пациента, ретривер, ответ, `done`. Никаких слотов, никаких tool calls, никаких guard'ов. Это та точка, с которой реально удобно запускать систему: классификатор + RAG + одна шаблонная ветка.
## О чём этот пример
Это сборник коротких самостоятельных диалогов по одному типу запросов — общая информация (часы, адрес, проезд, телефоны, что брать с собой, чего клиника не делает). У всех у них одна и та же траектория:
1. **Роутер** классифицирует реплику как `general_info`.
2. Запускается ветка `general_info`, шаг `answer` (он же единственный).
3. **Ретривер** достаёт чанки из соответствующего раздела вики (`/wiki/contacts/**`, `/wiki/branches/**`, `/wiki/hours/**`, `/wiki/preparation/**`, `/wiki/services/_general/**`).
4. Модель формирует ответ строго по найденным чанкам.
5. `state_after = done`.
Цель — на каждой реплике показать:
- **решение роутера** (router) — какое намерение распознано;
- **работу ретривера** (retriever) — какие чанки достаются и из каких разделов вики;
- **структурированный ответ модели** — `reply`, `state_after`, `slots_updated`;
- **итоговое состояние треда** (thread_state).
Слоты у `general_info` практически не используются — одно поле `info_topic` модель проставляет, чтобы ретривер при последующих репликах в том же треде не лез в посторонние разделы. Если в ходе ответа возникает запрос на запись («а можно тогда записаться?»), это уже **жёсткий переход** в `new_booking` (см. примеры 05+).
## Конфигурация ветки general_info
```yaml
intent: general_info
steps:
answer:
wiki_sources:
- /wiki/contacts/**
- /wiki/branches/**
- /wiki/hours/**
- /wiki/preparation/**
- /wiki/services/_general/**
tools: []
next: done
done:
wiki_sources: []
tools: []
```
## Начальное состояние (общее для всех примеров ниже)
Перед каждым диалогом в `thread_state` пусто — это первый ход свежего треда:
```json
{
"thread_id": "T-9XXX",
"current_intent": null,
"current_step": null,
"slots": {},
"handoff_count": 0,
"suspended_intent": null,
"resumable_step": null,
"resumable_slots": null
}
```
После ответа `current_intent = "general_info"`, `current_step = "done"`, и тред физически не закрывается — пациент может задать следующий вопрос. Если он задаст ещё один информационный вопрос, тот пройдёт по той же траектории заново (роутер его классифицирует, ветка отработает и снова уйдёт в `done`).
---
# A · Часы работы
## A.1 · «Во сколько вы открываетесь?»
**Решение роутера:** `general_info`. Чёткий вопрос про режим работы, никаких других намерений.
**Активная ветка / шаг:** `general_info` / `answer`.
**Ретривер:**
- Фильтр: `doc_path STARTS WITH any('/wiki/hours/**')`.
- top-k: 2.
- Достались чанки:
- `hours/clinic-czetkina-9.md` (score 0.93) — режим работы филиала на Клары Цеткин 9.
- `hours/clinic-pirogova.md` (score 0.71) — режим работы филиала «Пирогова».
Модели достаточно первого чанка. Второй пригодится, если пациент уточнит про другой адрес.
**Вызовы инструментов:** нет.
**Структурированный ответ модели:**
```json
{
"reply": "Здравствуйте! Клиника на Клары Цеткин 9 открывается в 8:00 в будни и в 9:00 в выходные. Закрываемся в 21:00 в будни и в 18:00 в выходные. Если интересует другой филиал — подскажу отдельно.",
"state_after": "done",
"slots_updated": {
"info_topic": "hours"
}
}
```
**`thread_state` после хода:**
```json
{
"current_intent": "general_info",
"current_step": "done",
"slots": {
"info_topic": "hours"
}
}
```
---
## A.2 · «До скольки работает клиника на Цеткина?»
**Решение роутера:** `general_info`. Конкретика про филиал — это всё ещё информационный вопрос, не запись.
**Активная ветка / шаг:** `general_info` / `answer`.
**Ретривер:**
- Фильтр: `doc_path STARTS WITH any('/wiki/hours/**', '/wiki/branches/**')`.
- top-k: 2.
- Достались чанки:
- `hours/clinic-czetkina-9.md` (score 0.94) — режим работы.
- `branches/czetkina-9.md` (score 0.78) — карточка филиала с адресом.
Реплика содержит «Цеткина» — в индексе совпадает по названию филиала, поэтому ретривер уверенно вытаскивает нужную карточку.
**Вызовы инструментов:** нет.
**Структурированный ответ модели:**
```json
{
"reply": "Клиника на Клары Цеткин 9 работает до 21:00 в будни и до 18:00 в выходные.",
"state_after": "done",
"slots_updated": {
"info_topic": "hours",
"branch_mention": "Клары Цеткин 9"
}
}
```
Заметим: `branch_mention` модель кладёт в слоты, чтобы при возможном следующем вопросе («а проезд как?») ретривер мог сразу дать чанк именно про этот филиал, а не про другой.
**`thread_state` после хода:**
```json
{
"current_intent": "general_info",
"current_step": "done",
"slots": {
"info_topic": "hours",
"branch_mention": "Клары Цеткин 9"
}
}
```
---
## A.3 · «У вас по выходным открыто?»
**Решение роутера:** `general_info`.
**Активная ветка / шаг:** `general_info` / `answer`.
**Ретривер:**
- Фильтр: `doc_path STARTS WITH any('/wiki/hours/**')`.
- top-k: 2.
- Достались чанки:
- `hours/clinic-czetkina-9.md` (score 0.86).
- `hours/clinic-pirogova.md` (score 0.81).
Здесь пациент не уточнил филиал, поэтому в ответе разумно перечислить оба.
**Вызовы инструментов:** нет.
**Структурированный ответ модели:**
```json
{
"reply": "Да, мы работаем и в субботу, и в воскресенье. На Клары Цеткин 9 — с 9:00 до 18:00, в Клинике Пирогова — с 9:00 до 17:00. Расписание конкретного врача может отличаться, при записи подскажу.",
"state_after": "done",
"slots_updated": {
"info_topic": "hours"
}
}
```
Замечание: фраза «расписание конкретного врача может отличаться» — это **зашитая в промпт ветки оговорка** (canned disclaimer), не результат RAG. Часы работы клиники и слоты конкретного врача — разные вещи, и модель должна на этом не зависать.
**`thread_state` после хода:**
```json
{
"current_intent": "general_info",
"current_step": "done",
"slots": {
"info_topic": "hours"
}
}
```
---
## A.4 · «Завтра праздник, вы работаете?»
**Решение роутера:** `general_info`.
**Активная ветка / шаг:** `general_info` / `answer`.
**Ретривер:**
- Фильтр: `doc_path STARTS WITH any('/wiki/hours/**')`.
- top-k: 3.
- Достались чанки:
- `hours/clinic-czetkina-9.md` (score 0.74) — обычный режим.
- `hours/holidays-2026.md` (score 0.42) — праздничный календарь, низкий score (документ короткий, неточное совпадение по датам).
- `hours/clinic-pirogova.md` (score 0.71).
Score праздничного чанка — ниже порога уверенности (0.50). По правилу шага: **если ни один чанк не покрывает запрос полностью — отвечаем шаблоном «уточним и перезвоним»**, а не выдумываем расписание праздников.
**Вызовы инструментов:** нет.
**Структурированный ответ модели:**
```json
{
"reply": "В обычные дни мы работаем с 8:00 до 21:00. В праздничные дни режим может меняться. Подскажите, пожалуйста, на какую дату вы планируете визит — я уточню у регистратуры и сообщу вам в течение дня.",
"state_after": "done",
"slots_updated": {
"info_topic": "hours",
"needs_followup": true
}
}
```
Слот `needs_followup: true` — сигнал для оператора в инбоксе, что тред нужно дозакрыть человеком. На дашборде такие треды попадают в очередь «требуется уточнение».
**`thread_state` после хода:**
```json
{
"current_intent": "general_info",
"current_step": "done",
"slots": {
"info_topic": "hours",
"needs_followup": true
}
}
```
---
# B · Адрес и филиалы
## B.1 · «Где вы находитесь?»
**Решение роутера:** `general_info`.
**Активная ветка / шаг:** `general_info` / `answer`.
**Ретривер:**
- Фильтр: `doc_path STARTS WITH any('/wiki/branches/**')`.
- top-k: 3.
- Достались чанки:
- `branches/_index.md` (score 0.88) — общий перечень филиалов.
- `branches/czetkina-9.md` (score 0.71).
- `branches/pirogova.md` (score 0.69).
**Вызовы инструментов:** нет.
**Структурированный ответ модели:**
```json
{
"reply": "У нас два адреса в Перми: ул. Клары Цеткин, 9 (основная клиника, ЛОР, сурдология, отоневрология) и Клиника Пирогова, ул. Героев Хасана 24 (терапия, аллергология, телемед). Подсказать, какой ближе к вам или какая специальность нужна?",
"state_after": "done",
"slots_updated": {
"info_topic": "branches"
}
}
```
Финальный встречный вопрос — лёгкий, без обязательного ответа. Если пациент проигнорирует, тред просто останется в `done`. Если ответит — пойдёт ещё один проход через роутера.
**`thread_state` после хода:**
```json
{
"current_intent": "general_info",
"current_step": "done",
"slots": {
"info_topic": "branches"
}
}
```
---
## B.2 · «Какой у вас точный адрес на Цеткина?»
**Решение роутера:** `general_info`.
**Активная ветка / шаг:** `general_info` / `answer`.
**Ретривер:**
- Фильтр: `doc_path STARTS WITH any('/wiki/branches/**')`.
- top-k: 1.
- Достались чанки:
- `branches/czetkina-9.md` (score 0.96) — карточка филиала: адрес, ориентиры, вход.
Высокий score, точный матч — достаточно одного чанка.
**Вызовы инструментов:** нет.
**Структурированный ответ модели:**
```json
{
"reply": "Адрес: г. Пермь, ул. Клары Цеткин, 9. Вход со стороны двора, ориентир — голубая вывеска «Клиника Ухо, Горло, Нос». Регистратура на первом этаже.",
"state_after": "done",
"slots_updated": {
"info_topic": "branches",
"branch_mention": "Клары Цеткин 9"
}
}
```
**`thread_state` после хода:**
```json
{
"current_intent": "general_info",
"current_step": "done",
"slots": {
"info_topic": "branches",
"branch_mention": "Клары Цеткин 9"
}
}
```
---
## B.3 · «Сколько у вас филиалов в городе?»
**Решение роутера:** `general_info`.
**Активная ветка / шаг:** `general_info` / `answer`.
**Ретривер:**
- Фильтр: `doc_path STARTS WITH any('/wiki/branches/**')`.
- top-k: 1.
- Достались чанки:
- `branches/_index.md` (score 0.90) — общий перечень.
**Вызовы инструментов:** нет.
**Структурированный ответ модели:**
```json
{
"reply": "В Перми у нас два филиала: основная клиника на ул. Клары Цеткин, 9, и Клиника Пирогова на ул. Героев Хасана, 24. Других филиалов и онлайн-клиник в других городах у нас нет — приём только в этих двух.",
"state_after": "done",
"slots_updated": {
"info_topic": "branches"
}
}
```
Замечание: пациенты иногда спрашивают «вы же есть в Москве?», путая клинику с одноимёнными. Ответ намеренно явно говорит «других филиалов нет» — чтобы не оставить надежду на не-существующее.
**`thread_state` после хода:**
```json
{
"current_intent": "general_info",
"current_step": "done",
"slots": {
"info_topic": "branches"
}
}
```
---
## B.4 · «Как вас на Яндекс Картах искать?»
**Решение роутера:** `general_info`.
**Активная ветка / шаг:** `general_info` / `answer`.
**Ретривер:**
- Фильтр: `doc_path STARTS WITH any('/wiki/branches/**', '/wiki/contacts/**')`.
- top-k: 2.
- Достались чанки:
- `contacts/online-presence.md` (score 0.84) — карточка организации, ссылки на 2ГИС / Я.Карты.
- `branches/_index.md` (score 0.66).
**Вызовы инструментов:** нет.
**Структурированный ответ модели:**
```json
{
"reply": "На Яндекс Картах ищите по запросу «Клиника Ухо Горло Нос Пермь» — найдутся обе наших точки: на Клары Цеткин 9 и на Героев Хасана 24. Можно сразу проложить маршрут оттуда.",
"state_after": "done",
"slots_updated": {
"info_topic": "branches"
}
}
```
**`thread_state` после хода:**
```json
{
"current_intent": "general_info",
"current_step": "done",
"slots": {
"info_topic": "branches"
}
}
```
---
# C · Как добраться и парковка
## C.1 · «На метро как до вас доехать?»
**Решение роутера:** `general_info`.
**Активная ветка / шаг:** `general_info` / `answer`.
**Ретривер:**
- Фильтр: `doc_path STARTS WITH any('/wiki/branches/**')`.
- top-k: 2.
- Достались чанки:
- `branches/czetkina-9.md` (score 0.62) — раздел «как добраться».
- `branches/pirogova.md` (score 0.59).
Ни один чанк не упоминает метро — потому что **в Перми метро нет**. Ретривер вытащил то, что про транспорт ближе всего по теме.
**Вызовы инструментов:** нет.
**Структурированный ответ модели:**
```json
{
"reply": "В Перми метро нет, поэтому удобнее всего добираться на автобусе, троллейбусе или такси. До Клары Цеткин 9 — остановка «Площадь Восстания» (5 минут пешком), маршруты 14, 32, 36, 67. До Клиники Пирогова — остановка «Героев Хасана», маршруты 19, 27. Подсказать что-то конкретнее, если знаете, откуда поедете?",
"state_after": "done",
"slots_updated": {
"info_topic": "transit"
}
}
```
Это полезный пограничный кейс: вопрос содержит **ложное допущение** (метро). Промпт ветки требует мягко поправить пациента, не оставляя его в недоумении, и сразу предложить рабочую альтернативу.
**`thread_state` после хода:**
```json
{
"current_intent": "general_info",
"current_step": "done",
"slots": {
"info_topic": "transit"
}
}
```
---
## C.2 · «Парковка у вас есть?»
**Решение роутера:** `general_info`.
**Активная ветка / шаг:** `general_info` / `answer`.
**Ретривер:**
- Фильтр: `doc_path STARTS WITH any('/wiki/branches/**')`.
- top-k: 2.
- Достались чанки:
- `branches/czetkina-9.md` (score 0.81) — раздел «парковка».
- `branches/pirogova.md` (score 0.78) — раздел «парковка».
**Вызовы инструментов:** нет.
**Структурированный ответ модели:**
```json
{
"reply": "Да, парковка есть на обоих филиалах. На Клары Цеткин 9 — небольшая бесплатная парковка во дворе клиники, обычно есть свободные места. У Клиники Пирогова — общая платная парковка торгового центра рядом, въезд с ул. Героев Хасана. Подсказать, какой филиал вам удобнее?",
"state_after": "done",
"slots_updated": {
"info_topic": "parking"
}
}
```
**`thread_state` после хода:**
```json
{
"current_intent": "general_info",
"current_step": "done",
"slots": {
"info_topic": "parking"
}
}
```
---
## C.3 · «Как пешком от центра дойти до Цеткина?»
**Решение роутера:** `general_info`.
**Активная ветка / шаг:** `general_info` / `answer`.
**Ретривер:**
- Фильтр: `doc_path STARTS WITH any('/wiki/branches/**')`.
- top-k: 1.
- Достались чанки:
- `branches/czetkina-9.md` (score 0.79) — раздел «как добраться» → подраздел «пешком».
**Вызовы инструментов:** нет.
**Структурированный ответ модели:**
```json
{
"reply": "От Комсомольского проспекта дойти можно за 12–15 минут: вниз по Сибирской до Клары Цеткин, направо до дома 9. Удобнее всего ориентироваться на Яндекс Карты — там же увидите маршрут.",
"state_after": "done",
"slots_updated": {
"info_topic": "transit",
"branch_mention": "Клары Цеткин 9"
}
}
```
**`thread_state` после хода:**
```json
{
"current_intent": "general_info",
"current_step": "done",
"slots": {
"info_topic": "transit",
"branch_mention": "Клары Цеткин 9"
}
}
```
---
# D · Контакты
## D.1 · «Дайте телефон регистратуры»
**Решение роутера:** `general_info`.
**Активная ветка / шаг:** `general_info` / `answer`.
**Ретривер:**
- Фильтр: `doc_path STARTS WITH any('/wiki/contacts/**')`.
- top-k: 1.
- Достались чанки:
- `contacts/registratura.md` (score 0.95) — телефоны регистратуры по филиалам.
**Вызовы инструментов:** нет.
**Структурированный ответ модели:**
```json
{
"reply": "Регистратура на Клары Цеткин 9 — +7 (342) 204-00-00. Регистратура Клиники Пирогова — +7 (342) 204-67-16. Звонят с 8:00 до 21:00 в будни. Если удобнее, можно записаться прямо здесь — я помогу.",
"state_after": "done",
"slots_updated": {
"info_topic": "contacts"
}
}
```
Хвостовая фраза «можно записаться прямо здесь» — это soft-cross-sell, разрешённая в ветке `general_info`. Пациент свободен её проигнорировать; если откликнется — следующая реплика уйдёт в `new_booking` через роутера.
**`thread_state` после хода:**
```json
{
"current_intent": "general_info",
"current_step": "done",
"slots": {
"info_topic": "contacts"
}
}
```
---
## D.2 · «У вас WhatsApp есть?»
**Решение роутера:** `general_info`.
**Активная ветка / шаг:** `general_info` / `answer`.
**Ретривер:**
- Фильтр: `doc_path STARTS WITH any('/wiki/contacts/**')`.
- top-k: 1.
- Достались чанки:
- `contacts/messengers.md` (score 0.93) — мессенджеры, в которых отвечает клиника.
**Вызовы инструментов:** нет.
**Структурированный ответ модели:**
```json
{
"reply": "Да, мы отвечаем в WhatsApp по номеру +7 (342) 204-00-00 — это тот же телефон регистратуры. Также есть Telegram: @ugn_clinic. Уведомления о приёме приходят на тот номер, который вы оставите при записи.",
"state_after": "done",
"slots_updated": {
"info_topic": "contacts"
}
}
```
**`thread_state` после хода:**
```json
{
"current_intent": "general_info",
"current_step": "done",
"slots": {
"info_topic": "contacts"
}
}
```
---
## D.3 · «А сайт у вас какой?»
**Решение роутера:** `general_info`.
**Активная ветка / шаг:** `general_info` / `answer`.
**Ретривер:**
- Фильтр: `doc_path STARTS WITH any('/wiki/contacts/**')`.
- top-k: 1.
- Достались чанки:
- `contacts/online-presence.md` (score 0.91).
**Вызовы инструментов:** нет.
**Структурированный ответ модели:**
```json
{
"reply": "Сайт клиники: ugn-clinic.ru. Там можно посмотреть прайс, расписание врачей и записаться. Если хотите — могу записать вас прямо сейчас, без перехода на сайт.",
"state_after": "done",
"slots_updated": {
"info_topic": "contacts"
}
}
```
**`thread_state` после хода:**
```json
{
"current_intent": "general_info",
"current_step": "done",
"slots": {
"info_topic": "contacts"
}
}
```
---
# E · Документы и подготовка к приёму
## E.1 · «Что нужно с собой принести?»
**Решение роутера:** `general_info`.
**Активная ветка / шаг:** `general_info` / `answer`.
**Ретривер:**
- Фильтр: `doc_path STARTS WITH any('/wiki/preparation/**')`.
- top-k: 2.
- Достались чанки:
- `preparation/what-to-bring.md` (score 0.92) — общий список.
- `preparation/лор-первичный.md` (score 0.65) — для ЛОРа отдельно.
**Вызовы инструментов:** нет.
**Структурированный ответ модели:**
```json
{
"reply": "На приём возьмите паспорт (для пациентов с 14 лет) и, если есть, выписки или результаты предыдущих обследований по теме визита. Полис ОМС не нужен — клиника частная. Если есть конкретная специальность, к которой записываетесь, могу подсказать, что взять именно для неё.",
"state_after": "done",
"slots_updated": {
"info_topic": "preparation"
}
}
```
**`thread_state` после хода:**
```json
{
"current_intent": "general_info",
"current_step": "done",
"slots": {
"info_topic": "preparation"
}
}
```
---
## E.2 · «Полис ОМС у вас принимают?»
**Решение роутера:** `general_info`. Это ещё не вопрос про цену в чистом виде — пациент уточняет принцип, не считает деньги. Но граница тонкая, см. замечание ниже.
**Активная ветка / шаг:** `general_info` / `answer`.
**Ретривер:**
- Фильтр: `doc_path STARTS WITH any('/wiki/preparation/**', '/wiki/services/_general/**')`.
- top-k: 2.
- Достались чанки:
- `preparation/what-to-bring.md` (score 0.74) — там есть строка про ОМС.
- `services/_general/payment-options.md` (score 0.81) — формы оплаты, ДМС.
**Вызовы инструментов:** нет.
**Структурированный ответ модели:**
```json
{
"reply": "Нет, мы частная клиника и по полису ОМС не работаем. Принимаем оплату наличными, картой и переводом. По ДМС — работаем не со всеми страховыми, нужно уточнять по конкретной компании.",
"state_after": "done",
"slots_updated": {
"info_topic": "preparation"
}
}
```
Замечание: следующая реплика «а с какими страховыми работаете?» — это уже **жёсткий переход** в `price_question` (там есть отдельная ветвь про ДМС-покрытие). На текущей реплике мы аккуратно касаемся темы, но не углубляемся.
**`thread_state` после хода:**
```json
{
"current_intent": "general_info",
"current_step": "done",
"slots": {
"info_topic": "preparation"
}
}
```
---
## E.3 · «Сыну 16, можно без меня прийти?»
**Решение роутера:** `general_info`. Вопрос про порядок приёма несовершеннолетних — это организационный, не медицинский и не запись.
**Активная ветка / шаг:** `general_info` / `answer`.
**Ретривер:**
- Фильтр: `doc_path STARTS WITH any('/wiki/preparation/**', '/wiki/_faq/**')`.
- top-k: 2.
- Достались чанки:
- `_faq/teenagers-without-parents.md` (score 0.94) — соответствует в точности.
- `preparation/what-to-bring.md` (score 0.61).
Чанк `teenagers-without-parents.md` — это статья из раздела ЧаВо (см. источник `vrachi-kliniki-...md`, пункт «Если пациенту 16-17 лет»), индексированная в общий пул `general_info`.
**Вызовы инструментов:** нет.
**Структурированный ответ модели:**
```json
{
"reply": "С 15 лет подросток может прийти на приём один и сам подписать договор — нужен паспорт. Если родители смогут позже подъехать или прислать документы — это тоже подойдёт. Записать сына на удобное время?",
"state_after": "done",
"slots_updated": {
"info_topic": "preparation"
}
}
```
**`thread_state` после хода:**
```json
{
"current_intent": "general_info",
"current_step": "done",
"slots": {
"info_topic": "preparation"
}
}
```
---
# F · Что клиника не делает
## F.1 · «У вас можно сделать бронхоскопию?»
**Решение роутера:** `general_info`. По формальному признаку — пациент спрашивает про услугу. Но это не запись и не цена, это уточнение «делаете ли вы такое в принципе». Промпт роутера явно относит подобные формулировки к `general_info`, чтобы не запускать механизм записи на услугу, которой нет.
**Активная ветка / шаг:** `general_info` / `answer`.
**Ретривер:**
- Фильтр: `doc_path STARTS WITH any('/wiki/services/_general/**')`.
- top-k: 2.
- Достались чанки:
- `services/_general/not-performed.md` (score 0.97) — список процедур, которые НЕ проводятся.
- `services/_general/scope.md` (score 0.62) — общий профиль клиники.
Чанк `not-performed.md` собран из соответствующего раздела вики (см. источник «Процедуры, которые в клинике НЕ проводятся» в `skripty-...md`). Бронхоскопия там есть в явном виде.
**Вызовы инструментов:** нет.
**Структурированный ответ модели:**
```json
{
"reply": "Бронхоскопию у нас не делают — это исследование с эндоскопом по слизистым трахеи и бронхов, его нужно искать в специализированных пульмонологических центрах. Если вас беспокоит кашель или дыхание — мы можем записать к пульмонологу на консультацию, он уже подскажет, куда дальше.",
"state_after": "done",
"slots_updated": {
"info_topic": "scope_of_services"
}
}
```
Это **важный сценарий для honesty-eval'а**: модель не должна выдумывать «да, конечно, запишем» в надежде, что пациент придёт и разберётся на месте. Прямой отказ + полезная альтернатива.
**`thread_state` после хода:**
```json
{
"current_intent": "general_info",
"current_step": "done",
"slots": {
"info_topic": "scope_of_services"
}
}
```
---
## F.2 · «Криодеструкцию миндалин делаете?»
**Решение роутера:** `general_info`.
**Активная ветка / шаг:** `general_info` / `answer`.
**Ретривер:**
- Фильтр: `doc_path STARTS WITH any('/wiki/services/_general/**')`.
- top-k: 1.
- Достались чанки:
- `services/_general/not-performed.md` (score 0.94).
**Вызовы инструментов:** нет.
**Структурированный ответ модели:**
```json
{
"reply": "Нет, криодеструкцию миндалин в нашей клинике не проводят. Если вас беспокоит хронический тонзиллит — можем записать на приём к ЛОР-врачу: он осмотрит, предложит варианты лечения из тех, что у нас доступны, и подскажет, куда обратиться, если понадобится именно эта процедура.",
"state_after": "done",
"slots_updated": {
"info_topic": "scope_of_services"
}
}
```
**`thread_state` после хода:**
```json
{
"current_intent": "general_info",
"current_step": "done",
"slots": {
"info_topic": "scope_of_services"
}
}
```
---
## F.3 · «Электрофорез носа можно у вас?»
**Решение роутера:** `general_info`.
**Активная ветка / шаг:** `general_info` / `answer`.
**Ретривер:**
- Фильтр: `doc_path STARTS WITH any('/wiki/services/_general/**')`.
- top-k: 1.
- Достались чанки:
- `services/_general/not-performed.md` (score 0.91) — электрофорез в списке «не проводится».
**Вызовы инструментов:** нет.
**Структурированный ответ модели:**
```json
{
"reply": "Нет, электрофорез у нас не делают. Из физиопроцедур по ЛОР-профилю мы предлагаем промывания, орошения, лечебные ингаляции — если интересно, могу подробнее рассказать или сразу записать на приём.",
"state_after": "done",
"slots_updated": {
"info_topic": "scope_of_services"
}
}
```
**`thread_state` после хода:**
```json
{
"current_intent": "general_info",
"current_step": "done",
"slots": {
"info_topic": "scope_of_services"
}
}
```
---
## Что показал этот пример
- **Одна реплика — один проход.** Ветка `general_info` не имеет машины состояний: единственный «осмысленный» шаг `answer` сразу уводит в `done`. Это самый дешёвый путь в системе и логичная точка запуска первой версии бота.
- **Слоты почти не используются.** Поле `info_topic` нужно ровно для того, чтобы при следующем вопросе в том же треде ретривер знал, в какую сторону смотреть в первую очередь. Опционально — `branch_mention`, чтобы помнить, про какой филиал шла речь.
- **Ретривер делает основную работу.** Все факты в ответе должны быть из чанков, не из памяти модели. Если score лучшего чанка ниже порога — модель уходит в шаблон «уточним и перезвоним» с `needs_followup=true` (см. A.4).
- **Пограничные кейсы важнее, чем happy path.** Метро в Перми (которого нет — C.1), услуги, которых клиника не предоставляет (F), праздничные дни без чанка (A.4) — именно на них модель ломается чаще всего и именно их полезно держать в eval-наборе с самого начала.
- **Soft cross-sell разрешён, но мягкий.** Хвост «могу записать прямо сейчас» — нормальная практика для информационных ответов. Жёстко продавать запись — нет.
## Что важно проверять в eval-наборе на этом примере
- Роутер **не уводит** информационные вопросы в `new_booking` или `price_question` (классическая ошибка — на «сколько у вас стоит» в чистом виде это уже `price_question`, а на «какие у вас услуги» — `general_info`; границы должны быть чёткими).
- Все факты в `reply` находимы в одном из чанков, попавших в контекст. Хорошая метрика — `groundedness` (доля утверждений, для которых есть прямое подтверждение в источниках).
- При отсутствии релевантных чанков модель отвечает шаблоном «уточним», а не выдумывает (см. A.4 и логика `needs_followup`).
- Ответы не превышают 3–4 предложений. `general_info` — не место для лекций.
- Слот `info_topic` проставлен корректно (`hours`, `branches`, `transit`, `parking`, `contacts`, `preparation`, `scope_of_services`) — эта же таксономия используется в дашборде «о чём чаще всего спрашивают».
- На пограничных кейсах (метро в Перми, услуги, которых нет, праздничные дни) ответ не выдаёт ложных утверждений — это критичный безопасный минимум для запуска.