Files
RAG_helper/docs/examples/01_basic_booking.md
T
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

18 KiB
Raw Blame History

Пример 01 · Базовая запись к ЛОР-врачу (happy path)

⚠️ Эта версия устарела. Актуальная — 01_basic_booking_v2.md. v1 был написан как архитектурный идеал и содержит вымышленные слоты (service_mention, patient_name, time_candidates, booking_id и др.) и CRM tool calls, которых в коде нет. v2 переписан под реальную таксономию слотов из промптов шагов и реальные имена полей thread_state. Список изменений — внизу v2 в Changelog.

Связано с GRAPH_ARCHITECTURE_v3.md, разделы 1, 2, 3. Демонстрирует линейный проход машины состояний (state machine) ветки new_booking: контакт → уточнение → презентация → слоты → запись → закрытие. Никаких guard'ов, никаких боковых вопросов, никаких переходов в другие ветки. Самый простой случай, на котором удобно увидеть базовое поведение системы.

О чём этот пример

Взрослый пациент звонит/пишет в чат, хочет записаться к ЛОР-врачу с жалобой на горло. Не уточняет конкретного врача, согласен на ближайший слот. Семь реплик, после которых запись создана в CRM.

Цель — показать на каждой реплике:

  • решение роутера (router) — какое намерение распознано;
  • активный шаг (current_step) — где мы сейчас в скрипте;
  • работу поисковика-ретривера (retriever) — какие чанки достаются и из каких разделов вики;
  • вызовы инструментов (tool calls) к CRM, если они есть;
  • структурированный ответ модели (structured output) — reply, state_after, slots_updated;
  • итоговое состояние треда (thread_state) — что записалось в БД.

Начальное состояние

Тред только что создан. В таблице thread_state записи ещё нет (или есть пустая):

{
  "thread_id": "T-9001",
  "current_intent": null,
  "current_step": null,
  "slots": {},
  "handoff_count": 0,
  "suspended_intent": null,
  "resumable_step": null,
  "resumable_slots": null
}

Реплика 1 · «Здравствуйте, хочу записаться к лору»

Решение роутера: new_booking. Однозначное упоминание записи + специальности.

Активная ветка / шаг: new_booking / intro. Шаг по умолчанию для свежей ветки.

Ретривер: не вызывается. Шаг intro имеет пустой wiki_sources — на приветствии модели нечего искать.

Вызовы инструментов: нет.

Структурированный ответ модели:

{
  "reply": "Здравствуйте! С удовольствием помогу вам записаться. Подскажите, пожалуйста, как могу к вам обращаться?",
  "state_after": "intro",
  "slots_updated": {
    "service_mention": "ЛОР"
  }
}

Замечание: шаг не меняется — мы остаёмся в intro, пока не получим имя пациента. Но один слот заполнили заранее: модель распарсила «к лору» и положила это в service_mention, чтобы потом не переспрашивать.

thread_state после хода:

{
  "thread_id": "T-9001",
  "current_intent": "new_booking",
  "current_step": "intro",
  "slots": {
    "service_mention": "ЛОР"
  },
  "handoff_count": 0
}

Реплика 2 · «Анна Петровна»

Решение роутера: new_booking. Намерение не сменилось.

Активная ветка / шаг: new_booking / intro. Всё ещё ждём имя — и вот оно.

Ретривер: не вызывается.

Вызовы инструментов: нет.

Структурированный ответ модели:

{
  "reply": "Очень приятно, Анна Петровна. Записать вас на первичный приём к ЛОР-врачу? Что именно беспокоит — есть конкретные жалобы?",
  "state_after": "qualify",
  "slots_updated": {
    "patient_name": "Анна Петровна"
  }
}

Шаг переходит в qualify — имя получено, далее нужно уточнить услугу, врача, детали.

thread_state после хода:

{
  "current_intent": "new_booking",
  "current_step": "qualify",
  "slots": {
    "service_mention": "ЛОР",
    "patient_name": "Анна Петровна"
  }
}

Реплика 3 · «Да, болит горло уже неделю»

Решение роутера: new_booking. Жалоба упомянута, но в контексте записи — не самостоятельный медицинский вопрос. Роутер не сворачивает в medical_question, потому что пациент уже на маршруте записи.

Активная ветка / шаг: new_booking / qualify.

Ретривер: срез на уровне шага — wiki_sources: ['/wiki/services/**', '/wiki/doctors/**'].

  • top-k: 3.
  • Достались чанки:
    • services/первичный-приём-лор.md (score 0.87) — описание услуги, длительность, что входит.
    • doctors/сушков-михаил-германович.md (score 0.71) — карточка врача.
    • doctors/иванов-сергей-петрович.md (score 0.69) — карточка второго ЛОР.

