Files
RAG_helper/docs/SPRINTS.md
T
AR 15 M4 52b46bc53e 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>
2026-04-27 20:00:44 +05:00

551 lines
65 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Спринты — Chat Agent for Patients (инструмент настройки)
Поэтапный план MVP: RAG-ядро + веб-инструмент для настройки агента операторами. Подключение реальных каналов (приложение, МАКС) — вне скоупа, это задача другого разработчика.
---
## Спринт 1. RAG-ядро, загрузка документов и тестовая страница
### Цель
Поднять FastAPI-сервис с ChromaDB и сразу получить воспроизводимый «пайплайн в действии»: на одной тестовой странице видно, какие файлы загружены, можно задать одиночный вопрос от лица пациента и увидеть одновременно три вещи — какие чанки нашёл RAG, какой промпт собрался, какой ответ вернул DeepSeek. Аналог Debug UI из `work-pcs-dr-cdss`.
### Статус: ⏳ Запланирован
### Задачи
**RAG-ядро:**
- [ ] Инициализация проекта (main.py, config.py, requirements.txt, Dockerfile, docker-compose, .env.example)
- [ ] Переиспользовать паттерны из `work-pcs-dr-cdss`: `services/embeddings.py`, `vectorstore.py`, `document_processor.py`, `llm_client.py`
- [ ] Адаптировать чанкер под wiki-статьи (не клинреки)
**Эндпоинты:**
- [ ] `GET /health` — статус, кол-во документов и чанков
- [ ] `POST /documents/upload` — загрузка + превью первых 3 чанков в ответе
- [ ] `GET /documents` — список загруженных
- [ ] `DELETE /documents/{id}` — удаление
- [ ] `POST /query` — одиночный вопрос от лица пациента → ответ + источники со `score` + `assembled_prompt` (как RAG for Doctors, но без полей карты — только текст вопроса)
**Тестовая страница (одна HTML-страница, vanilla JS):**
- [ ] Шапка со статусом сервиса (auto-refresh `/health`, счётчики документов и чанков)
- [ ] Блок «База знаний»: drag & drop загрузка, таблица документов с превью первых чанков, кнопка удаления
- [ ] Блок «Тест-вопрос от пациента»: поле ввода вопроса, поле `top_k`, кнопка «Отправить»
- [ ] 3-колоночный результат ответа: релевантные фрагменты (текст + document, section, page, score) | собранный промпт | ответ LLM
### Критерий готовности
- [ ] Оператор открывает `http://localhost:PORT/` → видит Debug UI со статусом сервиса
- [ ] Загружает wiki-статью → она появляется в таблице, превью чанков отображается
- [ ] Пишет вопрос «как записать ребёнка к лору?» → получает ответ DeepSeek с указанием источников
- [ ] В средней колонке виден собранный промпт, в левой — какие чанки подтянулись со score
- [ ] Может удалить статью, счётчики в шапке обновляются
---
## Спринт 2. Многошаговый диалог с памятью треда
### Цель
Перейти от одиночного `/query` к полноценному диалогу: агент помнит историю, оператор ведёт разговор из 5+ реплик. Текущую страницу отладки (одиночный вопрос) оставляем без изменений, добавляем **вторую отладочную страницу** — «Песочница» со списком всех сохранённых диалогов.
### Статус: ✅ Закрыт
### Задачи
**Хранилище:**
- [ ] Стек: SQLite + SQLAlchemy 2.0 (async, ORM-стиль) + Alembic для миграций
- [ ] Таблицы:
- `threads` (id, name, user_id nullable, agent_config_id nullable, created_at, updated_at)
- `messages` (id, thread_id FK, role, text, sources_json, assembled_prompt, created_at)
- Колонки `user_id` и `agent_config_id` заводим сразу nullable — под будущие Спринты 3+ (мульти-пользователи, мульти-промпты), чтобы не тащить миграции задним числом
- [ ] Первая миграция Alembic с этими двумя таблицами
- [ ] Все диалоги сохраняются навсегда (никакого авто-удаления)
- [ ] Имя треда генерируется автоматически по первой реплике пациента + дата; оператор может переименовать вручную
**Эндпоинты:**
- [ ] `POST /chat` — принимает `thread_id` (или создаёт новый если не передан) + `text` → возвращает ответ агента + источники со score + `assembled_prompt`
- [ ] `GET /threads` — список всех диалогов (id, name, created_at, messages_count, превью первой реплики)
- [ ] `GET /threads/{id}` — тред целиком с историей сообщений
- [ ] `PATCH /threads/{id}` — переименовать тред
- [ ] `DELETE /threads/{id}` — удалить тред со всеми сообщениями
**Сборка ответа:**
- [ ] Базовый системный промпт (хардкод для старта): роль агента, тон клиники, что можно и нельзя
- [ ] Сборка контекста для LLM: системный промпт + история треда + RAG-чанки по последней реплике
**Веб-интерфейс:**
- [ ] В шапке обеих страниц — ссылки «Отладка» (текущая `/`) / «Песочница» (новая `/sandbox`)
- [ ] Текущий `static/index.html` остаётся без изменений
- [ ] Новая страница `static/sandbox.html` на отдельном маршруте `/sandbox`:
- [ ] левая колонка — список сохранённых диалогов: превью, дата, кнопка «переименовать», кнопка «удалить», кнопка «новый тред»
- [ ] центральная колонка — сам чат (оператор пишет как пациент, видит ответы агента, история подгружается при клике на тред из списка)
- [ ] правая колонка — retrieved-чанки со score + собранный промпт по последней реплике
### Критерий готовности
- [ ] Оператор может провести диалог из 5+ реплик, агент помнит контекст
- [ ] Все диалоги сохраняются и видны в левой колонке после перезагрузки страницы
- [ ] Оператор может открыть старый диалог, переименовать его, удалить
- [ ] В правой колонке видно, что нашёл RAG и что улетело в LLM на последнем шаге
- [ ] Старая страница отладки (`/`) работает как раньше, ничего не сломано
---
## Спринт 2.5. Доработки после пилота Спринтов 1–2
### Цель
Закрыть технический долг, накопленный за первые два спринта: почистить чанки от markdown-мусора, сделать ответ агента читаемым в UI, подготовить системный промпт к вынесению в редактор (Спринт 3) и навести порядок в логах и README.
### Статус: ✅ Закрыт
### Задачи
**Качество RAG:**
- [ ] Почистить чанки: убрать markdown-ссылки `[текст](url)`, блоки навигации `**Вернуться на:**`, дубликаты меню
- [ ] Эндпоинт `POST /documents/{id}/reindex` — переразметить существующий документ с новыми правилами чанкера (без повторной загрузки файла — но у нас пока нет хранения исходников, поэтому надо хранить исходный текст в метаданных чанков или сохранять оригинал при `upload`); решение по способу — в рамках задачи
- [ ] Эндпоинт `POST /documents/reindex-all` — прогнать переиндексацию по всей базе
**UI:**
- [ ] Markdown-рендер ответов ассистента в «Песочнице» (жирный, курсив, списки, код); реплики пациента оставить plain text
**Системность:**
- [ ] Вынести системный промпт из `services/llm_client.py` в отдельный файл (например, `prompts/system_prompt.md`), загружать при старте — задел под Спринт 3
- [ ] Привести логи в порядок: настроить root-logger так, чтобы `logger.exception` писался в stderr/файл; не ломать uvicorn access/error
- [ ] Обновить `README.md` под текущее состояние: две страницы, `/chat` + `/threads`, SQLite + Alembic, как запустить и как мигрировать
### Критерий готовности
- [ ] Загружаем свежую wiki-статью → в её чанках нет markdown-ссылок и блоков «Вернуться на:»
- [ ] На «Песочнице» ответ агента рендерится с жирным/курсивом/списками
- [ ] Системный промпт хранится в отдельном файле, правится без трогания кода
- [ ] При ошибке в `/chat` в логах виден читаемый traceback
- [ ] README описывает актуальное состояние (две страницы, эндпоинты, запуск, миграции)
---
## Спринт 3. Настройки агента: системный промпт и правила
### Цель
Дать операторам веб-редактор системного промпта и списка правил («если спрашивают про X — отвечай так-то», «если пациент злится — делай то-то»). Версионирование: можно сохранить конфигурацию и откатиться.
### Статус: ✅ Закрыт
### Задачи
- [ ] Хранилище (SQLite): `agent_configs` (version, created_at, system_prompt, rules_text, is_active)
- [ ] Эндпоинты: `GET /configs`, `POST /configs` (создать новую версию), `POST /configs/{id}/activate`
- [ ] Песочница использует активную версию при каждом `/chat`
- [ ] Веб-страница «Настройки агента»:
- [ ] редактор системного промпта (textarea)
- [ ] редактор правил (отдельным блоком; на старте — просто textarea, позже — список записей)
- [ ] кнопка «Сохранить как новую версию»
- [ ] список версий с кнопкой «Сделать активной» и пометкой активной
- [ ] Показ активной версии в шапке песочницы
### Критерий готовности
- [ ] Оператор меняет промпт → сохраняет как v2 → активирует → тестирует в песочнице → при желании откатывается к v1
- [ ] Правила реально влияют на ответы агента (проверяется вручную через песочницу)
---
## Архитектурный разворот после Спринта 3 (2026-04-23)
После пилота Спринтов 1–3 решили уходить от одного «мега-промпта» ко графовой архитектуре: **роутер намерений + изолированные ветки + state machine + exit conditions**. Подробности — в [`architecture/GRAPH_ARCHITECTURE_v3.md`](./architecture/GRAPH_ARCHITECTURE_v3.md) (последняя версия). Исторические снапшоты — `architecture/GRAPH_ARCHITECTURE_v1.md` (изначальный, на момент разворота) и `architecture/GRAPH_ARCHITECTURE_v2.md`.
**Принятые решения по открытым вопросам:**
- **Фреймворк оркестровки:** пишем вручную на Python. LangGraph/n8n не берём — проект компактный, свой стек работает, не тянем лишних зависимостей.
- **Модель для роутера:** остаёмся на DeepSeek, но `RouterClient` делаем отдельным классом от `LLMClient` — потом сменим модель в одном месте, если станет дорого.
- **Exit conditions:** свободный текст в промпте ветки + независимый роутер на каждой реплике. Если ветка пропустит триггер — роутер подстрахует.
- **Эскалация на человека:** одна ветка `escalate_human` с полем `reason` (`acute_pain` / `surgery` / `angry` / `explicit_request`). Отдельная маршрутизация «куда именно» — задача смежного разработчика при подключении каналов.
- **Confidence score:** не тянем в первый спринт. Роутер всегда возвращает один из intent'ов, при сомнении — `general_info`. После первого живого прогона посмотрим на реальные ошибки.
Старые Спринт 4 (сценарии) и Спринт 5 (экспорт) не удалены — они переехали в Спринт 7 с дополнением под граф (прогон сценариев проверяет маршрутизацию, экспорт — снапшот графа).
---
## Спринт 4. Фундамент графа — `intents` + роутер + переключение веток
### Цель
Заменить «один активный промпт на всё» на «свой промпт на каждую ветку + роутер выбирает ветку на каждой реплике». Это первый шаг к графовой архитектуре из [`architecture/GRAPH_ARCHITECTURE_v3.md`](./architecture/GRAPH_ARCHITECTURE_v3.md).
### Статус: ✅ Закрыт
### Задачи
**Данные:**
- [ ] Новая таблица `intents` (code, name, description, is_enabled, order_index)
- [ ] Миграция Alembic + в `agent_configs` добавить `intent_id` (nullable для обратной совместимости)
- [ ] Сид при первом запуске: 6 стартовых веток — `new_booking`, `reschedule`, `price_question`, `medical_question`, `general_info`, `escalate_human`
- [ ] Перенос текущего v1 конфига в ветку `general_info` как стартовый промпт
**Роутер:**
- [ ] `services/router_client.py` — отдельный класс под DeepSeek, метод `classify(history, text) → intent_code`
- [ ] Короткий промпт-классификатор с фиксированным перечнем категорий
- [ ] При сомнении возвращает `general_info` (без confidence score на этом спринте)
**Оркестрация:**
- [ ] В `chat_service.send_message`: сначала `router.classify()` → активный конфиг выбранной ветки → `llm.chat()` с этим промптом
- [ ] В таблице `messages` сохраняется `intent_id` каждого обмена
**API:**
- [ ] `GET /intents` — список веток
- [ ] `PATCH /intents/{code}` — включить/выключить
- [ ] `POST /configs` принимает `intent_id`; создание новой версии — всегда в рамках ветки
**UI:**
- [ ] «Настройки»: слева список веток, справа редактор промпта/правил активной версии выбранной ветки
- [ ] В «Песочнице» в отладке показывать: решение роутера + выбранный intent + какая ветка ответила
### Критерий готовности
- [ ] «У меня острая боль» → `medical_question`
- [ ] «Сколько стоит приём» → `price_question`
- [ ] «Как доехать» → `general_info`
- [ ] В отладочной панели «Песочницы» виден intent и какая ветка дала ответ
- [ ] Для каждой ветки можно отдельно править промпт и сохранять версии
---
## Спринт 5. State machine + exit conditions (bouncing)
### Цель
Научить ветки вести многошаговые скрипты и бесшовно передавать тред в другую ветку, если пациент сменил тему.
### Статус: ✅ Закрыт (ядро; дотяжка до GRAPH_ARCHITECTURE v2 — в бэклоге)
### Задачи
**Данные:**
- [x] Таблица `thread_state` (thread_id, current_intent_code, current_step, slots_json, updated_at) + миграция Alembic (batch-режим под SQLite)
**State machine (первая ветка — `new_booking`):**
- [x] 6-шаговый скрипт: приветствие → повод → специалист → удобное время → подтверждение → запись
- [x] Модель на каждой реплике видит блок `[ТЕКУЩЕЕ СОСТОЯНИЕ]` с `step` и `slots`
- [x] Переход шагов управляется служебным тегом `[STATE: step=N; slots={...}]` в ответе модели (строковый тег, парсится балансировкой фигурных скобок)
**Exit conditions и bouncing:**
- [x] В промпт `new_booking` добавлен блок условий выхода с сигналом `[INTENT_CHANGE: <code>]`
- [x] Парсер в `services/chat_service._parse_assistant_signals` вырезает служебные теги из ответа
- [x] Bouncing: одна итерация (`MAX_BOUNCES=1`) — ветка может передать управление другой, делаем повторный вызов LLM
- [x] Роутер на каждой реплике: если классификация ≠ `thread_state.current_intent_code` → сброс `step` и `slots`
**UI:**
- [x] В «Песочнице» блок «Состояние треда»: intent, шаг, слоты (JSON), список переходов в текущей реплике
- [x] В отладке роутера — пометка, если ветка «передала управление»
### Критерий готовности
- [x] Сценарий new_booking проходит: ФИО → повод → специалист → время → подтверждение собираются в `thread_state.slots`
- [x] Переключение ветки через роутер: «Сколько стоит приём?» внутри записи → state сбрасывается в `price_question`
- [x] В отладке видна вся цепочка: роутер-intent, served-intent, шаг, слоты, переходы
### Что НЕ вошло в этот спринт (по сравнению с GRAPH_ARCHITECTURE_v2.md)
Реализовано ядро v1. Вся дотяжка до v2 — Спринт 6.
---
## Спринт 6a. State machine v2 — ядро, защита от петель, возврат в ветку
### Цель
Заменить строковый тег `[STATE: ...]` на структурированный выход модели с валидатором переходов по таблице `intent_steps`; добавить `handoff_count` с автовыходом в `escalate_human: routing_loop`; научить систему возобновлять прерванную ветку через `suspended_intent`. В конце Спринта 6a уже видно глазами: вкладка «Шаги» в «Настройках» для `new_booking`, в «Песочнице» — handoff_count и suspended_intent, timeline переходов первой версии.
Попутно реализована **sticky state machine** (мини-G): когда тред идёт по sm-ветке и роутер предлагает другую — state не сбрасывается, в системный промпт ветки подаётся `[ПОДСКАЗКА РОУТЕРА]`, LLM сама решает. Это сняло ключевую дыру Спринта 5 с коротким repликами внутри сценария.
### Статус: ⏳ Запланирован
### Принятые решения (зафиксировано 2026-04-24, действуют и для 6b)
- **Момент обновления `current_step`** — после успешного коммита сообщения ассистента в БД.
- **Cap на soft-insertion'ы подряд** — 3 (реализация в 6b).
- **Шаги `new_booking` — сверить с вики клиники по ЛОР** до переписывания промпта в блоке A.
- **Формат структурированного выхода** — JSON-блок в хвосте ответа, парсим сами балансировкой скобок + `json.loads`.
### Задачи и UI-чекпойнты (порядок: A → A2 → B → C)
**Блок A. Структурированный выход + таблица `intent_steps` + валидатор переходов (v2 §3.3)**
*Бекенд:*
- [ ] Новая таблица `intent_steps`: `id, intent_id FK, code (intro/qualify/present/offer_time/book/close), name, order_index, system_prompt Text, allowed_next JSON, guards JSON (пустой на этом спринте — наполняется в 6b/F)`. Миграция Alembic.
- [ ] Сид шагов `new_booking` при старте: читает `prompts/intents/new_booking/steps/{code}.md` + `prompts/intents/new_booking/transitions.yaml`.
- [ ] Разделить `prompts/intents/new_booking.md` на базовый промпт ветки (общие правила) + отдельные файлы на каждый из 6 шагов.
- [ ] В `services/chat_service` — сборка промпта: `base_prompt + intent_steps[current_step].system_prompt + state_context`.
- [ ] Парсер нового формата ответа: `{reply, state_after, slots_updated}` — JSON-блок в хвосте.
- [ ] Валидатор: сверка `state_after` с `intent_steps.allowed_next`. Легален → применяем, иначе — остаёмся на текущем шаге + warning в лог. Слоты сливаем `{**old, **slots_updated}`.
- [ ] `assistant_msg.text` = `reply`; служебные поля — в `assembled_prompt` для отладки.
*UI-чекпойнт A:*
- [ ] В «Настройках» для ветки `new_booking` появляется вкладка **«Шаги»**: список шагов из `intent_steps` + на клик открывается редактор (textarea с `system_prompt`, чекбоксы с `allowed_next`). Кнопка «Сохранить шаг» — `PATCH /intents/{code}/steps/{step_code}` пишет сразу (без версионирования).
- [ ] В «Песочнице» бейдж текущего шага берётся из `intent_steps.name`, а не из сырого числа. Если валидатор отклонил `state_after` — красная пометка «модель просилась в `X`, остались на `Y`».
- [ ] **Что проверяем глазами:** открыть `new_booking` → вкладка «Шаги» видит 6 шагов; править любой промпт → применяется в новом треде; в песочнице прогнать «Здравствуйте, хочу записаться» → шаг подписан словами («Приветствие»), а не числом.
**Блок A2. `exit_conditions_text` — отдельное поле в `agent_configs` (v2 §UI)**
*Бекенд:*
- [ ] Миграция: добавить `exit_conditions_text Text NULLABLE` в `agent_configs`.
- [ ] `compose_full_system_prompt` склеивает: `system_prompt + rules_text + exit_conditions_text`.
- [ ] Миграция данных: при старте для существующих конфигов попытаться распарсить блок «Условия выхода» / `[INTENT_CHANGE: ...]` из хвоста `system_prompt` и перенести в новое поле. Не удалось — оставить пусто.
*UI-чекпойнт A2:*
- [ ] В «Настройках» на вкладке активной версии — третья textarea `exit_conditions_text` рядом с `system_prompt` и `rules_text`.
- [ ] **Что проверяем глазами:** у ветки `general_info` после миграции данных в поле `exit_conditions_text` лежат правила `[INTENT_CHANGE: ...]`, а не в теле промпта. В песочнице поведение не изменилось.
**Блок B. `handoff_count` и защита от петель (v2 §4.3)**
*Бекенд:*
- [ ] Миграция `thread_state`: добавить `handoff_count INT NOT NULL DEFAULT 0`.
- [ ] В `chat_service` инкрементить при каждом hard-handoff (INTENT_CHANGE или router-инициированное переключение).
- [ ] При `handoff_count >= 2` — авто-уход в `escalate_human` c `reason=routing_loop`. Ответ-заглушка формируется без нового вызова LLM («Передаю ваш вопрос администратору»).
- [ ] Счётчик сбрасывается на 0 при возврате из `suspended_intent` (блок C) и при переходе в `escalate_human`.
*UI-чекпойнт B:*
- [ ] В «Песочнице» в «Состоянии треда» — строка `handoff_count: N`. При автоуходе в `escalate_human: routing_loop` — явная отметка в timeline.
- [ ] **Что проверяем глазами:** искусственная петля «хочу записаться → сколько стоит → хочу записаться → сколько стоит» → после второго-третьего handoff'а бот говорит «передаю администратору»; в песочнице `handoff_count` вырос, ветка сменилась на `escalate_human`.
**Блок C. `suspended_intent` + `resumable_step` + `resumable_slots` (v2 §4.4)**
*Бекенд:*
- [ ] Миграция: добавить колонки `suspended_intent`, `resumable_step INT`, `resumable_slots_json TEXT` (все nullable) в `thread_state`.
- [ ] При hard-handoff из многошаговой ветки (`new_booking`) — сохранять `current_*` в `suspended_*` перед сбросом.
- [ ] Возврат: роутер классифицировал реплику в `suspended_intent` → восстанавливаем `current_*` из `suspended_*` и очищаем поля. Альтернативный триггер — сигнал `[RESUME]` из ветки detour'а (наполняем в 6b).
*UI-чекпойнт C:*
- [ ] В «Состоянии треда» — `suspended_intent` и `resumable_step` (если заполнены).
- [ ] Timeline переходов между ветками в рамках треда: список типа `new_booking (step=4) → price_question → new_booking (step=4, восстановлено)`. Собирается на бекенде из diff'ов `intent_id` у соседних сообщений + лога handoff'ов.
- [ ] **Что проверяем глазами:** запись до 4 шага → «сколько это стоит?» → `suspended_intent=new_booking, resumable_step=4` видно в панели → «ок, тогда бронируем» → слоты `new_booking` вернулись, шаг=4, timeline показывает три перехода.
### Критерий готовности 6a
- [ ] Сценарии 1 (базовая запись), 3 (handoff с suspended), 4 (возврат из suspended), 6 (routing_loop) из блока H Спринта 6b проходят в «Песочнице».
- [ ] `handoff_count` и `suspended_intent` видны глазами в «Состоянии треда».
- [ ] Вкладка «Шаги» в «Настройках» работает — можно отредактировать промпт шага и увидеть эффект в песочнице без рестарта.
- [ ] Третья textarea `exit_conditions_text` работает; данные старых веток мигрированы.
- [ ] `current_step` пишется только после коммита `assistant_msg` — проверяется код-ревью.
- [ ] Парсер структурированного выхода устойчив к невалидному `state_after`.
---
## Спринт 6b. Глубина сценария — soft-insertion, guards, reason, умный роутер
### Цель
Поверх ядра из 6a — добавить различение soft/hard-handoff, guards в `new_booking`, структурированный reason в `escalate_human`, умный роутер, видящий `thread_state`. В конце Спринта 6b все 8 ручных сценариев из блока H проходят в «Песочнице».
### Статус: ⏳ Запланирован (после 6a)
### Задачи и UI-чекпойнты (порядок: D → F → E → G → H)
**Блок D. Soft-insertion vs hard-handoff (v2 §4.2)**
*Бекенд:*
- [ ] В промпт ветки `new_booking` (базовый + шаги `qualify/present/offer_time`) — правило «короткие боковые вопросы (цена услуги, адрес, часы, длительность приёма, требования к документам) отвечай сам, не покидая шаг». Модель возвращает `state_after=текущий_шаг`, `slots_updated={}`.
- [ ] Миграция `thread_state`: добавить `soft_insertion_count INT NOT NULL DEFAULT 0`.
- [ ] На soft-insertion счётчик инкрементится; на продвижение по шагу — сбрасывается в 0.
- [ ] При `soft_insertion_count >= 3` — ветка в промпте получает явное указание «вернуть пациента к вопросу шага».
*UI-чекпойнт D:*
- [ ] В «Состоянии треда» — `soft_insertion_count: N`.
- [ ] В timeline переходов помечать soft-insertion как `new_booking · soft-answer (price)` — без смены ветки.
- [ ] **Что проверяем глазами:** запись до шага 3 → «а сколько стоит?» → ответ по цене, шаг=3 сохранился, `soft_insertion_count=1`. Повторить 3 раза → на 3-м ответе бот возвращает к вопросу шага.
**Блок F. Guards в `new_booking` (v2 §3.2)**
*Бекенд:*
- [ ] В `intent_steps.guards` наполняем условия для `new_booking`: ребёнок → `legal_rep_name+legal_rep_phone` до перехода из `qualify`; запрос конкретного врача с листом ожидания → рукав `waitlist`; жалоба на слух без предварительного сурдолога → сначала `surgologist` в `specialist`.
- [ ] Слоты: `is_child`, `legal_rep_name`, `legal_rep_phone`, `requested_doctor`, `waitlist_flag`, `needs_surgologist_first`.
- [ ] Валидатор переходов (блок A 6a) проверяет `guards`: если не пройден — блокирует `state_after`, оставляет на шаге, возвращает пациенту ответ модели как есть.
- [ ] Обновить промпты шагов под сценарии guard'ов.
*UI-чекпойнт F:*
- [ ] На вкладке «Шаги» — отдельная textarea для `guards` (JSON) с валидацией формата.
- [ ] В «Состоянии треда» — если валидатор заблокировал переход guard'ом, явная отметка «guard `require_legal_rep` не пройден, ждём `legal_rep_phone`».
- [ ] **Что проверяем глазами:** сценарий 7 (ребёнок) — на шаге `qualify` после «это для сына, 5 лет» бот спрашивает ФИО и телефон родителя; пока не заполнены — не переходит; в песочнице видна причина блокировки. Сценарий 8 (конкретный врач) — переход в рукав `waitlist`.
**Блок E. `reason` в `escalate_human` (v2 §1, §5)**
*Бекенд:*
- [ ] Обновить промпт `_router`: при `escalate_human` возвращать пару `code + reason` (`acute_pain / surgery / angry / explicit_request / routing_loop`).
- [ ] `RouterClient.classify` парсит reason, дефолт при неразобранном — `explicit_request`.
- [ ] Ветка `escalate_human.md` и шаги (если есть) — reason влияет на текст первой реплики.
- [ ] В `messages` — колонка `escalation_reason NULLABLE` (миграция). В API-ответе `/chat` поле `escalation_reason`.
- [ ] Заготовка саммари для оператора: при эскалации формируется `{reason, history, slots_from_suspended}`, логируется в файл/консоль (канал передачи — Спринт 9).
*UI-чекпойнт E:*
- [ ] В «Состоянии треда» — при активной эскалации показывать `reason`.
- [ ] В «Отладке ответа» под блоком роутера — сгенерированное саммари оператора (read-only preview).
- [ ] **Что проверяем глазами:** сценарий 5 («упомянул хирургию») → эскалация с `reason=surgery`, превью саммари содержит всю историю + собранные слоты. Сценарий 6 (петля) → эскалация с `reason=routing_loop`.
**Блок G. Умный роутер (видит `thread_state`)**
Частично уже реализовано в Спринте 6a: **sticky state machine** — если тред в sm-ветке и роутер предлагает другую, state НЕ сбрасывается, а в системный промпт ветки подаётся блок `[ПОДСКАЗКА РОУТЕРА]`, LLM сама решает (STATE_JSON или INTENT_CHANGE). Это сняло основную проблему с короткими репликами («Кук», «болит ухо») внутри сценария.
*Что осталось на 6b:*
- [ ] Вторая линия защиты: в `RouterClient.classify` принимать снимок `thread_state` и вставлять в системный промпт **самого роутера** блок «Сейчас идёт сценарий X на шаге Y, слоты Z — если реплика укладывается в сценарий, предпочитай текущую ветку». Это помогает роутеру изначально реже ошибаться, а не только «поправляться» sticky-логикой.
- [ ] Обновить `prompts/intents/_router.md` под новый формат.
*UI-чекпойнт G:*
- [ ] В «Отладке ответа» → блок «Решение роутера» — кнопка развернуть промпт, который ушёл в роутер (включая блок состояния треда). Полезно для отладки.
- [ ] **Что проверяем глазами:** сценарий из 6a («болит ухо» внутри new_booking) — роутер теперь изначально возвращает `new_booking`, а не `medical_question` → без sticky-коррекции.
**Блок H. Финальный прогон 8 ручных сценариев (прокси-eval)**
- [ ] Зафиксировать в `eval/MANUAL_CASES.md` полный список 8 сценариев (уже описан в этом документе выше, просто консолидируем).
- [ ] Прогнать в «Песочнице». Для каждого сценария — в `eval/MANUAL_REPORT.md` фиксируем результат (ок / расхождение + детали).
1. Базовая запись (6 шагов → `confirmed=true`).
2. Запись → вопрос про цену (soft-insertion, без смены шага).
3. Запись → перенос старой записи (hard-handoff в `reschedule`, `suspended=new_booking`).
4. Запись → detour → возврат «бронируем на четверг» (восстановление из `suspended`).
5. Запись → упоминание хирургии (`escalate_human: surgery`, саммари).
6. Искусственная петля (`routing_loop` после cap).
7. Запись ребёнка (guard блокирует переход).
8. Конкретный врач (waitlist-рукав).
### Критерий готовности 6b
- [ ] Все 8 сценариев из блока H проходят в «Песочнице» без ручной правки state. `MANUAL_REPORT.md` закоммичен.
- [ ] Все UI-чекпойнты (D, F, E, G) проверены глазами.
- [ ] Роутер при активной state machine не сбрасывает intent на коротких репликах внутри сценария.
- [ ] Саммари оператору формируется и логируется при эскалации — пусть пока и без канала передачи.
---
## Спринт 6c. Терминология: словарь, документация, UI, страницы примеров
### Цель
Устранить терминологический кавардак между v3-архитектурой, кодом и UI: единый словарь, протянуть его сквозь страницу документации и UI Песочницы/Настроек, добавить разобранные примеры из `docs/examples/` как читаемые страницы внутри приложения. Делается **перед** Спринтом 8 (мини-eval), чтобы тесты роутера и handoff'а уже опирались на устоявшиеся термины и читаемое UI.
### Статус: ✅ Закрыт
### Задачи
- [x] Зафиксирован словарь: «намерение» (intent) и «ветка» (branch) разнесены концептуально, в коде остаётся `intent_code` (связь 1:1, см. идею в «Дальнейшие идеи»). «Маршрутизатор» вместо «роутер». «Защитное условие» вместо «guard» (буквально из v3 §3.2). «Пошаговая ветка» вместо «многошаговая». Введены: «Решение маршрутизатора», «Активная ветка», «Счётчик переключений», «Причина передачи оператору».
- [x] **Документация (`static/docs.html`)** — карточки терминов и текст приведены к словарю. Добавлены карточки «Намерение», «Ветка» (с историческим замечанием про intent в БД), «Решение маршрутизатора», «Активная ветка», «Счётчик переключений», «Причина передачи оператору». «Guard» переименован в «Защитное условие».
- [x] **Песочница (`static/sandbox.html`)** — «Решение роутера» → «Решение маршрутизатора». Бейдж «многошаговая» → «пошаговая ветка». Бейдж «🔒 guard X» → «🔒 защитное условие X». «Решение маршрутизатора» теперь всегда видимый бейдж (зелёный при совпадении с активной веткой, жёлтый при расхождении). Активная ветка названа явно. Счётчик переключений вынесен в визуальный элемент «N из 3» (красный при достижении капа).
- [x] **Настройки (`static/settings.html`)** — поле «Guards (JSON)» → «Защитные условия (guards, JSON)», тост ошибки переименован.
- [x] **Страницы примеров** — параметризованная страница `static/example.html`, рендерит markdown через marked.js + DOMPurify. Маленький роут `GET /api/docs/examples/{name}` в `main.py` отдаёт markdown из `docs/examples/` без дублирования. Навигация между 4 примерами + хлебные крошки обратно. Раздел «Разобранные примеры» добавлен в `docs.html`.
### Критерий готовности
- [ ] Слово «роутер» в UI отсутствует (только в коде как `_router` и в служебной константе `[ПОДСКАЗКА РОУТЕРА]`).
- [ ] Слово «guard» в UI заменено на «защитное условие». В коде остаётся `guards_json`, `check_guards()`.
- [ ] В Песочнице на каждой реплике видно отдельно «Решение маршрутизатора» и «Активная ветка»; счётчик переключений виден как «N из 3».
- [ ] Из `docs.html` есть навигация к 4 страницам примеров; со страницы примера — обратно в документацию.
---
## Спринт 7. Мульти-RAG, часть A: подписка ветки на загруженные документы
### Цель
Дать каждой ветке собственный срез базы знаний, чтобы документы для одной темы (например, скрипты по детскому приёму) не засоряли ответы другой темы (цены / общая справка). Делаем **до** мини-eval Спринта 8, чтобы тесты прогонялись уже с реальным per-intent retrieval.
**Часть A** этого спринта — ручная подписка через UI: оператор загружает документы как сейчас (на странице «Отладка»), а в «Настройках» ветки указывает галочками, какие из них в неё подмешивать. **Часть Б** (автосинхронизация с внешней вики операторов) — отдельной задачей в идеях на потом.
**Подход** — A (M:N через document_id, не префиксы путей и не теги). Причины: `vectorstore.query()` уже умеет фильтровать по `document_ids` (нечего переписывать); нулевая миграция Chroma; на текущем масштабе (~30 документов, 6 веток) ручная подписка — 3-минутная задача один раз при загрузке; дисциплина именования путей — слабое место в проектах с >1 оператором, а галочки понятны без инструкции.
### Статус: ⏳ Запланирован
### Задачи
**Бэкенд:**
- [ ] Миграция Alembic: новая таблица `intent_documents` с полями `intent_id` (FK на `intents.id`), `document_id` (varchar 36, тип как в metadata Chroma), `created_at`. PK составной (`intent_id`, `document_id`). Индекс по `document_id` для обратного поиска.
- [ ] Модель `db/models/intent_document.py` (`IntentDocument`).
- [ ] Сервис `services/intent_document_service.py` — функции `list_documents_for_intent(intent_code)`, `list_intents_for_document(document_id)`, `set_documents_for_intent(intent_code, document_ids)`, `set_intents_for_document(document_id, intent_codes)`.
- [ ] API:
- `GET /intents/{code}/documents` — список `document_id`, привязанных к ветке.
- `PUT /intents/{code}/documents` — перезаписать список (body: `{ "document_ids": [...] }`).
- `GET /documents/{id}/intents` — список кодов веток конкретного документа.
- `PUT /documents/{id}/intents` — перезаписать список (body: `{ "intent_codes": [...] }`).
- [ ] Retrieval-фильтр в `services/chat_service.py`: перед `vectorstore.query()` подтянуть список `document_id` для активной ветки. Передать как `document_ids=...`. **Дефолт пустой подписки — `document_ids=[]` (= 0 чанков), не «вся коллекция»**: пустая подписка означает «ветка не настроена», подмешивать случайное хуже, чем не подмешивать ничего.
**UI:**
- [ ] «Настройки» → страница ветки: новый блок «Документы базы знаний» — список всех загруженных документов с галочками, заголовок «подписано N из M», кнопка «Сохранить подписки».
- [ ] «Отладка» → рядом с каждым документом (или в разворачиваемой панели) — компактный список веток с галочками, чтобы быстро подписать прямо на месте загрузки.
- [ ] «Отладка» → кнопка «редактировать» рядом с «привязка»/«удалить»: разворачивает большой `<textarea>` с извлечённым `raw_text` документа. Кнопка «Сохранить и переиндексировать» делает `PUT /documents/{id}/raw` (обновляет `documents.raw_text` + переразметка + замена чанков в Chroma). С confirm перед сохранением. Подпись: правится извлечённый текст, для PDF/docx исходник теряется.
- [ ] Системный промпт страницы «Отладка» переехал в обычную ветку `_debug` («Страница отладки»). Удалён `prompts/system_prompt.md` и логика `DEFAULT_SYSTEM_PROMPT` в `services/llm_client.py`. `routers/query.py` подтягивает активный конфиг ветки `_debug` (через `config_service`) и её подписки на документы (через `intent_document_service`). Дефолт пустой подписки в `_debug` — вся коллекция, чтобы Отладка работала «из коробки» (для пациентских веток дефолт другой — 0 чанков). На странице Отладки info-bar показывает активную версию и счётчик подписок, ссылка → Настройки. В `QueryResponse` добавлены `intent_code`, `config_version`, `rag_subscription`.
- [ ] Песочница, отладочная панель: новый блок «Срез RAG: подписано N из M документов для ветки `<код>`». В «Найденных фрагментах» в каждой карточке — лейбл с `document_name`. Если подписка пуста и retrieval вернул 0 чанков — явная пометка «у ветки нет подписок, RAG-контекст пустой».
**Документация:**
- [ ] `static/docs.html` — карточка термина «Подписка ветки на документы», упоминание в разделе «Что происходит на каждой реплике».
- [ ] `docs/architecture/GRAPH_ARCHITECTURE_v5.md` — переписать §6 под подход A (M:N через `document_id`, без путей и без тегов). На v4 — шапка «устарело». Changelog v4→v5.
- [ ] `README.md` — раздел про мульти-RAG.
### Критерий готовности
- [ ] Документ, привязанный к `price_question`, появляется в retrieval только когда активна именно эта ветка. При переключении на `new_booking` — те же запросы возвращают другие чанки.
- [ ] Ветка без подписок (например, свежесозданная) получает в retrieval 0 чанков — модель отвечает по промпту без RAG-контекста.
- [ ] В Песочнице видно «подписано N из M», в найденных фрагментах — название документа.
- [ ] Подписка работает в обе стороны UI: можно настроить и со страницы ветки (Настройки), и со страницы документа (Отладка).
---
## Спринт 8. Мини-eval: роутер, handoff, resumable
### Цель
После дотяжки v2 (Спринт 6) и мульти-RAG (Спринт 7) — зафиксировать автоматизированный тест-набор, чтобы следующие правки промптов и `wiki_sources` не ломали собранное. Формализует ручные сценарии из блока H Спринта 6.
### Статус: ⏳ Запланирован
### Задачи
**Eval-наборы (отдельные файлы в репозитории, без БД):**
- [ ] `eval/router_cases.csv` — 20–30 фраз на каждую из 6 веток: типичные, пограничные (ловушечные), злые (короткие, эмоциональные, с опечатками). Колонки: `text, expected_intent, note`.
- [ ] `eval/handoff_cases.yaml` — 5–10 многошаговых мини-диалогов: реплики пациента по порядку + ожидаемый intent на каждую.
- [ ] `eval/resumable_cases.yaml` — 3–5 сценариев detour-и-возврат: реплики + ожидаемые `current_intent`, `current_step`, ключевые слоты на каждом шаге.
- [ ] `eval/loop_cases.yaml` — 1–2 сценария искусственной петли с проверкой `reason=routing_loop`.
- [ ] `eval/guard_cases.yaml` — сценарии на guards (ребёнок, waitlist).
- [ ] `eval/rag_cases.yaml` — сценарии на мульти-RAG: реплика внутри ветки → проверка, что retrieved-чанки из правильного раздела вики.
**Запускалка (CLI, не часть сервиса):**
- [ ] `eval/run.py` — читает наборы, прогоняет через живой сервис. Режимы:
- `router` — прямой вызов `RouterClient.classify()` на фразах из CSV (быстро).
- `dialog` — полный `/chat` на чистых тредах, сверка intent + step + slots + handoff_count + reason + источники.
- [ ] Вывод: per-ветка accuracy, confusion matrix, список расхождений с текстом реплики.
- [ ] Отчёт: stdout + `eval/reports/{timestamp}.md` (добавлять в git для сравнения во времени).
**Документация:**
- [ ] В `README.md` — раздел «Как прогнать eval» (одна команда).
- [ ] Договорённость: перед правкой промпта роутера / ветки / `wiki_sources` — прогнать eval, зафиксировать baseline; после — сравнить.
### Критерий готовности
- [ ] `eval/run.py` работает одной командой, полный набор проходит за ≤ 3 минуты.
- [ ] Отчёт покрывает все 8 сценариев из блока H Спринта 6 + базовые kv-тесты роутера + RAG-проверки Спринта 7.
- [ ] Baseline зафиксирован в `eval/reports/{date}_baseline.md` и добавлен в git.
---
## Спринт 9. Сценарии + экспорт графа
### Цель
То, что изначально планировалось как Спринты 4 + 5 до архитектурного разворота. Теперь встроено в граф: прогон сценария проверяет не только текст ответов, но и правильность маршрутизации; экспорт — снапшот всего графа (intents + промпты + коллекции).
### Статус: ⏳ Запланирован
### Задачи
**Сценарии:**
- [ ] Таблица `scenarios` (id, name, note, label, messages_json, expected_intents_json, config_snapshot_id)
- [ ] `POST /scenarios` — сохранить текущий тред «Песочницы» как сценарий, зафиксировать ожидаемый intent на каждую реплику пациента
- [ ] `POST /scenarios/{id}/run` — прогнать реплики пациента на текущих активных конфигах всех веток; вернуть новые ответы + распознанные intents
- [ ] Веб-страница «Сценарии»: список + открытая карточка со side-by-side (старый ответ / новый), подсветка «маршрутизация совпала / разошлась»
- [ ] Счётчик «ок / расхождение» по всей базе сценариев после последнего прогона
**Экспорт:**
- [ ] `GET /configs/export` — JSON-снапшот графа: все intents, для каждого — активный промпт и правила, список коллекций RAG и документов в них
- [ ] Документация API в README: `POST /chat`, `GET /health`, контракт ответов
- [ ] Инструкция «Как подключить канал» + пример curl / минимальный webhook-адаптер
- [ ] docker-compose поднимается одной командой, внешний разработчик получает рабочий `/chat`
### Критерий готовности
- [ ] После изменения промпта в одной из веток — прогон сценариев показывает расхождения именно в этой ветке
- [ ] Виден общий счётчик «ок / изменилось» по базе сценариев
- [ ] В README готов раздел «Как подключить канал», работает docker-compose-запуск
---
## Бэклог
### Дальнейшие идеи
- **Спринт 7, часть Б: автосинхронизация с внешней вики операторов.** Часть A Спринта 7 — ручная подписка через UI: оператор сам загружает документы и сам ставит галочки. Часть Б — подключение к внешней системе ведения вики (которая «тщательно ведётся операторами»): автоматическое обновление документов, привязка подписок к источникам в той системе, версионирование. Конкретика появится, когда будет известно, что за внешняя система.
- **Per-step `wiki_sources`** (из v4 §3.4): отдельная подписка на уровне шага машины состояний (например, на `book` подмешивать только документы про подготовку к приёму, на `qualify` — про услуги и врачей). Сейчас не нужно — все шаги `new_booking` логически работают с одной и той же базой. Возвращаться, когда увидим, что какой-то шаг подбирает не те чанки.
- **Превью markdown в редакторе документа** (страница «Отладка», кнопка «редактировать»): сейчас в textarea виден сырой markdown с символами `#`, `**`. Добавить split-view (слева исходник, справа отрендеренный markdown через уже подключённые `marked.js` + `DOMPurify` из Песочницы). На узких экранах — вертикальный стек. Альтернативы: вкладки «редактор/превью» (проще, но с переключением) или WYSIWYG (TipTap / EasyMDE — +500 KB и риск кривого экранирования). Рекомендация на момент записи — split-view.
- **Confidence threshold для RAG в `general_info`** (из v3 + пример 04, A.4): если score лучшего чанка ниже порога (например 0.50) — модель отвечает шаблоном «уточним и перезвоню», ставит слот `needs_followup=true`. Защита от выдумывания фактов в случаях вроде «работаете в праздник?» при отсутствии чанка.
- **Технические слоты для `general_info`** (из примера 04): `info_topic` (`hours` / `branches` / `transit` / `parking` / `contacts` / `preparation` / `scope_of_services`), `branch_mention`, `needs_followup`. Сейчас у `general_info` нет машины состояний и слоты не сохраняются — при втором вопросе в треде ретривер не знает, про какой филиал шла речь раньше. Подключить минимальный `answer→done` со слотами.
- **CRM-инструменты (`crm.get_slots`, `crm.create_booking`)** (из v3 + примеры 01/02): сейчас в коде нет интеграции с CRM, на шагах `offer_time` / `book` модель «обещает» запись, но никуда её не сохраняет. Реальная интеграция — задача смежника при подключении каналов, но мок-инструменты можно завести раньше, чтобы поддерживать сквозной сценарий в Песочнице.
- **Sub-states типа `qualify.legal_rep`** (из примера 03): сейчас тот же эффект достигается через conditional transitions + guards, и v3 сама рекомендует не плодить sub-states. Возвращаться, если guard'ов на одном шаге станет много и состояние перестанет читаться.
- **Разделение «намерения» и «ветки» в коде и БД** (из v3, раздел «Архитектура, к которой идём»): сейчас в коде и в таблице `intents` это одна сущность, связь намерение↔ветка жёстко 1:1. В словаре терминов их разнесли только концептуально (см. словарь в `static/docs.html`). Возвращаться к этому, **когда появится сценарий «одно намерение → разные ветки в зависимости от контекста»** — например, отдельные ветки записи для детей и взрослых под одно намерение `new_booking`. Тогда понадобится завести `branch_code` рядом с `intent_code`, пересобрать модель `Intent`, поменять выбор ветки в `chat_service.py`. До такого сценария — лишняя сложность.
- Раздельные правила по доменам — **перекрыто архитектурой: теперь это ветки (`intents`)**
- A/B сравнение двух версий промпта на одном тест-наборе (в рамках одной ветки или между ветками)
- Метрики качества ответов (MRR, CSAT по сценариям)
- Подсветка цитат источников в ответе агента
- Перевод правил из свободного текста в структурированный список (pattern → instruction)
- Мультипользовательский режим (несколько операторов одновременно настраивают)
- Хранение исходных файлов (`./data/uploads/{document_id}.{ext}` + `source_path` в метаданных Chroma) — чтобы переиндексировать без повторной загрузки и показывать оператору оригинал документа
- Confidence score роутера + clarifying question при низкой уверенности — включить после реального прогона eval'а, если будет много ошибок классификации
- Визуализация графа (веток и переходов между ними) — возможно, в виде отдельной панели
- Вынесение роутера на отдельную более дешёвую модель (gpt-4o-mini, локальная Qwen) — когда вызовов станет много
- Структурированные exit conditions (список триггеров с keyword-match) — если свободный текст в промпте будет пропускать реальные случаи смены темы
- `routing_log` (таблица решений роутера по каждой реплике) — для офлайн-анализа и тюнинга, когда eval покажет, что нужно