Спринт 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>
24 KiB
Пример 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]
Шаги оркестратора в одном ходе:
- Парсер видит
[INTENT_CHANGE: price_question]→ ответ веткиnew_bookingпациенту не показывается. - Сохраняет состояние ветки-донора в
thread_state:suspended_intent←"new_booking"resumable_step_code←"offer_time"resumable_slots← копия текущихslots
- Инкрементит счётчик переключений:
handoff_count: 0 → 1. Кап — 3; следующее переключение тоже допустимо, но при достижении 3 произойдёт авто-эскалация вescalate_humanс причинойrouting_loop. - Сбрасывает
slotsиcurrent_step_code— уprice_questionнет своей машины состояний, она линейная. - Запускает ветку
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. Чёткий сигнал возврата к записи + конкретное предпочтение по времени.
Шаги оркестратора:
- Видит, что
current_intent_code == "price_question", новое решение маршрутизатора ="new_booking". - Проверяет
suspended_intent— он равен"new_booking". Это и есть путь возврата, не новое переключение. - Восстанавливает:
current_intent_code←suspended_intentcurrent_step_code←resumable_step_code("offer_time")slots←resumable_slots
- Очищает поля сохранения:
suspended_intent = null,resumable_step_code = null,resumable_slots = null. - Сбрасывает
handoff_countв 0 — это возврат, а не новое переключение. Если бы счётчик не сбрасывался, диалог из 4 переключений и 4 возвратов засчитал бы 8 переходов и упёрся в кап. - Запускает ветку
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.