Спринт 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>
18 KiB
Пример 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содержит ровно тот набор данных из скрипта вики: дата, врач, адрес, стоимость, что принести.