Спринт 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>
22 KiB
Пример 03 · Запись ребёнка — защитное условие в шаге уточнения
⚠️ Эта версия устарела. Актуальная —
03_child_patient_guard_v2.md. v1 описывал три защитных условия (для ребёнка, для сурдолога, юридическая оговорка на close) — в коде реально есть только одно (require_legal_rep), остальные — инструкции в промптах. Также имена полейthread_stateи слоты приведены к реальной БД и таксономии. Список изменений — внизу v2 в Changelog.
Связано с
GRAPH_ARCHITECTURE_v3.md, разделы 3.2, 3.3. Демонстрирует защитное условие (guard) внутри шагаqualifyветкиnew_booking. Когда модель определяет, что пациент — ребёнок (is_child=true), линейный путь машины состояний прерывается: переходqualify → presentблокируется до тех пор, пока не будут собраны данные законного представителя (legal representative). Также показано, как эта особенность отражается на финальном шагеclose— там добавляются юридические оговорки из вики.
О чём этот пример
Мама записывает 8-летнего сына к ЛОР-врачу. Ситуация юридически регулируется: согласно ФЗ № 323-ФЗ «Об основах охраны здоровья граждан в РФ» приём ребёнка возможен только с участием законного представителя, и клиника обязана зафиксировать его данные на этапе записи. Скрипт записи в вике формулирует это как обязательный сбор ФИО и контактного телефона законного представителя.
В терминах архитектуры это означает: внутри шага qualify есть под-состояние (sub-state) qualify.legal_rep, активирующееся при is_child=true. Модель не может вернуть state_after: "present", пока в слотах не заполнены legal_rep_full_name и legal_rep_phone — валидатор перехода в коде это проверит и отклонит ход, если переход неправильный.
Начальное состояние
{
"thread_id": "T-9003",
"current_intent": null,
"current_step": null,
"slots": {},
"handoff_count": 0
}
Реплика 1 · «Здравствуйте, хочу записать ребёнка к лору»
Решение роутера: new_booking. Упоминание «ребёнка» само по себе не сигнал эскалации — это норма для записи.
Активная ветка / шаг: new_booking / intro.
Ретривер: не вызывается.
Вызовы инструментов: нет.
Структурированный ответ модели:
{
"reply": "Здравствуйте! Помогу записать ребёнка. Подскажите, как могу к вам обращаться, и как зовут ребёнка?",
"state_after": "intro",
"slots_updated": {
"service_mention": "ЛОР",
"is_child": true
}
}
Заметим: is_child=true модель устанавливает уже здесь, на основании слова «ребёнок». Это раннее срабатывание флага — и оно правильное, потому что определяет всю дальнейшую логику. Шаг при этом не меняется — ждём имена.
thread_state после хода:
{
"current_intent": "new_booking",
"current_step": "intro",
"slots": {
"service_mention": "ЛОР",
"is_child": true
}
}
Реплика 2 · «Меня зовут Мария, ребёнка зовут Матвей, ему 8 лет»
Решение роутера: new_booking.
Активная ветка / шаг: new_booking / intro.
Ретривер: не вызывается.
Структурированный ответ модели:
{
"reply": "Очень приятно, Мария. Записать Матвея на первичный приём к ЛОР-врачу? Что именно беспокоит?",
"state_after": "qualify",
"slots_updated": {
"patient_name": "Матвей",
"patient_age": 8,
"parent_first_name": "Мария"
}
}
Шаг переходит в qualify. Имя ребёнка — patient_name (это пациент, на чьё имя пойдёт запись), имя мамы временно фиксируем как parent_first_name — на следующем под-шаге его дополним до полного ФИО.
thread_state после хода:
{
"current_intent": "new_booking",
"current_step": "qualify",
"slots": {
"service_mention": "ЛОР",
"is_child": true,
"patient_name": "Матвей",
"patient_age": 8,
"parent_first_name": "Мария"
}
}
Реплика 3 · «У него уши болят и плохо слышит на одно ухо»
Решение роутера: new_booking. Жалоба упомянута, но в контексте записи — не самостоятельный медицинский вопрос.
Активная ветка / шаг: new_booking / qualify.
Срабатывает guard — при is_child=true обычный переход qualify → present заблокирован. Машина состояний ждёт под-состояние qualify.legal_rep. В промпте шага явно написано: «если is_child=true и не заполнены legal_rep_full_name + legal_rep_phone — соблюдай скрипт сбора данных представителя из вики».
Дополнительно срабатывает второй guard — упоминание «плохо слышит на одно ухо» это сигнал на сурдолога. Из v3 раздел 3.2: «жалоба на слух без обследования у сурдолога → на шаге present предложить сурдолога». Этот guard сработает позже, когда дойдём до present.
Ретривер: срез шага qualify — wiki_sources: ['/wiki/services/**', '/wiki/doctors/**', '/wiki/legal/**'] (последний раздел добавлен для шага под-состояния qualify.legal_rep).
- top-k: 3.
- Достались чанки:
legal/child-patient-rep-script.md(score 0.93) — скрипт сбора данных законного представителя из вики.legal/fz-323-rep.md(score 0.78) — выдержка из ФЗ-323 для случаев «зачем вам это нужно».services/первичный-приём-лор.md(score 0.62).
Вызовы инструментов: нет.
Структурированный ответ модели:
{
"reply": "Поняла, посмотрим. Прежде чем подобрать слот — для записи ребёнка нам по правилам клиники нужно зафиксировать данные законного представителя. Уточните, пожалуйста, ваше полное ФИО и контактный телефон.",
"state_after": "qualify.legal_rep",
"slots_updated": {
"complaint": "боль в ушах, ухудшение слуха на одно ухо"
}
}
Шаг переходит в под-состояние qualify.legal_rep — guard активен. Жалоба зафиксирована в слотах, но переход в present пока заблокирован.
thread_state после хода:
{
"current_intent": "new_booking",
"current_step": "qualify.legal_rep",
"slots": {
"service_mention": "ЛОР",
"is_child": true,
"patient_name": "Матвей",
"patient_age": 8,
"parent_first_name": "Мария",
"complaint": "боль в ушах, ухудшение слуха на одно ухо"
}
}
Реплика 4 · «А зачем вам это нужно?»
Возражение пациентки. Из вики: «Если возникает вопрос — А зачем вам это нужно? — вы можете объяснить, что это стандартная практика для обеспечения безопасности и контроля доступа к медицинским услугам ребенка...».
Решение роутера: new_booking.
Активная ветка / шаг: new_booking / qualify.legal_rep.
Ретривер: тот же срез, чанк legal/fz-323-rep.md уже в контексте.
Структурированный ответ модели:
{
"reply": "Это стандартная практика — нужна для безопасности ребёнка и для того, чтобы мы могли с вами связаться, если в ходе приёма потребуется ваше согласие на процедуру. Согласно ФЗ № 323-ФЗ мы должны иметь возможность связаться с законным представителем в любой момент. Подскажите, пожалуйста, ваше полное ФИО и телефон.",
"state_after": "qualify.legal_rep",
"slots_updated": {}
}
Шаг и слоты не меняются — guard всё ещё блокирует выход.
Реплика 5 · «Хорошо. Иванова Мария Сергеевна, +79991234567»
Решение роутера: new_booking.
Активная ветка / шаг: new_booking / qualify.legal_rep.
Структурированный ответ модели:
{
"reply": "Спасибо, Мария Сергеевна. Записываю Матвея на первичный приём к ЛОР-врачу. Подберу ближайший свободный слот?",
"state_after": "present",
"slots_updated": {
"legal_rep_full_name": "Иванова Мария Сергеевна",
"legal_rep_phone": "+79991234567"
}
}
Guard выполнен — оба обязательных слота заполнены. Валидатор переходов в коде проверяет: переход qualify.legal_rep → present допустим только если legal_rep_full_name и legal_rep_phone заполнены. Условие выполняется, переход разрешён.
thread_state после хода:
{
"current_intent": "new_booking",
"current_step": "present",
"slots": {
"service_mention": "ЛОР",
"is_child": true,
"patient_name": "Матвей",
"patient_age": 8,
"parent_first_name": "Мария",
"complaint": "боль в ушах, ухудшение слуха на одно ухо",
"legal_rep_full_name": "Иванова Мария Сергеевна",
"legal_rep_phone": "+79991234567"
}
}
Реплика 6 · «Да, давайте посмотрим, что есть»
Решение роутера: new_booking.
Активная ветка / шаг: new_booking / present.
Срабатывает второй guard — на основании слота complaint (упоминание ухудшения слуха) промпт шага present должен предложить сурдолога перед ЛОРом. Это правило из вики и v3 раздел 3.2.
Ретривер: срез шага — wiki_sources: ['/wiki/services/**', '/wiki/doctors/**', '/wiki/preparation/**'].
- top-k: 3.
- Достались чанки:
services/приём-сурдолога.md(score 0.88) — описание услуги.services/первичный-приём-лор.md(score 0.74).doctors/общая-информация-сурдолог.md(score 0.65).
Структурированный ответ модели:
{
"reply": "С учётом того, что есть жалоба на ухудшение слуха на одно ухо, я бы предложила сначала записать Матвея к нашему сурдологу — он проведёт обследование слуха, и уже с его заключением ЛОР сможет дать более точное лечение. Если вы согласны — подберу слот к сурдологу. Или, если хотите, всё равно идём сразу к ЛОРу?",
"state_after": "present",
"slots_updated": {
"guard_surdologist_suggested": true
}
}
Шаг не меняется — ждём решение мамы. Флаг guard_surdologist_suggested нужен, чтобы при следующей реплике не предлагать сурдолога повторно, если она откажется.
Реплика 7 · «Да, давайте сначала к сурдологу»
Решение роутера: new_booking.
Активная ветка / шаг: new_booking / present → offer_time.
Структурированный ответ модели:
{
"reply": "Хорошо. Подбираю свободные слоты к сурдологу.",
"state_after": "offer_time",
"slots_updated": {
"service": "приём_сурдолога",
"doctor_preference": null
}
}
Шаг переходит в offer_time. Услуга в слотах теперь — приём сурдолога, не ЛОР.
Реплика 8 · crm.get_slots() + предложение слотов
(Аналогично 01_basic_booking.md репликам 5-6, без особенностей. Получаем 2 слота, мама выбирает один, идём в book → close.)
Реплика 9 · Закрытие — финальная сводка с детскими оговорками
Активная ветка / шаг: new_booking / close.
Срабатывает guard на шаге close: при is_child=true промпт обязан включить специальный блок с юридическими оговорками из вики. Берётся из legal/child-patient-final-script.md.
Ретривер: срез шага — wiki_sources: ['/wiki/contacts/**', '/wiki/preparation/**', '/wiki/legal/**'].
- Достались чанки:
legal/child-patient-final-script.md(score 0.95) — обязательный текст про сопровождение, паспорт + свидетельство о рождении, доверенность для других сопровождающих.contacts/branch-klary-tsetkin-9.md(score 0.81) — адрес, как доехать.preparation/ребёнок-первичный.md(score 0.71) — что взять с собой.
Структурированный ответ модели:
{
"reply": "Записала Матвея на четверг 30 апреля, 11:00, к сурдологу Кузнецовой Елене Игоревне, ул. Газеты Звезда, 31-А. Стоимость приёма — 2 100 рублей. Если в ходе приёма понадобится дополнительная диагностика, она оплачивается отдельно. Подходите за 5 минут до назначенного времени, при себе паспорт одного из родителей и свидетельство о рождении ребёнка. \n\nОбращаю внимание: на приём вместе с ребёнком должен прийти его законный представитель. Если в качестве сопровождающего лица будет кто-то другой (бабушка, дедушка), у него обязательно должен быть паспорт, доверенность от законного представителя, копия свидетельства о рождении и копия паспорта представителя. Форма доверенности — на сайте клиники.\n\nПодтверждаете?",
"state_after": "close",
"slots_updated": {
"time_chosen": "2026-04-30 11:00",
"doctor": "Кузнецова Е. И.",
"branch": "ул. Газеты Звезда, 31-А",
"booking_id": "B-1236"
}
}
Длинная финальная реплика с юридической оговоркой из ФЗ-323 — ровно так, как написано в скрипте записи в вике для детских пациентов.
Что показал этот пример
- Guard как изменение допустимых переходов. Шаг
qualifyимеет два пути: обычныйqualify → present(для взрослых,is_child=false) и через под-шагqualify.legal_rep → present(для детей). Валидатор переходов в коде должен это учитывать, иначе модель сможет «прыгнуть» черезlegal_repи переход состоится с пустыми слотами представителя. - Раннее срабатывание флага
is_child. Модель установила его уже на реплике 1, до получения имени. Это важно: если быis_childпоявился только на реплике 2, сбор данных пошёл бы в неправильном порядке (сначала жалоба, потом данные представителя — пациентка успела бы рассказать симптомы, прежде чем мы попросили бы документы). - Несколько guard'ов в одной ветке. На шаге
presentсработал второй guard (предложить сурдолога), на шагеclose— третий (юридический текст для детей). Они независимы и могут срабатывать в одном диалоге. - Ретривер на каждом шаге достаёт релевантный для guard'а контент. На
qualify.legal_rep— раздел/wiki/legal/, наclose— финальные юридические оговорки. Без правильногоwiki_sourcesна уровне шага модель должна была бы «помнить» юридический текст из системного промпта — это плохо масштабируется. - Возражение «а зачем вам это нужно?» обрабатывается в-line, без выхода из guard'а. На реплике 4 модель пояснила, оставаясь в
qualify.legal_rep. Это похоже на мягкую вставку из примера 02, но внутри одного и того же шага, не между ветками.
Что важно проверять в eval-наборе на этом примере
- Без legal_rep слотов переход
qualify.legal_rep → presentне должен срабатывать. Тест: подать в ветку модельный ответ сstate_after: "present"при пустыхlegal_rep_full_nameилиlegal_rep_phone→ валидатор должен отклонить переход, состояние остаётсяqualify.legal_rep, в логе предупреждение. is_child=trueустанавливается рано. Тест: фраза «запишите ребёнка», без других слов → проверить, чтоis_child=trueпоявляется вslots_updatedуже на первой реплике.- На шаге
closeдля ребёнка ответ обязан содержать упоминание свидетельства о рождении и доверенности. Тест: прогнать сценарий сis_child=true→ проверить, что финальныйreplyсодержит подстроки «свидетельство о рождении» и «доверенность». Это простая проверка подстрокой, не нужен LLM-as-judge. - Guard сурдолога не срабатывает повторно. Тест: после реплики, в которой бот предложил сурдолога, мама отказалась → проверить, что на следующей реплике бот не предлагает сурдолога снова (флаг
guard_surdologist_suggestedсделал своё дело).