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>
This commit is contained in:
@@ -0,0 +1,335 @@
|
||||
# Пример 01 v2 · Базовая запись к ЛОР-врачу (happy path)
|
||||
|
||||
> **Версия v2 · 2026-04-27.** Переписано под реальный код: имена полей `thread_state` соответствуют `db/models/thread_state.py`, набор слотов — реальной таксономии из `prompts/intents/new_booking/steps/*.md`. Изменения относительно v1 — внизу в Changelog.
|
||||
>
|
||||
> Связано с [`../architecture/GRAPH_ARCHITECTURE_v4.md`](../architecture/GRAPH_ARCHITECTURE_v4.md), разделы 1–3. Демонстрирует **линейный** проход машины состояний ветки `new_booking`: `intro → qualify → present → offer_time → book → close`. Никаких защитных условий, никаких боковых вопросов, никаких переключений в другие ветки.
|
||||
|
||||
## О чём этот пример
|
||||
|
||||
Взрослый пациент пишет в чат, хочет записаться к ЛОР-врачу с жалобой на горло. Не уточняет конкретного врача, согласен на любое удобное время. Семь реплик, после которых ветка `new_booking` доходит до шага `close` и передаёт результат администратору клиники.
|
||||
|
||||
На каждой реплике показано:
|
||||
- **решение маршрутизатора** (router) — какое намерение распознано;
|
||||
- **активная ветка / шаг** (`current_intent_code` / `current_step_code`) — где мы сейчас;
|
||||
- **структурированный ответ модели** (хвостовой блок `STATE_JSON:`) — `state_after` и `slots_updated`;
|
||||
- **итоговое состояние треда** (`thread_state`) — что после хода легло в БД.
|
||||
|
||||
CRM-инструменты (`crm.get_slots`, `crm.create_booking`) и реальный календарь врачей **в этом коде ещё не подключены** — на шаге `book` модель только проговаривает собранные данные и получает явное «да», а на `close` сообщает, что администратор свяжется в течение дня. Реальная интеграция — в идеях на потом (см. `docs/SPRINTS.md`).
|
||||
|
||||
## Начальное состояние
|
||||
|
||||
Тред только что создан. Записи в `thread_state` ещё нет — она появится после первого ответа ассистента. Логически это эквивалентно:
|
||||
|
||||
```json
|
||||
{
|
||||
"thread_id": 9001,
|
||||
"current_intent_code": null,
|
||||
"current_step": 0,
|
||||
"current_step_code": null,
|
||||
"slots": {},
|
||||
"handoff_count": 0,
|
||||
"soft_insertion_count": 0,
|
||||
"suspended_intent": null,
|
||||
"resumable_step_code": null,
|
||||
"resumable_slots": null
|
||||
}
|
||||
```
|
||||
|
||||
> Примечание: `slots` и `resumable_slots` хранятся в БД как текстовые колонки `slots_json` / `resumable_slots_json`. В API распакованы в объекты — так и показываем во всех `thread_state` ниже.
|
||||
|
||||
---
|
||||
|
||||
## Реплика 1 · «Здравствуйте, хочу записаться к лору»
|
||||
|
||||
**Решение маршрутизатора:** `new_booking`. Однозначное упоминание записи + специальности.
|
||||
|
||||
**Активная ветка / шаг:** `new_booking` / `intro`. Шаг по умолчанию для свежей пошаговой ветки.
|
||||
|
||||
**Промпт шага `intro`** (`prompts/intents/new_booking/steps/intro.md`): представься, узнай имя пациента; не уточняй сразу повод, специальность, время. Слоты этого шага: `name`.
|
||||
|
||||
**Структурированный ответ модели:**
|
||||
|
||||
```
|
||||
Здравствуйте! Я виртуальный ассистент клиники, помогу вам записаться. Подскажите, как могу к вам обращаться?
|
||||
|
||||
STATE_JSON: {"state_after": "intro", "slots_updated": {}}
|
||||
```
|
||||
|
||||
Шаг **не меняется** — мы остаёмся в `intro`, пока не получили имя. Слоты пустые: специальность «лор» пациент назвал, но `intro.md` явно запрещает её фиксировать на этом шаге — это работа `qualify`.
|
||||
|
||||
**`thread_state` после хода:**
|
||||
|
||||
```json
|
||||
{
|
||||
"current_intent_code": "new_booking",
|
||||
"current_step": 0,
|
||||
"current_step_code": "intro",
|
||||
"slots": {},
|
||||
"handoff_count": 0,
|
||||
"soft_insertion_count": 0
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Реплика 2 · «Анна Петровна»
|
||||
|
||||
**Решение маршрутизатора:** `new_booking`. Намерение не сменилось.
|
||||
|
||||
**Активная ветка / шаг:** `new_booking` / `intro`. Имя получено — переход в `qualify` разрешён (`allowed_next: ["intro", "qualify"]`).
|
||||
|
||||
**Структурированный ответ модели:**
|
||||
|
||||
```
|
||||
Очень приятно, Анна Петровна. Подскажите, что вас беспокоит — чтобы я подобрал подходящего специалиста?
|
||||
|
||||
STATE_JSON: {"state_after": "qualify", "slots_updated": {"name": "Анна Петровна"}}
|
||||
```
|
||||
|
||||
Шаг переходит в `qualify`. Числовой счётчик `current_step` инкрементируется (это монотонный счётчик хода, нужен для совместимости с историей версий схемы; для логики используется `current_step_code`).
|
||||
|
||||
**`thread_state` после хода:**
|
||||
|
||||
```json
|
||||
{
|
||||
"current_intent_code": "new_booking",
|
||||
"current_step": 1,
|
||||
"current_step_code": "qualify",
|
||||
"slots": {
|
||||
"name": "Анна Петровна"
|
||||
},
|
||||
"handoff_count": 0,
|
||||
"soft_insertion_count": 0
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Реплика 3 · «Болит горло уже неделю»
|
||||
|
||||
**Решение маршрутизатора:** `new_booking`. Жалоба упомянута, но в контексте записи — не самостоятельный медицинский вопрос. По промпту `qualify.md` модель **не уходит** в `medical_question` по одному факту жалобы — это повод для записи, а не для обсуждения симптомов.
|
||||
|
||||
**Активная ветка / шаг:** `new_booking` / `qualify`.
|
||||
|
||||
**Промпт шага `qualify`:** записать жалобу в `reason`, выразить короткое сочувствие, при отсутствии специалиста — мягко предложить направление по поводу.
|
||||
|
||||
**Структурированный ответ модели:**
|
||||
|
||||
```
|
||||
Понимаю, боль в горле — это неприятно. С такой жалобой обычно записывают к ЛОР-врачу. Подходит?
|
||||
|
||||
STATE_JSON: {"state_after": "qualify", "slots_updated": {"reason": "боль в горле, неделю"}}
|
||||
```
|
||||
|
||||
Шаг **не меняется** — `specialist` ещё не зафиксирован, ждём подтверждения. Слот `is_child` модель не выставляет (пациент назвал ФИО, говорит от первого лица — взрослый).
|
||||
|
||||
**`thread_state` после хода:**
|
||||
|
||||
```json
|
||||
{
|
||||
"current_intent_code": "new_booking",
|
||||
"current_step": 1,
|
||||
"current_step_code": "qualify",
|
||||
"slots": {
|
||||
"name": "Анна Петровна",
|
||||
"reason": "боль в горле, неделю"
|
||||
},
|
||||
"handoff_count": 0,
|
||||
"soft_insertion_count": 0
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Реплика 4 · «Да, к ЛОРу»
|
||||
|
||||
**Решение маршрутизатора:** `new_booking`.
|
||||
|
||||
**Активная ветка / шаг:** `new_booking` / `qualify`. Известны `name`, `reason`, `specialist` — можно переходить в `present` (`allowed_next: ["qualify", "present"]`).
|
||||
|
||||
**Структурированный ответ модели:**
|
||||
|
||||
```
|
||||
Хорошо. Записываю вас к ЛОР-врачу — на приёме доктор осмотрит вас и обратит внимание на боль в горле.
|
||||
|
||||
STATE_JSON: {"state_after": "present", "slots_updated": {"specialist": "ЛОР"}}
|
||||
```
|
||||
|
||||
Шаг переходит в `present`. На самом шаге `present` (`prompts/intents/new_booking/steps/present.md`) новые слоты не собираются — модель использует уже известные `name`, `specialist`, `reason`, чтобы пациент почувствовал, что его услышали.
|
||||
|
||||
**`thread_state` после хода:**
|
||||
|
||||
```json
|
||||
{
|
||||
"current_intent_code": "new_booking",
|
||||
"current_step": 2,
|
||||
"current_step_code": "present",
|
||||
"slots": {
|
||||
"name": "Анна Петровна",
|
||||
"reason": "боль в горле, неделю",
|
||||
"specialist": "ЛОР"
|
||||
},
|
||||
"handoff_count": 0,
|
||||
"soft_insertion_count": 0
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Реплика 5 · «Хорошо, давайте»
|
||||
|
||||
**Решение маршрутизатора:** `new_booking`.
|
||||
|
||||
**Активная ветка / шаг:** `new_booking` / `present`. Согласие пациента → переход в `offer_time` (`allowed_next: ["present", "qualify", "offer_time"]`).
|
||||
|
||||
**Промпт шага `offer_time`:** спросить про удобные дни и часы, зафиксировать в `preferred_time`. Реального календаря нет — конкретные слоты не предлагаем.
|
||||
|
||||
**Структурированный ответ модели:**
|
||||
|
||||
```
|
||||
Отлично. Подскажите, в какое время вам удобнее — утро, день или вечер? Будни или выходные?
|
||||
|
||||
STATE_JSON: {"state_after": "offer_time", "slots_updated": {}}
|
||||
```
|
||||
|
||||
Шаг переходит в `offer_time`. Слотов на этом ходе не добавляется — ждём ответа пациента.
|
||||
|
||||
**`thread_state` после хода:**
|
||||
|
||||
```json
|
||||
{
|
||||
"current_intent_code": "new_booking",
|
||||
"current_step": 3,
|
||||
"current_step_code": "offer_time",
|
||||
"slots": {
|
||||
"name": "Анна Петровна",
|
||||
"reason": "боль в горле, неделю",
|
||||
"specialist": "ЛОР"
|
||||
},
|
||||
"handoff_count": 0,
|
||||
"soft_insertion_count": 0
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Реплика 6 · «Удобнее утром в будни»
|
||||
|
||||
**Решение маршрутизатора:** `new_booking`.
|
||||
|
||||
**Активная ветка / шаг:** `new_booking` / `offer_time`. Получили `preferred_time` → переход в `book` (`allowed_next: ["offer_time", "book"]`).
|
||||
|
||||
**Промпт шага `book`:** проговорить пациенту собранные данные (3–4 поля) и получить явное «да».
|
||||
|
||||
**Структурированный ответ модели:**
|
||||
|
||||
```
|
||||
Записываю: Анна Петровна, к ЛОР-врачу, повод — боль в горле, удобное время — утром в будни. Передам администратору клиники, он подберёт конкретное окно и подтвердит. Всё верно?
|
||||
|
||||
STATE_JSON: {"state_after": "book", "slots_updated": {"preferred_time": "утром в будни"}}
|
||||
```
|
||||
|
||||
Шаг переходит в `book`. Никаких CRM tool calls на этом шаге **сейчас не делается** — реальная интеграция с расписанием врачей пока не подключена.
|
||||
|
||||
**`thread_state` после хода:**
|
||||
|
||||
```json
|
||||
{
|
||||
"current_intent_code": "new_booking",
|
||||
"current_step": 4,
|
||||
"current_step_code": "book",
|
||||
"slots": {
|
||||
"name": "Анна Петровна",
|
||||
"reason": "боль в горле, неделю",
|
||||
"specialist": "ЛОР",
|
||||
"preferred_time": "утром в будни"
|
||||
},
|
||||
"handoff_count": 0,
|
||||
"soft_insertion_count": 0
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Реплика 7 · «Да, всё верно»
|
||||
|
||||
**Решение маршрутизатора:** `new_booking`.
|
||||
|
||||
**Активная ветка / шаг:** `new_booking` / `book`. Явное «да» → можно переходить в `close` (`allowed_next: ["book", "qualify", "offer_time", "close"]`).
|
||||
|
||||
**Промпт шага `close`:** короткое подтверждение, не задавать новых вопросов.
|
||||
|
||||
**Структурированный ответ модели:**
|
||||
|
||||
```
|
||||
Готово, Анна Петровна. Передаю администратору, он свяжется с вами в течение дня и подтвердит точное время. Спасибо за обращение!
|
||||
|
||||
STATE_JSON: {"state_after": "close", "slots_updated": {"confirmed": true}}
|
||||
```
|
||||
|
||||
Шаг переходит в `close`. Это финальный шаг ветки — `allowed_next: ["close"]`, дальше идти некуда. Тред физически не закрывается; если пациент напишет что-то ещё, маршрутизатор разберёт реплику и при необходимости откроет новую ветку (`general_info`, `reschedule` и т. п.).
|
||||
|
||||
**`thread_state` после хода:**
|
||||
|
||||
```json
|
||||
{
|
||||
"current_intent_code": "new_booking",
|
||||
"current_step": 5,
|
||||
"current_step_code": "close",
|
||||
"slots": {
|
||||
"name": "Анна Петровна",
|
||||
"reason": "боль в горле, неделю",
|
||||
"specialist": "ЛОР",
|
||||
"preferred_time": "утром в будни",
|
||||
"confirmed": true
|
||||
},
|
||||
"handoff_count": 0,
|
||||
"soft_insertion_count": 0
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Что показал этот пример
|
||||
|
||||
- **Линейный проход машины состояний.** Шаги шли строго в порядке `intro → qualify → present → offer_time → book → close`. Каждая реплика пациента = ровно один переход (или удержание на текущем шаге). Двойных переходов в одном ответе нет — `allowed_next` каждого шага это запрещает.
|
||||
- **Слоты накапливаются.** На каждом ходе `slots_updated` содержит только новые/изменённые поля, и они мерджатся с предыдущим состоянием в `thread_state.slots`. Старые значения не теряются.
|
||||
- **Маршрутизатор подтверждает ту же ветку.** На каждой реплике решение маршрутизатора совпадало с активной веткой `new_booking` — в Песочнице бейдж «решение маршрутизатора» был зелёным.
|
||||
- **Защитные условия не сработали.** Слот `is_child` отсутствует (пациент — взрослый) → guard `require_legal_rep` неактивен. Случай со срабатыванием — в `03_child_patient_guard_v2.md`.
|
||||
- **Никаких реальных tool calls.** На шагах `book` и `close` модель только проговаривает собранные данные, никаких записей в CRM или календарь — этой интеграции в коде ещё нет.
|
||||
|
||||
## Что важно проверять в eval-наборе на этом примере
|
||||
|
||||
- Все шаги машины состояний пройдены в правильном порядке (логи `state_after` на каждом ходе).
|
||||
- К моменту шага `book` в `slots` заполнены `name`, `reason`, `specialist`, `preferred_time`. Без любого из этих полей переход в `book` не должен случаться (валидатор `allowed_next` его пропустит, но модель не должна стремиться туда без данных).
|
||||
- Слот `confirmed: true` появляется только на шаге `close` — это маркер успешно завершённой записи.
|
||||
- На реплике 3 («Болит горло уже неделю») маршрутизатор НЕ уходит в `medical_question` — жалоба в контексте записи это не самостоятельный медицинский вопрос. Это типовая ловушка для роутера.
|
||||
|
||||
---
|
||||
|
||||
## Changelog
|
||||
|
||||
### v2 → 2026-04-27
|
||||
|
||||
**Имена полей `thread_state`** приведены к реальной БД (`db/models/thread_state.py`):
|
||||
- `current_intent` → `current_intent_code`
|
||||
- `current_step` (как строка) → `current_step_code`. Числовое `current_step` (счётчик хода) показано отдельно, как оно реально хранится в БД.
|
||||
- Добавлен `soft_insertion_count` (был и в v1 неявно, теперь показан явно во всех `thread_state`).
|
||||
- `slots` и `resumable_slots` показаны как объекты с пометкой, что в БД они хранятся текстовыми колонками `slots_json` / `resumable_slots_json`.
|
||||
- `thread_id` показан как `int` (было `str` «T-9001»).
|
||||
|
||||
**Слоты приведены к реальной таксономии** из `prompts/intents/new_booking/steps/*.md`:
|
||||
- Удалены вымышленные `service_mention`, `patient_name`, `service`, `complaint`, `doctor_preference`, `time_candidates`, `time_chosen`, `branch`, `booking_id`.
|
||||
- Используются только реальные: `name`, `reason`, `specialist`, `is_child` (опционально), `preferred_time`, `confirmed`.
|
||||
|
||||
**Структурированный ответ модели** показан в реальном формате (`STATE_JSON:` в хвосте текста, который парсер вырезает), а не как отдельный JSON-объект.
|
||||
|
||||
**Сценарий перестроен под реальные `allowed_next`:**
|
||||
- В v1 на реплике 6 была двойная склейка `offer_time → book → close` в одном ответе — это невозможно, модель выбирает один `state_after`. Теперь шаги идут по одному за реплику.
|
||||
- Реплик стало 7 (вместо 7), но сценарий другой: добавлена отдельная реплика про согласие с планом (`present → offer_time`).
|
||||
|
||||
**Удалены CRM tool calls** (`crm.get_slots`, `crm.create_booking`) и связанные с ними `branch`, `booking_id` — этой интеграции в коде нет, в v1 они были как иллюстрация будущего. Переехало в идеи на потом в `SPRINTS.md`.
|
||||
|
||||
**Терминология:** «роутер» → «маршрутизатор», «решение роутера» → «решение маршрутизатора» — выровнено со словарём в `static/docs.html`.
|
||||
|
||||
**Содержательно** (что показывает пример) — то же: линейный happy path записи без защитных условий и переключений.
|
||||
Reference in New Issue
Block a user