Вызовы инструментов: нет (на этом шаге слоты ещё собираем, в CRM не лезем).

Структурированный ответ модели:

{
  "reply": "Понимаю. На первичный приём ЛОР могу записать. Принимают двое: Сушков Михаил Германович и Иванов Сергей Петрович. Есть предпочтение, или подобрать ближайшее свободное время к любому?",
  "state_after": "present",
  "slots_updated": {
    "service": "первичный_ЛОР",
    "complaint": "боль в горле, неделя",
    "is_child": false
  }
}

Шаг переходит в present — услуга, жалоба, факт «не ребёнок» зафиксированы. Заметим: is_child=false модель определила сама из контекста (пациент назвался ФИО + жаловался от первого лица — взрослый), это снимает срабатывание guard'а для детской записи (см. 03_child_patient_guard.md, где наоборот).

thread_state после хода:

{
  "current_intent": "new_booking",
  "current_step": "present",
  "slots": {
    "service_mention": "ЛОР",
    "patient_name": "Анна Петровна",
    "service": "первичный_ЛОР",
    "complaint": "боль в горле, неделя",
    "is_child": false
  }
}

Реплика 4 · «Без разницы, что есть»

Решение роутера: new_booking.

Активная ветка / шаг: new_booking / present.

Ретривер: срез шага — ['/wiki/services/**', '/wiki/doctors/**', '/wiki/preparation/**'].

  • top-k: 3.
  • Достались чанки:
    • services/первичный-приём-лор.md — длительность, что входит, что оплачивается отдельно.
    • preparation/лор-первичный.md (score 0.74) — что принести, как подготовиться.
    • services/эндоскопия-лор.md (score 0.63) — упоминание дополнительных процедур.

Вызовы инструментов: нет.

Структурированный ответ модели:

{
  "reply": "Хорошо. Первичный приём ЛОР занимает около 30 минут. Врач осмотрит, при необходимости проведёт эндоскопию (оплачивается отдельно). Готова посмотреть свободные слоты?",
  "state_after": "offer_time",
  "slots_updated": {
    "doctor_preference": null
  }
}

Шаг переходит в offer_time. Поле doctor_preference: null явно фиксируем — это «без разницы», а не «не уточнили».

thread_state после хода:

{
  "current_intent": "new_booking",
  "current_step": "offer_time",
  "slots": {
    "patient_name": "Анна Петровна",
    "service": "первичный_ЛОР",
    "complaint": "боль в горле, неделя",
    "is_child": false,
    "doctor_preference": null
  }
}

Реплика 5 · «Да, посмотрите»

Решение роутера: new_booking.

Активная ветка / шаг: new_booking / offer_time.

Ретривер: на этом шаге используется только для боковых вопросов, в основном потоке не вызывается.

Вызовы инструментов:

crm.get_slots(
    service_code="primary_lor",
    doctor=None,
    days_ahead=7
)
# → [
#   {"datetime": "2026-04-28 10:00", "doctor": "Сушков М. Г.", "branch": "ул. Клары Цеткин, 9"},
#   {"datetime": "2026-04-28 15:00", "doctor": "Сушков М. Г.", "branch": "ул. Клары Цеткин, 9"}
# ]

Согласно скрипту записи в вике — предлагаем ровно два слота, не больше. Это правило зашито в промпт шага offer_time.

Структурированный ответ модели:

{
  "reply": "Есть свободно вторник 28 апреля: 10:00 или 15:00 — оба слота к Сушкову Михаилу Германовичу, ул. Клары Цеткин, 9. Какое удобнее?",
  "state_after": "offer_time",
  "slots_updated": {
    "time_candidates": ["2026-04-28 10:00", "2026-04-28 15:00"],
    "doctor": "Сушков М. Г.",
    "branch": "ул. Клары Цеткин, 9"
  }
}

Шаг не меняется — слоты предложили, но выбора пока нет, ждём.

thread_state после хода:

{
  "current_intent": "new_booking",
  "current_step": "offer_time",
  "slots": {
    "patient_name": "Анна Петровна",
    "service": "первичный_ЛОР",
    "complaint": "боль в горле, неделя",
    "is_child": false,
    "doctor_preference": null,
    "time_candidates": ["2026-04-28 10:00", "2026-04-28 15:00"],
    "doctor": "Сушков М. Г.",
    "branch": "ул. Клары Цеткин, 9"
  }
}

