Files
RAG_helper/docs/architecture/GRAPH_ARCHITECTURE_v4.md
AR 15 M4 52b46bc53e feat(sprint6c+sprint7): терминология, сверка примеров с кодом, мульти-RAG (часть A)
Спринт 6c — терминология и сверка документации с реальным кодом:
- Словарь терминов в static/docs.html: «маршрутизатор» вместо «роутер»,
  «защитное условие» вместо «guard», «пошаговая ветка» вместо «многошаговая».
  Разделены концепты «намерение» (intent) и «ветка» (branch) с пометкой,
  что в коде они хранятся как одна сущность 1:1.
- Песочница: «Решение маршрутизатора» виден всегда (зелёный/жёлтый),
  счётчик переключений «N из 3» отдельной плашкой, бейджи под словарь.
- Настройки: «Условия перехода» → «Защитные условия (guards, JSON)».
- GRAPH_ARCHITECTURE_v4.md: имена полей thread_state и слоты приведены
  к реальной БД (db/models/thread_state.py) и таксономии промптов шагов
  (prompts/intents/new_booking/steps/). Ссылки на *_v2 примеры. На v3
  поставлена шапка «устарело».
- 4 примера переписаны как *_v2: реальные current_intent_code/
  current_step_code/slots_json, реальные allowed_next без двойных переходов,
  реальная таксономия слотов name/reason/specialist/preferred_time/confirmed.
  Удалены вымышленные CRM tool calls и слоты, которых нет в коде.
- static/example.html — параметризованная страница с навигацией между
  4 примерами; роут GET /api/docs/examples/{name} в main.py отдаёт
  markdown без дублирования файлов.
- Редактирование документов в Отладке: GET/PUT /documents/{id}/raw,
  textarea с переразметкой и обновлением Chroma при сохранении.

Спринт 7, часть A — мульти-RAG через подписку ветка↔документы:
- Миграция: таблица intent_documents (M:N), модель IntentDocument,
  индекс по document_id для обратного поиска.
- API: GET/PUT /intents/{code}/documents и GET/PUT /documents/{id}/intents
  с PUT-семантикой «полный список», атомарно. Сервис
  services/intent_document_service.py.
- Retrieval-фильтр в chat_service: подтягивает document_ids активной
  ветки и передаёт в vectorstore.query(). Дефолт пустой подписки —
  document_ids=[] (= 0 чанков), не «вся коллекция»: пустая подписка
  означает «ветка не настроена», подмешивать случайное хуже, чем
  ничего. vectorstore.query() различает None (нет фильтра) и [] (0).
- UI Настроек: блок «Документы базы знаний» в правом сайдбаре,
  всегда видим независимо от вкладки, сортировка по имени, счётчик
  «N из M», PUT при сохранении.
- UI Отладки: третья кнопка «привязка» рядом с «удалить» —
  раскрывашка со списком веток (галочки), быстрая привязка прямо
  на странице загрузки.
- Песочница: блок «Срез RAG» с подпиской/найдено, ворнинг при пустой
  подписке. Поле rag_subscription в QueryResponse и ChatResponse.
- Системный промпт страницы Отладки переехал в обычную ветку _debug
  («Страница отладки»). Удалён prompts/system_prompt.md и логика
  DEFAULT_SYSTEM_PROMPT в llm_client. routers/query.py подтягивает
  активный конфиг ветки _debug и её подписки. Дефолт пустой подписки
  для _debug — None (вся коллекция), не [] как для пациентских — чтобы
  Отладка работала «из коробки». На странице Отладки info-bar показывает
  активную версию и счётчик подписок, ссылка → Настройки.
- Тест-блок «Тест-вопрос» в центре Настроек: расширил /query
  параметрами intent_code (default _debug), system_prompt (override
  для теста черновика из textarea), disable_rag (для _router).
  Редактор промпта обёрнут в <details open> — можно свернуть до
  одной строки. Под ним — три колонки результата (RAG / промпт /
  ответ). Для _router показывается подсказка про отсутствие RAG.

