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:
AR 15 M4
2026-04-27 20:00:44 +05:00
parent f348570b1b
commit 52b46bc53e
43 changed files with 5914 additions and 105 deletions
+335
View File
@@ -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 записи без защитных условий и переключений.