- 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>
35 KiB
Графовая архитектура: роутер намерений + изолированные ветки
Версия 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). Идея проста:
- Входная реплика пациента идёт не сразу в отвечающего агента, а в роутер.
- Роутер определяет намерение (intent) и передаёт диалог в конкретную изолированную ветку.
- Каждая ветка — это отдельный узкий промпт, который умеет делать одну вещь хорошо.
- Ветки не замкнуты: в любой момент агент может вернуть управление роутеру, если контекст изменился.
┌─────────────┐
│ Пациент │
└──────┬──────┘
│
┌──────▼──────────────────────────┐
│ Роутер (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.
Пример промпта роутера:
Определи намерение пользователя. Варианты:
new_booking— новая записьreschedule— перенос или отмена существующейprice_question— цены, ДМС, оплатаmedical_question— симптомы, диагноз, лечение (немедленная эскалация не требуется)general_info— как доехать, часы работы, контакты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:
{
"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-управляемые переходы («в промпте написано: если слот заполнен, переходи к следующему шагу») фрагильны: модель периодически «не замечает» заполнение слота и застревает или, наоборот, прыгает через шаг.
Гибридный подход надёжнее. Модель возвращает структурированный ответ:
{
"reply": "Записала вас на четверг, 10:00...",
"state_after": "close",
"slots_updated": {
"time_chosen": "2026-04-24 10:00"
}
}
Код:
- Валидирует легальность перехода —
offer_time → closeдопустим,intro → bookнет. - Сохраняет слоты строго — что модель обновила, то и попало в
thread_state. - Логирует несоответствия — если модель вернула несуществующее
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.
Когда оркестратор видит такой сигнал в ответе модели:
- Останавливает текущую ветку.
- Сохраняет текущее состояние как
suspended_intent+resumable_step+resumable_slots(см. 4.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 сохраняются:
{
"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_intentcurrent_stepslots(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. Актуальный список:
- Фреймворк оркестровки — решено: пишем вручную на Python. LangGraph/n8n не берём.
- Роутер — отдельная модель — отложено: пока DeepSeek через отдельный
RouterClient, чтобы сменить модель в одном месте. Пересмотрим, когда вызовов станет много. - Формат exit conditions — текстом в промпте ветки + независимый роутер на каждой реплике. Если реальный прогон покажет, что свободный текст пропускает случаи смены темы — добавим структурированный список триггеров (keyword-match).
- Confidence score роутера — не на первом спринте. После живого прогона посмотрим на реальные ошибки, и если их много — добавим clarifying question при низкой уверенности.
Новые вопросы после v2:
- Момент обновления
current_step. Сразу после парсингаstate_afterиз ответа модели, или после того как ответ успешно показан пациенту? При ошибке доставки состояние может разъехаться с тем, что пациент видел. - Cap на soft-insertion'ы. Если пациент крутит ассистенту пять побочных вопросов подряд, не продвигаясь по записи — это нормально или это сигнал «пациент не хочет записываться, эскалировать»? Нужен ли cap на число инлайн-ответов до возврата к шагу скрипта.
- Шаги записи — из вики или из головы. Шесть шагов
new_bookingформализованы нами, но скрипт в вике формулирует их слегка иначе («контакт → уточнение → презентация приёма → 2 слота → запись → закрытие»). До реализации Спринта 5 — свериться с вики по конкретной первой целевой специальности (ЛОР?) и принять официальный список шагов.
Вопрос из v1 про границу «бот vs. оператор по хирургии» — исключён из архитектурных открытых: это продуктовое решение (как у клиники устроен контакт-центр), не архитектурное, и на код не влияет — хирургия просто эскалируется с reason=surgery, а дальше смежный разработчик маршрутизирует в нужную очередь.
Ориентир на следующие спринты
Логичный порядок (согласован с SPRINTS.md, Спринты 4–7):
- Разделить «один промпт» на несколько → сделано (Спринт 4).
- Добавить роутер → сделано (Спринт 4).
- State machine + exit conditions → Спринт 5.
- Мульти-RAG → Спринт 6. С учётом v2: дизайн пересмотреть в сторону Варианта Б.
- Сценарии и экспорт → Спринт 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).