# Графовая архитектура: роутер намерений + изолированные ветки > **Версия 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`).