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
+237
View File
@@ -0,0 +1,237 @@
# Пример 04 v2 · Простые информационные запросы (general_info)
> **Версия v2 · 2026-04-27.** Переписано под реальный код. Ветка `general_info` **не имеет ни машины состояний, ни своих слотов** — это обычный отвечающий промпт (`prompts/intents/general_info.md`). После её отработки `current_step_code` остаётся `null`, `slots` — пустыми. Никаких слотов `info_topic`, `branch_mention`, `needs_followup` из v1 в коде нет. Список изменений — внизу в Changelog.
>
> Связано с [`../architecture/GRAPH_ARCHITECTURE_v4.md`](../architecture/GRAPH_ARCHITECTURE_v4.md), разделы 1, 2. Демонстрирует **простейший случай**: одна реплика → маршрутизатор → ветка `general_info` → ретривер → ответ. Никаких шагов, никаких слотов, никаких защитных условий.
## О чём этот пример
Это сборник коротких самостоятельных диалогов по одному типу запросов — общая информация (часы, адрес, проезд, телефоны, что брать с собой, чего клиника не делает). У всех у них одна и та же траектория:
1. **Маршрутизатор** классифицирует реплику как `general_info`.
2. Активируется ветка `general_info`. **Без шага** (`current_step_code = null`).
3. **Ретривер** достаёт чанки из общей коллекции `operators_wiki`. Per-intent фильтра в коде сейчас нет — это запланированный Спринт 7 (мульти-RAG). Сейчас все ветки берут топ-K по всей вики.
4. Модель формирует ответ строго по найденным чанкам.
5. `thread_state` после хода: `current_intent_code: "general_info"`, `current_step_code: null`, `slots: {}`. Ничего не добавляется.
Если пациент следом задаст вопрос про запись («а тогда запишите меня…»), маршрутизатор увидит смену темы и переключит на `new_booking` (это `hard handoff`, `handoff_count` инкрементируется). У `general_info` нет своей логики «кросс-села» — это решает маршрутизатор.
## Начальное состояние
Перед каждым диалогом ниже — пустой `thread_state` (свежий тред):
```json
{
"thread_id": 9XXX,
"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
}
```
После ответа `current_intent_code = "general_info"`, остальные поля — без изменений.
---
## A · Часы работы
### A.1 · «Во сколько вы открываетесь?»
**Решение маршрутизатора:** `general_info`.
**Активная ветка / шаг:** `general_info` / *(нет шага)*.
**Ретривер:** общая коллекция `operators_wiki`, top-K чанков. Если в загруженной вике есть документ с расписанием — он попадёт в контекст; если нет — модель ответит на основании промпта `general_info.md`, опираясь только на то, что было в системном описании.
**Ответ модели** (без `STATE_JSON:` — у этой ветки нет машины состояний):
```
Здравствуйте! Клиника на Клары Цеткин, 9 работает с 8:00 до 21:00 в будни и с 9:00 до 18:00 в выходные.
```
**`thread_state` после хода:**
```json
{
"current_intent_code": "general_info",
"current_step": 0,
"current_step_code": null,
"slots": {},
"handoff_count": 0,
"soft_insertion_count": 0
}
```
### A.2 · «Завтра праздник, вы работаете?»
**Решение маршрутизатора:** `general_info`.
**Поведение модели:** в промпте `general_info.md` явно прописано опираться **только** на выдержки из базы. Если про конкретный праздничный режим в вике ничего нет, модель должна не выдумывать, а честно сказать «уточню у администратора и сообщу».
**Ответ модели:**
```
В обычные дни мы работаем с 8:00 до 21:00. Праздничный режим уточню у администратора — он свяжется с вами и подтвердит.
```
`thread_state` после хода — то же, что в A.1.
> **Замечание для eval-набора.** В v1 этого примера предлагался флаг `needs_followup: true` и confidence threshold (если score лучшего чанка < 0.50, отвечать шаблоном). В коде этого механизма сейчас **нет**: ретривер просто возвращает top-K, без проверки relevance, и модель опирается на инструкцию промпта «не выдумывай — скажи, что уточнишь». Полноценный confidence-фильтр — в идеях на потом в `SPRINTS.md`.
---
## B · Адрес и проезд
### B.1 · «Где вы находитесь?»
**Решение маршрутизатора:** `general_info`.
**Ответ модели:**
```
У нас два адреса в Перми. Подсказать, какой ближе к вам?
```
(Точные адреса — из вики. Если их там нет, модель скажет «уточню у администратора».)
### B.2 · «На метро как до вас доехать?»
**Решение маршрутизатора:** `general_info`.
**Поведение модели:** вопрос содержит **ложное допущение** (в Перми метро нет). Промпт `general_info.md` обязывает модель опираться только на базу. Если в базе нет упоминания метро (а его и не будет), модель не должна сочинять станции — она мягко поправит и предложит реальные альтернативы из вики (автобус, троллейбус).
**Ответ модели:**
```
В Перми метро нет, поэтому удобнее всего на автобусе или такси. Подскажу маршруты, если уточните, откуда поедете.
```
Это **важный пограничный кейс для eval'а**: модель не должна изобретать «станцию метро» только потому, что её об этом спросили.
---
## C · Услуги, которых клиника не делает
### C.1 · «У вас можно сделать бронхоскопию?»
**Решение маршрутизатора:** `general_info`. Это вопрос «делаете ли вы такое в принципе», а не запись и не цена. Промпт маршрутизатора относит подобные формулировки к `general_info`, чтобы не запускать механизм записи на услугу, которой нет.
**Поведение модели:** в вики (если она правильно ведётся) есть раздел «процедуры, которые не проводятся» — оттуда и должен быть ответ. Если этого раздела нет, модель должна ответить «уточню», а не «да, конечно, запишем».
**Ответ модели:**
```
Бронхоскопию у нас не делают — её обычно проводят в специализированных пульмонологических центрах. Если беспокоит дыхание или кашель — могу записать на консультацию к терапевту, он подскажет, куда дальше.
```
Хвостовая фраза «могу записать на консультацию» — это **не cross-sell ветки**, а просто реплика модели. Если пациент скажет «да, запишите», следующая реплика уйдёт в `new_booking` через маршрутизатор (`hard handoff`).
---
## D · Переход в запись после справки
### D.1 · Реплика 1: «У вас по выходным открыто?»
**Решение маршрутизатора:** `general_info`.
**Ответ модели:** короткий ответ из вики.
**`thread_state` после хода:**
```json
{
"current_intent_code": "general_info",
"current_step_code": null,
"slots": {}
}
```
### D.2 · Реплика 2: «Хорошо, тогда запишите меня к ЛОРу»
**Решение маршрутизатора:** `new_booking`. Явный сигнал — пациент хочет записаться.
**Шаги оркестратора:**
1. Активная ветка `general_info` ≠ решение маршрутизатора `new_booking`. И `general_info` **не пошаговая** — поэтому никакого «удержания в ветке» не делается, переключаемся прямо.
2. **`handoff_count: 0 → 1`** (это первое переключение в этом диалоге).
3. `current_intent_code``new_booking`. Запускаем эту ветку с шага `intro` (если в треде уже была активна `general_info`, у `new_booking` начинаем с нуля; `suspended_intent` НЕ заполняется — `general_info` не пошаговая, восстанавливать нечего).
4. `slots` обнуляются (у `general_info` их и не было, у `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": 1,
"soft_insertion_count": 0
}
```
В Песочнице на этом ответе:
- Бейдж **«активная ветка: new_booking»** + **«пошаговая»**.
- Бейдж **«решение маршрутизатора: new_booking»** (зелёный).
- Бейдж **«решение: переключили ветку»** (оранжевый `hard_handoff`).
- Счётчик переключений — **«1 из 3»**.
Дальше — как в `01_basic_booking_v2.md`.
---
## Что показал этот пример
- **Одна реплика — один проход.** Ветка `general_info` не имеет машины состояний: `current_step_code` остаётся `null`. Это самый дешёвый путь в системе и логичная точка запуска первой версии бота.
- **У `general_info` нет своих слотов.** В отличие от `new_booking`, эта ветка ничего не накапливает. Если пациент в одном треде задаст три информационных вопроса подряд — каждый пройдёт через маршрутизатор → ретривер → ответ, без какого-либо состояния между ними.
- **Ретривер делает основную работу.** Все факты в ответе должны быть из чанков, не из памяти модели. Если чанков не хватило — модель уходит в шаблон «уточню у администратора», по инструкции из `general_info.md`.
- **Пограничные кейсы.** Метро в Перми (которого нет — B.2), услуги, которых клиника не делает (C), праздничные дни без чанка (A.2) — именно на них модель ломается чаще всего и именно их полезно держать в eval-наборе с самого начала.
- **Переход в запись — это hard handoff.** Когда после справки пациент говорит «запишите», происходит переключение ветки с инкрементом `handoff_count`. Никакого `suspended_intent` не сохраняется — у `general_info` нет состояния, восстанавливать нечего.
## Что важно проверять в eval-наборе на этом примере
- Маршрутизатор **не уводит** информационные вопросы в `new_booking` или `price_question`. Граничный случай: «сколько у вас стоит» — это `price_question`, а «какие у вас услуги» — `general_info`. Границы должны быть чёткими.
- Все факты в ответе находимы в одном из чанков, попавших в контекст. Хорошая метрика — `groundedness` (доля утверждений с прямым подтверждением в источниках).
- При отсутствии релевантных чанков модель отвечает шаблоном «уточню у администратора», а не выдумывает.
- На пограничных кейсах (метро в Перми, услуги, которых нет, праздничные дни без чанка) ответ не содержит ложных утверждений — критичный безопасный минимум для запуска.
- Переход «справка → запись» инкрементит `handoff_count`. Тест: задать `general_info`-вопрос, потом «запишите меня» → проверить, что `handoff_count == 1`, `current_intent_code == "new_booking"`, `current_step_code == "intro"`.
---
## Changelog
### v2 → 2026-04-27
**Имена полей `thread_state`** приведены к реальной БД (как в `01_basic_booking_v2.md`).
**Удалена несуществующая в коде машина состояний `general_info`:**
- В v1 описывались шаги `answer` и `done` со слотами. Реально у `general_info` шагов нет — это обычный отвечающий промпт (`prompts/intents/general_info.md`). После хода `current_step_code` остаётся `null`.
- Удалены вымышленные слоты `info_topic`, `branch_mention`, `needs_followup`, `dms_provider`. У ветки нет своих слотов вообще.
**Удалена несуществующая логика confidence threshold для RAG:**
- В v1 пример A.4 описывал «если score лучшего чанка < 0.50, отвечать шаблоном с `needs_followup: true`». Этого механизма в коде сейчас нет — ретривер не проверяет `relevance_score`. Поведение «не выдумывай — скажи, что уточнишь» обеспечивается **только инструкцией в промпте** `general_info.md`, не порогом. Confidence-фильтр и слот `needs_followup` остались как идея на потом в `SPRINTS.md`.
**Удалены детальные блоки про per-intent RAG-фильтры по путям вики:**
- В v1 показывались фильтры вида `doc_path STARTS WITH any('/wiki/hours/**')`. Этого в коде нет (используется общая коллекция `operators_wiki` без фильтра). Это запланированный Спринт 7 (мульти-RAG, вариант Б из v3).
**Сокращён объём примеров:**
- Было ~20 коротких диалогов разбито по 6 темам (часы, адрес, проезд, контакты, документы, услуги, которых нет). В v2 оставлены только пограничные кейсы и один сценарий перехода `general_info → new_booking`. Полный сборник можно собрать заново после прогона eval'а, когда станет понятно, какие именно граничные случаи нужны.
**Добавлен раздел D · «Переход в запись после справки»** — показывает, как маршрутизатор переключает ветку с `general_info` на `new_booking`, и почему `suspended_intent` при этом **не** заполняется (нет состояния для восстановления).
**Терминология:** «роутер» → «маршрутизатор», «soft cross-sell» → «реплика модели» (этот термин в v1 был мисслидингом — никакой ветки-логики там нет).
**Содержательно** — то же: показать, что `general_info` это самый простой путь в системе, и пограничные кейсы важнее happy path.