Files
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

389 lines
18 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Пример 01 · Базовая запись к ЛОР-врачу (happy path)
> ⚠️ **Эта версия устарела.** Актуальная — [`01_basic_booking_v2.md`](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`](../architecture/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` записи ещё нет (или есть пустая):
```json
{
"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` — на приветствии модели нечего искать.
**Вызовы инструментов:** нет.
**Структурированный ответ модели:**
```json
{
"reply": "Здравствуйте! С удовольствием помогу вам записаться. Подскажите, пожалуйста, как могу к вам обращаться?",
"state_after": "intro",
"slots_updated": {
"service_mention": "ЛОР"
}
}
```
Замечание: шаг **не меняется** — мы остаёмся в `intro`, пока не получим имя пациента. Но один слот заполнили заранее: модель распарсила «к лору» и положила это в `service_mention`, чтобы потом не переспрашивать.
**`thread_state` после хода:**
```json
{
"thread_id": "T-9001",
"current_intent": "new_booking",
"current_step": "intro",
"slots": {
"service_mention": "ЛОР"
},
"handoff_count": 0
}
```
---
## Реплика 2 · «Анна Петровна»
**Решение роутера:** `new_booking`. Намерение не сменилось.
**Активная ветка / шаг:** `new_booking` / `intro`. Всё ещё ждём имя — и вот оно.
**Ретривер:** не вызывается.
**Вызовы инструментов:** нет.
**Структурированный ответ модели:**
```json
{
"reply": "Очень приятно, Анна Петровна. Записать вас на первичный приём к ЛОР-врачу? Что именно беспокоит — есть конкретные жалобы?",
"state_after": "qualify",
"slots_updated": {
"patient_name": "Анна Петровна"
}
}
```
Шаг переходит в `qualify` — имя получено, далее нужно уточнить услугу, врача, детали.
**`thread_state` после хода:**
```json
{
"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 не лезем).
**Структурированный ответ модели:**
```json
{
"reply": "Понимаю. На первичный приём ЛОР могу записать. Принимают двое: Сушков Михаил Германович и Иванов Сергей Петрович. Есть предпочтение, или подобрать ближайшее свободное время к любому?",
"state_after": "present",
"slots_updated": {
"service": "первичный_ЛОР",
"complaint": "боль в горле, неделя",
"is_child": false
}
}
```
Шаг переходит в `present` — услуга, жалоба, факт «не ребёнок» зафиксированы. Заметим: `is_child=false` модель определила сама из контекста (пациент назвался ФИО + жаловался от первого лица — взрослый), это **снимает срабатывание guard'а** для детской записи (см. `03_child_patient_guard.md`, где наоборот).
**`thread_state` после хода:**
```json
{
"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) — упоминание дополнительных процедур.
**Вызовы инструментов:** нет.
**Структурированный ответ модели:**
```json
{
"reply": "Хорошо. Первичный приём ЛОР занимает около 30 минут. Врач осмотрит, при необходимости проведёт эндоскопию (оплачивается отдельно). Готова посмотреть свободные слоты?",
"state_after": "offer_time",
"slots_updated": {
"doctor_preference": null
}
}
```
Шаг переходит в `offer_time`. Поле `doctor_preference: null` явно фиксируем — это «без разницы», а не «не уточнили».
**`thread_state` после хода:**
```json
{
"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`.
**Ретривер:** на этом шаге используется только для боковых вопросов, в основном потоке не вызывается.
**Вызовы инструментов:**
```python
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`.
**Структурированный ответ модели:**
```json
{
"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` после хода:**
```json
{
"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`.
**Ретривер:** не вызывается.
**Вызовы инструментов:**
```python
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` — финальное проговаривание).
**Структурированный ответ модели:**
```json
{
"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` после хода:**
```json
{
"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`.
**Ретривер:** не вызывается.
**Вызовы инструментов:** нет.
**Структурированный ответ модели:**
```json
{
"reply": "Отлично! Жду вас 28 апреля в 10:00. Если что-то изменится — напишите, поможем перенести. Хорошего дня!",
"state_after": "done",
"slots_updated": {
"confirmed": true
}
}
```
`done` — терминальное состояние. Тред не закрывается физически (пациент может вернуться), но активная машина состояний `new_booking` отработала.
**`thread_state` после хода:**
```json
{
"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` содержит ровно тот набор данных из скрипта вики: дата, врач, адрес, стоимость, что принести.