Files
RAG_helper/docs/architecture/GRAPH_ARCHITECTURE_v2.md
AR 15 M4 f348570b1b 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>
2026-04-26 21:50:16 +05:00

406 lines
35 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Графовая архитектура: роутер намерений + изолированные ветки
> **Версия 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'а.** 510 многошаговых мини-диалогов: intent на реплике 1 → пациент сменил тему на реплике 2 → на реплике 3 проверяем, что ветка ушла на роутер и роутер правильно переключил.
- **Eval resumable.** 35 сценариев: 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`, Спринты 47):
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`).