docs: переезд в docs/ — SPRINTS, architecture (v1/v2/v3), examples

- SPRINTS.md → docs/SPRINTS.md
- GRAPH_ARCHITECTURE.md → docs/architecture/GRAPH_ARCHITECTURE_v1.md
- GRAPH_ARCHITECTURE_v2.md → docs/architecture/GRAPH_ARCHITECTURE_v2.md
- Новый docs/architecture/GRAPH_ARCHITECTURE_v3.md (билингв. термины + ссылки на примеры)
- Новые docs/examples/: 01 базовая запись, 02 цена во время записи (soft vs hard),
  03 запись ребёнка (guard), 04 простой general_info
- README обновлён: ссылки на новые пути + раздел «Документация»

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
AR 15 M4
2026-04-26 21:50:16 +05:00
parent 82bba34937
commit f348570b1b
9 changed files with 2427 additions and 3 deletions
+284
View File
@@ -0,0 +1,284 @@
# Пример 02 · Вопрос про цену в середине записи
> Связано с [`GRAPH_ARCHITECTURE_v3.md`](../architecture/GRAPH_ARCHITECTURE_v3.md), разделы 4.1, 4.2, 4.4. Демонстрирует разницу между **мягкой вставкой** (soft insertion) и **жёстким переходом в другую ветвь** (hard handoff) на одном и том же сценарии. Также показывает работу полей `suspended_intent` и `resumable_step` при возврате в исходную ветку после боковой.
## О чём этот пример
Пациент находится посреди записи (шаг `offer_time`, два слота уже предложены). Прямо перед выбором времени он задаёт вопрос про деньги. Вариантов поведения системы здесь — два, и **они оба корректны**, в зависимости от того, как сформулирован вопрос:
- **Вариант A.** «А сколько это стоит?» — короткий уточняющий вопрос. Ветка отвечает сама, не покидая шаг записи. Это **мягкая вставка** (soft insertion).
- **Вариант B.** «Стоп, я хочу разобраться с ценами и ДМС, у меня страховка ВТБ, что у вас по покрытию?» — это уже не короткий вопрос, а отдельный процесс с возможными подвопросами. Ветка отдаёт управление роутеру, тред уходит в `price_question`, потом возвращается в `new_booking` на тот же шаг.
Ниже разбор обоих вариантов.
## Общая отправная точка для обоих вариантов
После пяти реплик из `01_basic_booking.md` пациент стоит на шаге `offer_time`. Состояние треда:
```json
{
"thread_id": "T-9002",
"current_intent": "new_booking",
"current_step": "offer_time",
"slots": {
"patient_name": "Анна Петровна",
"service": "первичный_ЛОР",
"complaint": "боль в горле, неделя",
"is_child": false,
"doctor_preference": null,
"time_candidates": ["2026-04-28 10:00", "2026-04-28 15:00"],
"doctor": "Сушков М. Г.",
"branch": "ул. Клары Цеткин, 9"
},
"handoff_count": 0,
"suspended_intent": null,
"resumable_step": null,
"resumable_slots": null
}
```
Последняя реплика бота (реплика 5 из примера 01): «*Есть свободно вторник 28 апреля: 10:00 или 15:00 — оба слота к Сушкову Михаилу Германовичу, ул. Клары Цеткин, 9. Какое удобнее?*»
---
# Вариант A · Мягкая вставка
## Реплика 6A · «А сколько это стоит?»
**Решение роутера:** `new_booking`. Это важный момент — роутер видит короткий вопрос про цену, но контекст разговора (5 реплик про запись) и формулировка («это стоит» — отсылка к обсуждаемому приёму) позволяют ему **остаться** в `new_booking`. Если бы пациент написал «расскажите про ваши цены» без контекста — роутер ушёл бы в `price_question`.
**Активная ветка / шаг:** `new_booking` / `offer_time`.
**Ретривер:** срез шага плюс **разрешённый боковой срез** (read-only sideload) — конфигурация шага `offer_time` разрешает подмешивать чанки из `/wiki/pricing/**` для коротких уточнений.
- Фильтр: `doc_path STARTS WITH any('/wiki/services/**', '/wiki/pricing/**')`.
- top-k: 3.
- Достались чанки:
- `pricing/first-visit-lor.md` (score 0.91) — стоимость первичного ЛОР.
- `pricing/dms-coverage.md` (score 0.62) — общие условия ДМС.
- `services/первичный-приём-лор.md` (score 0.74) — описание услуги (уже в контексте).
**Вызовы инструментов:** нет.
**Структурированный ответ модели:**
```json
{
"reply": "Первичный приём ЛОР — 2 300 рублей. Эндоскопия, лечебные процедуры, забор анализов оплачиваются дополнительно. Возвращаемся к слотам — 10:00 или 15:00 удобнее?",
"state_after": "offer_time",
"slots_updated": {}
}
```
Ключевое: **шаг не меняется**, **слоты не обновляются**, **`handoff_count` не инкрементится**. Ветка ответила в-line и в той же реплике вернула пациента к выбору времени.
**`thread_state` после хода:** идентично состоянию до хода. Изменилась только история сообщений (`messages`), но `thread_state` — нет.
## Реплика 7A · «10 утра»
Дальше всё как в `01_basic_booking.md`, реплика 6: вызов `crm.create_booking`, переход в `close`, проговаривание итогов.
## Что показал вариант A
- Мягкая вставка — это **лёгкий механизм без побочных эффектов**: ни тред-стейт, ни счётчики не меняются, кроме истории сообщений.
- За счёт того, что шаг `offer_time` имеет настроенный «разрешённый боковой срез» по pricing, ретривер находит нужный чанк, не покидая основной поиск.
- В промпте ветки `new_booking` явно прописано правило: «короткие вопросы про цену, адрес, длительность — отвечай в-line и сразу возвращай к текущему шагу скрипта». Без этого правила модель скорее всего попыталась бы написать целый монолог про цены и ушла от слотов.
---
# Вариант B · Жёсткий переход в другую ветвь и возврат
## Реплика 6B · «Стоп, у меня вопросы по стоимости. У меня ДМС "ВТБ", вы их принимаете? Что покрывает?»
**Решение роутера:** `price_question`. Здесь:
- Явный сигнал «стоп, у меня вопросы» — это сознательная пауза в записи.
- Конкретный страховщик упоминается → нужен поиск по партнёрам ДМС, это не короткое уточнение.
- Тон — переключение, не побочный вопрос.
Роутер возвращает: `intent_code = price_question`. Оркестратор сравнивает с `current_intent` (`new_booking`) и видит расхождение → инициирует жёсткий переход.
**Шаги оркестратора в одном ходе:**
1. **Получает структурированный ответ от текущей ветки.** Ветка `new_booking` тоже видит смену темы (двойная защита из раздела 1 v3) и возвращает:
```json
{
"reply": "",
"signal": "[INTENT_CHANGE: price_question]",
"state_after": "offer_time"
}
```
2. **Сохраняет состояние ветки-донора** в `thread_state`:
```json
{
"suspended_intent": "new_booking",
"resumable_step": "offer_time",
"resumable_slots": {
"patient_name": "Анна Петровна",
"service": "первичный_ЛОР",
"complaint": "боль в горле, неделя",
"is_child": false,
"doctor_preference": null,
"time_candidates": ["2026-04-28 10:00", "2026-04-28 15:00"],
"doctor": "Сушков М. Г.",
"branch": "ул. Клары Цеткин, 9"
}
}
```
3. **Инкрементит счётчик переключений:** `handoff_count: 0 → 1`. (Кап — 2; на следующем переключении ещё ок, на третьем — автоматическая эскалация в `escalate_human` с `reason=routing_loop`, см. v3 раздел 4.3.)
4. **Запускает ветку `price_question`** с полной историей и пустым `current_step` (у этой ветки нет машины состояний — она линейная, отвечает сразу).
**`thread_state` после шагов оркестратора, до ответа модели:**
```json
{
"current_intent": "price_question",
"current_step": null,
"slots": {},
"handoff_count": 1,
"suspended_intent": "new_booking",
"resumable_step": "offer_time",
"resumable_slots": { "...сохранённые слоты new_booking..." }
}
```
Заметим, что `slots` ветки `price_question` начинается с нуля — слоты записи изолированы в `resumable_slots`. Это важно: если пациент в `price_question` назовёт ФИО, оно должно положиться в *её* слоты, не смешиваясь со слотами записи.
**Ретривер:**
- Срез ветки — `wiki_sources: ['/wiki/pricing/**', '/wiki/dms-partners/**', '/wiki/promotions/**']`.
- top-k: 5 (для ценовых ответов настроено больше).
- Достались чанки:
- `dms-partners/vtb.md` (score 0.94) — что покрывает ДМС ВТБ, какие услуги, лимиты.
- `pricing/first-visit-lor.md` (score 0.71) — стоимость для самооплаты.
- `dms-partners/general-rules.md` (score 0.65) — общие правила работы по ДМС.
**Вызовы инструментов:** нет.
**Структурированный ответ модели:**
```json
{
"reply": "Да, ДМС «ВТБ Страхование» мы принимаем. По вашему полису первичный приём ЛОР покрывается полностью, без доплаты. Эндоскопия — по медицинским показаниям, согласовывается со страховой через нашего администратора. Лечебные процедуры — частично, зависит от программы (уточнит координатор перед приёмом). При себе нужно иметь паспорт и полис ДМС. Если хотите — продолжим оформление записи?",
"state_after": null,
"slots_updated": {
"dms_provider": "ВТБ Страхование"
}
}
```
**`thread_state` после хода:**
```json
{
"current_intent": "price_question",
"current_step": null,
"slots": {
"dms_provider": "ВТБ Страхование"
},
"handoff_count": 1,
"suspended_intent": "new_booking",
"resumable_step": "offer_time",
"resumable_slots": { "...сохранённые слоты new_booking..." }
}
```
## Реплика 7B · «Понятно, спасибо. Тогда давайте всё-таки запишемся, на 10 утра»
**Решение роутера:** `new_booking`. Чёткий сигнал возврата к записи + конкретное время.
**Шаги оркестратора:**
1. Видит, что `current_intent=price_question`, новое решение роутера = `new_booking`.
2. Проверяет `suspended_intent` — оно равно `new_booking`. **Это и есть путь возврата**, не новый переход.
3. Восстанавливает `current_intent ← suspended_intent`, `current_step ← resumable_step`, `slots ← resumable_slots`.
4. Очищает поля сохранения: `suspended_intent=null`, `resumable_step=null`, `resumable_slots=null`.
5. **Не инкрементит** `handoff_count` — это возврат, а не новое переключение.
6. Дополнительно: переносит из `price_question.slots` в восстановленные `new_booking.slots` те поля, которые могут быть полезны (в нашем случае `dms_provider`). Это делается явным mapping'ом в коде, не автоматически.
**`thread_state` после восстановления, перед вызовом ветки:**
```json
{
"current_intent": "new_booking",
"current_step": "offer_time",
"slots": {
"patient_name": "Анна Петровна",
"service": "первичный_ЛОР",
"complaint": "боль в горле, неделя",
"is_child": false,
"doctor_preference": null,
"time_candidates": ["2026-04-28 10:00", "2026-04-28 15:00"],
"doctor": "Сушков М. Г.",
"branch": "ул. Клары Цеткин, 9",
"dms_provider": "ВТБ Страхование"
},
"handoff_count": 1,
"suspended_intent": null,
"resumable_step": null,
"resumable_slots": null
}
```
**Ретривер:** не вызывается (на шаге `offer_time` вне боковых вопросов).
**Вызовы инструментов:**
```python
crm.create_booking(
patient_name="Анна Петровна",
service_code="primary_lor",
doctor="Сушков М. Г.",
datetime="2026-04-28 10:00",
branch="ул. Клары Цеткин, 9",
dms_provider="ВТБ Страхование" # передаём, чтобы координатор знал
)
# → {"booking_id": "B-1235", "status": "confirmed"}
```
**Структурированный ответ модели:**
```json
{
"reply": "Записала вас на вторник 28 апреля, 10:00, к ЛОР-врачу Сушкову Михаилу Германовичу, ул. Клары Цеткин, 9. По вашему полису ДМС «ВТБ» приём покрыт. При себе паспорт и полис, подходите за 5 минут. Подтверждаете?",
"state_after": "close",
"slots_updated": {
"time_chosen": "2026-04-28 10:00",
"booking_id": "B-1235"
}
}
```
Шаг переходит в `close` — пациент вернулся ровно туда, где его ждали, без потери собранных слотов. Дополнительно ответ обогащён информацией про ДМС, потому что `dms_provider` теперь в слотах.
## Что показал вариант B
- **Жёсткий переход — это полноценное переключение ветки**, со всем что к этому прилагается: служебный сигнал из ветки, сохранение состояния, инкремент счётчика, изоляция слотов новой ветки.
- **Возврат — не «новый переход»**, а особый путь оркестратора: он сравнивает решение роутера с `suspended_intent` и, если они совпадают, восстанавливает состояние, не трогая `handoff_count`. Это критично — иначе диалог из 4 переходов и 4 возвратов засчитал бы 8 переключений и попал в кап.
- **Слоты могут обогащаться при возврате.** Если в боковой ветке выяснилась релевантная для исходной ветки информация (как `dms_provider` в этом примере), маппинг прописывается явно в коде. Не «автоматически слить всё», а контролируемое перетекание.
---
## Когда — мягкая вставка, когда — жёсткий переход
Решение принимает **роутер плюс ветка-донор** (двойная защита). На практике различение работает по таким признакам:
| Признак | Мягкая вставка | Жёсткий переход |
|---------|----------------|-----------------|
| Длина вопроса | Короткий, точечный | Развёрнутый, с подвопросами |
| Контекст | Уточнение к текущему шагу | Запрос самостоятельного процесса |
| Маркеры в реплике | «а ещё», «а сколько», «и где» | «стоп», «подождите», «расскажите про» |
| Можно ли ответить одной репликой | Да | Нет, минимум 2-3 обмена |
| Меняет ли это `slots`/`current_step` | Нет | Да (полное переключение) |
Ни одна модель и ни один роутер не сделают это безошибочно с первого захода. Для этого и нужна двойная защита: ветка имеет в промпте правило «короткие боковые отвечай сам», роутер на каждой реплике независимо классифицирует — если оба согласны, что переключения нет, остаёмся в ветке. Если ветка пропустила сигнал — роутер на следующей реплике увидит и переключит.
## Что важно проверять в eval-наборе на этом примере
- **Soft-insertion не должен менять `current_step`**. Тест: на шаге `offer_time` подать «а сколько стоит» → проверить, что `state_after == "offer_time"`, `slots_updated` пустой, `handoff_count == 0`.
- **Hard-handoff корректно сохраняет состояние**. Тест: на шаге `offer_time` подать «расскажите про ДМС» → проверить, что `suspended_intent == "new_booking"`, `resumable_step == "offer_time"`, `resumable_slots` содержит все слоты записи.
- **Возврат не инкрементит счётчик**. Тест: hard-handoff, потом «давайте записываться» → проверить, что `handoff_count` остался **1**, не стал **2**.
- **`dms_provider` при возврате попадает в слоты записи**. Тест: после возврата вызвать `crm.create_booking` и проверить, что параметр `dms_provider` был передан. Это проверка маппинга при возврате — частая точка регрессий.