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,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 (граница бота и оператора по хирургии) исключён из архитектурных — это продуктовый вопрос, на код не влияет.
|
||||
Reference in New Issue
Block a user