Реплика 6 · «10 утра, давайте»

Решение роутера: new_booking.

Активная ветка / шаг: new_booking / offer_time. Получили выбор времени → допустим переход offer_time → book.

Ретривер: не вызывается.

Вызовы инструментов:

crm.create_booking(
    patient_name="Анна Петровна",
    service_code="primary_lor",
    doctor="Сушков М. Г.",
    datetime="2026-04-28 10:00",
    branch="ул. Клары Цеткин, 9"
)
# → {"booking_id": "B-1234", "status": "confirmed"}

Модель в одном ходе проходит book, и сразу за ним — close (это легальный двойной переход, потому что book — это «вызови инструмент» без отдельной реплики пациенту, а close — финальное проговаривание).

Структурированный ответ модели:

{
  "reply": "Записала вас на вторник 28 апреля, 10:00, к ЛОР-врачу Сушкову Михаилу Германовичу, ул. Клары Цеткин, 9. Стоимость первичного приёма — 2 300 рублей. Эндоскопия, лечебные процедуры, забор анализов оплачиваются дополнительно. Подходите за 5 минут до приёма, при себе иметь паспорт. Подтверждаете?",
  "state_after": "close",
  "slots_updated": {
    "time_chosen": "2026-04-28 10:00",
    "booking_id": "B-1234"
  }
}

Шаг переходит в close. На этом шаге ретривер уже доставал бы wiki_sources ['/wiki/contacts/**', '/wiki/preparation/**'], но в этом конкретном ответе модель использовала чанки, оставшиеся в контексте с предыдущих шагов (стоимость уже была в карточке услуги, требования к документам — в preparation).

thread_state после хода:

{
  "current_intent": "new_booking",
  "current_step": "close",
  "slots": {
    "patient_name": "Анна Петровна",
    "service": "первичный_ЛОР",
    "complaint": "боль в горле, неделя",
    "is_child": false,
    "doctor_preference": null,
    "time_candidates": ["2026-04-28 10:00", "2026-04-28 15:00"],
    "doctor": "Сушков М. Г.",
    "branch": "ул. Клары Цеткин, 9",
    "time_chosen": "2026-04-28 10:00",
    "booking_id": "B-1234"
  }
}

Реплика 7 · «Да, подтверждаю. Спасибо!»

Решение роутера: new_booking (или general_info — реплика короткая, но контекст ясен).

Активная ветка / шаг: new_booking / close.

Ретривер: не вызывается.

Вызовы инструментов: нет.

Структурированный ответ модели:

{
  "reply": "Отлично! Жду вас 28 апреля в 10:00. Если что-то изменится — напишите, поможем перенести. Хорошего дня!",
  "state_after": "done",
  "slots_updated": {
    "confirmed": true
  }
}

done — терминальное состояние. Тред не закрывается физически (пациент может вернуться), но активная машина состояний new_booking отработала.

thread_state после хода:

{
  "current_intent": "new_booking",
  "current_step": "done",
  "slots": {
    "...все поля выше...": "...",
    "confirmed": true
  }
}

Что показал этот пример

  • Линейный проход машины состояний. Шаги шли строго в порядке intro → qualify → present → offer_time → book → close → done. Двойной переход в реплике 6 (offer_time → book → close) — допустимый случай, потому что book — технический шаг с вызовом инструмента, не требующий отдельной реплики пациенту.
  • Слоты накапливаются. На каждом ходе slots_updated содержит только новые/изменённые поля, а в thread_state.slots они мерджатся с предыдущим состоянием. Старые значения не теряются.
  • Ретривер использует срез шага. Один и тот же тред на разных шагах достаёт разные документы вики: на qualify — каталог услуг и врачей, на close — контакты и подготовку.
  • Намерение остаётся одним. Роутер на каждой реплике подтверждал new_booking. В этом примере смены намерения нет — для случаев, где она происходит, см. 02_price_during_booking.md.
  • Guard'ы не сработали. Пациент — взрослый (is_child=false), врача не выбирал — ушли по основному пути. Случай со срабатыванием guard'а — в 03_child_patient_guard.md.

Что важно проверять в eval-наборе на этом примере

  • Все шаги машины состояний были пройдены в правильном порядке (логи state_after на каждом ходе).
  • В slots к моменту шага book были заполнены: patient_name, service, doctor, time_chosen. Без любого из этих полей crm.create_booking не должен вызываться (валидатор перехода).
  • Ответ на шаге close содержит ровно тот набор данных из скрипта вики: дата, врач, адрес, стоимость, что принести.