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:
@@ -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` содержит ровно тот набор данных из скрипта вики: дата, врач, адрес, стоимость, что принести.
|
||||
@@ -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` был передан. Это проверка маппинга при возврате — частая точка регрессий.
|
||||
@@ -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` сделал своё дело).
|
||||
@@ -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`) — эта же таксономия используется в дашборде «о чём чаще всего спрашивают».
|
||||
- На пограничных кейсах (метро в Перми, услуги, которых нет, праздничные дни) ответ не выдаёт ложных утверждений — это критичный безопасный минимум для запуска.
|
||||
Reference in New Issue
Block a user