feat(sprint6c+sprint7): терминология, сверка примеров с кодом, мульти-RAG (часть A)
Спринт 6c — терминология и сверка документации с реальным кодом:
- Словарь терминов в static/docs.html: «маршрутизатор» вместо «роутер»,
«защитное условие» вместо «guard», «пошаговая ветка» вместо «многошаговая».
Разделены концепты «намерение» (intent) и «ветка» (branch) с пометкой,
что в коде они хранятся как одна сущность 1:1.
- Песочница: «Решение маршрутизатора» виден всегда (зелёный/жёлтый),
счётчик переключений «N из 3» отдельной плашкой, бейджи под словарь.
- Настройки: «Условия перехода» → «Защитные условия (guards, JSON)».
- GRAPH_ARCHITECTURE_v4.md: имена полей thread_state и слоты приведены
к реальной БД (db/models/thread_state.py) и таксономии промптов шагов
(prompts/intents/new_booking/steps/). Ссылки на *_v2 примеры. На v3
поставлена шапка «устарело».
- 4 примера переписаны как *_v2: реальные current_intent_code/
current_step_code/slots_json, реальные allowed_next без двойных переходов,
реальная таксономия слотов name/reason/specialist/preferred_time/confirmed.
Удалены вымышленные CRM tool calls и слоты, которых нет в коде.
- static/example.html — параметризованная страница с навигацией между
4 примерами; роут GET /api/docs/examples/{name} в main.py отдаёт
markdown без дублирования файлов.
- Редактирование документов в Отладке: GET/PUT /documents/{id}/raw,
textarea с переразметкой и обновлением Chroma при сохранении.
Спринт 7, часть A — мульти-RAG через подписку ветка↔документы:
- Миграция: таблица intent_documents (M:N), модель IntentDocument,
индекс по document_id для обратного поиска.
- API: GET/PUT /intents/{code}/documents и GET/PUT /documents/{id}/intents
с PUT-семантикой «полный список», атомарно. Сервис
services/intent_document_service.py.
- Retrieval-фильтр в chat_service: подтягивает document_ids активной
ветки и передаёт в vectorstore.query(). Дефолт пустой подписки —
document_ids=[] (= 0 чанков), не «вся коллекция»: пустая подписка
означает «ветка не настроена», подмешивать случайное хуже, чем
ничего. vectorstore.query() различает None (нет фильтра) и [] (0).
- UI Настроек: блок «Документы базы знаний» в правом сайдбаре,
всегда видим независимо от вкладки, сортировка по имени, счётчик
«N из M», PUT при сохранении.
- UI Отладки: третья кнопка «привязка» рядом с «удалить» —
раскрывашка со списком веток (галочки), быстрая привязка прямо
на странице загрузки.
- Песочница: блок «Срез RAG» с подпиской/найдено, ворнинг при пустой
подписке. Поле rag_subscription в QueryResponse и ChatResponse.
- Системный промпт страницы Отладки переехал в обычную ветку _debug
(«Страница отладки»). Удалён prompts/system_prompt.md и логика
DEFAULT_SYSTEM_PROMPT в llm_client. routers/query.py подтягивает
активный конфиг ветки _debug и её подписки. Дефолт пустой подписки
для _debug — None (вся коллекция), не [] как для пациентских — чтобы
Отладка работала «из коробки». На странице Отладки info-bar показывает
активную версию и счётчик подписок, ссылка → Настройки.
- Тест-блок «Тест-вопрос» в центре Настроек: расширил /query
параметрами intent_code (default _debug), system_prompt (override
для теста черновика из textarea), disable_rag (для _router).
Редактор промпта обёрнут в <details open> — можно свернуть до
одной строки. Под ним — три колонки результата (RAG / промпт /
ответ). Для _router показывается подсказка про отсутствие RAG.
Документы:
- data/datasets/*.md — наработки по 6 веткам (рабочие материалы оператора).
- docs/BRANCH_MAP_AND_PROMPTS_v1.md, docs/OPTIMIZATION_CONVERSION_v1.md,
docs/guides/state_machine_and_slots.md.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,7 @@
|
||||
# Пример 01 · Базовая запись к ЛОР-врачу (happy path)
|
||||
|
||||
> ⚠️ **Эта версия устарела.** Актуальная — [`01_basic_booking_v2.md`](01_basic_booking_v2.md). v1 был написан как архитектурный идеал и содержит вымышленные слоты (`service_mention`, `patient_name`, `time_candidates`, `booking_id` и др.) и CRM tool calls, которых в коде нет. v2 переписан под реальную таксономию слотов из промптов шагов и реальные имена полей `thread_state`. Список изменений — внизу v2 в Changelog.
|
||||
|
||||
> Связано с [`GRAPH_ARCHITECTURE_v3.md`](../architecture/GRAPH_ARCHITECTURE_v3.md), разделы 1, 2, 3. Демонстрирует **линейный** проход машины состояний (state machine) ветки `new_booking`: контакт → уточнение → презентация → слоты → запись → закрытие. Никаких guard'ов, никаких боковых вопросов, никаких переходов в другие ветки. Самый простой случай, на котором удобно увидеть базовое поведение системы.
|
||||
|
||||
## О чём этот пример
|
||||
|
||||
@@ -0,0 +1,335 @@
|
||||
# Пример 01 v2 · Базовая запись к ЛОР-врачу (happy path)
|
||||
|
||||
> **Версия v2 · 2026-04-27.** Переписано под реальный код: имена полей `thread_state` соответствуют `db/models/thread_state.py`, набор слотов — реальной таксономии из `prompts/intents/new_booking/steps/*.md`. Изменения относительно v1 — внизу в Changelog.
|
||||
>
|
||||
> Связано с [`../architecture/GRAPH_ARCHITECTURE_v4.md`](../architecture/GRAPH_ARCHITECTURE_v4.md), разделы 1–3. Демонстрирует **линейный** проход машины состояний ветки `new_booking`: `intro → qualify → present → offer_time → book → close`. Никаких защитных условий, никаких боковых вопросов, никаких переключений в другие ветки.
|
||||
|
||||
## О чём этот пример
|
||||
|
||||
Взрослый пациент пишет в чат, хочет записаться к ЛОР-врачу с жалобой на горло. Не уточняет конкретного врача, согласен на любое удобное время. Семь реплик, после которых ветка `new_booking` доходит до шага `close` и передаёт результат администратору клиники.
|
||||
|
||||
На каждой реплике показано:
|
||||
- **решение маршрутизатора** (router) — какое намерение распознано;
|
||||
- **активная ветка / шаг** (`current_intent_code` / `current_step_code`) — где мы сейчас;
|
||||
- **структурированный ответ модели** (хвостовой блок `STATE_JSON:`) — `state_after` и `slots_updated`;
|
||||
- **итоговое состояние треда** (`thread_state`) — что после хода легло в БД.
|
||||
|
||||
CRM-инструменты (`crm.get_slots`, `crm.create_booking`) и реальный календарь врачей **в этом коде ещё не подключены** — на шаге `book` модель только проговаривает собранные данные и получает явное «да», а на `close` сообщает, что администратор свяжется в течение дня. Реальная интеграция — в идеях на потом (см. `docs/SPRINTS.md`).
|
||||
|
||||
## Начальное состояние
|
||||
|
||||
Тред только что создан. Записи в `thread_state` ещё нет — она появится после первого ответа ассистента. Логически это эквивалентно:
|
||||
|
||||
```json
|
||||
{
|
||||
"thread_id": 9001,
|
||||
"current_intent_code": null,
|
||||
"current_step": 0,
|
||||
"current_step_code": null,
|
||||
"slots": {},
|
||||
"handoff_count": 0,
|
||||
"soft_insertion_count": 0,
|
||||
"suspended_intent": null,
|
||||
"resumable_step_code": null,
|
||||
"resumable_slots": null
|
||||
}
|
||||
```
|
||||
|
||||
> Примечание: `slots` и `resumable_slots` хранятся в БД как текстовые колонки `slots_json` / `resumable_slots_json`. В API распакованы в объекты — так и показываем во всех `thread_state` ниже.
|
||||
|
||||
---
|
||||
|
||||
## Реплика 1 · «Здравствуйте, хочу записаться к лору»
|
||||
|
||||
**Решение маршрутизатора:** `new_booking`. Однозначное упоминание записи + специальности.
|
||||
|
||||
**Активная ветка / шаг:** `new_booking` / `intro`. Шаг по умолчанию для свежей пошаговой ветки.
|
||||
|
||||
**Промпт шага `intro`** (`prompts/intents/new_booking/steps/intro.md`): представься, узнай имя пациента; не уточняй сразу повод, специальность, время. Слоты этого шага: `name`.
|
||||
|
||||
**Структурированный ответ модели:**
|
||||
|
||||
```
|
||||
Здравствуйте! Я виртуальный ассистент клиники, помогу вам записаться. Подскажите, как могу к вам обращаться?
|
||||
|
||||
STATE_JSON: {"state_after": "intro", "slots_updated": {}}
|
||||
```
|
||||
|
||||
Шаг **не меняется** — мы остаёмся в `intro`, пока не получили имя. Слоты пустые: специальность «лор» пациент назвал, но `intro.md` явно запрещает её фиксировать на этом шаге — это работа `qualify`.
|
||||
|
||||
**`thread_state` после хода:**
|
||||
|
||||
```json
|
||||
{
|
||||
"current_intent_code": "new_booking",
|
||||
"current_step": 0,
|
||||
"current_step_code": "intro",
|
||||
"slots": {},
|
||||
"handoff_count": 0,
|
||||
"soft_insertion_count": 0
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Реплика 2 · «Анна Петровна»
|
||||
|
||||
**Решение маршрутизатора:** `new_booking`. Намерение не сменилось.
|
||||
|
||||
**Активная ветка / шаг:** `new_booking` / `intro`. Имя получено — переход в `qualify` разрешён (`allowed_next: ["intro", "qualify"]`).
|
||||
|
||||
**Структурированный ответ модели:**
|
||||
|
||||
```
|
||||
Очень приятно, Анна Петровна. Подскажите, что вас беспокоит — чтобы я подобрал подходящего специалиста?
|
||||
|
||||
STATE_JSON: {"state_after": "qualify", "slots_updated": {"name": "Анна Петровна"}}
|
||||
```
|
||||
|
||||
Шаг переходит в `qualify`. Числовой счётчик `current_step` инкрементируется (это монотонный счётчик хода, нужен для совместимости с историей версий схемы; для логики используется `current_step_code`).
|
||||
|
||||
**`thread_state` после хода:**
|
||||
|
||||
```json
|
||||
{
|
||||
"current_intent_code": "new_booking",
|
||||
"current_step": 1,
|
||||
"current_step_code": "qualify",
|
||||
"slots": {
|
||||
"name": "Анна Петровна"
|
||||
},
|
||||
"handoff_count": 0,
|
||||
"soft_insertion_count": 0
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Реплика 3 · «Болит горло уже неделю»
|
||||
|
||||
**Решение маршрутизатора:** `new_booking`. Жалоба упомянута, но в контексте записи — не самостоятельный медицинский вопрос. По промпту `qualify.md` модель **не уходит** в `medical_question` по одному факту жалобы — это повод для записи, а не для обсуждения симптомов.
|
||||
|
||||
**Активная ветка / шаг:** `new_booking` / `qualify`.
|
||||
|
||||
**Промпт шага `qualify`:** записать жалобу в `reason`, выразить короткое сочувствие, при отсутствии специалиста — мягко предложить направление по поводу.
|
||||
|
||||
**Структурированный ответ модели:**
|
||||
|
||||
```
|
||||
Понимаю, боль в горле — это неприятно. С такой жалобой обычно записывают к ЛОР-врачу. Подходит?
|
||||
|
||||
STATE_JSON: {"state_after": "qualify", "slots_updated": {"reason": "боль в горле, неделю"}}
|
||||
```
|
||||
|
||||
Шаг **не меняется** — `specialist` ещё не зафиксирован, ждём подтверждения. Слот `is_child` модель не выставляет (пациент назвал ФИО, говорит от первого лица — взрослый).
|
||||
|
||||
**`thread_state` после хода:**
|
||||
|
||||
```json
|
||||
{
|
||||
"current_intent_code": "new_booking",
|
||||
"current_step": 1,
|
||||
"current_step_code": "qualify",
|
||||
"slots": {
|
||||
"name": "Анна Петровна",
|
||||
"reason": "боль в горле, неделю"
|
||||
},
|
||||
"handoff_count": 0,
|
||||
"soft_insertion_count": 0
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Реплика 4 · «Да, к ЛОРу»
|
||||
|
||||
**Решение маршрутизатора:** `new_booking`.
|
||||
|
||||
**Активная ветка / шаг:** `new_booking` / `qualify`. Известны `name`, `reason`, `specialist` — можно переходить в `present` (`allowed_next: ["qualify", "present"]`).
|
||||
|
||||
**Структурированный ответ модели:**
|
||||
|
||||
```
|
||||
Хорошо. Записываю вас к ЛОР-врачу — на приёме доктор осмотрит вас и обратит внимание на боль в горле.
|
||||
|
||||
STATE_JSON: {"state_after": "present", "slots_updated": {"specialist": "ЛОР"}}
|
||||
```
|
||||
|
||||
Шаг переходит в `present`. На самом шаге `present` (`prompts/intents/new_booking/steps/present.md`) новые слоты не собираются — модель использует уже известные `name`, `specialist`, `reason`, чтобы пациент почувствовал, что его услышали.
|
||||
|
||||
**`thread_state` после хода:**
|
||||
|
||||
```json
|
||||
{
|
||||
"current_intent_code": "new_booking",
|
||||
"current_step": 2,
|
||||
"current_step_code": "present",
|
||||
"slots": {
|
||||
"name": "Анна Петровна",
|
||||
"reason": "боль в горле, неделю",
|
||||
"specialist": "ЛОР"
|
||||
},
|
||||
"handoff_count": 0,
|
||||
"soft_insertion_count": 0
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Реплика 5 · «Хорошо, давайте»
|
||||
|
||||
**Решение маршрутизатора:** `new_booking`.
|
||||
|
||||
**Активная ветка / шаг:** `new_booking` / `present`. Согласие пациента → переход в `offer_time` (`allowed_next: ["present", "qualify", "offer_time"]`).
|
||||
|
||||
**Промпт шага `offer_time`:** спросить про удобные дни и часы, зафиксировать в `preferred_time`. Реального календаря нет — конкретные слоты не предлагаем.
|
||||
|
||||
**Структурированный ответ модели:**
|
||||
|
||||
```
|
||||
Отлично. Подскажите, в какое время вам удобнее — утро, день или вечер? Будни или выходные?
|
||||
|
||||
STATE_JSON: {"state_after": "offer_time", "slots_updated": {}}
|
||||
```
|
||||
|
||||
Шаг переходит в `offer_time`. Слотов на этом ходе не добавляется — ждём ответа пациента.
|
||||
|
||||
**`thread_state` после хода:**
|
||||
|
||||
```json
|
||||
{
|
||||
"current_intent_code": "new_booking",
|
||||
"current_step": 3,
|
||||
"current_step_code": "offer_time",
|
||||
"slots": {
|
||||
"name": "Анна Петровна",
|
||||
"reason": "боль в горле, неделю",
|
||||
"specialist": "ЛОР"
|
||||
},
|
||||
"handoff_count": 0,
|
||||
"soft_insertion_count": 0
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Реплика 6 · «Удобнее утром в будни»
|
||||
|
||||
**Решение маршрутизатора:** `new_booking`.
|
||||
|
||||
**Активная ветка / шаг:** `new_booking` / `offer_time`. Получили `preferred_time` → переход в `book` (`allowed_next: ["offer_time", "book"]`).
|
||||
|
||||
**Промпт шага `book`:** проговорить пациенту собранные данные (3–4 поля) и получить явное «да».
|
||||
|
||||
**Структурированный ответ модели:**
|
||||
|
||||
```
|
||||
Записываю: Анна Петровна, к ЛОР-врачу, повод — боль в горле, удобное время — утром в будни. Передам администратору клиники, он подберёт конкретное окно и подтвердит. Всё верно?
|
||||
|
||||
STATE_JSON: {"state_after": "book", "slots_updated": {"preferred_time": "утром в будни"}}
|
||||
```
|
||||
|
||||
Шаг переходит в `book`. Никаких CRM tool calls на этом шаге **сейчас не делается** — реальная интеграция с расписанием врачей пока не подключена.
|
||||
|
||||
**`thread_state` после хода:**
|
||||
|
||||
```json
|
||||
{
|
||||
"current_intent_code": "new_booking",
|
||||
"current_step": 4,
|
||||
"current_step_code": "book",
|
||||
"slots": {
|
||||
"name": "Анна Петровна",
|
||||
"reason": "боль в горле, неделю",
|
||||
"specialist": "ЛОР",
|
||||
"preferred_time": "утром в будни"
|
||||
},
|
||||
"handoff_count": 0,
|
||||
"soft_insertion_count": 0
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Реплика 7 · «Да, всё верно»
|
||||
|
||||
**Решение маршрутизатора:** `new_booking`.
|
||||
|
||||
**Активная ветка / шаг:** `new_booking` / `book`. Явное «да» → можно переходить в `close` (`allowed_next: ["book", "qualify", "offer_time", "close"]`).
|
||||
|
||||
**Промпт шага `close`:** короткое подтверждение, не задавать новых вопросов.
|
||||
|
||||
**Структурированный ответ модели:**
|
||||
|
||||
```
|
||||
Готово, Анна Петровна. Передаю администратору, он свяжется с вами в течение дня и подтвердит точное время. Спасибо за обращение!
|
||||
|
||||
STATE_JSON: {"state_after": "close", "slots_updated": {"confirmed": true}}
|
||||
```
|
||||
|
||||
Шаг переходит в `close`. Это финальный шаг ветки — `allowed_next: ["close"]`, дальше идти некуда. Тред физически не закрывается; если пациент напишет что-то ещё, маршрутизатор разберёт реплику и при необходимости откроет новую ветку (`general_info`, `reschedule` и т. п.).
|
||||
|
||||
**`thread_state` после хода:**
|
||||
|
||||
```json
|
||||
{
|
||||
"current_intent_code": "new_booking",
|
||||
"current_step": 5,
|
||||
"current_step_code": "close",
|
||||
"slots": {
|
||||
"name": "Анна Петровна",
|
||||
"reason": "боль в горле, неделю",
|
||||
"specialist": "ЛОР",
|
||||
"preferred_time": "утром в будни",
|
||||
"confirmed": true
|
||||
},
|
||||
"handoff_count": 0,
|
||||
"soft_insertion_count": 0
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Что показал этот пример
|
||||
|
||||
- **Линейный проход машины состояний.** Шаги шли строго в порядке `intro → qualify → present → offer_time → book → close`. Каждая реплика пациента = ровно один переход (или удержание на текущем шаге). Двойных переходов в одном ответе нет — `allowed_next` каждого шага это запрещает.
|
||||
- **Слоты накапливаются.** На каждом ходе `slots_updated` содержит только новые/изменённые поля, и они мерджатся с предыдущим состоянием в `thread_state.slots`. Старые значения не теряются.
|
||||
- **Маршрутизатор подтверждает ту же ветку.** На каждой реплике решение маршрутизатора совпадало с активной веткой `new_booking` — в Песочнице бейдж «решение маршрутизатора» был зелёным.
|
||||
- **Защитные условия не сработали.** Слот `is_child` отсутствует (пациент — взрослый) → guard `require_legal_rep` неактивен. Случай со срабатыванием — в `03_child_patient_guard_v2.md`.
|
||||
- **Никаких реальных tool calls.** На шагах `book` и `close` модель только проговаривает собранные данные, никаких записей в CRM или календарь — этой интеграции в коде ещё нет.
|
||||
|
||||
## Что важно проверять в eval-наборе на этом примере
|
||||
|
||||
- Все шаги машины состояний пройдены в правильном порядке (логи `state_after` на каждом ходе).
|
||||
- К моменту шага `book` в `slots` заполнены `name`, `reason`, `specialist`, `preferred_time`. Без любого из этих полей переход в `book` не должен случаться (валидатор `allowed_next` его пропустит, но модель не должна стремиться туда без данных).
|
||||
- Слот `confirmed: true` появляется только на шаге `close` — это маркер успешно завершённой записи.
|
||||
- На реплике 3 («Болит горло уже неделю») маршрутизатор НЕ уходит в `medical_question` — жалоба в контексте записи это не самостоятельный медицинский вопрос. Это типовая ловушка для роутера.
|
||||
|
||||
---
|
||||
|
||||
## Changelog
|
||||
|
||||
### v2 → 2026-04-27
|
||||
|
||||
**Имена полей `thread_state`** приведены к реальной БД (`db/models/thread_state.py`):
|
||||
- `current_intent` → `current_intent_code`
|
||||
- `current_step` (как строка) → `current_step_code`. Числовое `current_step` (счётчик хода) показано отдельно, как оно реально хранится в БД.
|
||||
- Добавлен `soft_insertion_count` (был и в v1 неявно, теперь показан явно во всех `thread_state`).
|
||||
- `slots` и `resumable_slots` показаны как объекты с пометкой, что в БД они хранятся текстовыми колонками `slots_json` / `resumable_slots_json`.
|
||||
- `thread_id` показан как `int` (было `str` «T-9001»).
|
||||
|
||||
**Слоты приведены к реальной таксономии** из `prompts/intents/new_booking/steps/*.md`:
|
||||
- Удалены вымышленные `service_mention`, `patient_name`, `service`, `complaint`, `doctor_preference`, `time_candidates`, `time_chosen`, `branch`, `booking_id`.
|
||||
- Используются только реальные: `name`, `reason`, `specialist`, `is_child` (опционально), `preferred_time`, `confirmed`.
|
||||
|
||||
**Структурированный ответ модели** показан в реальном формате (`STATE_JSON:` в хвосте текста, который парсер вырезает), а не как отдельный JSON-объект.
|
||||
|
||||
**Сценарий перестроен под реальные `allowed_next`:**
|
||||
- В v1 на реплике 6 была двойная склейка `offer_time → book → close` в одном ответе — это невозможно, модель выбирает один `state_after`. Теперь шаги идут по одному за реплику.
|
||||
- Реплик стало 7 (вместо 7), но сценарий другой: добавлена отдельная реплика про согласие с планом (`present → offer_time`).
|
||||
|
||||
**Удалены CRM tool calls** (`crm.get_slots`, `crm.create_booking`) и связанные с ними `branch`, `booking_id` — этой интеграции в коде нет, в v1 они были как иллюстрация будущего. Переехало в идеи на потом в `SPRINTS.md`.
|
||||
|
||||
**Терминология:** «роутер» → «маршрутизатор», «решение роутера» → «решение маршрутизатора» — выровнено со словарём в `static/docs.html`.
|
||||
|
||||
**Содержательно** (что показывает пример) — то же: линейный happy path записи без защитных условий и переключений.
|
||||
@@ -1,5 +1,7 @@
|
||||
# Пример 02 · Вопрос про цену в середине записи
|
||||
|
||||
> ⚠️ **Эта версия устарела.** Актуальная — [`02_price_during_booking_v2.md`](02_price_during_booking_v2.md). v1 содержит вымышленный маппинг слота `dms_provider` из `price_question` в `new_booking` при возврате — этого механизма в коде нет (у `price_question` слотов вообще нет). Также имена полей `thread_state` и слоты записи приведены к реальной БД и таксономии. Список изменений — внизу v2 в Changelog.
|
||||
|
||||
> Связано с [`GRAPH_ARCHITECTURE_v3.md`](../architecture/GRAPH_ARCHITECTURE_v3.md), разделы 4.1, 4.2, 4.4. Демонстрирует разницу между **мягкой вставкой** (soft insertion) и **жёстким переходом в другую ветвь** (hard handoff) на одном и том же сценарии. Также показывает работу полей `suspended_intent` и `resumable_step` при возврате в исходную ветку после боковой.
|
||||
|
||||
## О чём этот пример
|
||||
|
||||
@@ -0,0 +1,274 @@
|
||||
# Пример 02 v2 · Вопрос про цену в середине записи
|
||||
|
||||
> **Версия v2 · 2026-04-27.** Переписано под реальный код: имена полей `thread_state` соответствуют `db/models/thread_state.py`, набор слотов — реальной таксономии из `prompts/intents/new_booking/steps/*.md`. У ветки `price_question` нет ни шагов, ни слотов (`prompts/intents/price_question.md` — обычный отвечающий промпт), поэтому в варианте B после переключения `current_step_code = null`, `slots = {}` — никакого «маппинга `dms_provider`» из v1 здесь нет, этот механизм в коде не реализован. Изменения относительно v1 — внизу в Changelog.
|
||||
>
|
||||
> Связано с [`../architecture/GRAPH_ARCHITECTURE_v4.md`](../architecture/GRAPH_ARCHITECTURE_v4.md), разделы 4.1, 4.2, 4.4. Демонстрирует разницу между **боковым вопросом** (soft insertion) и **переключением ветки** (hard handoff) на одном и том же сценарии. Также показывает работу полей `suspended_intent`, `resumable_step_code`, `resumable_slots` при возврате в исходную ветку.
|
||||
|
||||
## О чём этот пример
|
||||
|
||||
Пациент находится посреди записи (шаг `offer_time`, мы только что спросили про удобное время). Прямо перед ответом он задаёт вопрос про деньги. Вариантов поведения системы — два, и **они оба корректны**, в зависимости от того, как сформулирован вопрос:
|
||||
|
||||
- **Вариант A.** «А сколько стоит приём?» — короткий уточняющий вопрос. Ветка `new_booking` отвечает сама, **не покидая шаг `offer_time`**. Это **боковой вопрос** (soft insertion), счётчик `soft_insertion_count` инкрементируется.
|
||||
- **Вариант B.** «А что у вас по ДМС? У меня ВТБ страхование» — это уже не короткое уточнение, а развёрнутый вопрос с подтемой. Ветка `new_booking` выдаёт служебный сигнал `[INTENT_CHANGE: price_question]`, оркестратор сохраняет состояние записи в `suspended_*` и запускает ветку `price_question`. После короткого ответа пациент возвращается к записи — состояние `new_booking` восстанавливается.
|
||||
|
||||
Ниже разбор обоих вариантов.
|
||||
|
||||
## Общая отправная точка
|
||||
|
||||
После пяти реплик из `01_basic_booking_v2.md` пациент стоит на шаге `offer_time` (мы только что спросили про удобное время). Состояние треда:
|
||||
|
||||
```json
|
||||
{
|
||||
"thread_id": 9002,
|
||||
"current_intent_code": "new_booking",
|
||||
"current_step": 3,
|
||||
"current_step_code": "offer_time",
|
||||
"slots": {
|
||||
"name": "Анна Петровна",
|
||||
"reason": "боль в горле, неделю",
|
||||
"specialist": "ЛОР"
|
||||
},
|
||||
"handoff_count": 0,
|
||||
"soft_insertion_count": 0,
|
||||
"suspended_intent": null,
|
||||
"resumable_step_code": null,
|
||||
"resumable_slots": null
|
||||
}
|
||||
```
|
||||
|
||||
Последняя реплика бота (реплика 5 из примера 01 v2): «*Отлично. Подскажите, в какое время вам удобнее — утро, день или вечер? Будни или выходные?*»
|
||||
|
||||
---
|
||||
|
||||
# Вариант A · Боковой вопрос (soft insertion)
|
||||
|
||||
## Реплика 6A · «А сколько стоит приём ЛОРа?»
|
||||
|
||||
**Решение маршрутизатора:** `new_booking`. Это важный момент — маршрутизатор видит короткий вопрос про цену, но контекст разговора (5 реплик про запись) и формулировка («приём ЛОРа» — отсылка к обсуждаемому приёму) позволяют ему остаться в `new_booking`. В Песочнице бейдж «решение маршрутизатора» — зелёный, совпадает с активной веткой.
|
||||
|
||||
**Активная ветка / шаг:** `new_booking` / `offer_time`.
|
||||
|
||||
**Поведение модели:** ветка `new_booking` видит, что вопрос вне сценария, но короткий и относится к текущему обсуждаемому приёму. По правилам ветки — отвечает в-line, ставит флаг `soft_insertion: true` и **остаётся на текущем шаге**.
|
||||
|
||||
**Структурированный ответ модели:**
|
||||
|
||||
```
|
||||
Первичный приём ЛОР-врача — 2 300 рублей. Возвращаемся к удобному времени: утро или вечер вам ближе?
|
||||
|
||||
STATE_JSON: {"state_after": "offer_time", "slots_updated": {}, "soft_insertion": true}
|
||||
```
|
||||
|
||||
Ключевое:
|
||||
- **Шаг не меняется** (`state_after == current_step_code`).
|
||||
- **Слоты не обновляются** (`slots_updated: {}`).
|
||||
- **Флаг `soft_insertion: true`** — оркестратор по этому признаку (плюс отсутствие изменений) увеличивает `soft_insertion_count` на 1.
|
||||
- **`handoff_count` не меняется**.
|
||||
|
||||
В Песочнице на этом ответе появится бейдж **«тип ответа: боковой вопрос»** (жёлтый).
|
||||
|
||||
**`thread_state` после хода:**
|
||||
|
||||
```json
|
||||
{
|
||||
"current_intent_code": "new_booking",
|
||||
"current_step": 3,
|
||||
"current_step_code": "offer_time",
|
||||
"slots": {
|
||||
"name": "Анна Петровна",
|
||||
"reason": "боль в горле, неделю",
|
||||
"specialist": "ЛОР"
|
||||
},
|
||||
"handoff_count": 0,
|
||||
"soft_insertion_count": 1
|
||||
}
|
||||
```
|
||||
|
||||
## Реплика 7A · «Утром в будни»
|
||||
|
||||
Дальше всё как в `01_basic_booking_v2.md`, реплика 6: переход в `book`, проговаривание, подтверждение, `close`. На переходе `offer_time → book` счётчик `soft_insertion_count` сбрасывается в 0 (он считает только подряд идущие боковые вопросы без движения шага).
|
||||
|
||||
## Что показал вариант A
|
||||
|
||||
- Боковой вопрос — это **лёгкий механизм без побочных эффектов**: ни шаг, ни слоты, ни `handoff_count` не меняются. Сдвигается только `soft_insertion_count`.
|
||||
- Защита от «бесконечных боковых»: если `soft_insertion_count` дойдёт до 3, в следующий системный промпт ветки добавляется указание вернуть пациента к шагу скрипта (`SOFT_INSERTION_NUDGE` в `services/chat_service.py`).
|
||||
- В промпте ветки `new_booking` явно прописано правило отвечать на короткие вопросы про цену/адрес/длительность в-line, не уходя в другую ветку.
|
||||
|
||||
---
|
||||
|
||||
# Вариант B · Переключение ветки и возврат
|
||||
|
||||
## Реплика 6B · «А что у вас по ДМС? У меня ВТБ страхование. Они покрывают?»
|
||||
|
||||
**Решение маршрутизатора:** `price_question`. Здесь:
|
||||
- Конкретный страховщик упомянут → нужен поиск по партнёрам ДМС, это не короткое уточнение.
|
||||
- Вопрос с подтемой («покрывают ли они»), требует развёрнутого ответа.
|
||||
- Это уже не «вставка в скрипт записи», а самостоятельная подтема.
|
||||
|
||||
Оркестратор сравнивает: маршрутизатор сказал `price_question`, активная ветка — `new_booking` (пошаговая, на шаге `offer_time`). Это случай **удержания в ветке** (sticky): по умолчанию оркестратор не переключает пошаговую ветку по решению маршрутизатора, а добавляет в её системный промпт блок `[ПОДСКАЗКА РОУТЕРА]` и даёт ветке самой решить.
|
||||
|
||||
**Поведение модели ветки `new_booking`:** видит подсказку, что маршрутизатор счёл тему сменившейся. Сама оценивает: вопрос явно вне сценария записи, не короткий, требует другого контекста (база знаний по ДМС). Возвращает служебный сигнал:
|
||||
|
||||
```
|
||||
[INTENT_CHANGE: price_question]
|
||||
```
|
||||
|
||||
**Шаги оркестратора в одном ходе:**
|
||||
|
||||
1. Парсер видит `[INTENT_CHANGE: price_question]` → ответ ветки `new_booking` пациенту не показывается.
|
||||
2. **Сохраняет состояние ветки-донора** в `thread_state`:
|
||||
- `suspended_intent` ← `"new_booking"`
|
||||
- `resumable_step_code` ← `"offer_time"`
|
||||
- `resumable_slots` ← копия текущих `slots`
|
||||
3. **Инкрементит счётчик переключений:** `handoff_count: 0 → 1`. Кап — 3; следующее переключение тоже допустимо, но при достижении 3 произойдёт авто-эскалация в `escalate_human` с причиной `routing_loop`.
|
||||
4. **Сбрасывает `slots` и `current_step_code`** — у `price_question` нет своей машины состояний, она линейная.
|
||||
5. **Запускает ветку `price_question`** с полной историей и пустым шагом, в обработке этой же реплики (так называемый bounce — модель вызывается дважды в одном ходе).
|
||||
|
||||
**`thread_state` после переключения, перед ответом `price_question`:**
|
||||
|
||||
```json
|
||||
{
|
||||
"current_intent_code": "price_question",
|
||||
"current_step": 0,
|
||||
"current_step_code": null,
|
||||
"slots": {},
|
||||
"handoff_count": 1,
|
||||
"soft_insertion_count": 0,
|
||||
"suspended_intent": "new_booking",
|
||||
"resumable_step_code": "offer_time",
|
||||
"resumable_slots": {
|
||||
"name": "Анна Петровна",
|
||||
"reason": "боль в горле, неделю",
|
||||
"specialist": "ЛОР"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Заметим: `slots` ветки `price_question` начинается с пустого объекта — у этой ветки **нет своих слотов** (`prompts/intents/price_question.md` — обычный отвечающий промпт без машины состояний). Слоты записи **полностью изолированы в `resumable_slots`** и не «смешиваются».
|
||||
|
||||
**Ответ модели `price_question`:**
|
||||
|
||||
```
|
||||
Да, мы работаем с ДМС «ВТБ Страхование». Точный список покрываемых услуг и условия согласования есть у нашего администратора — он подтвердит покрытие конкретно на вашу программу при оформлении записи. Если хотите — продолжим запись, а администратор по ДМС свяжется с вами отдельно?
|
||||
```
|
||||
|
||||
(Эта ветка не возвращает `STATE_JSON:` — у неё нет машины состояний.)
|
||||
|
||||
**`thread_state` после хода не меняется** (никаких слотов и шагов у `price_question` нет).
|
||||
|
||||
В Песочнице на этом ответе:
|
||||
- Бейдж **«активная ветка: price_question»** (без отметки «пошаговая»).
|
||||
- Бейдж **«решение маршрутизатора: price_question»** (зелёный — совпадает).
|
||||
- В правой панели «Состояние диалога»: видно **«📌 отложен сценарий: `new_booking` (шаг `offer_time`)»**, слотов отложенного — 3.
|
||||
- Счётчик переключений — **«1 из 3»** (синий бейдж, не красный).
|
||||
|
||||
## Реплика 7B · «Хорошо, давайте записываться. Утром в будни»
|
||||
|
||||
**Решение маршрутизатора:** `new_booking`. Чёткий сигнал возврата к записи + конкретное предпочтение по времени.
|
||||
|
||||
**Шаги оркестратора:**
|
||||
|
||||
1. Видит, что `current_intent_code == "price_question"`, новое решение маршрутизатора = `"new_booking"`.
|
||||
2. **Проверяет `suspended_intent`** — он равен `"new_booking"`. **Это и есть путь возврата**, не новое переключение.
|
||||
3. Восстанавливает:
|
||||
- `current_intent_code` ← `suspended_intent`
|
||||
- `current_step_code` ← `resumable_step_code` (`"offer_time"`)
|
||||
- `slots` ← `resumable_slots`
|
||||
4. Очищает поля сохранения: `suspended_intent = null`, `resumable_step_code = null`, `resumable_slots = null`.
|
||||
5. **Сбрасывает `handoff_count` в 0** — это возврат, а не новое переключение. Если бы счётчик не сбрасывался, диалог из 4 переключений и 4 возвратов засчитал бы 8 переходов и упёрся в кап.
|
||||
6. Запускает ветку `new_booking` на восстановленном шаге `offer_time` для обработки этой же реплики.
|
||||
|
||||
**`thread_state` после восстановления, перед ответом `new_booking`:**
|
||||
|
||||
```json
|
||||
{
|
||||
"current_intent_code": "new_booking",
|
||||
"current_step": 3,
|
||||
"current_step_code": "offer_time",
|
||||
"slots": {
|
||||
"name": "Анна Петровна",
|
||||
"reason": "боль в горле, неделю",
|
||||
"specialist": "ЛОР"
|
||||
},
|
||||
"handoff_count": 0,
|
||||
"soft_insertion_count": 0,
|
||||
"suspended_intent": null,
|
||||
"resumable_step_code": null,
|
||||
"resumable_slots": null
|
||||
}
|
||||
```
|
||||
|
||||
**Ответ модели `new_booking`** (теперь на шаге `offer_time`, видит `preferred_time` в реплике пациента):
|
||||
|
||||
```
|
||||
Хорошо. Записываю: Анна Петровна, к ЛОР-врачу, повод — боль в горле, удобное время — утром в будни. Передам администратору, он подберёт конкретное окно. Всё верно?
|
||||
|
||||
STATE_JSON: {"state_after": "book", "slots_updated": {"preferred_time": "утром в будни"}}
|
||||
```
|
||||
|
||||
Шаг переходит в `book`, слот `preferred_time` заполнен. Дальше — реплика 7 из примера 01 v2 (явное «да» → `close`).
|
||||
|
||||
В Песочнице на этом ответе:
|
||||
- Бейдж **«активная ветка: new_booking»** + **«пошаговая»** + бейдж **«шаг ветки: book»**.
|
||||
- Бейдж **«решение маршрутизатора: new_booking»** (зелёный).
|
||||
- В правой панели — событие **«решение: восстановили сценарий»** (зелёный бейдж `resumed`).
|
||||
- В «Состоянии диалога» больше нет блока «📌 отложен сценарий».
|
||||
- Счётчик переключений — **«0 из 3»** (сбросился).
|
||||
|
||||
## Что показал вариант B
|
||||
|
||||
- **Переключение ветки — это полноценный hard handoff**, со всем что к нему прилагается: служебный сигнал из ветки, сохранение состояния в `suspended_*`, инкремент `handoff_count`, изоляция слотов новой ветки.
|
||||
- **Возврат — не «новое переключение»**, а особый путь оркестратора: он сравнивает решение маршрутизатора с `suspended_intent` и при совпадении восстанавливает состояние, **сбрасывая `handoff_count` в 0**. Это критично для защиты от петли — иначе чередование «запись ↔ цены» съело бы кап за один-два цикла.
|
||||
- **У `price_question` нет своих слотов** — это простая отвечающая ветка. Никакого «маппинга `dms_provider`» при возврате нет (этого механизма в коде нет; в v1 он был как иллюстрация).
|
||||
- **Pendant возврата — `slots` записи восстанавливается полностью**, ничего не теряется.
|
||||
|
||||
---
|
||||
|
||||
## Когда боковой вопрос, а когда переключение
|
||||
|
||||
Решение принимает **маршрутизатор плюс ветка-донор** (двойная защита). На практике различение работает по таким признакам:
|
||||
|
||||
| Признак | Боковой вопрос | Переключение ветки |
|
||||
|---------|----------------|---------------------|
|
||||
| Длина вопроса | Короткий, точечный | Развёрнутый, с подвопросами |
|
||||
| Контекст | Уточнение к текущему шагу | Запрос самостоятельной темы |
|
||||
| Маркеры в реплике | «а сколько», «а где», «и как долго» | «стоп», «подождите», «расскажите про», «у меня …, что насчёт» |
|
||||
| Можно ли ответить одной репликой | Да | Нет, минимум 2-3 обмена |
|
||||
| Меняет `slots` / `current_step_code` | Нет | Да (полное переключение) |
|
||||
| Меняет `handoff_count` | Нет | Да (+1) |
|
||||
| Меняет `soft_insertion_count` | Да (+1) | Нет (сбрасывается) |
|
||||
|
||||
Ни одна модель и ни один маршрутизатор не сделают это безошибочно с первого захода. Двойная защита: ветка имеет в промпте правило «короткие боковые отвечай сам», маршрутизатор на каждой реплике независимо классифицирует. Если оба согласны — остаёмся; если ветка пропустила сигнал — маршрутизатор на следующей реплике увидит и переключит.
|
||||
|
||||
## Что важно проверять в eval-наборе на этом примере
|
||||
|
||||
- **Soft insertion не меняет `current_step_code` и `slots`.** Тест: на шаге `offer_time` подать «а сколько стоит» → проверить `state_after == "offer_time"`, `slots_updated == {}`, `soft_insertion: true` в ответе, `handoff_count == 0`, `soft_insertion_count` увеличился на 1.
|
||||
- **Hard handoff корректно сохраняет состояние.** Тест: на шаге `offer_time` подать «расскажите про ДМС» → проверить, что `suspended_intent == "new_booking"`, `resumable_step_code == "offer_time"`, `resumable_slots` содержит все три слота записи.
|
||||
- **Возврат сбрасывает `handoff_count`.** Тест: hard handoff, потом «давайте записываться» → проверить, что `handoff_count` стал **0**, не **2**.
|
||||
- **При возврате `slots` не дополнились ничем «случайным».** В v1 был ожидаем `dms_provider` после возврата — этого механизма нет, проверять нечего; но если вдруг в `slots` после возврата появятся поля, которых не было в `resumable_slots`, — это регрессия.
|
||||
|
||||
---
|
||||
|
||||
## Changelog
|
||||
|
||||
### v2 → 2026-04-27
|
||||
|
||||
**Имена полей `thread_state`** приведены к реальной БД (как в `01_basic_booking_v2.md`).
|
||||
|
||||
**Слоты приведены к реальной таксономии:**
|
||||
- В отправной точке (после 5 реплик примера 01) теперь только `name`, `reason`, `specialist` — никаких `service`, `complaint`, `doctor_preference`, `time_candidates`, `branch`. После возврата в варианте B на шаге `book` появляется `preferred_time` и `confirmed: true` на `close`.
|
||||
|
||||
**`price_question` показана как ветка без слотов и без шагов** — реально она именно такая (`prompts/intents/price_question.md`). Удалён вымышленный mapping `dms_provider` из `price_question` в `new_booking` после возврата — этого механизма в коде нет (нет ни слота `dms_provider`, ни логики mapping'а).
|
||||
|
||||
**Структурированный ответ модели** — формат `STATE_JSON:` в хвосте текста (а не отдельный JSON). Для `price_question` показано, что ветка не возвращает `STATE_JSON:` (нет машины состояний).
|
||||
|
||||
**Описание hard handoff** уточнено под реальную механику оркестратора:
|
||||
- Сначала маршрутизатор предлагает `price_question`, оркестратор применяет sticky (передаёт ветке `new_booking` подсказку), и **только если ветка сама выдаёт `[INTENT_CHANGE]`**, происходит переключение. Прямое переключение по решению маршрутизатора — только для веток без машины состояний.
|
||||
- В одном ходе модель может быть вызвана дважды (bounce): сначала ветка-донор выдаёт сигнал, потом запускается ветка-приёмник.
|
||||
|
||||
**Удалён CRM-вызов** `crm.create_booking(... dms_provider=...)` — таких tool calls в коде нет.
|
||||
|
||||
**Добавлены признаки UI Песочницы** на каждом шаге (бейджи активной ветки, решения маршрутизатора, событий, счётчика переключений) — чтобы пример читался как сверка с тем, что оператор реально увидит.
|
||||
|
||||
**Терминология:** «роутер» → «маршрутизатор», «жёсткий переход / hard handoff» → «переключение ветки».
|
||||
|
||||
**Содержательно** (что показывает пример) — то же: разница между боковым вопросом и переключением ветки + механика возврата через `suspended_intent`.
|
||||
@@ -1,5 +1,7 @@
|
||||
# Пример 03 · Запись ребёнка — защитное условие в шаге уточнения
|
||||
|
||||
> ⚠️ **Эта версия устарела.** Актуальная — [`03_child_patient_guard_v2.md`](03_child_patient_guard_v2.md). v1 описывал три защитных условия (для ребёнка, для сурдолога, юридическая оговорка на close) — в коде реально есть только одно (`require_legal_rep`), остальные — инструкции в промптах. Также имена полей `thread_state` и слоты приведены к реальной БД и таксономии. Список изменений — внизу v2 в Changelog.
|
||||
|
||||
> Связано с [`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` — там добавляются юридические оговорки из вики.
|
||||
|
||||
## О чём этот пример
|
||||
|
||||
@@ -0,0 +1,315 @@
|
||||
# Пример 03 v2 · Запись ребёнка — защитное условие в шаге уточнения
|
||||
|
||||
> **Версия v2 · 2026-04-27.** Переписано под реальный код. В коде есть **только одно защитное условие** — `require_legal_rep` на шаге `qualify` (см. `services/intent_step_service.py`, `SEED_INTENT_STEPS`). Никаких guard'ов на `present` (сурдолог) и `close` (юридическая оговорка) в коде нет — в v1 они были как иллюстрация будущего, в реальности это просто инструкции в промпте `qualify.md`. Список изменений — внизу в Changelog.
|
||||
>
|
||||
> Связано с [`../architecture/GRAPH_ARCHITECTURE_v4.md`](../architecture/GRAPH_ARCHITECTURE_v4.md), разделы 3.2, 3.3. Демонстрирует **защитное условие** (guard) внутри шага `qualify` ветки `new_booking`. Когда модель определяет, что пациент — ребёнок (`is_child=true`), переход в `present` блокируется, пока не заполнены `legal_rep_name` и `legal_rep_phone`. Также показано, как валидатор переходов отклоняет ход модели, если она попыталась двинуться без выполнения защитного условия.
|
||||
|
||||
## О чём этот пример
|
||||
|
||||
Мама записывает 8-летнего сына к ЛОР-врачу. Скрипт записи в вики клиники (см. `prompts/intents/new_booking/steps/qualify.md`) формализует это как обязательный сбор ФИО и контактного телефона законного представителя.
|
||||
|
||||
В терминах архитектуры это означает: на шаге `qualify` стоит **защитное условие** `require_legal_rep` со следующей семантикой:
|
||||
|
||||
```json
|
||||
{
|
||||
"require_legal_rep": {
|
||||
"description": "Для записи ребёнка нужны ФИО и телефон законного представителя",
|
||||
"trigger_slot": "is_child",
|
||||
"trigger_value": true,
|
||||
"required_slots": ["legal_rep_name", "legal_rep_phone"]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Пока `is_child == true` и в `slots` нет `legal_rep_name` или `legal_rep_phone`, валидатор отклоняет любой переход с шага `qualify` (в том числе на `present`). Модель остаётся на `qualify`.
|
||||
|
||||
## Начальное состояние
|
||||
|
||||
```json
|
||||
{
|
||||
"thread_id": 9003,
|
||||
"current_intent_code": null,
|
||||
"current_step": 0,
|
||||
"current_step_code": null,
|
||||
"slots": {},
|
||||
"handoff_count": 0,
|
||||
"soft_insertion_count": 0,
|
||||
"suspended_intent": null,
|
||||
"resumable_step_code": null,
|
||||
"resumable_slots": null
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Реплика 1 · «Здравствуйте, хочу записать ребёнка к лору»
|
||||
|
||||
**Решение маршрутизатора:** `new_booking`. Упоминание «ребёнка» само по себе не сигнал эскалации — это норма для записи.
|
||||
|
||||
**Активная ветка / шаг:** `new_booking` / `intro`.
|
||||
|
||||
**Структурированный ответ модели:**
|
||||
|
||||
```
|
||||
Здравствуйте! Помогу записать ребёнка. Подскажите, как могу к вам обращаться?
|
||||
|
||||
STATE_JSON: {"state_after": "intro", "slots_updated": {}}
|
||||
```
|
||||
|
||||
Шаг не меняется — ждём имя того, с кем разговариваем.
|
||||
|
||||
**`thread_state` после хода:**
|
||||
|
||||
```json
|
||||
{
|
||||
"current_intent_code": "new_booking",
|
||||
"current_step": 0,
|
||||
"current_step_code": "intro",
|
||||
"slots": {},
|
||||
"handoff_count": 0,
|
||||
"soft_insertion_count": 0
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Реплика 2 · «Меня Мария зовут»
|
||||
|
||||
**Решение маршрутизатора:** `new_booking`.
|
||||
|
||||
**Активная ветка / шаг:** `new_booking` / `intro`. Имя получено → переход в `qualify`.
|
||||
|
||||
**Структурированный ответ модели:**
|
||||
|
||||
```
|
||||
Очень приятно, Мария. Что беспокоит ребёнка? И сколько ему лет?
|
||||
|
||||
STATE_JSON: {"state_after": "qualify", "slots_updated": {"name": "Мария"}}
|
||||
```
|
||||
|
||||
Шаг переходит в `qualify`. Слот `name` — это обращение к собеседнику в чате (к маме). Имя ребёнка отдельным слотом не сохраняется — оно нужно только в финальном проговаривании, и модель использует его прямо из истории сообщений.
|
||||
|
||||
**`thread_state` после хода:**
|
||||
|
||||
```json
|
||||
{
|
||||
"current_intent_code": "new_booking",
|
||||
"current_step": 1,
|
||||
"current_step_code": "qualify",
|
||||
"slots": {
|
||||
"name": "Мария"
|
||||
},
|
||||
"handoff_count": 0,
|
||||
"soft_insertion_count": 0
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Реплика 3 · «Сыну 8 лет, болят уши»
|
||||
|
||||
**Решение маршрутизатора:** `new_booking`. Жалоба упомянута, но в контексте записи — не самостоятельный медицинский вопрос. Маршрутизатор не уходит в `medical_question`.
|
||||
|
||||
**Активная ветка / шаг:** `new_booking` / `qualify`.
|
||||
|
||||
**Промпт `qualify.md`** прямо предусматривает «особую ситуацию 1: запись ребёнка»: при упоминании «сын/дочка», «ребёнок», возраст — модель фиксирует `is_child: true` и переходит к сбору данных представителя.
|
||||
|
||||
**Структурированный ответ модели:**
|
||||
|
||||
```
|
||||
Понимаю, боль в ушах у ребёнка — это всегда тревожно. С такой жалобой обычно записывают к ЛОР-врачу — подходит? И, чтобы оформить запись, нужны ФИО и контактный телефон родителя или опекуна — подскажете?
|
||||
|
||||
STATE_JSON: {"state_after": "qualify", "slots_updated": {"is_child": true, "reason": "боль в ушах у ребёнка"}}
|
||||
```
|
||||
|
||||
Шаг **не меняется** — `specialist` ещё не подтверждён, и данные представителя не собраны. Слот `is_child: true` теперь активирует защитное условие `require_legal_rep`: с этого момента переход `qualify → present` запрещён, пока в `slots` не появятся `legal_rep_name` и `legal_rep_phone`.
|
||||
|
||||
**`thread_state` после хода:**
|
||||
|
||||
```json
|
||||
{
|
||||
"current_intent_code": "new_booking",
|
||||
"current_step": 1,
|
||||
"current_step_code": "qualify",
|
||||
"slots": {
|
||||
"name": "Мария",
|
||||
"is_child": true,
|
||||
"reason": "боль в ушах у ребёнка"
|
||||
},
|
||||
"handoff_count": 0,
|
||||
"soft_insertion_count": 0
|
||||
}
|
||||
```
|
||||
|
||||
В Песочнице на этом ответе в правой панели «Состояние диалога» появится индикатор: **«🔒 защитное условие активно: `require_legal_rep` — ждём заполнения: `legal_rep_name`, `legal_rep_phone`»** (жёлтый блок). Пациент его не видит — это сигнал оператору.
|
||||
|
||||
---
|
||||
|
||||
## Реплика 4 · «А зачем вам ФИО и телефон родителя?»
|
||||
|
||||
Возражение мамы. Из `qualify.md` модель должна спокойно объяснить.
|
||||
|
||||
**Решение маршрутизатора:** `new_booking`.
|
||||
|
||||
**Активная ветка / шаг:** `new_booking` / `qualify`. Защитное условие всё ещё активно.
|
||||
|
||||
**Структурированный ответ модели:**
|
||||
|
||||
```
|
||||
Это стандартная практика: при записи ребёнка нам важно иметь возможность связаться с родителем — например, если на приёме потребуется согласие на процедуру. Это требование клиники для безопасности ребёнка. Подскажете ФИО и телефон?
|
||||
|
||||
STATE_JSON: {"state_after": "qualify", "slots_updated": {}}
|
||||
```
|
||||
|
||||
Шаг и слоты не меняются — защитное условие всё ещё блокирует переход.
|
||||
|
||||
**`thread_state` после хода:** идентично состоянию до хода.
|
||||
|
||||
---
|
||||
|
||||
## Реплика 5 · «Хорошо. Иванова Мария Сергеевна, +79991234567»
|
||||
|
||||
**Решение маршрутизатора:** `new_booking`.
|
||||
|
||||
**Активная ветка / шаг:** `new_booking` / `qualify`. Получены данные представителя → защитное условие выполнено.
|
||||
|
||||
**Структурированный ответ модели:**
|
||||
|
||||
```
|
||||
Спасибо, записываю. Подходит ЛОР-врач?
|
||||
|
||||
STATE_JSON: {"state_after": "qualify", "slots_updated": {"legal_rep_name": "Иванова Мария Сергеевна", "legal_rep_phone": "+79991234567"}}
|
||||
```
|
||||
|
||||
Слоты заполнены, защитное условие выполнено, но модель ещё не закрыла вопрос со специалистом — поэтому остаётся на `qualify`. Это корректное поведение: даже когда guard сейчас выполнен, **переход в `present` логически нужен только когда есть и `specialist`** (промпт ветки требует все три: `name`, `reason`, `specialist`).
|
||||
|
||||
**`thread_state` после хода:**
|
||||
|
||||
```json
|
||||
{
|
||||
"current_intent_code": "new_booking",
|
||||
"current_step": 1,
|
||||
"current_step_code": "qualify",
|
||||
"slots": {
|
||||
"name": "Мария",
|
||||
"is_child": true,
|
||||
"reason": "боль в ушах у ребёнка",
|
||||
"legal_rep_name": "Иванова Мария Сергеевна",
|
||||
"legal_rep_phone": "+79991234567"
|
||||
},
|
||||
"handoff_count": 0,
|
||||
"soft_insertion_count": 0
|
||||
}
|
||||
```
|
||||
|
||||
В Песочнице индикатор «🔒 защитное условие активно» **исчезает** — guard выполнен.
|
||||
|
||||
---
|
||||
|
||||
## Реплика 6 · «Да, ЛОР подходит»
|
||||
|
||||
**Решение маршрутизатора:** `new_booking`.
|
||||
|
||||
**Активная ветка / шаг:** `new_booking` / `qualify`. Защитное условие выполнено + `specialist` заполнен → переход в `present` разрешён.
|
||||
|
||||
**Структурированный ответ модели:**
|
||||
|
||||
```
|
||||
Хорошо. Записываю Матвея к ЛОР-врачу — на приёме доктор осмотрит и обратит внимание на боль в ушах.
|
||||
|
||||
STATE_JSON: {"state_after": "present", "slots_updated": {"specialist": "ЛОР"}}
|
||||
```
|
||||
|
||||
Шаг переходит в `present` — это легально (`allowed_next: ["qualify", "present"]`), и теперь валидатор `check_guards()` тоже пропускает: `is_child: true` + оба `legal_rep_*` заполнены.
|
||||
|
||||
**`thread_state` после хода:**
|
||||
|
||||
```json
|
||||
{
|
||||
"current_intent_code": "new_booking",
|
||||
"current_step": 2,
|
||||
"current_step_code": "present",
|
||||
"slots": {
|
||||
"name": "Мария",
|
||||
"is_child": true,
|
||||
"reason": "боль в ушах у ребёнка",
|
||||
"legal_rep_name": "Иванова Мария Сергеевна",
|
||||
"legal_rep_phone": "+79991234567",
|
||||
"specialist": "ЛОР"
|
||||
},
|
||||
"handoff_count": 0,
|
||||
"soft_insertion_count": 0
|
||||
}
|
||||
```
|
||||
|
||||
Дальше всё как в `01_basic_booking_v2.md`, начиная с реплики 5: согласие → `offer_time`, время → `book`, подтверждение → `close`. Никаких особенностей `is_child` на этих шагах в коде сейчас нет.
|
||||
|
||||
---
|
||||
|
||||
## Что было бы, если модель попыталась перейти без слотов представителя
|
||||
|
||||
Допустим, после реплики 4 («А зачем?») модель проигнорирует инструкцию и вернёт:
|
||||
|
||||
```
|
||||
Хорошо, к ЛОР-врачу. Подбираю удобное время.
|
||||
|
||||
STATE_JSON: {"state_after": "present", "slots_updated": {"specialist": "ЛОР"}}
|
||||
```
|
||||
|
||||
`state_after: present` сам по себе легален (`allowed_next` его допускает). Но `check_guards()` запускается **после** `validate_transition()` и видит:
|
||||
- активна `is_child: true` (триггер защитного условия `require_legal_rep`),
|
||||
- `legal_rep_name` и `legal_rep_phone` отсутствуют.
|
||||
|
||||
Валидатор отклоняет переход. Поведение оркестратора:
|
||||
- `current_step_code` остаётся `qualify`.
|
||||
- Пациенту всё равно показывается ответ модели (нельзя «съесть» реплику бота).
|
||||
- В Песочнице на этом ответе появится событие **«валидатор: переход отклонён»** (красный бейдж `validation_blocked`), а в правой панели — детализация: **«🔒 защитное условие `require_legal_rep` не пройдено — ждём: `legal_rep_name`, `legal_rep_phone`»**.
|
||||
|
||||
Так что даже если модель «забыла» — состояние не разъезжается.
|
||||
|
||||
---
|
||||
|
||||
## Что показал этот пример
|
||||
|
||||
- **Защитное условие как страховка от регрессии.** Промпт `qualify.md` сам предписывает не переходить, но модель иногда забывает; `check_guards()` ловит это механически.
|
||||
- **Активация только при триггере.** Поле `trigger_slot: "is_child"` + `trigger_value: true` означает, что защитное условие **бездействует**, пока `is_child != true`. Для взрослых пациентов (как в `01_basic_booking_v2.md`) этого блока вообще нет в индикаторах — так и должно быть.
|
||||
- **Защитное условие проверяется после `allowed_next`.** Сначала валидатор смотрит, легален ли вообще переход (есть ли в `allowed_next` следующего шага); затем — выполнено ли активное защитное условие.
|
||||
- **Один guard в коде сейчас.** Сурдолог при жалобе на слух и юридическая оговорка в `close` — это **не** защитные условия в коде, это инструкции в промптах (`qualify.md`, `present.md`). Если они начнут пробуксовывать, можно будет дотянуть как guards (см. идеи на потом в `SPRINTS.md`).
|
||||
|
||||
## Что важно проверять в eval-наборе на этом примере
|
||||
|
||||
- **Без `legal_rep_*` переход `qualify → present` отклоняется.** Тест: подать в ветку модельный ответ с `state_after: "present"` при `is_child: true` и пустых `legal_rep_*` → валидатор должен отклонить, состояние остаётся `qualify`, в логе и в Песочнице — событие `validation_blocked` с деталями guard'а.
|
||||
- **`is_child: true` устанавливается рано.** Тест: фраза «запишите ребёнка», без других слов → проверить, что `is_child: true` появляется в `slots_updated` уже на 2-3 реплике (как только пациент явно упомянул ребёнка).
|
||||
- **После заполнения `legal_rep_*` индикатор `pending_guard` исчезает.** Тест: дойти до реплики «Иванова Мария Сергеевна, +79991234567» → проверить, что в `state.pending_guard` стало `null` и в Песочнице нет жёлтого блока.
|
||||
- **Защитное условие не активно для взрослых.** Регрессионный тест: сценарий из `01_basic_booking_v2.md` (без `is_child`) → проверить, что переход `qualify → present` разрешён без `legal_rep_*` и в логах нет упоминаний `require_legal_rep`.
|
||||
|
||||
---
|
||||
|
||||
## Changelog
|
||||
|
||||
### v2 → 2026-04-27
|
||||
|
||||
**Имена полей `thread_state`** приведены к реальной БД (как в `01_basic_booking_v2.md`).
|
||||
|
||||
**Слоты приведены к реальной таксономии** из `qualify.md`:
|
||||
- Удалены вымышленные `patient_name`, `patient_age`, `parent_first_name`, `complaint`, `service`, `time_chosen`, `branch`, `booking_id`, `guard_surdologist_suggested`.
|
||||
- Используются только реальные: `name` (обращение к собеседнику в чате — у нас это родитель), `is_child`, `reason`, `specialist`, `legal_rep_name`, `legal_rep_phone`. Отсутствуют слоты для имени и возраста ребёнка — этих слотов в коде нет, имя ребёнка модель использует прямо из истории сообщений.
|
||||
|
||||
**Один guard в коде, не три:**
|
||||
- Удалены guard'ы про сурдолога (на `present`) и юридическую оговорку (на `close`) — в `services/intent_step_service.py` `SEED_INTENT_STEPS` их нет. Сурдолог при жалобе на слух — упомянут как инструкция в `qualify.md` (особая ситуация 3), но не как защитное условие. Юридический текст для записи ребёнка в `close.md` сейчас отсутствует вообще.
|
||||
- Сценарий укорочен с 9 до 6 реплик с подробным разбором (плюс отдельный раздел «что было бы, если…»).
|
||||
|
||||
**Структурированный ответ модели** — формат `STATE_JSON:` в хвосте текста.
|
||||
|
||||
**Поведение защитного условия** уточнено под реальный механизм (`services/state_machine.py:check_guards()`):
|
||||
- Защитное условие проверяется **после** `validate_transition()` (т.е. после `allowed_next`).
|
||||
- Срабатывает только при `slots[trigger_slot] == trigger_value`.
|
||||
- Если не пройдено — `current_step_code` не меняется, ответ модели всё равно показывается пациенту, событие `validation_blocked` уходит в Песочницу.
|
||||
|
||||
**Удалены sub-states** (`qualify.legal_rep`, `qualify.base`) — их в коде нет; v3 архитектуры сама рекомендует не плодить sub-states, и реальная реализация идёт через condition-based guards. См. также идею на потом в `SPRINTS.md` про возможные sub-states при росте числа guards.
|
||||
|
||||
**Удалены вымышленные UI-теги** про реакцию валидатора на сурдолог-флаг — таких событий в коде нет.
|
||||
|
||||
**Терминология:** «guard» → «защитное условие», «роутер» → «маршрутизатор».
|
||||
|
||||
**Содержательно** (что показывает пример) — то же: блокировка перехода защитным условием при записи ребёнка, разблокировка после заполнения слотов представителя.
|
||||
@@ -1,5 +1,7 @@
|
||||
# Пример 04 · Простые информационные запросы (general_info)
|
||||
|
||||
> ⚠️ **Эта версия устарела.** Актуальная — [`04_general_info_simple_v2.md`](04_general_info_simple_v2.md). У ветки `general_info` в коде **нет** машины состояний, шагов (`answer`, `done`) и слотов (`info_topic`, `branch_mention`, `needs_followup`) — всё это в v1 было как иллюстрация будущего. Также нет confidence threshold для RAG и per-intent фильтров по путям вики (Спринт 7). Список изменений — внизу v2 в Changelog.
|
||||
|
||||
> Связано с [`GRAPH_ARCHITECTURE_v3.md`](../architecture/GRAPH_ARCHITECTURE_v3.md), разделы 1, 2, 6. Демонстрирует **простейший случай**: ветка `general_info` без машины состояний — одна реплика пациента, ретривер, ответ, `done`. Никаких слотов, никаких tool calls, никаких guard'ов. Это та точка, с которой реально удобно запускать систему: классификатор + RAG + одна шаблонная ветка.
|
||||
|
||||
## О чём этот пример
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
# Пример 04 v2 · Простые информационные запросы (general_info)
|
||||
|
||||
> **Версия v2 · 2026-04-27.** Переписано под реальный код. Ветка `general_info` **не имеет ни машины состояний, ни своих слотов** — это обычный отвечающий промпт (`prompts/intents/general_info.md`). После её отработки `current_step_code` остаётся `null`, `slots` — пустыми. Никаких слотов `info_topic`, `branch_mention`, `needs_followup` из v1 в коде нет. Список изменений — внизу в Changelog.
|
||||
>
|
||||
> Связано с [`../architecture/GRAPH_ARCHITECTURE_v4.md`](../architecture/GRAPH_ARCHITECTURE_v4.md), разделы 1, 2. Демонстрирует **простейший случай**: одна реплика → маршрутизатор → ветка `general_info` → ретривер → ответ. Никаких шагов, никаких слотов, никаких защитных условий.
|
||||
|
||||
## О чём этот пример
|
||||
|
||||
Это сборник коротких самостоятельных диалогов по одному типу запросов — общая информация (часы, адрес, проезд, телефоны, что брать с собой, чего клиника не делает). У всех у них одна и та же траектория:
|
||||
|
||||
1. **Маршрутизатор** классифицирует реплику как `general_info`.
|
||||
2. Активируется ветка `general_info`. **Без шага** (`current_step_code = null`).
|
||||
3. **Ретривер** достаёт чанки из общей коллекции `operators_wiki`. Per-intent фильтра в коде сейчас нет — это запланированный Спринт 7 (мульти-RAG). Сейчас все ветки берут топ-K по всей вики.
|
||||
4. Модель формирует ответ строго по найденным чанкам.
|
||||
5. `thread_state` после хода: `current_intent_code: "general_info"`, `current_step_code: null`, `slots: {}`. Ничего не добавляется.
|
||||
|
||||
Если пациент следом задаст вопрос про запись («а тогда запишите меня…»), маршрутизатор увидит смену темы и переключит на `new_booking` (это `hard handoff`, `handoff_count` инкрементируется). У `general_info` нет своей логики «кросс-села» — это решает маршрутизатор.
|
||||
|
||||
## Начальное состояние
|
||||
|
||||
Перед каждым диалогом ниже — пустой `thread_state` (свежий тред):
|
||||
|
||||
```json
|
||||
{
|
||||
"thread_id": 9XXX,
|
||||
"current_intent_code": null,
|
||||
"current_step": 0,
|
||||
"current_step_code": null,
|
||||
"slots": {},
|
||||
"handoff_count": 0,
|
||||
"soft_insertion_count": 0,
|
||||
"suspended_intent": null,
|
||||
"resumable_step_code": null,
|
||||
"resumable_slots": null
|
||||
}
|
||||
```
|
||||
|
||||
После ответа `current_intent_code = "general_info"`, остальные поля — без изменений.
|
||||
|
||||
---
|
||||
|
||||
## A · Часы работы
|
||||
|
||||
### A.1 · «Во сколько вы открываетесь?»
|
||||
|
||||
**Решение маршрутизатора:** `general_info`.
|
||||
|
||||
**Активная ветка / шаг:** `general_info` / *(нет шага)*.
|
||||
|
||||
**Ретривер:** общая коллекция `operators_wiki`, top-K чанков. Если в загруженной вике есть документ с расписанием — он попадёт в контекст; если нет — модель ответит на основании промпта `general_info.md`, опираясь только на то, что было в системном описании.
|
||||
|
||||
**Ответ модели** (без `STATE_JSON:` — у этой ветки нет машины состояний):
|
||||
|
||||
```
|
||||
Здравствуйте! Клиника на Клары Цеткин, 9 работает с 8:00 до 21:00 в будни и с 9:00 до 18:00 в выходные.
|
||||
```
|
||||
|
||||
**`thread_state` после хода:**
|
||||
|
||||
```json
|
||||
{
|
||||
"current_intent_code": "general_info",
|
||||
"current_step": 0,
|
||||
"current_step_code": null,
|
||||
"slots": {},
|
||||
"handoff_count": 0,
|
||||
"soft_insertion_count": 0
|
||||
}
|
||||
```
|
||||
|
||||
### A.2 · «Завтра праздник, вы работаете?»
|
||||
|
||||
**Решение маршрутизатора:** `general_info`.
|
||||
|
||||
**Поведение модели:** в промпте `general_info.md` явно прописано опираться **только** на выдержки из базы. Если про конкретный праздничный режим в вике ничего нет, модель должна не выдумывать, а честно сказать «уточню у администратора и сообщу».
|
||||
|
||||
**Ответ модели:**
|
||||
|
||||
```
|
||||
В обычные дни мы работаем с 8:00 до 21:00. Праздничный режим уточню у администратора — он свяжется с вами и подтвердит.
|
||||
```
|
||||
|
||||
`thread_state` после хода — то же, что в A.1.
|
||||
|
||||
> **Замечание для eval-набора.** В v1 этого примера предлагался флаг `needs_followup: true` и confidence threshold (если score лучшего чанка < 0.50, отвечать шаблоном). В коде этого механизма сейчас **нет**: ретривер просто возвращает top-K, без проверки relevance, и модель опирается на инструкцию промпта «не выдумывай — скажи, что уточнишь». Полноценный confidence-фильтр — в идеях на потом в `SPRINTS.md`.
|
||||
|
||||
---
|
||||
|
||||
## B · Адрес и проезд
|
||||
|
||||
### B.1 · «Где вы находитесь?»
|
||||
|
||||
**Решение маршрутизатора:** `general_info`.
|
||||
|
||||
**Ответ модели:**
|
||||
|
||||
```
|
||||
У нас два адреса в Перми. Подсказать, какой ближе к вам?
|
||||
```
|
||||
|
||||
(Точные адреса — из вики. Если их там нет, модель скажет «уточню у администратора».)
|
||||
|
||||
### B.2 · «На метро как до вас доехать?»
|
||||
|
||||
**Решение маршрутизатора:** `general_info`.
|
||||
|
||||
**Поведение модели:** вопрос содержит **ложное допущение** (в Перми метро нет). Промпт `general_info.md` обязывает модель опираться только на базу. Если в базе нет упоминания метро (а его и не будет), модель не должна сочинять станции — она мягко поправит и предложит реальные альтернативы из вики (автобус, троллейбус).
|
||||
|
||||
**Ответ модели:**
|
||||
|
||||
```
|
||||
В Перми метро нет, поэтому удобнее всего на автобусе или такси. Подскажу маршруты, если уточните, откуда поедете.
|
||||
```
|
||||
|
||||
Это **важный пограничный кейс для eval'а**: модель не должна изобретать «станцию метро» только потому, что её об этом спросили.
|
||||
|
||||
---
|
||||
|
||||
## C · Услуги, которых клиника не делает
|
||||
|
||||
### C.1 · «У вас можно сделать бронхоскопию?»
|
||||
|
||||
**Решение маршрутизатора:** `general_info`. Это вопрос «делаете ли вы такое в принципе», а не запись и не цена. Промпт маршрутизатора относит подобные формулировки к `general_info`, чтобы не запускать механизм записи на услугу, которой нет.
|
||||
|
||||
**Поведение модели:** в вики (если она правильно ведётся) есть раздел «процедуры, которые не проводятся» — оттуда и должен быть ответ. Если этого раздела нет, модель должна ответить «уточню», а не «да, конечно, запишем».
|
||||
|
||||
**Ответ модели:**
|
||||
|
||||
```
|
||||
Бронхоскопию у нас не делают — её обычно проводят в специализированных пульмонологических центрах. Если беспокоит дыхание или кашель — могу записать на консультацию к терапевту, он подскажет, куда дальше.
|
||||
```
|
||||
|
||||
Хвостовая фраза «могу записать на консультацию» — это **не cross-sell ветки**, а просто реплика модели. Если пациент скажет «да, запишите», следующая реплика уйдёт в `new_booking` через маршрутизатор (`hard handoff`).
|
||||
|
||||
---
|
||||
|
||||
## D · Переход в запись после справки
|
||||
|
||||
### D.1 · Реплика 1: «У вас по выходным открыто?»
|
||||
|
||||
**Решение маршрутизатора:** `general_info`.
|
||||
|
||||
**Ответ модели:** короткий ответ из вики.
|
||||
|
||||
**`thread_state` после хода:**
|
||||
|
||||
```json
|
||||
{
|
||||
"current_intent_code": "general_info",
|
||||
"current_step_code": null,
|
||||
"slots": {}
|
||||
}
|
||||
```
|
||||
|
||||
### D.2 · Реплика 2: «Хорошо, тогда запишите меня к ЛОРу»
|
||||
|
||||
**Решение маршрутизатора:** `new_booking`. Явный сигнал — пациент хочет записаться.
|
||||
|
||||
**Шаги оркестратора:**
|
||||
1. Активная ветка `general_info` ≠ решение маршрутизатора `new_booking`. И `general_info` **не пошаговая** — поэтому никакого «удержания в ветке» не делается, переключаемся прямо.
|
||||
2. **`handoff_count: 0 → 1`** (это первое переключение в этом диалоге).
|
||||
3. `current_intent_code` ← `new_booking`. Запускаем эту ветку с шага `intro` (если в треде уже была активна `general_info`, у `new_booking` начинаем с нуля; `suspended_intent` НЕ заполняется — `general_info` не пошаговая, восстанавливать нечего).
|
||||
4. `slots` обнуляются (у `general_info` их и не было, у `new_booking` начинаем с пустого набора).
|
||||
|
||||
**Ответ модели `new_booking`** (на шаге `intro`):
|
||||
|
||||
```
|
||||
Да, конечно. Подскажите, как могу к вам обращаться?
|
||||
|
||||
STATE_JSON: {"state_after": "intro", "slots_updated": {}}
|
||||
```
|
||||
|
||||
**`thread_state` после хода:**
|
||||
|
||||
```json
|
||||
{
|
||||
"current_intent_code": "new_booking",
|
||||
"current_step": 0,
|
||||
"current_step_code": "intro",
|
||||
"slots": {},
|
||||
"handoff_count": 1,
|
||||
"soft_insertion_count": 0
|
||||
}
|
||||
```
|
||||
|
||||
В Песочнице на этом ответе:
|
||||
- Бейдж **«активная ветка: new_booking»** + **«пошаговая»**.
|
||||
- Бейдж **«решение маршрутизатора: new_booking»** (зелёный).
|
||||
- Бейдж **«решение: переключили ветку»** (оранжевый `hard_handoff`).
|
||||
- Счётчик переключений — **«1 из 3»**.
|
||||
|
||||
Дальше — как в `01_basic_booking_v2.md`.
|
||||
|
||||
---
|
||||
|
||||
## Что показал этот пример
|
||||
|
||||
- **Одна реплика — один проход.** Ветка `general_info` не имеет машины состояний: `current_step_code` остаётся `null`. Это самый дешёвый путь в системе и логичная точка запуска первой версии бота.
|
||||
- **У `general_info` нет своих слотов.** В отличие от `new_booking`, эта ветка ничего не накапливает. Если пациент в одном треде задаст три информационных вопроса подряд — каждый пройдёт через маршрутизатор → ретривер → ответ, без какого-либо состояния между ними.
|
||||
- **Ретривер делает основную работу.** Все факты в ответе должны быть из чанков, не из памяти модели. Если чанков не хватило — модель уходит в шаблон «уточню у администратора», по инструкции из `general_info.md`.
|
||||
- **Пограничные кейсы.** Метро в Перми (которого нет — B.2), услуги, которых клиника не делает (C), праздничные дни без чанка (A.2) — именно на них модель ломается чаще всего и именно их полезно держать в eval-наборе с самого начала.
|
||||
- **Переход в запись — это hard handoff.** Когда после справки пациент говорит «запишите», происходит переключение ветки с инкрементом `handoff_count`. Никакого `suspended_intent` не сохраняется — у `general_info` нет состояния, восстанавливать нечего.
|
||||
|
||||
## Что важно проверять в eval-наборе на этом примере
|
||||
|
||||
- Маршрутизатор **не уводит** информационные вопросы в `new_booking` или `price_question`. Граничный случай: «сколько у вас стоит» — это `price_question`, а «какие у вас услуги» — `general_info`. Границы должны быть чёткими.
|
||||
- Все факты в ответе находимы в одном из чанков, попавших в контекст. Хорошая метрика — `groundedness` (доля утверждений с прямым подтверждением в источниках).
|
||||
- При отсутствии релевантных чанков модель отвечает шаблоном «уточню у администратора», а не выдумывает.
|
||||
- На пограничных кейсах (метро в Перми, услуги, которых нет, праздничные дни без чанка) ответ не содержит ложных утверждений — критичный безопасный минимум для запуска.
|
||||
- Переход «справка → запись» инкрементит `handoff_count`. Тест: задать `general_info`-вопрос, потом «запишите меня» → проверить, что `handoff_count == 1`, `current_intent_code == "new_booking"`, `current_step_code == "intro"`.
|
||||
|
||||
---
|
||||
|
||||
## Changelog
|
||||
|
||||
### v2 → 2026-04-27
|
||||
|
||||
**Имена полей `thread_state`** приведены к реальной БД (как в `01_basic_booking_v2.md`).
|
||||
|
||||
**Удалена несуществующая в коде машина состояний `general_info`:**
|
||||
- В v1 описывались шаги `answer` и `done` со слотами. Реально у `general_info` шагов нет — это обычный отвечающий промпт (`prompts/intents/general_info.md`). После хода `current_step_code` остаётся `null`.
|
||||
- Удалены вымышленные слоты `info_topic`, `branch_mention`, `needs_followup`, `dms_provider`. У ветки нет своих слотов вообще.
|
||||
|
||||
**Удалена несуществующая логика confidence threshold для RAG:**
|
||||
- В v1 пример A.4 описывал «если score лучшего чанка < 0.50, отвечать шаблоном с `needs_followup: true`». Этого механизма в коде сейчас нет — ретривер не проверяет `relevance_score`. Поведение «не выдумывай — скажи, что уточнишь» обеспечивается **только инструкцией в промпте** `general_info.md`, не порогом. Confidence-фильтр и слот `needs_followup` остались как идея на потом в `SPRINTS.md`.
|
||||
|
||||
**Удалены детальные блоки про per-intent RAG-фильтры по путям вики:**
|
||||
- В v1 показывались фильтры вида `doc_path STARTS WITH any('/wiki/hours/**')`. Этого в коде нет (используется общая коллекция `operators_wiki` без фильтра). Это запланированный Спринт 7 (мульти-RAG, вариант Б из v3).
|
||||
|
||||
**Сокращён объём примеров:**
|
||||
- Было ~20 коротких диалогов разбито по 6 темам (часы, адрес, проезд, контакты, документы, услуги, которых нет). В v2 оставлены только пограничные кейсы и один сценарий перехода `general_info → new_booking`. Полный сборник можно собрать заново после прогона eval'а, когда станет понятно, какие именно граничные случаи нужны.
|
||||
|
||||
**Добавлен раздел D · «Переход в запись после справки»** — показывает, как маршрутизатор переключает ветку с `general_info` на `new_booking`, и почему `suspended_intent` при этом **не** заполняется (нет состояния для восстановления).
|
||||
|
||||
**Терминология:** «роутер» → «маршрутизатор», «soft cross-sell» → «реплика модели» (этот термин в v1 был мисслидингом — никакой ветки-логики там нет).
|
||||
|
||||
**Содержательно** — то же: показать, что `general_info` это самый простой путь в системе, и пограничные кейсы важнее happy path.
|
||||
Reference in New Issue
Block a user