Files
RAG_helper/docs/examples/02_price_during_booking_v2.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

24 KiB
Raw Permalink Blame History

Пример 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, разделы 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 (мы только что спросили про удобное время). Состояние треда:

{
  "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 после хода:

{
  "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:

{
  "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_codesuspended_intent
    • current_step_coderesumable_step_code ("offer_time")
    • slotsresumable_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:

{
  "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.