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:
+500
@@ -0,0 +1,500 @@
|
||||
# Спринты — Chat Agent for Patients (инструмент настройки)
|
||||
|
||||
Поэтапный план MVP: RAG-ядро + веб-инструмент для настройки агента операторами. Подключение реальных каналов (приложение, МАКС) — вне скоупа, это задача другого разработчика.
|
||||
|
||||
---
|
||||
|
||||
## Спринт 1. RAG-ядро, загрузка документов и тестовая страница
|
||||
|
||||
### Цель
|
||||
Поднять FastAPI-сервис с ChromaDB и сразу получить воспроизводимый «пайплайн в действии»: на одной тестовой странице видно, какие файлы загружены, можно задать одиночный вопрос от лица пациента и увидеть одновременно три вещи — какие чанки нашёл RAG, какой промпт собрался, какой ответ вернул DeepSeek. Аналог Debug UI из `work-pcs-dr-cdss`.
|
||||
|
||||
### Статус: ⏳ Запланирован
|
||||
|
||||
### Задачи
|
||||
|
||||
**RAG-ядро:**
|
||||
- [ ] Инициализация проекта (main.py, config.py, requirements.txt, Dockerfile, docker-compose, .env.example)
|
||||
- [ ] Переиспользовать паттерны из `work-pcs-dr-cdss`: `services/embeddings.py`, `vectorstore.py`, `document_processor.py`, `llm_client.py`
|
||||
- [ ] Адаптировать чанкер под wiki-статьи (не клинреки)
|
||||
|
||||
**Эндпоинты:**
|
||||
- [ ] `GET /health` — статус, кол-во документов и чанков
|
||||
- [ ] `POST /documents/upload` — загрузка + превью первых 3 чанков в ответе
|
||||
- [ ] `GET /documents` — список загруженных
|
||||
- [ ] `DELETE /documents/{id}` — удаление
|
||||
- [ ] `POST /query` — одиночный вопрос от лица пациента → ответ + источники со `score` + `assembled_prompt` (как RAG for Doctors, но без полей карты — только текст вопроса)
|
||||
|
||||
**Тестовая страница (одна HTML-страница, vanilla JS):**
|
||||
- [ ] Шапка со статусом сервиса (auto-refresh `/health`, счётчики документов и чанков)
|
||||
- [ ] Блок «База знаний»: drag & drop загрузка, таблица документов с превью первых чанков, кнопка удаления
|
||||
- [ ] Блок «Тест-вопрос от пациента»: поле ввода вопроса, поле `top_k`, кнопка «Отправить»
|
||||
- [ ] 3-колоночный результат ответа: релевантные фрагменты (текст + document, section, page, score) | собранный промпт | ответ LLM
|
||||
|
||||
### Критерий готовности
|
||||
- [ ] Оператор открывает `http://localhost:PORT/` → видит Debug UI со статусом сервиса
|
||||
- [ ] Загружает wiki-статью → она появляется в таблице, превью чанков отображается
|
||||
- [ ] Пишет вопрос «как записать ребёнка к лору?» → получает ответ DeepSeek с указанием источников
|
||||
- [ ] В средней колонке виден собранный промпт, в левой — какие чанки подтянулись со score
|
||||
- [ ] Может удалить статью, счётчики в шапке обновляются
|
||||
|
||||
---
|
||||
|
||||
## Спринт 2. Многошаговый диалог с памятью треда
|
||||
|
||||
### Цель
|
||||
Перейти от одиночного `/query` к полноценному диалогу: агент помнит историю, оператор ведёт разговор из 5+ реплик. Текущую страницу отладки (одиночный вопрос) оставляем без изменений, добавляем **вторую отладочную страницу** — «Песочница» со списком всех сохранённых диалогов.
|
||||
|
||||
### Статус: ✅ Закрыт
|
||||
|
||||
### Задачи
|
||||
|
||||
**Хранилище:**
|
||||
- [ ] Стек: SQLite + SQLAlchemy 2.0 (async, ORM-стиль) + Alembic для миграций
|
||||
- [ ] Таблицы:
|
||||
- `threads` (id, name, user_id nullable, agent_config_id nullable, created_at, updated_at)
|
||||
- `messages` (id, thread_id FK, role, text, sources_json, assembled_prompt, created_at)
|
||||
- Колонки `user_id` и `agent_config_id` заводим сразу nullable — под будущие Спринты 3+ (мульти-пользователи, мульти-промпты), чтобы не тащить миграции задним числом
|
||||
- [ ] Первая миграция Alembic с этими двумя таблицами
|
||||
- [ ] Все диалоги сохраняются навсегда (никакого авто-удаления)
|
||||
- [ ] Имя треда генерируется автоматически по первой реплике пациента + дата; оператор может переименовать вручную
|
||||
|
||||
**Эндпоинты:**
|
||||
- [ ] `POST /chat` — принимает `thread_id` (или создаёт новый если не передан) + `text` → возвращает ответ агента + источники со score + `assembled_prompt`
|
||||
- [ ] `GET /threads` — список всех диалогов (id, name, created_at, messages_count, превью первой реплики)
|
||||
- [ ] `GET /threads/{id}` — тред целиком с историей сообщений
|
||||
- [ ] `PATCH /threads/{id}` — переименовать тред
|
||||
- [ ] `DELETE /threads/{id}` — удалить тред со всеми сообщениями
|
||||
|
||||
**Сборка ответа:**
|
||||
- [ ] Базовый системный промпт (хардкод для старта): роль агента, тон клиники, что можно и нельзя
|
||||
- [ ] Сборка контекста для LLM: системный промпт + история треда + RAG-чанки по последней реплике
|
||||
|
||||
**Веб-интерфейс:**
|
||||
- [ ] В шапке обеих страниц — ссылки «Отладка» (текущая `/`) / «Песочница» (новая `/sandbox`)
|
||||
- [ ] Текущий `static/index.html` остаётся без изменений
|
||||
- [ ] Новая страница `static/sandbox.html` на отдельном маршруте `/sandbox`:
|
||||
- [ ] левая колонка — список сохранённых диалогов: превью, дата, кнопка «переименовать», кнопка «удалить», кнопка «новый тред»
|
||||
- [ ] центральная колонка — сам чат (оператор пишет как пациент, видит ответы агента, история подгружается при клике на тред из списка)
|
||||
- [ ] правая колонка — retrieved-чанки со score + собранный промпт по последней реплике
|
||||
|
||||
### Критерий готовности
|
||||
- [ ] Оператор может провести диалог из 5+ реплик, агент помнит контекст
|
||||
- [ ] Все диалоги сохраняются и видны в левой колонке после перезагрузки страницы
|
||||
- [ ] Оператор может открыть старый диалог, переименовать его, удалить
|
||||
- [ ] В правой колонке видно, что нашёл RAG и что улетело в LLM на последнем шаге
|
||||
- [ ] Старая страница отладки (`/`) работает как раньше, ничего не сломано
|
||||
|
||||
---
|
||||
|
||||
## Спринт 2.5. Доработки после пилота Спринтов 1–2
|
||||
|
||||
### Цель
|
||||
Закрыть технический долг, накопленный за первые два спринта: почистить чанки от markdown-мусора, сделать ответ агента читаемым в UI, подготовить системный промпт к вынесению в редактор (Спринт 3) и навести порядок в логах и README.
|
||||
|
||||
### Статус: ✅ Закрыт
|
||||
|
||||
### Задачи
|
||||
|
||||
**Качество RAG:**
|
||||
- [ ] Почистить чанки: убрать markdown-ссылки `[текст](url)`, блоки навигации `**Вернуться на:**`, дубликаты меню
|
||||
- [ ] Эндпоинт `POST /documents/{id}/reindex` — переразметить существующий документ с новыми правилами чанкера (без повторной загрузки файла — но у нас пока нет хранения исходников, поэтому надо хранить исходный текст в метаданных чанков или сохранять оригинал при `upload`); решение по способу — в рамках задачи
|
||||
- [ ] Эндпоинт `POST /documents/reindex-all` — прогнать переиндексацию по всей базе
|
||||
|
||||
**UI:**
|
||||
- [ ] Markdown-рендер ответов ассистента в «Песочнице» (жирный, курсив, списки, код); реплики пациента оставить plain text
|
||||
|
||||
**Системность:**
|
||||
- [ ] Вынести системный промпт из `services/llm_client.py` в отдельный файл (например, `prompts/system_prompt.md`), загружать при старте — задел под Спринт 3
|
||||
- [ ] Привести логи в порядок: настроить root-logger так, чтобы `logger.exception` писался в stderr/файл; не ломать uvicorn access/error
|
||||
- [ ] Обновить `README.md` под текущее состояние: две страницы, `/chat` + `/threads`, SQLite + Alembic, как запустить и как мигрировать
|
||||
|
||||
### Критерий готовности
|
||||
- [ ] Загружаем свежую wiki-статью → в её чанках нет markdown-ссылок и блоков «Вернуться на:»
|
||||
- [ ] На «Песочнице» ответ агента рендерится с жирным/курсивом/списками
|
||||
- [ ] Системный промпт хранится в отдельном файле, правится без трогания кода
|
||||
- [ ] При ошибке в `/chat` в логах виден читаемый traceback
|
||||
- [ ] README описывает актуальное состояние (две страницы, эндпоинты, запуск, миграции)
|
||||
|
||||
---
|
||||
|
||||
## Спринт 3. Настройки агента: системный промпт и правила
|
||||
|
||||
### Цель
|
||||
Дать операторам веб-редактор системного промпта и списка правил («если спрашивают про X — отвечай так-то», «если пациент злится — делай то-то»). Версионирование: можно сохранить конфигурацию и откатиться.
|
||||
|
||||
### Статус: ✅ Закрыт
|
||||
|
||||
### Задачи
|
||||
- [ ] Хранилище (SQLite): `agent_configs` (version, created_at, system_prompt, rules_text, is_active)
|
||||
- [ ] Эндпоинты: `GET /configs`, `POST /configs` (создать новую версию), `POST /configs/{id}/activate`
|
||||
- [ ] Песочница использует активную версию при каждом `/chat`
|
||||
- [ ] Веб-страница «Настройки агента»:
|
||||
- [ ] редактор системного промпта (textarea)
|
||||
- [ ] редактор правил (отдельным блоком; на старте — просто textarea, позже — список записей)
|
||||
- [ ] кнопка «Сохранить как новую версию»
|
||||
- [ ] список версий с кнопкой «Сделать активной» и пометкой активной
|
||||
- [ ] Показ активной версии в шапке песочницы
|
||||
|
||||
### Критерий готовности
|
||||
- [ ] Оператор меняет промпт → сохраняет как v2 → активирует → тестирует в песочнице → при желании откатывается к v1
|
||||
- [ ] Правила реально влияют на ответы агента (проверяется вручную через песочницу)
|
||||
|
||||
---
|
||||
|
||||
## Архитектурный разворот после Спринта 3 (2026-04-23)
|
||||
|
||||
После пилота Спринтов 1–3 решили уходить от одного «мега-промпта» ко графовой архитектуре: **роутер намерений + изолированные ветки + state machine + exit conditions**. Подробности — в [`architecture/GRAPH_ARCHITECTURE_v3.md`](./architecture/GRAPH_ARCHITECTURE_v3.md) (последняя версия). Исторические снапшоты — `architecture/GRAPH_ARCHITECTURE_v1.md` (изначальный, на момент разворота) и `architecture/GRAPH_ARCHITECTURE_v2.md`.
|
||||
|
||||
**Принятые решения по открытым вопросам:**
|
||||
- **Фреймворк оркестровки:** пишем вручную на Python. LangGraph/n8n не берём — проект компактный, свой стек работает, не тянем лишних зависимостей.
|
||||
- **Модель для роутера:** остаёмся на DeepSeek, но `RouterClient` делаем отдельным классом от `LLMClient` — потом сменим модель в одном месте, если станет дорого.
|
||||
- **Exit conditions:** свободный текст в промпте ветки + независимый роутер на каждой реплике. Если ветка пропустит триггер — роутер подстрахует.
|
||||
- **Эскалация на человека:** одна ветка `escalate_human` с полем `reason` (`acute_pain` / `surgery` / `angry` / `explicit_request`). Отдельная маршрутизация «куда именно» — задача смежного разработчика при подключении каналов.
|
||||
- **Confidence score:** не тянем в первый спринт. Роутер всегда возвращает один из intent'ов, при сомнении — `general_info`. После первого живого прогона посмотрим на реальные ошибки.
|
||||
|
||||
Старые Спринт 4 (сценарии) и Спринт 5 (экспорт) не удалены — они переехали в Спринт 7 с дополнением под граф (прогон сценариев проверяет маршрутизацию, экспорт — снапшот графа).
|
||||
|
||||
---
|
||||
|
||||
## Спринт 4. Фундамент графа — `intents` + роутер + переключение веток
|
||||
|
||||
### Цель
|
||||
Заменить «один активный промпт на всё» на «свой промпт на каждую ветку + роутер выбирает ветку на каждой реплике». Это первый шаг к графовой архитектуре из [`architecture/GRAPH_ARCHITECTURE_v3.md`](./architecture/GRAPH_ARCHITECTURE_v3.md).
|
||||
|
||||
### Статус: ✅ Закрыт
|
||||
|
||||
### Задачи
|
||||
|
||||
**Данные:**
|
||||
- [ ] Новая таблица `intents` (code, name, description, is_enabled, order_index)
|
||||
- [ ] Миграция Alembic + в `agent_configs` добавить `intent_id` (nullable для обратной совместимости)
|
||||
- [ ] Сид при первом запуске: 6 стартовых веток — `new_booking`, `reschedule`, `price_question`, `medical_question`, `general_info`, `escalate_human`
|
||||
- [ ] Перенос текущего v1 конфига в ветку `general_info` как стартовый промпт
|
||||
|
||||
**Роутер:**
|
||||
- [ ] `services/router_client.py` — отдельный класс под DeepSeek, метод `classify(history, text) → intent_code`
|
||||
- [ ] Короткий промпт-классификатор с фиксированным перечнем категорий
|
||||
- [ ] При сомнении возвращает `general_info` (без confidence score на этом спринте)
|
||||
|
||||
**Оркестрация:**
|
||||
- [ ] В `chat_service.send_message`: сначала `router.classify()` → активный конфиг выбранной ветки → `llm.chat()` с этим промптом
|
||||
- [ ] В таблице `messages` сохраняется `intent_id` каждого обмена
|
||||
|
||||
**API:**
|
||||
- [ ] `GET /intents` — список веток
|
||||
- [ ] `PATCH /intents/{code}` — включить/выключить
|
||||
- [ ] `POST /configs` принимает `intent_id`; создание новой версии — всегда в рамках ветки
|
||||
|
||||
**UI:**
|
||||
- [ ] «Настройки»: слева список веток, справа редактор промпта/правил активной версии выбранной ветки
|
||||
- [ ] В «Песочнице» в отладке показывать: решение роутера + выбранный intent + какая ветка ответила
|
||||
|
||||
### Критерий готовности
|
||||
- [ ] «У меня острая боль» → `medical_question`
|
||||
- [ ] «Сколько стоит приём» → `price_question`
|
||||
- [ ] «Как доехать» → `general_info`
|
||||
- [ ] В отладочной панели «Песочницы» виден intent и какая ветка дала ответ
|
||||
- [ ] Для каждой ветки можно отдельно править промпт и сохранять версии
|
||||
|
||||
---
|
||||
|
||||
## Спринт 5. State machine + exit conditions (bouncing)
|
||||
|
||||
### Цель
|
||||
Научить ветки вести многошаговые скрипты и бесшовно передавать тред в другую ветку, если пациент сменил тему.
|
||||
|
||||
### Статус: ✅ Закрыт (ядро; дотяжка до GRAPH_ARCHITECTURE v2 — в бэклоге)
|
||||
|
||||
### Задачи
|
||||
|
||||
**Данные:**
|
||||
- [x] Таблица `thread_state` (thread_id, current_intent_code, current_step, slots_json, updated_at) + миграция Alembic (batch-режим под SQLite)
|
||||
|
||||
**State machine (первая ветка — `new_booking`):**
|
||||
- [x] 6-шаговый скрипт: приветствие → повод → специалист → удобное время → подтверждение → запись
|
||||
- [x] Модель на каждой реплике видит блок `[ТЕКУЩЕЕ СОСТОЯНИЕ]` с `step` и `slots`
|
||||
- [x] Переход шагов управляется служебным тегом `[STATE: step=N; slots={...}]` в ответе модели (строковый тег, парсится балансировкой фигурных скобок)
|
||||
|
||||
**Exit conditions и bouncing:**
|
||||
- [x] В промпт `new_booking` добавлен блок условий выхода с сигналом `[INTENT_CHANGE: <code>]`
|
||||
- [x] Парсер в `services/chat_service._parse_assistant_signals` вырезает служебные теги из ответа
|
||||
- [x] Bouncing: одна итерация (`MAX_BOUNCES=1`) — ветка может передать управление другой, делаем повторный вызов LLM
|
||||
- [x] Роутер на каждой реплике: если классификация ≠ `thread_state.current_intent_code` → сброс `step` и `slots`
|
||||
|
||||
**UI:**
|
||||
- [x] В «Песочнице» блок «Состояние треда»: intent, шаг, слоты (JSON), список переходов в текущей реплике
|
||||
- [x] В отладке роутера — пометка, если ветка «передала управление»
|
||||
|
||||
### Критерий готовности
|
||||
- [x] Сценарий new_booking проходит: ФИО → повод → специалист → время → подтверждение собираются в `thread_state.slots`
|
||||
- [x] Переключение ветки через роутер: «Сколько стоит приём?» внутри записи → state сбрасывается в `price_question`
|
||||
- [x] В отладке видна вся цепочка: роутер-intent, served-intent, шаг, слоты, переходы
|
||||
|
||||
### Что НЕ вошло в этот спринт (по сравнению с GRAPH_ARCHITECTURE_v2.md)
|
||||
Реализовано ядро v1. Вся дотяжка до v2 — Спринт 6.
|
||||
|
||||
---
|
||||
|
||||
## Спринт 6a. State machine v2 — ядро, защита от петель, возврат в ветку
|
||||
|
||||
### Цель
|
||||
Заменить строковый тег `[STATE: ...]` на структурированный выход модели с валидатором переходов по таблице `intent_steps`; добавить `handoff_count` с автовыходом в `escalate_human: routing_loop`; научить систему возобновлять прерванную ветку через `suspended_intent`. В конце Спринта 6a уже видно глазами: вкладка «Шаги» в «Настройках» для `new_booking`, в «Песочнице» — handoff_count и suspended_intent, timeline переходов первой версии.
|
||||
|
||||
Попутно реализована **sticky state machine** (мини-G): когда тред идёт по sm-ветке и роутер предлагает другую — state не сбрасывается, в системный промпт ветки подаётся `[ПОДСКАЗКА РОУТЕРА]`, LLM сама решает. Это сняло ключевую дыру Спринта 5 с коротким repликами внутри сценария.
|
||||
|
||||
### Статус: ⏳ Запланирован
|
||||
|
||||
### Принятые решения (зафиксировано 2026-04-24, действуют и для 6b)
|
||||
- **Момент обновления `current_step`** — после успешного коммита сообщения ассистента в БД.
|
||||
- **Cap на soft-insertion'ы подряд** — 3 (реализация в 6b).
|
||||
- **Шаги `new_booking` — сверить с вики клиники по ЛОР** до переписывания промпта в блоке A.
|
||||
- **Формат структурированного выхода** — JSON-блок в хвосте ответа, парсим сами балансировкой скобок + `json.loads`.
|
||||
|
||||
### Задачи и UI-чекпойнты (порядок: A → A2 → B → C)
|
||||
|
||||
**Блок A. Структурированный выход + таблица `intent_steps` + валидатор переходов (v2 §3.3)**
|
||||
|
||||
*Бекенд:*
|
||||
- [ ] Новая таблица `intent_steps`: `id, intent_id FK, code (intro/qualify/present/offer_time/book/close), name, order_index, system_prompt Text, allowed_next JSON, guards JSON (пустой на этом спринте — наполняется в 6b/F)`. Миграция Alembic.
|
||||
- [ ] Сид шагов `new_booking` при старте: читает `prompts/intents/new_booking/steps/{code}.md` + `prompts/intents/new_booking/transitions.yaml`.
|
||||
- [ ] Разделить `prompts/intents/new_booking.md` на базовый промпт ветки (общие правила) + отдельные файлы на каждый из 6 шагов.
|
||||
- [ ] В `services/chat_service` — сборка промпта: `base_prompt + intent_steps[current_step].system_prompt + state_context`.
|
||||
- [ ] Парсер нового формата ответа: `{reply, state_after, slots_updated}` — JSON-блок в хвосте.
|
||||
- [ ] Валидатор: сверка `state_after` с `intent_steps.allowed_next`. Легален → применяем, иначе — остаёмся на текущем шаге + warning в лог. Слоты сливаем `{**old, **slots_updated}`.
|
||||
- [ ] `assistant_msg.text` = `reply`; служебные поля — в `assembled_prompt` для отладки.
|
||||
|
||||
*UI-чекпойнт A:*
|
||||
- [ ] В «Настройках» для ветки `new_booking` появляется вкладка **«Шаги»**: список шагов из `intent_steps` + на клик открывается редактор (textarea с `system_prompt`, чекбоксы с `allowed_next`). Кнопка «Сохранить шаг» — `PATCH /intents/{code}/steps/{step_code}` пишет сразу (без версионирования).
|
||||
- [ ] В «Песочнице» бейдж текущего шага берётся из `intent_steps.name`, а не из сырого числа. Если валидатор отклонил `state_after` — красная пометка «модель просилась в `X`, остались на `Y`».
|
||||
- [ ] **Что проверяем глазами:** открыть `new_booking` → вкладка «Шаги» видит 6 шагов; править любой промпт → применяется в новом треде; в песочнице прогнать «Здравствуйте, хочу записаться» → шаг подписан словами («Приветствие»), а не числом.
|
||||
|
||||
**Блок A2. `exit_conditions_text` — отдельное поле в `agent_configs` (v2 §UI)**
|
||||
|
||||
*Бекенд:*
|
||||
- [ ] Миграция: добавить `exit_conditions_text Text NULLABLE` в `agent_configs`.
|
||||
- [ ] `compose_full_system_prompt` склеивает: `system_prompt + rules_text + exit_conditions_text`.
|
||||
- [ ] Миграция данных: при старте для существующих конфигов попытаться распарсить блок «Условия выхода» / `[INTENT_CHANGE: ...]` из хвоста `system_prompt` и перенести в новое поле. Не удалось — оставить пусто.
|
||||
|
||||
*UI-чекпойнт A2:*
|
||||
- [ ] В «Настройках» на вкладке активной версии — третья textarea `exit_conditions_text` рядом с `system_prompt` и `rules_text`.
|
||||
- [ ] **Что проверяем глазами:** у ветки `general_info` после миграции данных в поле `exit_conditions_text` лежат правила `[INTENT_CHANGE: ...]`, а не в теле промпта. В песочнице поведение не изменилось.
|
||||
|
||||
**Блок B. `handoff_count` и защита от петель (v2 §4.3)**
|
||||
|
||||
*Бекенд:*
|
||||
- [ ] Миграция `thread_state`: добавить `handoff_count INT NOT NULL DEFAULT 0`.
|
||||
- [ ] В `chat_service` инкрементить при каждом hard-handoff (INTENT_CHANGE или router-инициированное переключение).
|
||||
- [ ] При `handoff_count >= 2` — авто-уход в `escalate_human` c `reason=routing_loop`. Ответ-заглушка формируется без нового вызова LLM («Передаю ваш вопрос администратору»).
|
||||
- [ ] Счётчик сбрасывается на 0 при возврате из `suspended_intent` (блок C) и при переходе в `escalate_human`.
|
||||
|
||||
*UI-чекпойнт B:*
|
||||
- [ ] В «Песочнице» в «Состоянии треда» — строка `handoff_count: N`. При автоуходе в `escalate_human: routing_loop` — явная отметка в timeline.
|
||||
- [ ] **Что проверяем глазами:** искусственная петля «хочу записаться → сколько стоит → хочу записаться → сколько стоит» → после второго-третьего handoff'а бот говорит «передаю администратору»; в песочнице `handoff_count` вырос, ветка сменилась на `escalate_human`.
|
||||
|
||||
**Блок C. `suspended_intent` + `resumable_step` + `resumable_slots` (v2 §4.4)**
|
||||
|
||||
*Бекенд:*
|
||||
- [ ] Миграция: добавить колонки `suspended_intent`, `resumable_step INT`, `resumable_slots_json TEXT` (все nullable) в `thread_state`.
|
||||
- [ ] При hard-handoff из многошаговой ветки (`new_booking`) — сохранять `current_*` в `suspended_*` перед сбросом.
|
||||
- [ ] Возврат: роутер классифицировал реплику в `suspended_intent` → восстанавливаем `current_*` из `suspended_*` и очищаем поля. Альтернативный триггер — сигнал `[RESUME]` из ветки detour'а (наполняем в 6b).
|
||||
|
||||
*UI-чекпойнт C:*
|
||||
- [ ] В «Состоянии треда» — `suspended_intent` и `resumable_step` (если заполнены).
|
||||
- [ ] Timeline переходов между ветками в рамках треда: список типа `new_booking (step=4) → price_question → new_booking (step=4, восстановлено)`. Собирается на бекенде из diff'ов `intent_id` у соседних сообщений + лога handoff'ов.
|
||||
- [ ] **Что проверяем глазами:** запись до 4 шага → «сколько это стоит?» → `suspended_intent=new_booking, resumable_step=4` видно в панели → «ок, тогда бронируем» → слоты `new_booking` вернулись, шаг=4, timeline показывает три перехода.
|
||||
|
||||
### Критерий готовности 6a
|
||||
- [ ] Сценарии 1 (базовая запись), 3 (handoff с suspended), 4 (возврат из suspended), 6 (routing_loop) из блока H Спринта 6b проходят в «Песочнице».
|
||||
- [ ] `handoff_count` и `suspended_intent` видны глазами в «Состоянии треда».
|
||||
- [ ] Вкладка «Шаги» в «Настройках» работает — можно отредактировать промпт шага и увидеть эффект в песочнице без рестарта.
|
||||
- [ ] Третья textarea `exit_conditions_text` работает; данные старых веток мигрированы.
|
||||
- [ ] `current_step` пишется только после коммита `assistant_msg` — проверяется код-ревью.
|
||||
- [ ] Парсер структурированного выхода устойчив к невалидному `state_after`.
|
||||
|
||||
---
|
||||
|
||||
## Спринт 6b. Глубина сценария — soft-insertion, guards, reason, умный роутер
|
||||
|
||||
### Цель
|
||||
Поверх ядра из 6a — добавить различение soft/hard-handoff, guards в `new_booking`, структурированный reason в `escalate_human`, умный роутер, видящий `thread_state`. В конце Спринта 6b все 8 ручных сценариев из блока H проходят в «Песочнице».
|
||||
|
||||
### Статус: ⏳ Запланирован (после 6a)
|
||||
|
||||
### Задачи и UI-чекпойнты (порядок: D → F → E → G → H)
|
||||
|
||||
**Блок D. Soft-insertion vs hard-handoff (v2 §4.2)**
|
||||
|
||||
*Бекенд:*
|
||||
- [ ] В промпт ветки `new_booking` (базовый + шаги `qualify/present/offer_time`) — правило «короткие боковые вопросы (цена услуги, адрес, часы, длительность приёма, требования к документам) отвечай сам, не покидая шаг». Модель возвращает `state_after=текущий_шаг`, `slots_updated={}`.
|
||||
- [ ] Миграция `thread_state`: добавить `soft_insertion_count INT NOT NULL DEFAULT 0`.
|
||||
- [ ] На soft-insertion счётчик инкрементится; на продвижение по шагу — сбрасывается в 0.
|
||||
- [ ] При `soft_insertion_count >= 3` — ветка в промпте получает явное указание «вернуть пациента к вопросу шага».
|
||||
|
||||
*UI-чекпойнт D:*
|
||||
- [ ] В «Состоянии треда» — `soft_insertion_count: N`.
|
||||
- [ ] В timeline переходов помечать soft-insertion как `new_booking · soft-answer (price)` — без смены ветки.
|
||||
- [ ] **Что проверяем глазами:** запись до шага 3 → «а сколько стоит?» → ответ по цене, шаг=3 сохранился, `soft_insertion_count=1`. Повторить 3 раза → на 3-м ответе бот возвращает к вопросу шага.
|
||||
|
||||
**Блок F. Guards в `new_booking` (v2 §3.2)**
|
||||
|
||||
*Бекенд:*
|
||||
- [ ] В `intent_steps.guards` наполняем условия для `new_booking`: ребёнок → `legal_rep_name+legal_rep_phone` до перехода из `qualify`; запрос конкретного врача с листом ожидания → рукав `waitlist`; жалоба на слух без предварительного сурдолога → сначала `surgologist` в `specialist`.
|
||||
- [ ] Слоты: `is_child`, `legal_rep_name`, `legal_rep_phone`, `requested_doctor`, `waitlist_flag`, `needs_surgologist_first`.
|
||||
- [ ] Валидатор переходов (блок A 6a) проверяет `guards`: если не пройден — блокирует `state_after`, оставляет на шаге, возвращает пациенту ответ модели как есть.
|
||||
- [ ] Обновить промпты шагов под сценарии guard'ов.
|
||||
|
||||
*UI-чекпойнт F:*
|
||||
- [ ] На вкладке «Шаги» — отдельная textarea для `guards` (JSON) с валидацией формата.
|
||||
- [ ] В «Состоянии треда» — если валидатор заблокировал переход guard'ом, явная отметка «guard `require_legal_rep` не пройден, ждём `legal_rep_phone`».
|
||||
- [ ] **Что проверяем глазами:** сценарий 7 (ребёнок) — на шаге `qualify` после «это для сына, 5 лет» бот спрашивает ФИО и телефон родителя; пока не заполнены — не переходит; в песочнице видна причина блокировки. Сценарий 8 (конкретный врач) — переход в рукав `waitlist`.
|
||||
|
||||
**Блок E. `reason` в `escalate_human` (v2 §1, §5)**
|
||||
|
||||
*Бекенд:*
|
||||
- [ ] Обновить промпт `_router`: при `escalate_human` возвращать пару `code + reason` (`acute_pain / surgery / angry / explicit_request / routing_loop`).
|
||||
- [ ] `RouterClient.classify` парсит reason, дефолт при неразобранном — `explicit_request`.
|
||||
- [ ] Ветка `escalate_human.md` и шаги (если есть) — reason влияет на текст первой реплики.
|
||||
- [ ] В `messages` — колонка `escalation_reason NULLABLE` (миграция). В API-ответе `/chat` поле `escalation_reason`.
|
||||
- [ ] Заготовка саммари для оператора: при эскалации формируется `{reason, history, slots_from_suspended}`, логируется в файл/консоль (канал передачи — Спринт 9).
|
||||
|
||||
*UI-чекпойнт E:*
|
||||
- [ ] В «Состоянии треда» — при активной эскалации показывать `reason`.
|
||||
- [ ] В «Отладке ответа» под блоком роутера — сгенерированное саммари оператора (read-only preview).
|
||||
- [ ] **Что проверяем глазами:** сценарий 5 («упомянул хирургию») → эскалация с `reason=surgery`, превью саммари содержит всю историю + собранные слоты. Сценарий 6 (петля) → эскалация с `reason=routing_loop`.
|
||||
|
||||
**Блок G. Умный роутер (видит `thread_state`)**
|
||||
|
||||
Частично уже реализовано в Спринте 6a: **sticky state machine** — если тред в sm-ветке и роутер предлагает другую, state НЕ сбрасывается, а в системный промпт ветки подаётся блок `[ПОДСКАЗКА РОУТЕРА]`, LLM сама решает (STATE_JSON или INTENT_CHANGE). Это сняло основную проблему с короткими репликами («Кук», «болит ухо») внутри сценария.
|
||||
|
||||
*Что осталось на 6b:*
|
||||
- [ ] Вторая линия защиты: в `RouterClient.classify` принимать снимок `thread_state` и вставлять в системный промпт **самого роутера** блок «Сейчас идёт сценарий X на шаге Y, слоты Z — если реплика укладывается в сценарий, предпочитай текущую ветку». Это помогает роутеру изначально реже ошибаться, а не только «поправляться» sticky-логикой.
|
||||
- [ ] Обновить `prompts/intents/_router.md` под новый формат.
|
||||
|
||||
*UI-чекпойнт G:*
|
||||
- [ ] В «Отладке ответа» → блок «Решение роутера» — кнопка развернуть промпт, который ушёл в роутер (включая блок состояния треда). Полезно для отладки.
|
||||
- [ ] **Что проверяем глазами:** сценарий из 6a («болит ухо» внутри new_booking) — роутер теперь изначально возвращает `new_booking`, а не `medical_question` → без sticky-коррекции.
|
||||
|
||||
**Блок H. Финальный прогон 8 ручных сценариев (прокси-eval)**
|
||||
- [ ] Зафиксировать в `eval/MANUAL_CASES.md` полный список 8 сценариев (уже описан в этом документе выше, просто консолидируем).
|
||||
- [ ] Прогнать в «Песочнице». Для каждого сценария — в `eval/MANUAL_REPORT.md` фиксируем результат (ок / расхождение + детали).
|
||||
1. Базовая запись (6 шагов → `confirmed=true`).
|
||||
2. Запись → вопрос про цену (soft-insertion, без смены шага).
|
||||
3. Запись → перенос старой записи (hard-handoff в `reschedule`, `suspended=new_booking`).
|
||||
4. Запись → detour → возврат «бронируем на четверг» (восстановление из `suspended`).
|
||||
5. Запись → упоминание хирургии (`escalate_human: surgery`, саммари).
|
||||
6. Искусственная петля (`routing_loop` после cap).
|
||||
7. Запись ребёнка (guard блокирует переход).
|
||||
8. Конкретный врач (waitlist-рукав).
|
||||
|
||||
### Критерий готовности 6b
|
||||
- [ ] Все 8 сценариев из блока H проходят в «Песочнице» без ручной правки state. `MANUAL_REPORT.md` закоммичен.
|
||||
- [ ] Все UI-чекпойнты (D, F, E, G) проверены глазами.
|
||||
- [ ] Роутер при активной state machine не сбрасывает intent на коротких репликах внутри сценария.
|
||||
- [ ] Саммари оператору формируется и логируется при эскалации — пусть пока и без канала передачи.
|
||||
|
||||
---
|
||||
|
||||
## Спринт 7. Мульти-RAG (вариант Б из v2: подписка ветки на разделы вики)
|
||||
|
||||
### Цель
|
||||
Дать каждой ветке собственный срез базы знаний, чтобы детская wiki не засоряла ответы по записи, а скрипты возражений — ответы по ценам. Согласно `GRAPH_ARCHITECTURE_v2.md` §6 — **Вариант Б** предпочтительнее отдельных коллекций: одна общая коллекция + фильтр по разделам вики в метаданных чанков. Делаем **до** мини-eval, чтобы наборы в Спринте 8 проверяли поведение уже с реальным per-intent retrieval.
|
||||
|
||||
### Статус: ⏳ Запланирован
|
||||
|
||||
### Задачи
|
||||
- [ ] В `intents` — поле `wiki_sources: list[str]` (префиксы путей или doc-ID). Миграция.
|
||||
- [ ] В метаданные чанка при загрузке записывать `doc_path` / раздел вики.
|
||||
- [ ] В `services/vectorstore.py` — where-фильтр по `doc_path` на основе `wiki_sources` активной ветки при query.
|
||||
- [ ] UI «Настройки» — редактор `wiki_sources` у ветки (список префиксов).
|
||||
- [ ] Если `wiki_sources` пуст — дефолт: вся коллекция (для `general_info`).
|
||||
- [ ] Задел под v2 §3.4: опциональный `wiki_sources_by_step` (на уровне шага state machine) — сделать именно здесь, раз у нас уже есть state machine из Спринта 6.
|
||||
|
||||
### Критерий готовности
|
||||
- [ ] Документ раздела `/wiki/pricing/*` автоматически используется только в `price_question` (без ручного дублирования).
|
||||
- [ ] При переключении ветки в диалоге retrieval берёт нужный срез.
|
||||
- [ ] В «Отладке» видно: какие префиксы активны, какие чанки пришли из каких разделов.
|
||||
- [ ] Для шага `offer_time` в `new_booking` отдельный per-step срез работает (если ветка его заполнила).
|
||||
|
||||
---
|
||||
|
||||
## Спринт 8. Мини-eval: роутер, handoff, resumable
|
||||
|
||||
### Цель
|
||||
После дотяжки v2 (Спринт 6) и мульти-RAG (Спринт 7) — зафиксировать автоматизированный тест-набор, чтобы следующие правки промптов и `wiki_sources` не ломали собранное. Формализует ручные сценарии из блока H Спринта 6.
|
||||
|
||||
### Статус: ⏳ Запланирован
|
||||
|
||||
### Задачи
|
||||
|
||||
**Eval-наборы (отдельные файлы в репозитории, без БД):**
|
||||
- [ ] `eval/router_cases.csv` — 20–30 фраз на каждую из 6 веток: типичные, пограничные (ловушечные), злые (короткие, эмоциональные, с опечатками). Колонки: `text, expected_intent, note`.
|
||||
- [ ] `eval/handoff_cases.yaml` — 5–10 многошаговых мини-диалогов: реплики пациента по порядку + ожидаемый intent на каждую.
|
||||
- [ ] `eval/resumable_cases.yaml` — 3–5 сценариев detour-и-возврат: реплики + ожидаемые `current_intent`, `current_step`, ключевые слоты на каждом шаге.
|
||||
- [ ] `eval/loop_cases.yaml` — 1–2 сценария искусственной петли с проверкой `reason=routing_loop`.
|
||||
- [ ] `eval/guard_cases.yaml` — сценарии на guards (ребёнок, waitlist).
|
||||
- [ ] `eval/rag_cases.yaml` — сценарии на мульти-RAG: реплика внутри ветки → проверка, что retrieved-чанки из правильного раздела вики.
|
||||
|
||||
**Запускалка (CLI, не часть сервиса):**
|
||||
- [ ] `eval/run.py` — читает наборы, прогоняет через живой сервис. Режимы:
|
||||
- `router` — прямой вызов `RouterClient.classify()` на фразах из CSV (быстро).
|
||||
- `dialog` — полный `/chat` на чистых тредах, сверка intent + step + slots + handoff_count + reason + источники.
|
||||
- [ ] Вывод: per-ветка accuracy, confusion matrix, список расхождений с текстом реплики.
|
||||
- [ ] Отчёт: stdout + `eval/reports/{timestamp}.md` (добавлять в git для сравнения во времени).
|
||||
|
||||
**Документация:**
|
||||
- [ ] В `README.md` — раздел «Как прогнать eval» (одна команда).
|
||||
- [ ] Договорённость: перед правкой промпта роутера / ветки / `wiki_sources` — прогнать eval, зафиксировать baseline; после — сравнить.
|
||||
|
||||
### Критерий готовности
|
||||
- [ ] `eval/run.py` работает одной командой, полный набор проходит за ≤ 3 минуты.
|
||||
- [ ] Отчёт покрывает все 8 сценариев из блока H Спринта 6 + базовые kv-тесты роутера + RAG-проверки Спринта 7.
|
||||
- [ ] Baseline зафиксирован в `eval/reports/{date}_baseline.md` и добавлен в git.
|
||||
|
||||
---
|
||||
|
||||
## Спринт 9. Сценарии + экспорт графа
|
||||
|
||||
### Цель
|
||||
То, что изначально планировалось как Спринты 4 + 5 до архитектурного разворота. Теперь встроено в граф: прогон сценария проверяет не только текст ответов, но и правильность маршрутизации; экспорт — снапшот всего графа (intents + промпты + коллекции).
|
||||
|
||||
### Статус: ⏳ Запланирован
|
||||
|
||||
### Задачи
|
||||
|
||||
**Сценарии:**
|
||||
- [ ] Таблица `scenarios` (id, name, note, label, messages_json, expected_intents_json, config_snapshot_id)
|
||||
- [ ] `POST /scenarios` — сохранить текущий тред «Песочницы» как сценарий, зафиксировать ожидаемый intent на каждую реплику пациента
|
||||
- [ ] `POST /scenarios/{id}/run` — прогнать реплики пациента на текущих активных конфигах всех веток; вернуть новые ответы + распознанные intents
|
||||
- [ ] Веб-страница «Сценарии»: список + открытая карточка со side-by-side (старый ответ / новый), подсветка «маршрутизация совпала / разошлась»
|
||||
- [ ] Счётчик «ок / расхождение» по всей базе сценариев после последнего прогона
|
||||
|
||||
**Экспорт:**
|
||||
- [ ] `GET /configs/export` — JSON-снапшот графа: все intents, для каждого — активный промпт и правила, список коллекций RAG и документов в них
|
||||
- [ ] Документация API в README: `POST /chat`, `GET /health`, контракт ответов
|
||||
- [ ] Инструкция «Как подключить канал» + пример curl / минимальный webhook-адаптер
|
||||
- [ ] docker-compose поднимается одной командой, внешний разработчик получает рабочий `/chat`
|
||||
|
||||
### Критерий готовности
|
||||
- [ ] После изменения промпта в одной из веток — прогон сценариев показывает расхождения именно в этой ветке
|
||||
- [ ] Виден общий счётчик «ок / изменилось» по базе сценариев
|
||||
- [ ] В README готов раздел «Как подключить канал», работает docker-compose-запуск
|
||||
|
||||
---
|
||||
|
||||
## Бэклог
|
||||
|
||||
### Дальнейшие идеи
|
||||
- Раздельные правила по доменам — **перекрыто архитектурой: теперь это ветки (`intents`)**
|
||||
- A/B сравнение двух версий промпта на одном тест-наборе (в рамках одной ветки или между ветками)
|
||||
- Метрики качества ответов (MRR, CSAT по сценариям)
|
||||
- Подсветка цитат источников в ответе агента
|
||||
- Автосинхронизация wiki
|
||||
- Перевод правил из свободного текста в структурированный список (pattern → instruction)
|
||||
- Мультипользовательский режим (несколько операторов одновременно настраивают)
|
||||
- Хранение исходных файлов (`./data/uploads/{document_id}.{ext}` + `source_path` в метаданных Chroma) — чтобы переиндексировать без повторной загрузки и показывать оператору оригинал документа
|
||||
- Confidence score роутера + clarifying question при низкой уверенности — включить после реального прогона eval'а, если будет много ошибок классификации
|
||||
- Визуализация графа (веток и переходов между ними) — возможно, в виде отдельной панели
|
||||
- Вынесение роутера на отдельную более дешёвую модель (gpt-4o-mini, локальная Qwen) — когда вызовов станет много
|
||||
- Структурированные exit conditions (список триггеров с keyword-match) — если свободный текст в промпте будет пропускать реальные случаи смены темы
|
||||
- `routing_log` (таблица решений роутера по каждой реплике) — для офлайн-анализа и тюнинга, когда eval покажет, что нужно
|
||||
@@ -0,0 +1,206 @@
|
||||
# Графовая архитектура: роутер намерений + изолированные ветки
|
||||
|
||||
Документ фиксирует направление, в которое двигается проект после пилота Спринтов 1–3. Перепланировка спринтов будет сделана отдельно — здесь только сама архитектура и почему она нам нужна.
|
||||
|
||||
---
|
||||
|
||||
## Проблема, с которой сталкиваемся
|
||||
|
||||
Текущая реализация — это «мега-промпт»: в один системный промпт положен весь скрипт поведения агента, плюс правила, плюс инструкции по всем возможным темам (запись, перенос, цены, подготовка к приёму, ДМС, детский приём и т. д.).
|
||||
|
||||
На MVP это работает. Но как только добавим реальные бизнес-процессы с несколькими этапами (например, запись с перехватом инициативы в 6 шагов) — модель начнёт «плыть»:
|
||||
|
||||
- **Забывать начало инструкций** в конце длинного промпта.
|
||||
- **Перескакивать этапы** мини-интервью.
|
||||
- **Пытаться применять правила не к месту** — например, запустить скрипт записи, когда пациент просто спросил, как доехать.
|
||||
- **Путать ветки** между собой, потому что они все лежат в одном контексте.
|
||||
|
||||
Это классическая ловушка production-ready ассистентов. Дело не в мощности модели (DeepSeek более чем достаточно), а в архитектуре: **один промпт не должен знать про всё одновременно**.
|
||||
|
||||
---
|
||||
|
||||
## Архитектура, к которой идём
|
||||
|
||||
Паттерн называется **graph-based routing** (или multi-agent system). Идея проста:
|
||||
|
||||
1. Входная реплика пациента идёт не сразу в отвечающего агента, а в **роутер**.
|
||||
2. Роутер определяет **намерение** (intent) и передаёт диалог в конкретную изолированную ветку.
|
||||
3. Каждая ветка — это отдельный узкий промпт, который умеет делать одну вещь хорошо.
|
||||
4. Ветки не замкнуты: в любой момент агент может вернуть управление роутеру, если контекст изменился.
|
||||
|
||||
```
|
||||
┌─────────────┐
|
||||
│ Пациент │
|
||||
└──────┬──────┘
|
||||
│
|
||||
┌──────▼──────────────────────────┐
|
||||
│ Роутер (LLM-классификатор) │
|
||||
│ определяет намерение │
|
||||
└──────┬──────────────────────────┘
|
||||
│
|
||||
├──→ Ветка «Новая запись» (скрипт 6 этапов)
|
||||
├──→ Ветка «Перенос / отмена»
|
||||
├──→ Ветка «Цены и ДМС»
|
||||
├──→ Ветка «Хирургия» → сразу передача человеку
|
||||
├──→ Ветка «Острая боль / медвопрос» → передача человеку
|
||||
└──→ Ветка «Общая справка» (как доехать, часы работы и т. п.)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 1. Роутер (входной узел)
|
||||
|
||||
Отдельный, быстрый и дешёвый вызов LLM. Не отвечает пациенту сам — только классифицирует.
|
||||
|
||||
Задача роутера:
|
||||
|
||||
- Проанализировать последнюю реплику + краткую историю.
|
||||
- Вернуть **intent** — одну из заранее заданных категорий.
|
||||
- При необходимости — передать сигнал «нужен человек» (острая боль, конфликт, хирургия).
|
||||
|
||||
Пример промпта роутера:
|
||||
|
||||
> Определи намерение пользователя. Варианты:
|
||||
> 1. `new_booking` — новая запись
|
||||
> 2. `reschedule` — перенос или отмена существующей
|
||||
> 3. `price_question` — цены, ДМС, оплата
|
||||
> 4. `medical_question` — симптомы, диагноз, лечение
|
||||
> 5. `surgery` — хирургическое вмешательство
|
||||
> 6. `general_info` — как доехать, часы работы, контакты
|
||||
> 7. `escalate_human` — пациент явно просит оператора или злится
|
||||
>
|
||||
> Верни только код намерения.
|
||||
|
||||
Роутер продолжает **незримо присутствовать** в диалоге — его вызывают на каждой реплике, не один раз при входе.
|
||||
|
||||
---
|
||||
|
||||
## 2. Узкоспециализированные ветки (sub-agents)
|
||||
|
||||
Каждая ветка — отдельный промпт, который не знает про другие ветки. Он видит:
|
||||
|
||||
- Свой системный промпт (узкий, под одну задачу).
|
||||
- Свою базу знаний (специализированный RAG — эта коллекция, не общая).
|
||||
- Историю диалога (чтобы не переспрашивать имя/симптомы).
|
||||
- Текущий шаг state machine (см. ниже).
|
||||
|
||||
Примеры:
|
||||
|
||||
**Ветка «Новая запись».** 6-этапный промпт-продавец. Перехват инициативы, мини-интервью по симптомам, презентация двух слотов, бронирование.
|
||||
|
||||
**Ветка «Перенос / отмена».** Другой промпт: извиниться, уточнить текущую запись, сверить с календарём, предложить варианты.
|
||||
|
||||
**Ветка «Хирургия».** Короткая: «секунду, перевожу на координатора хирургии». Никакой попытки вести диалог — сразу передача. Запись на операцию — это другой JTBD, другой уровень стресса и чек, его не стоит отдавать боту.
|
||||
|
||||
**Ветка «Острая боль / медвопрос».** Тоже короткая: извинение, предложение записаться к врачу, эскалация на оператора.
|
||||
|
||||
---
|
||||
|
||||
## 3. State machine внутри ветки
|
||||
|
||||
Для сложных скриптов (вроде записи в 6 шагов) недостаточно иметь промпт — нужна ещё память о том, **на каком шаге мы сейчас находимся**.
|
||||
|
||||
Пример состояния:
|
||||
|
||||
```
|
||||
{
|
||||
"intent": "new_booking",
|
||||
"step": 3, // «Презентация слотов»
|
||||
"slots_shown": ["2026-04-24 10:00", "2026-04-24 15:00"],
|
||||
"patient_name": "Анна",
|
||||
"reason": "заложенность носа"
|
||||
}
|
||||
```
|
||||
|
||||
Модель на каждом шаге видит: *«Я на шаге 3, значит следующим сообщением я должна предложить выбор времени без лишних уточнений»*. Это убирает «перескоки» и «забывания».
|
||||
|
||||
State хранится в таблице треда (можно использовать уже имеющуюся `threads` или добавить `thread_state` с JSON-колонкой).
|
||||
|
||||
---
|
||||
|
||||
## 4. Exit conditions: динамическая маршрутизация
|
||||
|
||||
Главная проблема «жёстких скриптов» — невозможность сменить тему на лету. Пациент — живой человек, он может вспомнить важную деталь посреди диалога. Решение:
|
||||
|
||||
Каждая ветка знает не только **как вести**, но и **когда выйти**. В системный промпт ветки зашивается блок «условий выхода»:
|
||||
|
||||
> Если в любой момент пациент упоминает операцию, наркоз, стационар, удаление гланд, септопластику, стапедопластику — прекрати скрипт записи и выдай служебный сигнал: `[INTENT_CHANGE: surgery]`.
|
||||
|
||||
Когда оркестратор видит такой сигнал в ответе модели:
|
||||
|
||||
1. **Останавливает текущую ветку.**
|
||||
2. **Передаёт всю историю** в роутер (чтобы пациент не начинал с начала).
|
||||
3. **Запускает новую ветку** — бесшовно для пользователя.
|
||||
|
||||
Пример из жизни:
|
||||
|
||||
- *Пациент:* Запишите меня к лору на завтра.
|
||||
- *Бот (ветка «Новая запись»):* На завтра есть окно в 15:00. Бронируем?
|
||||
- *Пациент:* Да, давайте. А он посмотрит мои снимки? Мне сказали, нужна операция на перегородке.
|
||||
- *(Exit condition срабатывает: это хирургия → переход в ветку «Surgery» → передача человеку.)*
|
||||
- *Система:* Поняла вас. Планирование операций требует отдельного приёма для изучения КТ. Секунду, передаю координатору хирургии.
|
||||
|
||||
---
|
||||
|
||||
## 5. Передача человеку (escalation)
|
||||
|
||||
Часть веток не пытаются вести диалог до конца — они **маршрутизируют пациента в контакт-центр**. Важное отличие от текущей реализации: система не просто скидывает диалог, а отдаёт оператору **контекст**:
|
||||
|
||||
- Полную историю переписки.
|
||||
- Распознанный intent («горячий лид на хирургию», «острая боль», «жалоба»).
|
||||
- Паспортные данные пациента, если он их уже назвал.
|
||||
|
||||
Это превращает ассистента не в «фильтр перед оператором», а в инструмент **квалификации лида**.
|
||||
|
||||
---
|
||||
|
||||
## Что это меняет в данных
|
||||
|
||||
Сейчас в БД:
|
||||
|
||||
- `threads`, `messages` — диалоги (Спринт 2).
|
||||
- `agent_configs` — один активный системный промпт на всё (Спринт 3).
|
||||
|
||||
После перехода на графовую архитектуру понадобится:
|
||||
|
||||
- **`intents`** — справочник веток (код, имя, описание, статус активно/выключено).
|
||||
- **`agent_configs`** растёт: каждый конфиг привязан к `intent_id`, у каждой ветки — свой текущий активный промпт и свой набор exit conditions. Активен не «один промпт», а **набор промптов по веткам**.
|
||||
- **`thread_state`** — текущий intent треда, шаг state machine, собранные слоты (имя, симптом, выбранное время и т. п.).
|
||||
- **Несколько RAG-коллекций** вместо одной: под каждую ветку свой срез базы знаний. Уже заложено как направление в памяти — `project_future_architecture.md`.
|
||||
- **`routing_log`** (опционально) — лог решений роутера: интент, уверенность, срабатывание exit condition. Нужен для отладки и тюнинга.
|
||||
|
||||
---
|
||||
|
||||
## Что это меняет в UI
|
||||
|
||||
- «Настройки агента» превращаются в **настройки веток**: слева список веток, справа редактор промпта и exit conditions для выбранной ветки.
|
||||
- В «Песочнице» отладочная панель показывает не только найденные чанки и собранный промпт, но и: **текущий intent**, **шаг state machine**, **историю переходов между ветками** в рамках треда.
|
||||
- «Сценарии» (то, что планировалось в Спринте 4) становятся ценнее: можно прогонять не просто «диалог агента», а проверять, что роутер правильно классифицирует намерение и что exit conditions срабатывают там, где ожидается.
|
||||
|
||||
---
|
||||
|
||||
## Открытые вопросы
|
||||
|
||||
Решение по ним нужно до следующей перепланировки:
|
||||
|
||||
1. **Фреймворк оркестровки.** Писать логику маршрутизации вручную на Python (наш текущий подход) или взять готовое — LangGraph, n8n? Самописное даёт контроль и меньше зависимостей, фреймворк — быстрее старт и встроенная визуализация графа.
|
||||
|
||||
2. **Роутер: отдельная LLM-модель или тот же DeepSeek?** Для классификации хватит модели поменьше и подешевле (Haiku, GPT-4o-mini, локальная модель). Это важно: роутер зовётся на каждую реплику, а не один раз за тред.
|
||||
|
||||
3. **Как хранить exit conditions.** Текстом в конце системного промпта ветки? Отдельной структурой (список триггеров)? Первое гибче, второе — надёжнее срабатывает.
|
||||
|
||||
4. **Где проходит граница между ботом и человеком по хирургии.** Координацией хирургических пациентов (запись на операцию, контроль анализов) занимаются те же операторы контакт-центра, что и обычной записью, или есть отдельный хирургический куратор? От ответа зависит, куда маршрутизируется тред из ветки `surgery`.
|
||||
|
||||
5. **Точность роутера.** Нужна ли на старте классификация по уверенности (`confidence score`), fallback на уточняющий вопрос («Правильно понимаю, вы хотите записаться?») при низкой уверенности, или на первом этапе хватает грубой классификации?
|
||||
|
||||
---
|
||||
|
||||
## Ориентир на следующие спринты
|
||||
|
||||
Этот документ — **ещё не план**. План будет сверстан отдельно после обсуждения. Но уже видно, что логичный порядок переходов примерно такой:
|
||||
|
||||
1. **Разделить «один промпт» на несколько.** Завести таблицу `intents`, сделать в `agent_configs` привязку к `intent_id`. UI настроек — по веткам.
|
||||
2. **Добавить роутер.** Отдельный вызов LLM перед каждым ответом, возвращает intent. Без state machine пока — просто выбирается нужный промпт.
|
||||
3. **State machine и exit conditions.** Ветки получают память по шагам и умеют передавать управление обратно.
|
||||
4. **Мульти-RAG.** Каждая ветка тянет свою коллекцию.
|
||||
5. **Сценарии и эскалация на оператора с контекстом.** То, что планировалось как Спринт 4, встраивается в новую архитектуру.
|
||||
@@ -0,0 +1,405 @@
|
||||
# Графовая архитектура: роутер намерений + изолированные ветки
|
||||
|
||||
> **Версия 2 · 2026-04-24.** Уточнения после обсуждения и анализа скрипта записи в вики клиники. Основные добавления относительно v1: различение *soft-insertion* и *hard-handoff*, защита от петель маршрутизации, resumable state при возврате, guards внутри ветки `new_booking`, альтернативный дизайн мульти-RAG (подписка на разделы вики), RAG-срез на уровне шага, позиционирование eval-набора до Спринта 5. Полный список изменений — в разделе **Changelog** в конце документа.
|
||||
|
||||
Документ фиксирует направление, в которое двигается проект после пилота Спринтов 1–3. Перепланировка спринтов сделана в `SPRINTS.md` — здесь только сама архитектура и почему она нам нужна.
|
||||
|
||||
---
|
||||
|
||||
## Проблема, с которой сталкиваемся
|
||||
|
||||
Текущая реализация — это «мега-промпт»: в один системный промпт положен весь скрипт поведения агента, плюс правила, плюс инструкции по всем возможным темам (запись, перенос, цены, подготовка к приёму, ДМС, детский приём и т. д.).
|
||||
|
||||
На MVP это работает. Но как только добавим реальные бизнес-процессы с несколькими этапами (например, запись с перехватом инициативы в 6 шагов) — модель начнёт «плыть»:
|
||||
|
||||
- **Забывать начало инструкций** в конце длинного промпта.
|
||||
- **Перескакивать этапы** мини-интервью.
|
||||
- **Пытаться применять правила не к месту** — например, запустить скрипт записи, когда пациент просто спросил, как доехать.
|
||||
- **Путать ветки** между собой, потому что они все лежат в одном контексте.
|
||||
|
||||
Это классическая ловушка production-ready ассистентов. Дело не в мощности модели (DeepSeek более чем достаточно), а в архитектуре: **один промпт не должен знать про всё одновременно**.
|
||||
|
||||
---
|
||||
|
||||
## Архитектура, к которой идём
|
||||
|
||||
Паттерн называется **graph-based routing** (или multi-agent system). Идея проста:
|
||||
|
||||
1. Входная реплика пациента идёт не сразу в отвечающего агента, а в **роутер**.
|
||||
2. Роутер определяет **намерение** (intent) и передаёт диалог в конкретную изолированную ветку.
|
||||
3. Каждая ветка — это отдельный узкий промпт, который умеет делать одну вещь хорошо.
|
||||
4. Ветки не замкнуты: в любой момент агент может вернуть управление роутеру, если контекст изменился.
|
||||
|
||||
```
|
||||
┌─────────────┐
|
||||
│ Пациент │
|
||||
└──────┬──────┘
|
||||
│
|
||||
┌──────▼──────────────────────────┐
|
||||
│ Роутер (LLM-классификатор) │
|
||||
│ определяет намерение │
|
||||
└──────┬──────────────────────────┘
|
||||
│
|
||||
├──→ Ветка «Новая запись» (state machine, 6 шагов + guards)
|
||||
├──→ Ветка «Перенос / отмена»
|
||||
├──→ Ветка «Цены и ДМС»
|
||||
├──→ Ветка «Медицинский вопрос» (канонический ответ → запись)
|
||||
├──→ Ветка «Общая справка» (как доехать, часы работы)
|
||||
└──→ Ветка «Эскалация» reason: surgery | acute_pain |
|
||||
angry | explicit_request |
|
||||
routing_loop
|
||||
```
|
||||
|
||||
Шесть веток — в точности то, что сидится при первом запуске Спринта 4. Хирургия и острая боль не отдельные ветки, а поле `reason` внутри `escalate_human` — так решили на развороте 2026-04-23.
|
||||
|
||||
---
|
||||
|
||||
## 1. Роутер (входной узел)
|
||||
|
||||
Отдельный, быстрый и дешёвый вызов LLM. Не отвечает пациенту сам — только классифицирует.
|
||||
|
||||
Задача роутера:
|
||||
|
||||
- Проанализировать последнюю реплику + краткую историю.
|
||||
- Вернуть **intent** — одну из заранее заданных категорий.
|
||||
- Если детектирован острый случай (боль, кровотечение, упоминание операции) — маршрутизировать в `escalate_human` с соответствующим `reason`.
|
||||
|
||||
Пример промпта роутера:
|
||||
|
||||
> Определи намерение пользователя. Варианты:
|
||||
> 1. `new_booking` — новая запись
|
||||
> 2. `reschedule` — перенос или отмена существующей
|
||||
> 3. `price_question` — цены, ДМС, оплата
|
||||
> 4. `medical_question` — симптомы, диагноз, лечение (немедленная эскалация не требуется)
|
||||
> 5. `general_info` — как доехать, часы работы, контакты
|
||||
> 6. `escalate_human` — пациент явно просит оператора, злится, описывает острое состояние, упоминает операцию
|
||||
>
|
||||
> Верни только код намерения. Для `escalate_human` дополнительно верни `reason` из списка: `acute_pain`, `surgery`, `angry`, `explicit_request`.
|
||||
|
||||
Роутер продолжает **незримо присутствовать** в диалоге — его вызывают на каждой реплике, не один раз при входе. Это двойная защита: если ветка не поймала exit condition сама, роутер увидит смещение intent'а и инициирует handoff.
|
||||
|
||||
---
|
||||
|
||||
## 2. Узкоспециализированные ветки (sub-agents)
|
||||
|
||||
Каждая ветка — отдельный промпт, который не знает про другие ветки. Он видит:
|
||||
|
||||
- Свой системный промпт (узкий, под одну задачу).
|
||||
- Свой срез базы знаний (см. раздел 6).
|
||||
- Историю диалога (чтобы не переспрашивать имя/симптомы).
|
||||
- Текущий шаг state machine (если она в этой ветке есть).
|
||||
|
||||
Примеры:
|
||||
|
||||
**Ветка «Новая запись».** 6-этапный промпт-продавец с guard'ами. Перехват инициативы, мини-интервью по услуге и врачу, презентация приёма, два слота + «настоять на записи», бронирование, закрытие с проговариванием даты/врача/адреса/стоимости. Подробно — в разделе 3.
|
||||
|
||||
**Ветка «Перенос / отмена».** Другой промпт: извиниться, уточнить текущую запись, сверить с календарём, предложить варианты. RAG не используется — работа через CRM tool-calls.
|
||||
|
||||
**Ветка «Медицинский вопрос».** Канонический ответ: «не могу консультировать, это к врачу. Записать вас к профильному специалисту?» — с мягким переходом в `new_booking`. Никакого RAG по медицинским темам намеренно.
|
||||
|
||||
**Ветка «Эскалация».** Короткая: извиниться, передать оператору. Перед передачей формируется саммари с `reason`, историей, собранными слотами.
|
||||
|
||||
---
|
||||
|
||||
## 3. State machine внутри ветки
|
||||
|
||||
Для сложных скриптов (вроде записи) недостаточно иметь промпт — нужна ещё память о том, **на каком шаге мы сейчас находимся**.
|
||||
|
||||
### 3.1 Базовая линейная цепочка
|
||||
|
||||
Пример состояния для `new_booking`:
|
||||
|
||||
```json
|
||||
{
|
||||
"intent": "new_booking",
|
||||
"step": "offer_time",
|
||||
"slots": {
|
||||
"patient_name": "Анна",
|
||||
"is_child": false,
|
||||
"service": "первичный ЛОР",
|
||||
"doctor": "Сушков М. Г.",
|
||||
"time_candidates": ["2026-04-24 10:00", "2026-04-24 15:00"],
|
||||
"time_chosen": null
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Модель на каждом ходе видит: *«Я на шаге `offer_time`, слоты `time_candidates` заполнены, значит следующим сообщением я должна получить выбор времени, а не представляться заново»*. Это убирает «перескоки» и «забывания».
|
||||
|
||||
State хранится в отдельной таблице `thread_state` с JSON-колонкой под слоты (см. раздел «Что это меняет в данных»).
|
||||
|
||||
### 3.2 Guards и ветвления внутри скрипта
|
||||
|
||||
Линейная цепочка из шести шагов — идеальный случай. В реальном скрипте записи (см. вики) есть как минимум три guard'а, которые ломают линейность:
|
||||
|
||||
- **Пациент — ребёнок.** На шаге `qualify` обязательно собрать ФИО и телефон законного представителя. Блокирует переход в `present`, пока слоты не заполнены. Юридическое требование, не косметика.
|
||||
- **Запрос конкретного врача (напр., Ворончихина).** Вместо шага `offer_time` диалог уходит в рукав `waitlist`: запись в лист ожидания вместо предложения слотов.
|
||||
- **Жалоба на слух без обследования у сурдолога.** На шаге `present` модель должна предложить записаться сначала к сурдологу, и только потом — к отоневрологу.
|
||||
|
||||
Моделировать это можно двумя способами:
|
||||
|
||||
— **Условные переходы.** Шаг `qualify` имеет два возможных next-step'а: `present` (обычно) или `collect_legal_rep` (если `is_child=true`), и только после заполнения переходит дальше.
|
||||
|
||||
— **Под-состояния.** Внутри `qualify` есть `qualify.base` и `qualify.legal_rep`, последнее активируется при `is_child=true`.
|
||||
|
||||
Рекомендую первый вариант — он проще и легче тестируется.
|
||||
|
||||
### 3.3 Структурированный выход модели + валидатор переходов
|
||||
|
||||
Чисто LLM-управляемые переходы («в промпте написано: если слот заполнен, переходи к следующему шагу») фрагильны: модель периодически «не замечает» заполнение слота и застревает или, наоборот, прыгает через шаг.
|
||||
|
||||
Гибридный подход надёжнее. Модель возвращает структурированный ответ:
|
||||
|
||||
```json
|
||||
{
|
||||
"reply": "Записала вас на четверг, 10:00...",
|
||||
"state_after": "close",
|
||||
"slots_updated": {
|
||||
"time_chosen": "2026-04-24 10:00"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Код:
|
||||
|
||||
1. **Валидирует легальность перехода** — `offer_time → close` допустим, `intro → book` нет.
|
||||
2. **Сохраняет слоты строго** — что модель обновила, то и попало в `thread_state`.
|
||||
3. **Логирует несоответствия** — если модель вернула несуществующее `state_after`, состояние остаётся прежним, в лог пишется предупреждение.
|
||||
|
||||
Модель рассуждает содержательно, код защищает механически. Прибавка — около 50 строк валидатора, снижение нестабильности — сильное.
|
||||
|
||||
### 3.4 RAG на уровне шага, а не только ветки
|
||||
|
||||
Разным шагам одной ветки нужны разные куски вики. Для `new_booking`:
|
||||
|
||||
| Шаг | RAG-срез | Tool |
|
||||
|-----|----------|------|
|
||||
| `intro` | — | — |
|
||||
| `qualify` | `/wiki/services/**`, `/wiki/doctors/**` | — |
|
||||
| `present` | `/wiki/services/**`, `/wiki/doctors/**`, `/wiki/preparation/**` | — |
|
||||
| `offer_time` | `/wiki/services/**` (для боковых вопросов) | `crm.get_slots` |
|
||||
| `book` | — | `crm.create_booking` |
|
||||
| `close` | `/wiki/contacts/**`, `/wiki/preparation/**` | — |
|
||||
|
||||
Поле `wiki_sources` имеет смысл определять на уровне шага, а не только ветки. Ветка задаёт дефолт, шаг может его сузить или расширить.
|
||||
|
||||
---
|
||||
|
||||
## 4. Exit conditions: динамическая маршрутизация
|
||||
|
||||
### 4.1 Жёсткий handoff
|
||||
|
||||
Каждая ветка знает не только **как вести**, но и **когда выйти**. В системный промпт ветки зашивается блок «условий выхода»:
|
||||
|
||||
> Если в любой момент пациент упоминает операцию, наркоз, стационар, удаление гланд, септопластику, стапедопластику — прекрати скрипт записи и выдай служебный сигнал: `[INTENT_CHANGE: escalate_human]` с `reason=surgery`.
|
||||
|
||||
Когда оркестратор видит такой сигнал в ответе модели:
|
||||
|
||||
1. **Останавливает текущую ветку.**
|
||||
2. **Сохраняет текущее состояние** как `suspended_intent` + `resumable_step` + `resumable_slots` (см. 4.4).
|
||||
3. **Передаёт всю историю** в роутер.
|
||||
4. **Запускает новую ветку** — бесшовно для пользователя.
|
||||
|
||||
### 4.2 Мягкая вставка — боковой вопрос без выхода
|
||||
|
||||
Не каждое отклонение от темы — это handoff. Частый случай: пациент посреди записи спрашивает «а сколько это стоит?» или «где вы находитесь?». Это не смена темы, это короткий параллельный вопрос, после которого нужно продолжить скрипт записи с того же шага.
|
||||
|
||||
Различие:
|
||||
|
||||
- **Мягкая вставка** — на вопрос можно ответить *одной репликой* без запуска собственной машины состояний. Цена услуги, адрес, длительность приёма, требования к документам. Ветка отвечает сама, `current_step` не меняется.
|
||||
- **Жёсткий handoff** — вопрос сам по себе требует процесса (перенос существующей записи, запись другого человека, хирургия). Полный выход к роутеру.
|
||||
|
||||
Практически: ветка `new_booking` имеет *read-only* доступ к RAG-срезам `price` и `info`, и в её промпте прописано правило: «короткие боковые вопросы отвечай сам, не покидая шаг». Модели этого обычно достаточно; если проскакивает ошибка — двойной прогон роутера поймает её.
|
||||
|
||||
### 4.3 Защита от петель: `handoff_count`
|
||||
|
||||
Без ограничения легко получить цикл «`booking` → `price` → `booking` → `price`» на несогласованных промптах. Поэтому в `thread_state` заводится счётчик:
|
||||
|
||||
- `handoff_count` инкрементится при каждом жёстком handoff.
|
||||
- Кап — 2–3 переключения за сессию.
|
||||
- При превышении — автоматическая маршрутизация в `escalate_human` с `reason=routing_loop`.
|
||||
|
||||
Это дешёвая страховка, которая окупается на первом же багованном промпте.
|
||||
|
||||
### 4.4 Возобновление после handoff: `suspended_intent` + `resumable_state`
|
||||
|
||||
Если ветка вышла по soft-handoff'у для короткого ответа — ок, через мгновение продолжает. Если произошёл жёсткий handoff и detour-ветка закрылась — пациент часто возвращается к исходной задаче. Пример:
|
||||
|
||||
- Пациент в `new_booking` на шаге `offer_time`.
|
||||
- Переспросил про цену — ушли в `price_question`.
|
||||
- Получил ответ, говорит «ок, тогда бронируем на четверг».
|
||||
- Должен вернуться в `new_booking` на шаг `offer_time`, не в `intro`.
|
||||
|
||||
Для этого при выходе из ветки в `thread_state` сохраняются:
|
||||
|
||||
```json
|
||||
{
|
||||
"current_intent": "price_question",
|
||||
"current_step": null,
|
||||
"suspended_intent": "new_booking",
|
||||
"resumable_step": "offer_time",
|
||||
"resumable_slots": { /* копия slots new_booking */ }
|
||||
}
|
||||
```
|
||||
|
||||
Роутер, приняв решение о возврате, восстанавливает `current_intent` из `suspended_intent`, `current_step` из `resumable_step`, слоты — из `resumable_slots`.
|
||||
|
||||
---
|
||||
|
||||
## 5. Передача человеку (escalation)
|
||||
|
||||
Часть сценариев не заканчивается в боте — агент **маршрутизирует пациента в контакт-центр**. Важное отличие от «просто сбросить диалог» — система отдаёт оператору **полный контекст**:
|
||||
|
||||
- Полную историю переписки.
|
||||
- Распознанный intent + `reason` (из списка `acute_pain` / `surgery` / `angry` / `explicit_request` / `routing_loop`).
|
||||
- Собранные слоты, если они уже есть (ФИО, телефон, услуга, предпочитаемый врач).
|
||||
- Флаг `suspended_intent`, если эскалация прервала другую ветку.
|
||||
|
||||
Это превращает ассистента не в «фильтр перед оператором», а в инструмент **квалификации лида**. Дальнейшая маршрутизация (какому именно оператору, в какую очередь) — задача смежного разработчика при подключении каналов.
|
||||
|
||||
---
|
||||
|
||||
## 6. RAG: выбор между коллекциями и подпиской на разделы вики
|
||||
|
||||
Здесь два технически рабочих подхода с очень разными эксплуатационными свойствами.
|
||||
|
||||
### Вариант А: отдельная коллекция на ветку
|
||||
|
||||
(как описано в v1, как запланировано в Спринте 6.)
|
||||
|
||||
- Каждая ветка имеет собственную Chroma-коллекцию.
|
||||
- Загрузка документа требует выбора ветки.
|
||||
- Поле `collection_name` в `intents`.
|
||||
- **Плюсы:** жёсткая изоляция по умолчанию, простой query-путь.
|
||||
- **Минусы:** дублирование (одна статья wiki часто нужна нескольким веткам); лишнее решение на каждый upload; сложнее поддерживать при росте вики.
|
||||
|
||||
### Вариант Б: одна коллекция + подписка ветки на разделы
|
||||
|
||||
- Одна общая Chroma-коллекция `clinic_wiki`.
|
||||
- В `intents` поле `wiki_sources: list[str]` — список префиксов путей или набор документ-ID.
|
||||
- Retriever применяет where-фильтр по метаданным чанка (`doc_path STARTS WITH any(...)`).
|
||||
- Один документ, нужный нескольким веткам, перечисляется в `wiki_sources` нескольких веток — физического дублирования нет.
|
||||
- **Плюсы:** структура вики = источник истины; новая страница в `/wiki/pricing/` автоматически попадает в `price_question` без правок конфига; операторы и так ведут вики — не добавляется отдельный процесс тегирования.
|
||||
- **Минусы:** требует дисциплины в структуре папок вики.
|
||||
|
||||
**Рекомендация для проекта — Вариант Б.** Причина: вики у клиники уже атомарная, регулярно обновляемая, с осмысленной структурой. Добавлять поверх неё тегирование чанков или физическую фрагментацию по коллекциям — это второй слой, который будет расходиться с первым. При Варианте Б «источник правды» один — сама вика.
|
||||
|
||||
### Дополнительно: `wiki_sources` на уровне шага
|
||||
|
||||
Внутри ветки `new_booking` разным шагам нужны разные срезы (см. 3.4). Это решается тем, что поле `wiki_sources` существует на двух уровнях:
|
||||
|
||||
- на `intents` — дефолт для ветки;
|
||||
- на шаге state machine — уточнение/сужение для конкретного состояния.
|
||||
|
||||
---
|
||||
|
||||
## Что это меняет в данных
|
||||
|
||||
Сейчас в БД:
|
||||
|
||||
- `threads`, `messages` — диалоги (Спринт 2).
|
||||
- `agent_configs` — один активный системный промпт на всё (Спринт 3).
|
||||
- `intents` — справочник веток (Спринт 4).
|
||||
|
||||
После полного перехода на графовую архитектуру понадобится:
|
||||
|
||||
- **`intents`** — добавить поле `wiki_sources: list[str]` для Варианта Б мульти-RAG.
|
||||
- **`agent_configs`** — привязан к `intent_id`, у каждой ветки свой активный промпт и свои exit conditions (уже заложено в Спринте 4).
|
||||
- **`thread_state`** — текущее состояние треда:
|
||||
- `thread_id` (PK, FK)
|
||||
- `current_intent`
|
||||
- `current_step`
|
||||
- `slots` (JSON)
|
||||
- `handoff_count` (int, default 0) — защита от петель
|
||||
- `suspended_intent` (nullable) — ветка, из которой вышли по handoff'у
|
||||
- `resumable_step` (nullable) — шаг в `suspended_intent`, куда возвращаться
|
||||
- `resumable_slots` (JSON, nullable) — слоты той ветки
|
||||
- `updated_at`
|
||||
- **State machine на ветке** — для `new_booking` справочник шагов + допустимых переходов (может быть в коде или в БД, на старте достаточно в коде).
|
||||
- **`routing_log`** (опционально) — лог решений роутера: intent, срабатывание exit condition, инкремент `handoff_count`. Нужен для отладки и тюнинга.
|
||||
|
||||
---
|
||||
|
||||
## Что это меняет в UI
|
||||
|
||||
- «Настройки агента» — настройки веток: слева список веток, справа редактор промпта и exit conditions для выбранной ветки. Для веток с state machine — дополнительная вкладка со списком шагов и их промптами.
|
||||
- В «Песочнице» отладочная панель показывает: **текущий intent**, **шаг state machine**, **собранные слоты**, **handoff_count**, **suspended_intent** (если есть), **историю переходов между ветками** в рамках треда.
|
||||
- «Сценарии» (Спринт 7) прогоняют не только диалог, но и проверяют: правильно ли роутер классифицировал intent на каждой реплике, корректно ли сработали exit conditions, восстановилось ли состояние после detour'а.
|
||||
|
||||
---
|
||||
|
||||
## 7. Eval-набор нужен до Спринта 5
|
||||
|
||||
В плане Спринт 7 — полноценная подсистема сценариев. Это правильная цель, но реализация bouncing в Спринте 5 требует минимального eval-набора уже на входе. Иначе реализуем handoff «на глазок», без способа понять, стало лучше или хуже после правки промпта.
|
||||
|
||||
Минимум:
|
||||
|
||||
- **Eval роутера.** 20–30 фраз на каждую ветку: типичные, пограничные (ловушечные), злые (опечатки, короткие, эмоциональные). Формат: CSV `фраза, ожидаемый_intent`.
|
||||
- **Eval handoff'а.** 5–10 многошаговых мини-диалогов: intent на реплике 1 → пациент сменил тему на реплике 2 → на реплике 3 проверяем, что ветка ушла на роутер и роутер правильно переключил.
|
||||
- **Eval resumable.** 3–5 сценариев: detour → возврат. Проверяем, что `current_step` восстановился.
|
||||
|
||||
Реализация — короткий скрипт, прогоняющий набор через `/chat` и сравнивающий решения. Будет заменён полноценной подсистемой Спринта 7, но до этого закроет ~80% регрессий.
|
||||
|
||||
---
|
||||
|
||||
## Открытые вопросы
|
||||
|
||||
Часть вопросов из v1 закрылась на развороте 2026-04-23. Актуальный список:
|
||||
|
||||
1. **Фреймворк оркестровки** — решено: пишем вручную на Python. LangGraph/n8n не берём.
|
||||
2. **Роутер — отдельная модель** — отложено: пока DeepSeek через отдельный `RouterClient`, чтобы сменить модель в одном месте. Пересмотрим, когда вызовов станет много.
|
||||
3. **Формат exit conditions** — текстом в промпте ветки + независимый роутер на каждой реплике. Если реальный прогон покажет, что свободный текст пропускает случаи смены темы — добавим структурированный список триггеров (keyword-match).
|
||||
4. **Confidence score роутера** — не на первом спринте. После живого прогона посмотрим на реальные ошибки, и если их много — добавим clarifying question при низкой уверенности.
|
||||
|
||||
Новые вопросы после v2:
|
||||
|
||||
5. **Момент обновления `current_step`.** Сразу после парсинга `state_after` из ответа модели, или после того как ответ успешно показан пациенту? При ошибке доставки состояние может разъехаться с тем, что пациент видел.
|
||||
6. **Cap на soft-insertion'ы.** Если пациент крутит ассистенту пять побочных вопросов подряд, не продвигаясь по записи — это нормально или это сигнал «пациент не хочет записываться, эскалировать»? Нужен ли cap на число инлайн-ответов до возврата к шагу скрипта.
|
||||
7. **Шаги записи — из вики или из головы.** Шесть шагов `new_booking` формализованы нами, но скрипт в вике формулирует их слегка иначе («контакт → уточнение → презентация приёма → 2 слота → запись → закрытие»). До реализации Спринта 5 — свериться с вики по конкретной первой целевой специальности (ЛОР?) и принять официальный список шагов.
|
||||
|
||||
Вопрос из v1 про границу «бот vs. оператор по хирургии» — исключён из архитектурных открытых: это продуктовое решение (как у клиники устроен контакт-центр), не архитектурное, и на код не влияет — хирургия просто эскалируется с `reason=surgery`, а дальше смежный разработчик маршрутизирует в нужную очередь.
|
||||
|
||||
---
|
||||
|
||||
## Ориентир на следующие спринты
|
||||
|
||||
Логичный порядок (согласован с `SPRINTS.md`, Спринты 4–7):
|
||||
|
||||
1. **Разделить «один промпт» на несколько** → сделано (Спринт 4).
|
||||
2. **Добавить роутер** → сделано (Спринт 4).
|
||||
3. **State machine + exit conditions** → Спринт 5.
|
||||
4. **Мульти-RAG** → Спринт 6. С учётом v2: дизайн пересмотреть в сторону Варианта Б.
|
||||
5. **Сценарии и экспорт** → Спринт 7. С учётом v2: минимальный eval-набор сделать до Спринта 5, полный Спринт 7 реализовать позже.
|
||||
|
||||
**Рекомендация v2 по Спринту 5:** разделить на 5a (handoff, exit conditions, двойной роутер, `handoff_count`, `suspended_intent`) и 5b (state machine внутри `new_booking` с guard'ами, структурированный ответ модели, валидатор переходов, per-step RAG). Объём работ неравномерный: 5a — несколько дней, 5b — неделя+. Если слить, велик шанс получить «наполовину сделано, протестировать нечем».
|
||||
|
||||
---
|
||||
|
||||
## Changelog
|
||||
|
||||
### v2 → 2026-04-24
|
||||
|
||||
**Добавлено:**
|
||||
- Раздел 3.2: guards внутри ветки `new_booking` (ребёнок, Ворончихина, сурдолог) — из анализа скрипта записи в вике.
|
||||
- Раздел 3.3: структурированный выход модели `{reply, state_after, slots_updated}` и валидатор переходов в коде.
|
||||
- Раздел 3.4: `wiki_sources` на уровне шага, а не только ветки.
|
||||
- Раздел 4.2: мягкая вставка (soft-insertion) для боковых вопросов без выхода из ветки.
|
||||
- Раздел 4.3: `handoff_count` с капом и автоматическим уходом в `escalate_human` с `reason=routing_loop`.
|
||||
- Раздел 4.4: `suspended_intent` + `resumable_step` + `resumable_slots` для возврата в исходную ветку после detour'а.
|
||||
- Раздел 6 (новый): две схемы мульти-RAG. Рекомендация — Вариант Б (одна коллекция + подписка ветки на разделы вики через `wiki_sources`).
|
||||
- Раздел 7 (новый): eval-набор (20–30 фраз на ветку + handoff-сценарии) нужен до Спринта 5, а не в Спринте 7.
|
||||
- Рекомендация разделить Спринт 5 на 5a/5b.
|
||||
|
||||
**Исправлено:**
|
||||
- Убрана «Хирургия» как отдельная ветка (была в v1). Актуальная модель (решение разворота 2026-04-23) — одна ветка `escalate_human` с полем `reason`: `acute_pain | surgery | angry | explicit_request | routing_loop`.
|
||||
- Пример exit condition переписан с `[INTENT_CHANGE: surgery]` на `[INTENT_CHANGE: escalate_human]` + `reason=surgery`.
|
||||
- Список веток в разделе 1 приведён к шести (как в сиде Спринта 4).
|
||||
- Открытый вопрос #4 из v1 (граница бота и оператора по хирургии) исключён из архитектурных — это продуктовый вопрос, на код не влияет.
|
||||
|
||||
**Без изменений:**
|
||||
- Раздел «Проблема» — в v1 сформулирована точно.
|
||||
- Роутер как отдельный дешёвый вызов на каждой реплике.
|
||||
- `[INTENT_CHANGE: code]` как формат служебного сигнала из ветки.
|
||||
- Эскалация с полным контекстом (история, intent, слоты).
|
||||
- `routing_log` для отладки.
|
||||
- Общий ориентир на спринты (совпадает с `SPRINTS.md`).
|
||||
@@ -0,0 +1,439 @@
|
||||
# Графовая архитектура: роутер намерений + изолированные ветки
|
||||
|
||||
> **Версия 3 · 2026-04-26.** По содержанию — то же, что v2, но переписано в стиле «русское объяснение + английский термин в скобках» для всех понятий, которые будут встречаться в коде, в промптах или в названиях полей БД. В конце документа — разделы со ссылками на разобранные примеры (см. файлы в [`../examples/`](../examples/)). Изменения относительно v2 — в разделе **Changelog**.
|
||||
|
||||
Документ фиксирует направление, в которое двигается проект после пилота Спринтов 1–3. Перепланировка спринтов сделана в `SPRINTS.md` — здесь только сама архитектура и почему она нам нужна.
|
||||
|
||||
---
|
||||
|
||||
## Соглашение о терминах
|
||||
|
||||
В документе встречаются понятия, которые одновременно:
|
||||
- являются обычными русскими словами в обиходе,
|
||||
- и одновременно — идентификаторами полей БД, переменных в коде, ключей в JSON-структурах.
|
||||
|
||||
Чтобы не было путаницы, при первом упоминании в разделе мы пишем русский термин и сразу даём английский эквивалент (в том виде, в котором он живёт в коде). Например: **намерение** (intent), **машина состояний** (state machine), **счётчик переключений** (handoff_count). Ниже по тексту того же раздела используется тот вариант, который удобнее по контексту.
|
||||
|
||||
---
|
||||
|
||||
## Проблема, с которой сталкиваемся
|
||||
|
||||
Текущая реализация — это «мега-промпт»: в один системный промпт положен весь скрипт поведения агента, плюс правила, плюс инструкции по всем возможным темам (запись, перенос, цены, подготовка к приёму, ДМС, детский приём и т. д.).
|
||||
|
||||
На MVP это работает. Но как только добавим реальные бизнес-процессы с несколькими этапами (например, запись с перехватом инициативы в 6 шагов) — модель начнёт «плыть»:
|
||||
|
||||
- **Забывать начало инструкций** в конце длинного промпта.
|
||||
- **Перескакивать этапы** мини-интервью.
|
||||
- **Пытаться применять правила не к месту** — например, запустить скрипт записи, когда пациент просто спросил, как доехать.
|
||||
- **Путать ветки** между собой, потому что они все лежат в одном контексте.
|
||||
|
||||
Это классическая ловушка production-ready ассистентов. Дело не в мощности модели (DeepSeek более чем достаточно), а в архитектуре: **один промпт не должен знать про всё одновременно**.
|
||||
|
||||
---
|
||||
|
||||
## Архитектура, к которой идём
|
||||
|
||||
Паттерн называется **маршрутизация на основе графа** (graph-based routing) или **мультиагентная система** (multi-agent system). Идея проста:
|
||||
|
||||
1. Входная реплика пациента идёт не сразу в отвечающего агента, а в **роутер** (router).
|
||||
2. Роутер определяет **намерение** (intent) пациента и передаёт диалог в конкретную изолированную **ветку** (branch).
|
||||
3. Каждая ветка — это отдельный узкий промпт, который умеет делать одну вещь хорошо.
|
||||
4. Ветки не замкнуты: в любой момент агент может вернуть управление роутеру, если контекст изменился.
|
||||
|
||||
```
|
||||
┌─────────────┐
|
||||
│ Пациент │
|
||||
└──────┬──────┘
|
||||
│
|
||||
┌──────▼──────────────────────────┐
|
||||
│ Роутер (LLM-классификатор) │
|
||||
│ определяет намерение │
|
||||
└──────┬──────────────────────────┘
|
||||
│
|
||||
├──→ Ветка «Новая запись» (new_booking, машина состояний, 6 шагов + guard'ы)
|
||||
├──→ Ветка «Перенос / отмена» (reschedule)
|
||||
├──→ Ветка «Цены и ДМС» (price_question)
|
||||
├──→ Ветка «Медицинский вопрос» (medical_question, канонический ответ)
|
||||
├──→ Ветка «Общая справка» (general_info, адрес, часы, проезд)
|
||||
└──→ Ветка «Эскалация» (escalate_human, reason: surgery |
|
||||
acute_pain |
|
||||
angry |
|
||||
explicit_request |
|
||||
routing_loop)
|
||||
```
|
||||
|
||||
Шесть веток — то же количество, что сидится при первом запуске Спринта 4. Хирургия и острая боль не отдельные ветки, а значение поля **причина эскалации** (reason) внутри `escalate_human` — так решили на развороте 2026-04-23.
|
||||
|
||||
---
|
||||
|
||||
## 1. Роутер — входной узел
|
||||
|
||||
Отдельный, быстрый и дешёвый вызов **языковой модели** (LLM, large language model). Сам пациенту не отвечает — только классифицирует.
|
||||
|
||||
Задача роутера:
|
||||
|
||||
- Проанализировать последнюю реплику пациента + краткую историю диалога.
|
||||
- Вернуть **код намерения** (intent code) — одну из заранее заданных категорий.
|
||||
- Если детектирован острый случай (боль, кровотечение, упоминание операции) — маршрутизировать в `escalate_human` с соответствующим `reason`.
|
||||
|
||||
Пример промпта роутера:
|
||||
|
||||
> Определи намерение пользователя. Варианты:
|
||||
> 1. `new_booking` — новая запись.
|
||||
> 2. `reschedule` — перенос или отмена существующей записи.
|
||||
> 3. `price_question` — цены, ДМС, оплата.
|
||||
> 4. `medical_question` — симптомы, диагноз, лечение (немедленная эскалация не требуется).
|
||||
> 5. `general_info` — как доехать, часы работы, контакты.
|
||||
> 6. `escalate_human` — пациент явно просит оператора, злится, описывает острое состояние, упоминает операцию.
|
||||
>
|
||||
> Верни только код намерения. Для `escalate_human` дополнительно верни `reason` из списка: `acute_pain`, `surgery`, `angry`, `explicit_request`.
|
||||
|
||||
Роутер продолжает **незримо присутствовать** в диалоге — его вызывают на каждой реплике, не один раз при входе. Это двойная защита: если ветка не поймала **условие выхода** (exit condition) сама, роутер увидит изменение намерения (intent'а) и инициирует **переход в другую ветвь** (handoff).
|
||||
|
||||
---
|
||||
|
||||
## 2. Узкоспециализированные ветки (sub-agents)
|
||||
|
||||
Каждая ветка — отдельный промпт, который ничего не знает про другие ветки. Он видит:
|
||||
|
||||
- Свой системный промпт (узкий, под одну задачу).
|
||||
- Свой срез базы знаний (см. раздел 6).
|
||||
- Историю диалога (чтобы не переспрашивать имя/симптомы).
|
||||
- Текущий шаг машины состояний — если она в этой ветке есть.
|
||||
|
||||
Примеры:
|
||||
|
||||
**Ветка «Новая запись» (`new_booking`).** 6-этапный промпт-продавец с условными ветвлениями. Перехват инициативы, мини-интервью по услуге и врачу, презентация приёма, два слота + «настоять на записи», бронирование, закрытие с проговариванием даты/врача/адреса/стоимости. Подробно — в разделе 3 и в `01_basic_booking.md`.
|
||||
|
||||
**Ветка «Перенос / отмена» (`reschedule`).** Другой промпт: извиниться, уточнить текущую запись, сверить с календарём, предложить варианты. RAG не используется — работа через **вызовы инструментов** (tool calls) к CRM.
|
||||
|
||||
**Ветка «Медицинский вопрос» (`medical_question`).** Канонический ответ: «не могу консультировать, это к врачу. Записать вас к профильному специалисту?» — с мягким переходом в `new_booking`. Никакого RAG по медицинским темам намеренно (юридический риск).
|
||||
|
||||
**Ветка «Эскалация» (`escalate_human`).** Короткая: извиниться, передать оператору. Перед передачей формируется саммари с `reason`, историей и собранными слотами.
|
||||
|
||||
---
|
||||
|
||||
## 3. Машина состояний внутри ветки
|
||||
|
||||
Для сложных скриптов (вроде записи) недостаточно иметь один промпт — нужна ещё память о том, **на каком шаге мы сейчас находимся** (current_step) и **какие данные мы уже собрали** (slots).
|
||||
|
||||
### 3.1 Базовая линейная цепочка
|
||||
|
||||
Пример **состояния треда** (thread state) для `new_booking`:
|
||||
|
||||
```json
|
||||
{
|
||||
"intent": "new_booking",
|
||||
"step": "offer_time",
|
||||
"slots": {
|
||||
"patient_name": "Анна",
|
||||
"is_child": false,
|
||||
"service": "первичный ЛОР",
|
||||
"doctor": "Сушков М. Г.",
|
||||
"time_candidates": ["2026-04-24 10:00", "2026-04-24 15:00"],
|
||||
"time_chosen": null
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Модель на каждом ходе видит: *«Я на шаге `offer_time`, слот `time_candidates` заполнен, значит следующим сообщением я должна получить выбор времени, а не представляться заново»*. Это убирает «перескоки» и «забывания».
|
||||
|
||||
Состояние треда хранится в отдельной таблице `thread_state` с JSON-колонкой под слоты (см. раздел «Что это меняет в данных»). Полный пример заполнения слотов реплика за репликой — в `01_basic_booking.md`.
|
||||
|
||||
### 3.2 Защитные условия (guards) и ветвления внутри скрипта
|
||||
|
||||
Линейная цепочка из шести шагов — идеальный случай. В реальном скрипте записи (см. вики клиники) есть как минимум три **защитных условия** (guards), которые ломают линейность:
|
||||
|
||||
- **Пациент — ребёнок.** На шаге `qualify` обязательно собрать ФИО и телефон законного представителя. Блокирует переход в `present`, пока слоты не заполнены. Юридическое требование, не косметика.
|
||||
- **Запрос конкретного врача (например, Ворончихиной).** Вместо шага `offer_time` диалог уходит в рукав «лист ожидания» (waitlist): запись в очередь вместо предложения слотов.
|
||||
- **Жалоба на слух без обследования у сурдолога.** На шаге `present` модель должна предложить записаться сначала к сурдологу, и только потом — к отоневрологу.
|
||||
|
||||
Моделировать guard'ы можно двумя способами:
|
||||
|
||||
— **Условные переходы** (conditional transitions). Шаг `qualify` имеет два возможных next-step'а: `present` (обычно) или `collect_legal_rep` (если `is_child=true`), и только после заполнения переходит дальше.
|
||||
|
||||
— **Под-состояния** (sub-states). Внутри `qualify` есть `qualify.base` и `qualify.legal_rep`, последнее активируется при `is_child=true`.
|
||||
|
||||
Рекомендуем первый вариант — он проще и легче тестируется. Разбор guard'а с ребёнком на конкретном диалоге — в `03_child_patient_guard.md`.
|
||||
|
||||
### 3.3 Структурированный выход модели + валидатор переходов
|
||||
|
||||
Чисто **управляемые моделью** (LLM-driven) переходы — где в промпте написано «если слот заполнен, переходи к следующему шагу» — фрагильны. Модель периодически «не замечает» заполнение слота и застревает или, наоборот, прыгает через шаг.
|
||||
|
||||
Гибридный подход надёжнее. Модель возвращает **структурированный ответ** (structured output):
|
||||
|
||||
```json
|
||||
{
|
||||
"reply": "Записала вас на четверг, 10:00...",
|
||||
"state_after": "close",
|
||||
"slots_updated": {
|
||||
"time_chosen": "2026-04-24 10:00"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Код:
|
||||
|
||||
1. **Валидирует легальность перехода** — `offer_time → close` допустим, `intro → book` нет.
|
||||
2. **Сохраняет слоты строго** — что модель обновила, то и попало в `thread_state`.
|
||||
3. **Логирует несоответствия** — если модель вернула несуществующее `state_after`, состояние остаётся прежним, в лог пишется предупреждение.
|
||||
|
||||
Модель рассуждает содержательно, код защищает механически. Прибавка — около 50 строк валидатора, снижение нестабильности — заметное.
|
||||
|
||||
### 3.4 RAG-срез на уровне шага, а не только ветки
|
||||
|
||||
Разным шагам одной ветки нужны разные куски вики. Для `new_booking`:
|
||||
|
||||
| Шаг (`step`) | Срез базы знаний (`wiki_sources`) | Инструмент (`tool`) |
|
||||
|--------------|-----------------------------------|---------------------|
|
||||
| `intro` | — | — |
|
||||
| `qualify` | `/wiki/services/**`, `/wiki/doctors/**` | — |
|
||||
| `present` | `/wiki/services/**`, `/wiki/doctors/**`, `/wiki/preparation/**` | — |
|
||||
| `offer_time` | `/wiki/services/**` (для боковых вопросов) | `crm.get_slots` |
|
||||
| `book` | — | `crm.create_booking` |
|
||||
| `close` | `/wiki/contacts/**`, `/wiki/preparation/**` | — |
|
||||
|
||||
Поле «источники базы знаний» (`wiki_sources`) имеет смысл определять на уровне шага, а не только ветки. Ветка задаёт значения по умолчанию, шаг может их сузить или расширить.
|
||||
|
||||
---
|
||||
|
||||
## 4. Условия выхода: динамическая маршрутизация
|
||||
|
||||
### 4.1 Жёсткий переход в другую ветвь (hard handoff)
|
||||
|
||||
Каждая ветка знает не только **как вести разговор**, но и **когда из него выйти**. В системный промпт ветки зашивается блок «условий выхода» (exit conditions):
|
||||
|
||||
> Если в любой момент пациент упоминает операцию, наркоз, стационар, удаление гланд, септопластику, стапедопластику — прекрати скрипт записи и выдай служебный сигнал: `[INTENT_CHANGE: escalate_human]` с `reason=surgery`.
|
||||
|
||||
Когда оркестратор видит такой сигнал в ответе модели:
|
||||
|
||||
1. **Останавливает текущую ветку.**
|
||||
2. **Сохраняет текущее состояние** как `suspended_intent` + `resumable_step` + `resumable_slots` (см. 4.4).
|
||||
3. **Передаёт всю историю** в роутер.
|
||||
4. **Запускает новую ветку** — бесшовно для пользователя.
|
||||
|
||||
Полный разбор жёсткого перехода с возвратом — в `02_price_during_booking.md`.
|
||||
|
||||
### 4.2 Мягкая вставка (soft insertion) — боковой вопрос без выхода из ветки
|
||||
|
||||
Не каждое отклонение от темы — это переход в другую ветвь. Частый случай: пациент посреди записи спрашивает «а сколько это стоит?» или «где вы находитесь?». Это не смена темы, это короткий параллельный вопрос, после которого нужно продолжить скрипт записи с того же шага.
|
||||
|
||||
Различение:
|
||||
|
||||
- **Мягкая вставка** (soft insertion) — на вопрос можно ответить *одной репликой* без запуска собственной машины состояний. Цена услуги, адрес, длительность приёма, требования к документам. Ветка отвечает сама, поле `current_step` не меняется.
|
||||
- **Жёсткий переход** (hard handoff) — вопрос сам по себе требует процесса (перенос существующей записи, запись другого человека, хирургия). Полный выход к роутеру.
|
||||
|
||||
Практически: ветка `new_booking` имеет *read-only* доступ к RAG-срезам `price` и `info`, и в её промпте прописано правило: «короткие боковые вопросы отвечай сам, не покидая шаг». Модели этого обычно достаточно; если правило проскакивает — двойной прогон роутера на следующей реплике поймает ошибку.
|
||||
|
||||
Сравнение мягкой вставки и жёсткого перехода на одном и том же сценарии — в `02_price_during_booking.md`.
|
||||
|
||||
### 4.3 Защита от петель: `handoff_count`
|
||||
|
||||
Без ограничения легко получить **цикл маршрутизации** (routing loop) — «`booking → price → booking → price → ...`» на несогласованных промптах. Поэтому в `thread_state` заводится счётчик:
|
||||
|
||||
- `handoff_count` инкрементится при каждом жёстком переходе.
|
||||
- Кап — 2–3 переключения за сессию.
|
||||
- При превышении — автоматическая маршрутизация в `escalate_human` с `reason=routing_loop`.
|
||||
|
||||
Это дешёвая страховка, которая окупается на первом же багованном промпте.
|
||||
|
||||
### 4.4 Возобновление после перехода: `suspended_intent` + `resumable_state`
|
||||
|
||||
Если ветка вышла по soft-handoff'у для короткого ответа — ок, через мгновение продолжает. Если произошёл жёсткий переход и боковая (detour) ветка закрылась — пациент часто возвращается к исходной задаче. Пример:
|
||||
|
||||
- Пациент в `new_booking` на шаге `offer_time`.
|
||||
- Переспросил про цену — ушли в `price_question`.
|
||||
- Получил ответ, говорит «ок, тогда бронируем на четверг».
|
||||
- Должен вернуться в `new_booking` на шаг `offer_time`, не в `intro`.
|
||||
|
||||
Для этого при выходе из ветки в `thread_state` сохраняются:
|
||||
|
||||
```json
|
||||
{
|
||||
"current_intent": "price_question",
|
||||
"current_step": null,
|
||||
"suspended_intent": "new_booking",
|
||||
"resumable_step": "offer_time",
|
||||
"resumable_slots": { "...копия слотов new_booking..." }
|
||||
}
|
||||
```
|
||||
|
||||
Роутер, приняв решение о возврате, восстанавливает `current_intent` из `suspended_intent`, `current_step` из `resumable_step`, слоты — из `resumable_slots`. Полный диалог с разбором изменений `thread_state` на каждом ходе — в `02_price_during_booking.md`.
|
||||
|
||||
---
|
||||
|
||||
## 5. Передача человеку (escalation)
|
||||
|
||||
Часть сценариев не заканчивается в боте — агент **маршрутизирует пациента в контакт-центр**. Важное отличие от «просто сбросить диалог» — система отдаёт оператору **полный контекст** (full handoff context):
|
||||
|
||||
- Полную историю переписки.
|
||||
- Распознанное намерение + причину эскалации (`reason` из списка `acute_pain` / `surgery` / `angry` / `explicit_request` / `routing_loop`).
|
||||
- Собранные слоты, если они уже есть (ФИО, телефон, услуга, предпочитаемый врач).
|
||||
- Флаг `suspended_intent`, если эскалация прервала другую ветку.
|
||||
|
||||
Это превращает ассистента не в «фильтр перед оператором», а в инструмент **квалификации лида** (lead qualification). Дальнейшая маршрутизация (какому именно оператору, в какую очередь) — задача смежного разработчика при подключении каналов.
|
||||
|
||||
---
|
||||
|
||||
## 6. RAG: коллекции на ветку или подписка ветки на разделы вики?
|
||||
|
||||
Здесь два технически рабочих подхода с очень разными эксплуатационными свойствами.
|
||||
|
||||
### Вариант А — отдельная коллекция на ветку
|
||||
|
||||
(как описано в v1 и как было запланировано в Спринте 6.)
|
||||
|
||||
- Каждая ветка имеет собственную **векторную коллекцию** (vector collection) в Chroma.
|
||||
- Загрузка документа требует выбора ветки.
|
||||
- Поле `collection_name` в `intents`.
|
||||
- **Плюсы:** жёсткая изоляция по умолчанию, простой query-путь.
|
||||
- **Минусы:** дублирование (одна статья вики часто нужна нескольким веткам); лишнее решение на каждый upload; сложнее поддерживать при росте вики.
|
||||
|
||||
### Вариант Б — одна коллекция + подписка ветки на разделы
|
||||
|
||||
- Одна общая Chroma-коллекция `clinic_wiki`.
|
||||
- В таблице `intents` поле «источники» (`wiki_sources: list[str]`) — список префиксов путей или набор идентификаторов документов (document ids).
|
||||
- **Поисковик-ретривер** (retriever) применяет **фильтр по метаданным** (metadata filter, where-filter): `doc_path STARTS WITH any(...)`.
|
||||
- Один документ, нужный нескольким веткам, перечисляется в `wiki_sources` нескольких веток — физического дублирования нет.
|
||||
- **Плюсы:** структура вики = единый источник истины (single source of truth); новая страница в `/wiki/pricing/` автоматически попадает в `price_question` без правок конфига; операторы и так ведут вики — не добавляется отдельный процесс тегирования.
|
||||
- **Минусы:** требует дисциплины в структуре папок вики.
|
||||
|
||||
**Рекомендация для проекта — Вариант Б.** Причина: вики у клиники уже атомарная, регулярно обновляемая, с осмысленной структурой. Добавлять поверх неё тегирование чанков или физическую фрагментацию по коллекциям — это второй слой, который будет расходиться с первым. При Варианте Б «источник правды» один — сама вика.
|
||||
|
||||
### Дополнительно: `wiki_sources` на уровне шага
|
||||
|
||||
Внутри ветки `new_booking` разным шагам нужны разные срезы (см. 3.4). Это решается тем, что поле `wiki_sources` существует на двух уровнях:
|
||||
|
||||
- на `intents` — значения по умолчанию для ветки;
|
||||
- на шаге машины состояний — уточнение/сужение для конкретного состояния.
|
||||
|
||||
---
|
||||
|
||||
## Что это меняет в данных
|
||||
|
||||
Сейчас в БД:
|
||||
|
||||
- `threads`, `messages` — диалоги (Спринт 2).
|
||||
- `agent_configs` — один активный системный промпт на всё (Спринт 3).
|
||||
- `intents` — справочник веток (Спринт 4).
|
||||
|
||||
После полного перехода на графовую архитектуру понадобится:
|
||||
|
||||
- **`intents`** — добавить поле `wiki_sources: list[str]` для Варианта Б мульти-RAG.
|
||||
- **`agent_configs`** — привязан к `intent_id`, у каждой ветки свой активный промпт и свои условия выхода (уже заложено в Спринте 4).
|
||||
- **`thread_state`** — текущее состояние треда:
|
||||
- `thread_id` (PK, FK)
|
||||
- `current_intent`
|
||||
- `current_step`
|
||||
- `slots` (JSON)
|
||||
- `handoff_count` (int, default 0) — счётчик переключений, защита от петель.
|
||||
- `suspended_intent` (nullable) — отложенное намерение, ветка, из которой вышли по жёсткому переходу.
|
||||
- `resumable_step` (nullable) — шаг в `suspended_intent`, куда возвращаться.
|
||||
- `resumable_slots` (JSON, nullable) — слоты той ветки.
|
||||
- `updated_at`.
|
||||
- **Машина состояний на ветке** — для `new_booking` справочник шагов + допустимых переходов (может быть в коде или в БД, на старте достаточно в коде).
|
||||
- **`routing_log`** (опционально) — лог решений роутера: намерение, срабатывание условия выхода, инкремент `handoff_count`. Нужен для отладки и тюнинга.
|
||||
|
||||
---
|
||||
|
||||
## Что это меняет в UI
|
||||
|
||||
- «Настройки агента» — настройки веток: слева список веток, справа редактор промпта и условий выхода для выбранной ветки. Для веток с машиной состояний — дополнительная вкладка со списком шагов и их промптами.
|
||||
- В «Песочнице» отладочная панель показывает: **текущее намерение** (current_intent), **шаг машины состояний** (current_step), **собранные слоты** (slots), **счётчик переключений** (handoff_count), **отложенное намерение** (suspended_intent), если есть, и **историю переходов между ветками** (handoff history) в рамках треда.
|
||||
- «Сценарии» (Спринт 7) прогоняют не только диалог, но и проверяют: правильно ли роутер классифицировал намерение на каждой реплике, корректно ли сработали условия выхода, восстановилось ли состояние после боковой ветки.
|
||||
|
||||
---
|
||||
|
||||
## 7. Eval-набор нужен до Спринта 5
|
||||
|
||||
В плане Спринт 7 — полноценная подсистема сценариев. Это правильная цель, но реализация bouncing'а в Спринте 5 требует минимального **набора оценочных кейсов** (eval set, evaluation set) уже на входе. Иначе реализуем переход «на глазок», без способа понять, стало лучше или хуже после правки промпта.
|
||||
|
||||
Минимум:
|
||||
|
||||
- **Eval роутера.** 20–30 фраз на каждую ветку: типичные, пограничные (ловушечные), злые (опечатки, короткие, эмоциональные). Формат: CSV `фраза, ожидаемый_intent`.
|
||||
- **Eval перехода.** 5–10 многошаговых мини-диалогов: намерение на реплике 1 → пациент сменил тему на реплике 2 → на реплике 3 проверяем, что ветка ушла на роутер и роутер правильно переключил.
|
||||
- **Eval возобновления.** 3–5 сценариев: detour → возврат. Проверяем, что `current_step` восстановился из `resumable_step`.
|
||||
|
||||
Реализация — короткий скрипт, прогоняющий набор через `/chat` и сравнивающий решения. Будет заменён полноценной подсистемой Спринта 7, но до этого закроет ~80% регрессий.
|
||||
|
||||
---
|
||||
|
||||
## Открытые вопросы
|
||||
|
||||
Часть вопросов из v1 закрылась на развороте 2026-04-23. Актуальный список:
|
||||
|
||||
1. **Фреймворк оркестровки** — решено: пишем вручную на Python. LangGraph/n8n не берём.
|
||||
2. **Роутер — отдельная модель** — отложено: пока DeepSeek через отдельный `RouterClient`, чтобы сменить модель в одном месте. Пересмотрим, когда вызовов станет много.
|
||||
3. **Формат условий выхода** — текстом в промпте ветки + независимый прогон роутера на каждой реплике. Если реальный прогон покажет, что свободный текст пропускает случаи смены темы — добавим **структурированный список триггеров** (trigger list, keyword-match).
|
||||
4. **Уверенность роутера (confidence score)** — не на первом спринте. После живого прогона посмотрим на реальные ошибки, и если их много — добавим **уточняющий вопрос** (clarifying question) при низкой уверенности.
|
||||
|
||||
Новые вопросы после v2:
|
||||
|
||||
5. **Момент обновления `current_step`.** Сразу после парсинга `state_after` из ответа модели, или после того как ответ успешно показан пациенту? При ошибке доставки состояние может разъехаться с тем, что пациент видел.
|
||||
6. **Кап на мягкие вставки.** Если пациент крутит ассистенту пять побочных вопросов подряд, не продвигаясь по записи — это нормально или это сигнал «пациент не хочет записываться, эскалировать»? Нужен ли кап на число инлайн-ответов до возврата к шагу скрипта.
|
||||
7. **Шаги записи — из вики или из головы.** Шесть шагов `new_booking` формализованы нами, но скрипт в вике формулирует их слегка иначе («контакт → уточнение → презентация приёма → 2 слота → запись → закрытие»). До реализации Спринта 5 — свериться с вики по конкретной первой целевой специальности (ЛОР?) и принять официальный список шагов.
|
||||
|
||||
Вопрос из v1 про границу «бот vs. оператор по хирургии» — исключён из архитектурных открытых: это продуктовое решение (как у клиники устроен контакт-центр), не архитектурное, и на код не влияет — хирургия просто эскалируется с `reason=surgery`, а дальше смежный разработчик маршрутизирует в нужную очередь.
|
||||
|
||||
---
|
||||
|
||||
## Ориентир на следующие спринты
|
||||
|
||||
Логичный порядок (согласован с `SPRINTS.md`, Спринты 4–7):
|
||||
|
||||
1. **Разделить «один промпт» на несколько** → сделано (Спринт 4).
|
||||
2. **Добавить роутер** → сделано (Спринт 4).
|
||||
3. **Машина состояний + условия выхода** → Спринт 5.
|
||||
4. **Мульти-RAG** → Спринт 6. С учётом v3: дизайн пересмотреть в сторону Варианта Б (подписка на разделы вики).
|
||||
5. **Сценарии и экспорт** → Спринт 7. С учётом v3: минимальный eval-набор сделать до Спринта 5, полный Спринт 7 реализовать позже.
|
||||
|
||||
**Рекомендация v3 по Спринту 5:** разделить на 5a (переходы между ветками: условия выхода, двойной прогон роутера, `handoff_count`, `suspended_intent`) и 5b (машина состояний внутри `new_booking` с guard'ами, структурированный ответ модели, валидатор переходов, `wiki_sources` на уровне шага). Объём работ неравномерный: 5a — несколько дней, 5b — неделя+. Если слить, велик шанс получить «наполовину сделано, протестировать нечем».
|
||||
|
||||
---
|
||||
|
||||
## Разобранные примеры
|
||||
|
||||
Эти документы показывают архитектуру в работе на конкретных диалогах — реплика за репликой, с фиксацией того, что в этот момент происходит в `thread_state`, какое решение принял роутер, какой шаг машины состояний активен, что вернула модель в `state_after` и `slots_updated`.
|
||||
|
||||
- [`01_basic_booking.md`](../examples/01_basic_booking.md) — happy path записи к ЛОР-врачу. Базовый случай, в котором всё идёт по линейному скрипту: контакт → уточнение → презентация → слоты → запись → закрытие. Показывает, как заполняются слоты, как меняется `current_step`, что видит модель на каждой реплике.
|
||||
|
||||
- [`02_price_during_booking.md`](../examples/02_price_during_booking.md) — пациент в середине записи спрашивает про цену. Один и тот же сценарий разобран в двух вариантах: мягкая вставка (без выхода из ветки) и жёсткий переход с возвратом (через `suspended_intent` + `resumable_state`). Лучший пример для понимания различий между этими двумя механизмами.
|
||||
|
||||
- [`03_child_patient_guard.md`](../examples/03_child_patient_guard.md) — запись ребёнка к врачу. Показывает срабатывание guard'а в шаге `qualify`, нелинейный путь по машине состояний, сбор данных законного представителя, юридические оговорки в шаге `close`.
|
||||
|
||||
- [`04_general_info_simple.md`](../examples/04_general_info_simple.md) — простые информационные запросы (часы, адрес, проезд, контакты, документы, не-предоставляемые услуги). Самый дешёвый путь в системе: одна реплика, одна ветка `general_info`, один шаг `answer`, прямой ретривер → ответ → `done`. Логичная стартовая точка для запуска первой версии бота.
|
||||
|
||||
---
|
||||
|
||||
## Changelog
|
||||
|
||||
### v3 → 2026-04-26
|
||||
|
||||
**Стиль:**
|
||||
- Все технические понятия, которые встречаются в коде, в промптах или в названиях полей БД, оформлены по схеме «русское объяснение + английский термин в скобках» при первом упоминании в разделе. Это позволяет читать документ без догадок: что русское слово, что код, что переменная.
|
||||
- Добавлен раздел «Соглашение о терминах» в начале.
|
||||
|
||||
**Ссылки на примеры:**
|
||||
- В разделах 3, 4 и в новом разделе «Разобранные примеры» добавлены ссылки на четыре документа с пошаговыми разборами диалогов: `01_basic_booking.md`, `02_price_during_booking.md`, `03_child_patient_guard.md`, `04_general_info_simple.md` (последний — для простых одношаговых запросов общей информации, добавлен 2026-04-26 как стартовая точка для запуска).
|
||||
|
||||
**Содержательно:**
|
||||
- Без изменений. Все архитектурные решения, открытые вопросы, рекомендации по спринтам — те же, что в v2.
|
||||
|
||||
### v2 → 2026-04-24
|
||||
|
||||
**Добавлено:**
|
||||
- Раздел 3.2: guards внутри ветки `new_booking` (ребёнок, Ворончихина, сурдолог) — из анализа скрипта записи в вике.
|
||||
- Раздел 3.3: структурированный выход модели `{reply, state_after, slots_updated}` и валидатор переходов в коде.
|
||||
- Раздел 3.4: `wiki_sources` на уровне шага, а не только ветки.
|
||||
- Раздел 4.2: мягкая вставка (soft-insertion) для боковых вопросов без выхода из ветки.
|
||||
- Раздел 4.3: `handoff_count` с капом и автоматическим уходом в `escalate_human` с `reason=routing_loop`.
|
||||
- Раздел 4.4: `suspended_intent` + `resumable_step` + `resumable_slots` для возврата в исходную ветку после detour'а.
|
||||
- Раздел 6 (новый): две схемы мульти-RAG. Рекомендация — Вариант Б (одна коллекция + подписка ветки на разделы вики через `wiki_sources`).
|
||||
- Раздел 7 (новый): eval-набор (20–30 фраз на ветку + handoff-сценарии) нужен до Спринта 5, а не в Спринте 7.
|
||||
- Рекомендация разделить Спринт 5 на 5a/5b.
|
||||
|
||||
**Исправлено:**
|
||||
- Убрана «Хирургия» как отдельная ветка (была в v1). Актуальная модель (решение разворота 2026-04-23) — одна ветка `escalate_human` с полем `reason`: `acute_pain | surgery | angry | explicit_request | routing_loop`.
|
||||
- Пример условия выхода переписан с `[INTENT_CHANGE: surgery]` на `[INTENT_CHANGE: escalate_human]` + `reason=surgery`.
|
||||
- Список веток в разделе 1 приведён к шести (как в сиде Спринта 4).
|
||||
- Открытый вопрос #4 из v1 (граница бота и оператора по хирургии) исключён из архитектурных — это продуктовый вопрос, на код не влияет.
|
||||
@@ -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