# Спринты — 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: ]` - [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», кнопка «Сохранить подписки». - [ ] «Отладка» → рядом с каждым документом (или в разворачиваемой панели) — компактный список веток с галочками, чтобы быстро подписать прямо на месте загрузки. - [ ] «Отладка» → кнопка «редактировать» рядом с «привязка»/«удалить»: разворачивает большой `