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
+315
View File
@@ -0,0 +1,315 @@
# Пример 03 v2 · Запись ребёнка — защитное условие в шаге уточнения
> **Версия v2 · 2026-04-27.** Переписано под реальный код. В коде есть **только одно защитное условие** — `require_legal_rep` на шаге `qualify` (см. `services/intent_step_service.py`, `SEED_INTENT_STEPS`). Никаких guard'ов на `present` (сурдолог) и `close` (юридическая оговорка) в коде нет — в v1 они были как иллюстрация будущего, в реальности это просто инструкции в промпте `qualify.md`. Список изменений — внизу в Changelog.
>
> Связано с [`../architecture/GRAPH_ARCHITECTURE_v4.md`](../architecture/GRAPH_ARCHITECTURE_v4.md), разделы 3.2, 3.3. Демонстрирует **защитное условие** (guard) внутри шага `qualify` ветки `new_booking`. Когда модель определяет, что пациент — ребёнок (`is_child=true`), переход в `present` блокируется, пока не заполнены `legal_rep_name` и `legal_rep_phone`. Также показано, как валидатор переходов отклоняет ход модели, если она попыталась двинуться без выполнения защитного условия.
## О чём этот пример
Мама записывает 8-летнего сына к ЛОР-врачу. Скрипт записи в вики клиники (см. `prompts/intents/new_booking/steps/qualify.md`) формализует это как обязательный сбор ФИО и контактного телефона законного представителя.
В терминах архитектуры это означает: на шаге `qualify` стоит **защитное условие** `require_legal_rep` со следующей семантикой:
```json
{
"require_legal_rep": {
"description": "Для записи ребёнка нужны ФИО и телефон законного представителя",
"trigger_slot": "is_child",
"trigger_value": true,
"required_slots": ["legal_rep_name", "legal_rep_phone"]
}
}
```
Пока `is_child == true` и в `slots` нет `legal_rep_name` или `legal_rep_phone`, валидатор отклоняет любой переход с шага `qualify` (в том числе на `present`). Модель остаётся на `qualify`.
## Начальное состояние
```json
{
"thread_id": 9003,
"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
}
```
---
## Реплика 1 · «Здравствуйте, хочу записать ребёнка к лору»
**Решение маршрутизатора:** `new_booking`. Упоминание «ребёнка» само по себе не сигнал эскалации — это норма для записи.
**Активная ветка / шаг:** `new_booking` / `intro`.
**Структурированный ответ модели:**
```
Здравствуйте! Помогу записать ребёнка. Подскажите, как могу к вам обращаться?
STATE_JSON: {"state_after": "intro", "slots_updated": {}}
```
Шаг не меняется — ждём имя того, с кем разговариваем.
**`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`.
**Структурированный ответ модели:**
```
Очень приятно, Мария. Что беспокоит ребёнка? И сколько ему лет?
STATE_JSON: {"state_after": "qualify", "slots_updated": {"name": "Мария"}}
```
Шаг переходит в `qualify`. Слот `name` — это обращение к собеседнику в чате (к маме). Имя ребёнка отдельным слотом не сохраняется — оно нужно только в финальном проговаривании, и модель использует его прямо из истории сообщений.
**`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 · «Сыну 8 лет, болят уши»
**Решение маршрутизатора:** `new_booking`. Жалоба упомянута, но в контексте записи — не самостоятельный медицинский вопрос. Маршрутизатор не уходит в `medical_question`.
**Активная ветка / шаг:** `new_booking` / `qualify`.
**Промпт `qualify.md`** прямо предусматривает «особую ситуацию 1: запись ребёнка»: при упоминании «сын/дочка», «ребёнок», возраст — модель фиксирует `is_child: true` и переходит к сбору данных представителя.
**Структурированный ответ модели:**
```
Понимаю, боль в ушах у ребёнка — это всегда тревожно. С такой жалобой обычно записывают к ЛОР-врачу — подходит? И, чтобы оформить запись, нужны ФИО и контактный телефон родителя или опекуна — подскажете?
STATE_JSON: {"state_after": "qualify", "slots_updated": {"is_child": true, "reason": "боль в ушах у ребёнка"}}
```
Шаг **не меняется**`specialist` ещё не подтверждён, и данные представителя не собраны. Слот `is_child: true` теперь активирует защитное условие `require_legal_rep`: с этого момента переход `qualify → present` запрещён, пока в `slots` не появятся `legal_rep_name` и `legal_rep_phone`.
**`thread_state` после хода:**
```json
{
"current_intent_code": "new_booking",
"current_step": 1,
"current_step_code": "qualify",
"slots": {
"name": "Мария",
"is_child": true,
"reason": "боль в ушах у ребёнка"
},
"handoff_count": 0,
"soft_insertion_count": 0
}
```
В Песочнице на этом ответе в правой панели «Состояние диалога» появится индикатор: **«🔒 защитное условие активно: `require_legal_rep` — ждём заполнения: `legal_rep_name`, `legal_rep_phone`»** (жёлтый блок). Пациент его не видит — это сигнал оператору.
---
## Реплика 4 · «А зачем вам ФИО и телефон родителя?»
Возражение мамы. Из `qualify.md` модель должна спокойно объяснить.
**Решение маршрутизатора:** `new_booking`.
**Активная ветка / шаг:** `new_booking` / `qualify`. Защитное условие всё ещё активно.
**Структурированный ответ модели:**
```
Это стандартная практика: при записи ребёнка нам важно иметь возможность связаться с родителем — например, если на приёме потребуется согласие на процедуру. Это требование клиники для безопасности ребёнка. Подскажете ФИО и телефон?
STATE_JSON: {"state_after": "qualify", "slots_updated": {}}
```
Шаг и слоты не меняются — защитное условие всё ещё блокирует переход.
**`thread_state` после хода:** идентично состоянию до хода.
---
## Реплика 5 · «Хорошо. Иванова Мария Сергеевна, +79991234567»
**Решение маршрутизатора:** `new_booking`.
**Активная ветка / шаг:** `new_booking` / `qualify`. Получены данные представителя → защитное условие выполнено.
**Структурированный ответ модели:**
```
Спасибо, записываю. Подходит ЛОР-врач?
STATE_JSON: {"state_after": "qualify", "slots_updated": {"legal_rep_name": "Иванова Мария Сергеевна", "legal_rep_phone": "+79991234567"}}
```
Слоты заполнены, защитное условие выполнено, но модель ещё не закрыла вопрос со специалистом — поэтому остаётся на `qualify`. Это корректное поведение: даже когда guard сейчас выполнен, **переход в `present` логически нужен только когда есть и `specialist`** (промпт ветки требует все три: `name`, `reason`, `specialist`).
**`thread_state` после хода:**
```json
{
"current_intent_code": "new_booking",
"current_step": 1,
"current_step_code": "qualify",
"slots": {
"name": "Мария",
"is_child": true,
"reason": "боль в ушах у ребёнка",
"legal_rep_name": "Иванова Мария Сергеевна",
"legal_rep_phone": "+79991234567"
},
"handoff_count": 0,
"soft_insertion_count": 0
}
```
В Песочнице индикатор «🔒 защитное условие активно» **исчезает** — guard выполнен.
---
## Реплика 6 · «Да, ЛОР подходит»
**Решение маршрутизатора:** `new_booking`.
**Активная ветка / шаг:** `new_booking` / `qualify`. Защитное условие выполнено + `specialist` заполнен → переход в `present` разрешён.
**Структурированный ответ модели:**
```
Хорошо. Записываю Матвея к ЛОР-врачу — на приёме доктор осмотрит и обратит внимание на боль в ушах.
STATE_JSON: {"state_after": "present", "slots_updated": {"specialist": "ЛОР"}}
```
Шаг переходит в `present` — это легально (`allowed_next: ["qualify", "present"]`), и теперь валидатор `check_guards()` тоже пропускает: `is_child: true` + оба `legal_rep_*` заполнены.
**`thread_state` после хода:**
```json
{
"current_intent_code": "new_booking",
"current_step": 2,
"current_step_code": "present",
"slots": {
"name": "Мария",
"is_child": true,
"reason": "боль в ушах у ребёнка",
"legal_rep_name": "Иванова Мария Сергеевна",
"legal_rep_phone": "+79991234567",
"specialist": "ЛОР"
},
"handoff_count": 0,
"soft_insertion_count": 0
}
```
Дальше всё как в `01_basic_booking_v2.md`, начиная с реплики 5: согласие → `offer_time`, время → `book`, подтверждение → `close`. Никаких особенностей `is_child` на этих шагах в коде сейчас нет.
---
## Что было бы, если модель попыталась перейти без слотов представителя
Допустим, после реплики 4 («А зачем?») модель проигнорирует инструкцию и вернёт:
```
Хорошо, к ЛОР-врачу. Подбираю удобное время.
STATE_JSON: {"state_after": "present", "slots_updated": {"specialist": "ЛОР"}}
```
`state_after: present` сам по себе легален (`allowed_next` его допускает). Но `check_guards()` запускается **после** `validate_transition()` и видит:
- активна `is_child: true` (триггер защитного условия `require_legal_rep`),
- `legal_rep_name` и `legal_rep_phone` отсутствуют.
Валидатор отклоняет переход. Поведение оркестратора:
- `current_step_code` остаётся `qualify`.
- Пациенту всё равно показывается ответ модели (нельзя «съесть» реплику бота).
- В Песочнице на этом ответе появится событие **«валидатор: переход отклонён»** (красный бейдж `validation_blocked`), а в правой панели — детализация: **«🔒 защитное условие `require_legal_rep` не пройдено — ждём: `legal_rep_name`, `legal_rep_phone`»**.
Так что даже если модель «забыла» — состояние не разъезжается.
---
## Что показал этот пример
- **Защитное условие как страховка от регрессии.** Промпт `qualify.md` сам предписывает не переходить, но модель иногда забывает; `check_guards()` ловит это механически.
- **Активация только при триггере.** Поле `trigger_slot: "is_child"` + `trigger_value: true` означает, что защитное условие **бездействует**, пока `is_child != true`. Для взрослых пациентов (как в `01_basic_booking_v2.md`) этого блока вообще нет в индикаторах — так и должно быть.
- **Защитное условие проверяется после `allowed_next`.** Сначала валидатор смотрит, легален ли вообще переход (есть ли в `allowed_next` следующего шага); затем — выполнено ли активное защитное условие.
- **Один guard в коде сейчас.** Сурдолог при жалобе на слух и юридическая оговорка в `close` — это **не** защитные условия в коде, это инструкции в промптах (`qualify.md`, `present.md`). Если они начнут пробуксовывать, можно будет дотянуть как guards (см. идеи на потом в `SPRINTS.md`).
## Что важно проверять в eval-наборе на этом примере
- **Без `legal_rep_*` переход `qualify → present` отклоняется.** Тест: подать в ветку модельный ответ с `state_after: "present"` при `is_child: true` и пустых `legal_rep_*` → валидатор должен отклонить, состояние остаётся `qualify`, в логе и в Песочнице — событие `validation_blocked` с деталями guard'а.
- **`is_child: true` устанавливается рано.** Тест: фраза «запишите ребёнка», без других слов → проверить, что `is_child: true` появляется в `slots_updated` уже на 2-3 реплике (как только пациент явно упомянул ребёнка).
- **После заполнения `legal_rep_*` индикатор `pending_guard` исчезает.** Тест: дойти до реплики «Иванова Мария Сергеевна, +79991234567» → проверить, что в `state.pending_guard` стало `null` и в Песочнице нет жёлтого блока.
- **Защитное условие не активно для взрослых.** Регрессионный тест: сценарий из `01_basic_booking_v2.md` (без `is_child`) → проверить, что переход `qualify → present` разрешён без `legal_rep_*` и в логах нет упоминаний `require_legal_rep`.
---
## Changelog
### v2 → 2026-04-27
**Имена полей `thread_state`** приведены к реальной БД (как в `01_basic_booking_v2.md`).
**Слоты приведены к реальной таксономии** из `qualify.md`:
- Удалены вымышленные `patient_name`, `patient_age`, `parent_first_name`, `complaint`, `service`, `time_chosen`, `branch`, `booking_id`, `guard_surdologist_suggested`.
- Используются только реальные: `name` (обращение к собеседнику в чате — у нас это родитель), `is_child`, `reason`, `specialist`, `legal_rep_name`, `legal_rep_phone`. Отсутствуют слоты для имени и возраста ребёнка — этих слотов в коде нет, имя ребёнка модель использует прямо из истории сообщений.
**Один guard в коде, не три:**
- Удалены guard'ы про сурдолога (на `present`) и юридическую оговорку (на `close`) — в `services/intent_step_service.py` `SEED_INTENT_STEPS` их нет. Сурдолог при жалобе на слух — упомянут как инструкция в `qualify.md` (особая ситуация 3), но не как защитное условие. Юридический текст для записи ребёнка в `close.md` сейчас отсутствует вообще.
- Сценарий укорочен с 9 до 6 реплик с подробным разбором (плюс отдельный раздел «что было бы, если…»).
**Структурированный ответ модели** — формат `STATE_JSON:` в хвосте текста.
**Поведение защитного условия** уточнено под реальный механизм (`services/state_machine.py:check_guards()`):
- Защитное условие проверяется **после** `validate_transition()` (т.е. после `allowed_next`).
- Срабатывает только при `slots[trigger_slot] == trigger_value`.
- Если не пройдено — `current_step_code` не меняется, ответ модели всё равно показывается пациенту, событие `validation_blocked` уходит в Песочницу.
**Удалены sub-states** (`qualify.legal_rep`, `qualify.base`) — их в коде нет; v3 архитектуры сама рекомендует не плодить sub-states, и реальная реализация идёт через condition-based guards. См. также идею на потом в `SPRINTS.md` про возможные sub-states при росте числа guards.
**Удалены вымышленные UI-теги** про реакцию валидатора на сурдолог-флаг — таких событий в коде нет.
**Терминология:** «guard» → «защитное условие», «роутер» → «маршрутизатор».
**Содержательно** (что показывает пример) — то же: блокировка перехода защитным условием при записи ребёнка, разблокировка после заполнения слотов представителя.