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,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» → «защитное условие», «роутер» → «маршрутизатор».
|
||||
|
||||
**Содержательно** (что показывает пример) — то же: блокировка перехода защитным условием при записи ребёнка, разблокировка после заполнения слотов представителя.
|
||||
Reference in New Issue
Block a user