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>
This commit is contained in:
AR 15 M4
2026-04-27 20:00:44 +05:00
parent f348570b1b
commit 52b46bc53e
43 changed files with 5914 additions and 105 deletions
+274
View File
@@ -0,0 +1,274 @@
# Пример 02 v2 · Вопрос про цену в середине записи
> **Версия v2 · 2026-04-27.** Переписано под реальный код: имена полей `thread_state` соответствуют `db/models/thread_state.py`, набор слотов — реальной таксономии из `prompts/intents/new_booking/steps/*.md`. У ветки `price_question` нет ни шагов, ни слотов (`prompts/intents/price_question.md` — обычный отвечающий промпт), поэтому в варианте B после переключения `current_step_code = null`, `slots = {}` — никакого «маппинга `dms_provider`» из v1 здесь нет, этот механизм в коде не реализован. Изменения относительно v1 — внизу в Changelog.
>
> Связано с [`../architecture/GRAPH_ARCHITECTURE_v4.md`](../architecture/GRAPH_ARCHITECTURE_v4.md), разделы 4.1, 4.2, 4.4. Демонстрирует разницу между **боковым вопросом** (soft insertion) и **переключением ветки** (hard handoff) на одном и том же сценарии. Также показывает работу полей `suspended_intent`, `resumable_step_code`, `resumable_slots` при возврате в исходную ветку.
## О чём этот пример
Пациент находится посреди записи (шаг `offer_time`, мы только что спросили про удобное время). Прямо перед ответом он задаёт вопрос про деньги. Вариантов поведения системы — два, и **они оба корректны**, в зависимости от того, как сформулирован вопрос:
- **Вариант A.** «А сколько стоит приём?» — короткий уточняющий вопрос. Ветка `new_booking` отвечает сама, **не покидая шаг `offer_time`**. Это **боковой вопрос** (soft insertion), счётчик `soft_insertion_count` инкрементируется.
- **Вариант B.** «А что у вас по ДМС? У меня ВТБ страхование» — это уже не короткое уточнение, а развёрнутый вопрос с подтемой. Ветка `new_booking` выдаёт служебный сигнал `[INTENT_CHANGE: price_question]`, оркестратор сохраняет состояние записи в `suspended_*` и запускает ветку `price_question`. После короткого ответа пациент возвращается к записи — состояние `new_booking` восстанавливается.
Ниже разбор обоих вариантов.
## Общая отправная точка
После пяти реплик из `01_basic_booking_v2.md` пациент стоит на шаге `offer_time` (мы только что спросили про удобное время). Состояние треда:
```json
{
"thread_id": 9002,
"current_intent_code": "new_booking",
"current_step": 3,
"current_step_code": "offer_time",
"slots": {
"name": "Анна Петровна",
"reason": "боль в горле, неделю",
"specialist": "ЛОР"
},
"handoff_count": 0,
"soft_insertion_count": 0,
"suspended_intent": null,
"resumable_step_code": null,
"resumable_slots": null
}
```
Последняя реплика бота (реплика 5 из примера 01 v2): «*Отлично. Подскажите, в какое время вам удобнее — утро, день или вечер? Будни или выходные?*»
---
# Вариант A · Боковой вопрос (soft insertion)
## Реплика 6A · «А сколько стоит приём ЛОРа?»
**Решение маршрутизатора:** `new_booking`. Это важный момент — маршрутизатор видит короткий вопрос про цену, но контекст разговора (5 реплик про запись) и формулировка («приём ЛОРа» — отсылка к обсуждаемому приёму) позволяют ему остаться в `new_booking`. В Песочнице бейдж «решение маршрутизатора» — зелёный, совпадает с активной веткой.
**Активная ветка / шаг:** `new_booking` / `offer_time`.
**Поведение модели:** ветка `new_booking` видит, что вопрос вне сценария, но короткий и относится к текущему обсуждаемому приёму. По правилам ветки — отвечает в-line, ставит флаг `soft_insertion: true` и **остаётся на текущем шаге**.
**Структурированный ответ модели:**
```
Первичный приём ЛОР-врача — 2 300 рублей. Возвращаемся к удобному времени: утро или вечер вам ближе?
STATE_JSON: {"state_after": "offer_time", "slots_updated": {}, "soft_insertion": true}
```
Ключевое:
- **Шаг не меняется** (`state_after == current_step_code`).
- **Слоты не обновляются** (`slots_updated: {}`).
- **Флаг `soft_insertion: true`** — оркестратор по этому признаку (плюс отсутствие изменений) увеличивает `soft_insertion_count` на 1.
- **`handoff_count` не меняется**.
В Песочнице на этом ответе появится бейдж **«тип ответа: боковой вопрос»** (жёлтый).
**`thread_state` после хода:**
```json
{
"current_intent_code": "new_booking",
"current_step": 3,
"current_step_code": "offer_time",
"slots": {
"name": "Анна Петровна",
"reason": "боль в горле, неделю",
"specialist": "ЛОР"
},
"handoff_count": 0,
"soft_insertion_count": 1
}
```
## Реплика 7A · «Утром в будни»
Дальше всё как в `01_basic_booking_v2.md`, реплика 6: переход в `book`, проговаривание, подтверждение, `close`. На переходе `offer_time → book` счётчик `soft_insertion_count` сбрасывается в 0 (он считает только подряд идущие боковые вопросы без движения шага).
## Что показал вариант A
- Боковой вопрос — это **лёгкий механизм без побочных эффектов**: ни шаг, ни слоты, ни `handoff_count` не меняются. Сдвигается только `soft_insertion_count`.
- Защита от «бесконечных боковых»: если `soft_insertion_count` дойдёт до 3, в следующий системный промпт ветки добавляется указание вернуть пациента к шагу скрипта (`SOFT_INSERTION_NUDGE` в `services/chat_service.py`).
- В промпте ветки `new_booking` явно прописано правило отвечать на короткие вопросы про цену/адрес/длительность в-line, не уходя в другую ветку.
---
# Вариант B · Переключение ветки и возврат
## Реплика 6B · «А что у вас по ДМС? У меня ВТБ страхование. Они покрывают?»
**Решение маршрутизатора:** `price_question`. Здесь:
- Конкретный страховщик упомянут → нужен поиск по партнёрам ДМС, это не короткое уточнение.
- Вопрос с подтемой («покрывают ли они»), требует развёрнутого ответа.
- Это уже не «вставка в скрипт записи», а самостоятельная подтема.
Оркестратор сравнивает: маршрутизатор сказал `price_question`, активная ветка — `new_booking` (пошаговая, на шаге `offer_time`). Это случай **удержания в ветке** (sticky): по умолчанию оркестратор не переключает пошаговую ветку по решению маршрутизатора, а добавляет в её системный промпт блок `[ПОДСКАЗКА РОУТЕРА]` и даёт ветке самой решить.
**Поведение модели ветки `new_booking`:** видит подсказку, что маршрутизатор счёл тему сменившейся. Сама оценивает: вопрос явно вне сценария записи, не короткий, требует другого контекста (база знаний по ДМС). Возвращает служебный сигнал:
```
[INTENT_CHANGE: price_question]
```
**Шаги оркестратора в одном ходе:**
1. Парсер видит `[INTENT_CHANGE: price_question]` → ответ ветки `new_booking` пациенту не показывается.
2. **Сохраняет состояние ветки-донора** в `thread_state`:
- `suspended_intent``"new_booking"`
- `resumable_step_code``"offer_time"`
- `resumable_slots` ← копия текущих `slots`
3. **Инкрементит счётчик переключений:** `handoff_count: 0 → 1`. Кап — 3; следующее переключение тоже допустимо, но при достижении 3 произойдёт авто-эскалация в `escalate_human` с причиной `routing_loop`.
4. **Сбрасывает `slots` и `current_step_code`**у `price_question` нет своей машины состояний, она линейная.
5. **Запускает ветку `price_question`** с полной историей и пустым шагом, в обработке этой же реплики (так называемый bounce — модель вызывается дважды в одном ходе).
**`thread_state` после переключения, перед ответом `price_question`:**
```json
{
"current_intent_code": "price_question",
"current_step": 0,
"current_step_code": null,
"slots": {},
"handoff_count": 1,
"soft_insertion_count": 0,
"suspended_intent": "new_booking",
"resumable_step_code": "offer_time",
"resumable_slots": {
"name": "Анна Петровна",
"reason": "боль в горле, неделю",
"specialist": "ЛОР"
}
}
```
Заметим: `slots` ветки `price_question` начинается с пустого объекта — у этой ветки **нет своих слотов** (`prompts/intents/price_question.md` — обычный отвечающий промпт без машины состояний). Слоты записи **полностью изолированы в `resumable_slots`** и не «смешиваются».
**Ответ модели `price_question`:**
```
Да, мы работаем с ДМС «ВТБ Страхование». Точный список покрываемых услуг и условия согласования есть у нашего администратора — он подтвердит покрытие конкретно на вашу программу при оформлении записи. Если хотите — продолжим запись, а администратор по ДМС свяжется с вами отдельно?
```
(Эта ветка не возвращает `STATE_JSON:` — у неё нет машины состояний.)
**`thread_state` после хода не меняется** (никаких слотов и шагов у `price_question` нет).
В Песочнице на этом ответе:
- Бейдж **«активная ветка: price_question»** (без отметки «пошаговая»).
- Бейдж **«решение маршрутизатора: price_question»** (зелёный — совпадает).
- В правой панели «Состояние диалога»: видно **«📌 отложен сценарий: `new_booking` (шаг `offer_time`)»**, слотов отложенного — 3.
- Счётчик переключений — **«1 из 3»** (синий бейдж, не красный).
## Реплика 7B · «Хорошо, давайте записываться. Утром в будни»
**Решение маршрутизатора:** `new_booking`. Чёткий сигнал возврата к записи + конкретное предпочтение по времени.
**Шаги оркестратора:**
1. Видит, что `current_intent_code == "price_question"`, новое решение маршрутизатора = `"new_booking"`.
2. **Проверяет `suspended_intent`** — он равен `"new_booking"`. **Это и есть путь возврата**, не новое переключение.
3. Восстанавливает:
- `current_intent_code``suspended_intent`
- `current_step_code``resumable_step_code` (`"offer_time"`)
- `slots``resumable_slots`
4. Очищает поля сохранения: `suspended_intent = null`, `resumable_step_code = null`, `resumable_slots = null`.
5. **Сбрасывает `handoff_count` в 0** — это возврат, а не новое переключение. Если бы счётчик не сбрасывался, диалог из 4 переключений и 4 возвратов засчитал бы 8 переходов и упёрся в кап.
6. Запускает ветку `new_booking` на восстановленном шаге `offer_time` для обработки этой же реплики.
**`thread_state` после восстановления, перед ответом `new_booking`:**
```json
{
"current_intent_code": "new_booking",
"current_step": 3,
"current_step_code": "offer_time",
"slots": {
"name": "Анна Петровна",
"reason": "боль в горле, неделю",
"specialist": "ЛОР"
},
"handoff_count": 0,
"soft_insertion_count": 0,
"suspended_intent": null,
"resumable_step_code": null,
"resumable_slots": null
}
```
**Ответ модели `new_booking`** (теперь на шаге `offer_time`, видит `preferred_time` в реплике пациента):
```
Хорошо. Записываю: Анна Петровна, к ЛОР-врачу, повод — боль в горле, удобное время — утром в будни. Передам администратору, он подберёт конкретное окно. Всё верно?
STATE_JSON: {"state_after": "book", "slots_updated": {"preferred_time": "утром в будни"}}
```
Шаг переходит в `book`, слот `preferred_time` заполнен. Дальше — реплика 7 из примера 01 v2 (явное «да» → `close`).
В Песочнице на этом ответе:
- Бейдж **«активная ветка: new_booking»** + **«пошаговая»** + бейдж **«шаг ветки: book»**.
- Бейдж **«решение маршрутизатора: new_booking»** (зелёный).
- В правой панели — событие **«решение: восстановили сценарий»** (зелёный бейдж `resumed`).
- В «Состоянии диалога» больше нет блока «📌 отложен сценарий».
- Счётчик переключений — **«0 из 3»** (сбросился).
## Что показал вариант B
- **Переключение ветки — это полноценный hard handoff**, со всем что к нему прилагается: служебный сигнал из ветки, сохранение состояния в `suspended_*`, инкремент `handoff_count`, изоляция слотов новой ветки.
- **Возврат — не «новое переключение»**, а особый путь оркестратора: он сравнивает решение маршрутизатора с `suspended_intent` и при совпадении восстанавливает состояние, **сбрасывая `handoff_count` в 0**. Это критично для защиты от петли — иначе чередование «запись ↔ цены» съело бы кап за один-два цикла.
- **У `price_question` нет своих слотов** — это простая отвечающая ветка. Никакого «маппинга `dms_provider`» при возврате нет (этого механизма в коде нет; в v1 он был как иллюстрация).
- **Pendant возврата — `slots` записи восстанавливается полностью**, ничего не теряется.
---
## Когда боковой вопрос, а когда переключение
Решение принимает **маршрутизатор плюс ветка-донор** (двойная защита). На практике различение работает по таким признакам:
| Признак | Боковой вопрос | Переключение ветки |
|---------|----------------|---------------------|
| Длина вопроса | Короткий, точечный | Развёрнутый, с подвопросами |
| Контекст | Уточнение к текущему шагу | Запрос самостоятельной темы |
| Маркеры в реплике | «а сколько», «а где», «и как долго» | «стоп», «подождите», «расскажите про», «у меня …, что насчёт» |
| Можно ли ответить одной репликой | Да | Нет, минимум 2-3 обмена |
| Меняет `slots` / `current_step_code` | Нет | Да (полное переключение) |
| Меняет `handoff_count` | Нет | Да (+1) |
| Меняет `soft_insertion_count` | Да (+1) | Нет (сбрасывается) |
Ни одна модель и ни один маршрутизатор не сделают это безошибочно с первого захода. Двойная защита: ветка имеет в промпте правило «короткие боковые отвечай сам», маршрутизатор на каждой реплике независимо классифицирует. Если оба согласны — остаёмся; если ветка пропустила сигнал — маршрутизатор на следующей реплике увидит и переключит.
## Что важно проверять в eval-наборе на этом примере
- **Soft insertion не меняет `current_step_code` и `slots`.** Тест: на шаге `offer_time` подать «а сколько стоит» → проверить `state_after == "offer_time"`, `slots_updated == {}`, `soft_insertion: true` в ответе, `handoff_count == 0`, `soft_insertion_count` увеличился на 1.
- **Hard handoff корректно сохраняет состояние.** Тест: на шаге `offer_time` подать «расскажите про ДМС» → проверить, что `suspended_intent == "new_booking"`, `resumable_step_code == "offer_time"`, `resumable_slots` содержит все три слота записи.
- **Возврат сбрасывает `handoff_count`.** Тест: hard handoff, потом «давайте записываться» → проверить, что `handoff_count` стал **0**, не **2**.
- **При возврате `slots` не дополнились ничем «случайным».** В v1 был ожидаем `dms_provider` после возврата — этого механизма нет, проверять нечего; но если вдруг в `slots` после возврата появятся поля, которых не было в `resumable_slots`, — это регрессия.
---
## Changelog
### v2 → 2026-04-27
**Имена полей `thread_state`** приведены к реальной БД (как в `01_basic_booking_v2.md`).
**Слоты приведены к реальной таксономии:**
- В отправной точке (после 5 реплик примера 01) теперь только `name`, `reason`, `specialist` — никаких `service`, `complaint`, `doctor_preference`, `time_candidates`, `branch`. После возврата в варианте B на шаге `book` появляется `preferred_time` и `confirmed: true` на `close`.
**`price_question` показана как ветка без слотов и без шагов** — реально она именно такая (`prompts/intents/price_question.md`). Удалён вымышленный mapping `dms_provider` из `price_question` в `new_booking` после возврата — этого механизма в коде нет (нет ни слота `dms_provider`, ни логики mapping'а).
**Структурированный ответ модели** — формат `STATE_JSON:` в хвосте текста (а не отдельный JSON). Для `price_question` показано, что ветка не возвращает `STATE_JSON:` (нет машины состояний).
**Описание hard handoff** уточнено под реальную механику оркестратора:
- Сначала маршрутизатор предлагает `price_question`, оркестратор применяет sticky (передаёт ветке `new_booking` подсказку), и **только если ветка сама выдаёт `[INTENT_CHANGE]`**, происходит переключение. Прямое переключение по решению маршрутизатора — только для веток без машины состояний.
- В одном ходе модель может быть вызвана дважды (bounce): сначала ветка-донор выдаёт сигнал, потом запускается ветка-приёмник.
**Удалён CRM-вызов** `crm.create_booking(... dms_provider=...)` — таких tool calls в коде нет.
**Добавлены признаки UI Песочницы** на каждом шаге (бейджи активной ветки, решения маршрутизатора, событий, счётчика переключений) — чтобы пример читался как сверка с тем, что оператор реально увидит.
**Терминология:** «роутер» → «маршрутизатор», «жёсткий переход / hard handoff» → «переключение ветки».
**Содержательно** (что показывает пример) — то же: разница между боковым вопросом и переключением ветки + механика возврата через `suspended_intent`.