docs: переезд в docs/ — SPRINTS, architecture (v1/v2/v3), examples
- SPRINTS.md → docs/SPRINTS.md - GRAPH_ARCHITECTURE.md → docs/architecture/GRAPH_ARCHITECTURE_v1.md - GRAPH_ARCHITECTURE_v2.md → docs/architecture/GRAPH_ARCHITECTURE_v2.md - Новый docs/architecture/GRAPH_ARCHITECTURE_v3.md (билингв. термины + ссылки на примеры) - Новые docs/examples/: 01 базовая запись, 02 цена во время записи (soft vs hard), 03 запись ребёнка (guard), 04 простой general_info - README обновлён: ссылки на новые пути + раздел «Документация» Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,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` был передан. Это проверка маппинга при возврате — частая точка регрессий.
|
||||
Reference in New Issue
Block a user