Документы:
- data/datasets/*.md — наработки по 6 веткам (рабочие материалы оператора).
- docs/BRANCH_MAP_AND_PROMPTS_v1.md, docs/OPTIMIZATION_CONVERSION_v1.md,
  docs/guides/state_machine_and_slots.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-27 20:00:44 +05:00

471 lines
50 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.
# Графовая архитектура: маршрутизатор намерений + изолированные ветки
> **Версия 4 · 2026-04-27.** Архитектурные решения те же, что в v3, но имена полей `thread_state` и иллюстративные слоты приведены в соответствие с тем, как они реально называются в БД (`db/models/thread_state.py`) и в промптах шагов `new_booking` (`prompts/intents/new_booking/steps/`). Также по терминологии: «маршрутизатор» вместо «роутер», «защитное условие» вместо «guard» в русском тексте (см. словарь на странице `/docs.html` в приложении). Английские идентификаторы полей в коде и кодовые имена не меняются. Полный список изменений — в разделе **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_code`) и **какие данные мы уже собрали** (`slots`).
### 3.1 Базовая линейная цепочка
Пример **состояния треда** (thread state) для `new_booking` к моменту шага `offer_time`:
```json
{
"current_intent_code": "new_booking",
"current_step_code": "offer_time",
"slots": {
"name": "Анна",
"reason": "боль в горле",
"specialist": "ЛОР",
"is_child": false
},
"handoff_count": 0,
"soft_insertion_count": 0,
"suspended_intent": null,
"resumable_step_code": null,
"resumable_slots": null
}
```
Модель на каждом ходе видит: *«Я на шаге `offer_time`, слоты `name`, `reason`, `specialist` уже собраны на предыдущих шагах — значит следующим сообщением я должна узнать у пациента предпочитаемое время и положить его в `preferred_time`, а не представляться заново»*. Это убирает «перескоки» и «забывания».
Состояние треда хранится в отдельной таблице `thread_state` (см. раздел «Что это меняет в данных»). В БД слоты хранятся в текстовой колонке `slots_json`, в API распаковываются в объект `slots` — поэтому в иллюстрациях везде показываем уже распакованный объект. Полный пример заполнения слотов реплика за репликой — в `01_basic_booking_v2.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) — обычным текстом для пациента + служебный блок `STATE_JSON:` в хвосте, который парсер вырезает (пациент его не видит):
```
Записала вас на четверг, 10:00. Подтверждаете?
STATE_JSON: {"state_after": "book", "slots_updated": {"preferred_time": "четверг 10:00"}}
```
Код:
1. **Валидирует легальность перехода**`offer_time → book` допустим (есть в `allowed_next` шага), `intro → book` нет.
2. **Сохраняет слоты строго** — что модель прислала в `slots_updated`, то и мерджится в `slots` (старые поля не теряются).
3. **Логирует несоответствия** — если модель вернула несуществующее `state_after` или забыла блок `STATE_JSON:`, состояние остаётся прежним, в лог и в отладочную панель Песочницы пишется предупреждение.
Модель рассуждает содержательно, код защищает механически. Прибавка — около 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_code` + `resumable_slots` (см. 4.4).
3. **Передаёт всю историю** в роутер.
4. **Запускает новую ветку** — бесшовно для пользователя.
Полный разбор жёсткого перехода с возвратом — в `02_price_during_booking.md`.
### 4.2 Мягкая вставка (soft insertion) — боковой вопрос без выхода из ветки
Не каждое отклонение от темы — это переход в другую ветвь. Частый случай: пациент посреди записи спрашивает «а сколько это стоит?» или «где вы находитесь?». Это не смена темы, это короткий параллельный вопрос, после которого нужно продолжить скрипт записи с того же шага.
Различение:
- **Мягкая вставка** (soft insertion) — на вопрос можно ответить *одной репликой* без запуска собственной машины состояний. Цена услуги, адрес, длительность приёма, требования к документам. Ветка отвечает сама, поле `current_step_code` не меняется.
- **Жёсткий переход** (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_step_code` + `resumable_slots`
Если ветка вышла по soft-handoff'у для короткого ответа — ок, через мгновение продолжает. Если произошёл жёсткий переход и боковая (detour) ветка закрылась — пациент часто возвращается к исходной задаче. Пример:
- Пациент в `new_booking` на шаге `offer_time`.
- Переспросил про цену — ушли в `price_question`.
- Получил ответ, говорит «ок, тогда бронируем на четверг».
- Должен вернуться в `new_booking` на шаг `offer_time`, не в `intro`.
Для этого при выходе из ветки в `thread_state` сохраняются:
```json
{
"current_intent_code": "price_question",
"current_step_code": null,
"slots": {},
"suspended_intent": "new_booking",
"resumable_step_code": "offer_time",
"resumable_slots": { "name": "Анна", "reason": "боль в горле", "specialist": "ЛОР", "is_child": false }
}
```
Маршрутизатор, приняв решение о возврате, восстанавливает `current_intent_code` из `suspended_intent`, `current_step_code` из `resumable_step_code`, слоты — из `resumable_slots`. Поля сохранения очищаются. Полный диалог с разбором изменений `thread_state` на каждом ходе — в `02_price_during_booking_v2.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`** — текущее состояние треда (одна строка на тред, актуальная схема — в `db/models/thread_state.py`):
- `thread_id` (PK, FK на `threads.id`).
- `current_intent_code` (nullable, str 50) — код активной ветки.
- `current_step` (int, default 0) — числовой счётчик хода внутри ветки. Инкрементируется на каждом успешном переходе шага. Используется только как монотонный счётчик; для логики берём `current_step_code`.
- `current_step_code` (nullable, str 50) — код текущего шага машины состояний. У веток без шагов остаётся `null`.
- `slots_json` (text, default `"{}"`) — слоты, собранные веткой. В API распаковывается в объект `slots`.
- `handoff_count` (int, default 0) — счётчик переключений ветки за диалог; защита от петель.
- `soft_insertion_count` (int, default 0) — сколько боковых вопросов подряд модель ответила, не двигая шаг. Сбрасывается при смене шага или ветки. При достижении капа (3) в системный промпт ветки добавляется указание вернуть пациента к шагу.
- `suspended_intent` (nullable, str 50) — код отложенной ветки, из которой вышли по жёсткому переходу.
- `resumable_step_code` (nullable, str 50) — шаг в отложенной ветке, куда возвращаться.
- `resumable_slots_json` (text, nullable) — слоты той ветки. В API распаковывается в `resumable_slots`.
- `updated_at`.
- **Машина состояний на ветке** — для `new_booking` справочник шагов + допустимых переходов (может быть в коде или в БД, на старте достаточно в коде).
- **`routing_log`** (опционально) — лог решений роутера: намерение, срабатывание условия выхода, инкремент `handoff_count`. Нужен для отладки и тюнинга.
---
## Что это меняет в UI
- «Настройки агента» — настройки веток: слева список веток, справа редактор промпта и условий выхода для выбранной ветки. Для веток с машиной состояний — дополнительная вкладка со списком шагов и их промптами.
- В «Песочнице» отладочная панель показывает: **активную ветку** (`current_intent_code`), **шаг пошагового сценария** (`current_step_code`), **собранные слоты** (`slots`), **счётчик переключений** (`handoff_count`, выводится как «N из 3»), **счётчик боковых вопросов подряд** (`soft_insertion_count`), **отложенный сценарий** (`suspended_intent` + `resumable_step_code` + `resumable_slots`), если есть, и **историю переходов между ветками** в рамках треда. Также — **решение маршрутизатора** на текущей реплике (всегда), чтобы оператор видел, совпало ли оно с активной веткой или сработало удержание / возврат из отложенного сценария.
- «Сценарии» (Спринт 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_code` восстановился из `resumable_step_code`, а `slots` — из `resumable_slots`.
Реализация — короткий скрипт, прогоняющий набор через `/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_code`.** Сразу после парсинга `state_after` из ответа модели, или после того как ответ успешно показан пациенту? При ошибке доставки состояние может разъехаться с тем, что пациент видел.
6. **Кап на мягкие вставки.** Если пациент крутит ассистенту пять побочных вопросов подряд, не продвигаясь по записи — это нормально или это сигнал «пациент не хочет записываться, эскалировать»? Нужен ли кап на число инлайн-ответов до возврата к шагу скрипта.
7. **Шаги записи — из вики или из головы.** Шесть шагов `new_booking` формализованы нами, но скрипт в вике формулирует их слегка иначе («контакт → уточнение → презентация приёма → 2 слота → запись → закрытие»). До реализации Спринта 5 — свериться с вики по конкретной первой целевой специальности (ЛОР?) и принять официальный список шагов.
Вопрос из v1 про границу «бот vs. оператор по хирургии» — исключён из архитектурных открытых: это продуктовое решение (как у клиники устроен контакт-центр), не архитектурное, и на код не влияет — хирургия просто эскалируется с `reason=surgery`, а дальше смежный разработчик маршрутизирует в нужную очередь.
---
## Ориентир на следующие спринты
Логичный порядок (согласован с `SPRINTS.md`, Спринты 47):
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`. Все примеры сверены с реальной таксономией слотов (`prompts/intents/new_booking/steps/`) и реальными именами полей `thread_state` (`db/models/thread_state.py`).
- [`01_basic_booking_v2.md`](../examples/01_basic_booking_v2.md) — happy path записи к ЛОР-врачу. Базовый случай, в котором всё идёт по линейному скрипту: `intro → qualify → present → offer_time → book → close`. Показывает, как заполняются реальные слоты (`name`, `reason`, `specialist`, `preferred_time`, `confirmed`), как меняется `current_step_code`, что видит модель на каждой реплике.
- [`02_price_during_booking_v2.md`](../examples/02_price_during_booking_v2.md) — пациент в середине записи спрашивает про цену. Один и тот же сценарий разобран в двух вариантах: боковой вопрос (без выхода из ветки) и переключение ветки с возвратом (через `suspended_intent` + `resumable_step_code` + `resumable_slots`). Лучший пример для понимания различий между этими двумя механизмами.
- [`03_child_patient_guard_v2.md`](../examples/03_child_patient_guard_v2.md) — запись ребёнка к врачу. Показывает срабатывание защитного условия `require_legal_rep` в шаге `qualify` при `is_child=true`: переход в `present` блокируется, пока не заполнены `legal_rep_name` и `legal_rep_phone`.
- [`04_general_info_simple_v2.md`](../examples/04_general_info_simple_v2.md) — простые информационные запросы (часы, адрес, проезд, контакты, документы, не-предоставляемые услуги). Самый дешёвый путь в системе: одна реплика, одна ветка `general_info`, без машины состояний и без слотов — прямой ретривер → ответ. Логичная стартовая точка для запуска первой версии бота.
---
## Changelog
### v4 → 2026-04-27
**Имена полей `thread_state` приведены к реальной БД** (`db/models/thread_state.py`):
- `intent` / `current_intent``current_intent_code`.
- `step` / `current_step` (как строка) → `current_step_code`. Числовое поле `current_step` в БД тоже есть (это монотонный счётчик хода), но для логики сценариев используется `current_step_code`.
- `resumable_step``resumable_step_code`.
- `slots` и `resumable_slots` показаны как объекты (так они приходят в API); в самой БД это текстовые колонки `slots_json` и `resumable_slots_json` — добавлено замечание в §3.1 и в раздел «Что это меняет в данных».
- В список полей `thread_state` добавлен `soft_insertion_count` (счётчик подряд идущих боковых вопросов; был введён в Спринте 6b, в v3 описан вне таблицы).
**Иллюстративные слоты в примерах JSON приведены к реальной таксономии** из промптов шагов `new_booking` (`prompts/intents/new_booking/steps/*.md`):
- На шаге `intro` собирается `name`.
- На шаге `qualify``reason`, `specialist`, `is_child`, плюс при ребёнке `legal_rep_name` / `legal_rep_phone`, плюс при запросе конкретного врача `requested_doctor` / `waitlist_flag`, плюс при жалобе на слух `needs_surgologist_first`.
- На шаге `present` новые слоты не собираются.
- На шаге `offer_time``preferred_time` (свободное описание удобного времени, не CRM-список слотов).
- На шаге `book``confirmed`.
- На шаге `close` слоты не меняются.
- Удалены вымышленные слоты `patient_name`, `service`, `complaint`, `doctor_preference`, `time_candidates`, `time_chosen`, `branch`, `booking_id` — их нет ни в промптах, ни в коде. Связанные с ними CRM-tool-calls (`crm.get_slots`, `crm.create_booking`) по-прежнему упомянуты в §3.4 как **планируемая интеграция** (запись в БД ещё не делается; см. соответствующую идею на потом в `SPRINTS.md`).
**Терминология** — выровнена со словарём в `static/docs.html` (зафиксирован в Спринте 6c):
- В русском тексте «роутер» → «маршрутизатор» (английский термин `router` остаётся в скобках).
- В русском тексте «guard» → «защитное условие» (английский термин `guard` остаётся в скобках, идентификаторы кода `guards`, `pending_guard`, `check_guards()` не меняются).
**Ссылки на разобранные примеры** обновлены на `*_v2.md` — версии примеров, синхронные с реальным кодом.
**Содержательно** (архитектурно) — без изменений. Все решения, открытые вопросы, рекомендации по спринтам — те же, что в v3.
### 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 (граница бота и оператора по хирургии) исключён из архитектурных — это продуктовый вопрос, на код не влияет.