Files
RAG_helper/docs/SPRINTS.md
T
AR 15 M4 bb5e3f5eb3 feat(sprint8b): регрессия ответов веток · general_info + фикс PRAGMA foreign_keys
Параллель к 8a, но проверяем не код intent от роутера, а содержимое ответа
конкретной ветки на одиночную реплику. Старт — general_info, 46 кейсов.

Логика pass/fail (для одного кейса):
- A — RAG-секция: среди retrieved-чанков есть кусок с
  section == expected_doc_section (точное совпадение). Если поле не задано —
  пропускаем.
- B — keywords: обязательные expected_keywords встречаются в predicted_answer
  (case-insensitive). По умолчанию все; поддерживаются keywords_min: N
  и keywords_any: true. Запрещённые expected_must_not — ни одного.
- Pass = A ∧ B. Незаданные поля не проверяются.
- Кэш: (text_hash, branch_config_id) → {answer_text, retrieved_sections}.
  Привязан к версии промпта ветки. Смена версии = пустой кэш = свежий прогон.
  Правка JSONL без изменения text → pass/fail пересчитывается без LLM.

Backend:
- Таблицы eval_branch_runs / eval_branch_run_cases / eval_branch_predictions.
  Миграция m9g1f7e89j56.
- services/eval_branch_run_service.py: загрузка JSONL, фоновый прогон через
  asyncio.create_task, кэш, оценка A+B с поддержкой keywords_min/keywords_any.
- chat_service.run_branch_single_turn — изолированный single-turn без
  роутера и треда (использует существующий config_service + vectorstore + llm).
- API: POST /eval/branch-runs, GET /eval/branch-runs?intent_code=,
  GET /eval/branch-runs/{id}, GET /eval/branch-cases-with-status?intent_code=.

UI (static/regression.html):
- Селектор режима «Роутер / Ветка · general_info». Логика пикера переиспользуется
  (фильтры, диапазон, массовый выбор, счётчик «новые / в кэше»).
- Для режима «Ветка»: фильтр по coverage, колонки секция/coverage, keywords,
  частота, кэш. Drill-down прогона: ожидание, retrieved-секции, причины fail,
  полный ответ ветки.

База кейсов (eval/branch_cases_general_info.jsonl) — от пользователя, 46 кейсов
по схеме {text, intent, coverage, expected_doc_section?, expected_keywords?,
expected_must_not?, keywords_min?, keywords_any?, count?, note?}.

Связанная правка SQLite (нашли при удалении документа в этом спринте):
- db/session.py: connect-listener PRAGMA foreign_keys=ON на каждое подключение.
  Без этого ondelete=CASCADE в SQLite не enforced, и удаление документа
  оставляло подписки в intent_documents висячими (что давало пустой RAG
  и fail регрессии).
- Миграция n0h2g8f9a0k67 — одноразовая чистка существующих висячих подписок.

docs/SPRINTS.md: Спринт 8b →  Закрыт. Diff vs предыдущий прогон для веток
и кнопка «Сбросить кэш регрессии» вынесены в docs/BACKLOG.md.

Также включены обновлённые data/datasets/general_info.md и price_question.md
(рабочий материал оператора), и черновик eval/branch_cases_price_question.jsonl
для следующего захода (8b на price_question).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 01:20:59 +05:00

720 lines
81 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 оператором, а галочки понятны без инструкции.
### Статус: ✅ Закрыт (коммит `52b46bc`, 2026-04-27)
### Задачи
**Бэкенд:**
- [x] Миграция Alembic `i5c8b3a45f12_add_intent_documents`: новая таблица `intent_documents` с полями `intent_id` (FK на `intents.id`), `document_id` (varchar 36, тип как в metadata Chroma), `created_at`. PK составной, индекс по `document_id`.
- [x] Модель `db/models/intent_document.py` (`IntentDocument`) с каскадом удаления.
- [x] Сервис `services/intent_document_service.py` — функции `list_documents_for_intent_code`, `list_intents_for_document`, `set_documents_for_intent`, `set_intents_for_document`.
- [x] API: `GET/PUT /intents/{code}/documents` и `GET/PUT /documents/{id}/intents` с PUT-семантикой «полный список», атомарно.
- [x] Retrieval-фильтр в `services/chat_service.py` + `vectorstore.query()` различает `None` (нет фильтра, вся коллекция) и `[]` (пустая подписка, 0 чанков). Дефолт для пациентских веток — `[]`. Для `_debug``None` (отладка работает из коробки).
**UI:**
- [x] «Настройки» → блок «Документы базы знаний» в правом сайдбаре, всегда видим (независимо от вкладки), сортировка по имени, счётчик «N из M».
- [x] «Отладка» → кнопка «привязка» рядом с «удалить» → раскрывашка со списком веток, быстрая привязка прямо на месте.
- [x] «Отладка» → кнопка «редактировать» → большой textarea с raw_text, `PUT /documents/{id}/raw` обновляет текст и переиндексирует в Chroma. С confirm.
- [x] Системный промпт страницы «Отладка» переехал в ветку `_debug`. Удалён `prompts/system_prompt.md` и `DEFAULT_SYSTEM_PROMPT` в `llm_client.py`. info-bar на странице Отладки: версия + подписки + ссылка в Настройки.
- [x] Песочница: блок «Срез RAG», поле `rag_subscription` в `ChatResponse`, ворнинг при пустой подписке.
- [x] Тест-блок «Тест-вопрос от пациента» в центре Настроек (для любой выбранной ветки): textarea черновика → `/query` с `intent_code`, `system_prompt` (override), `disable_rag` для `_router`. Промпт-секция в `<details open>`, можно свернуть.
**Документация:**
- [ ] (отложено в идеи на потом) `static/docs.html` — карточка термина «Подписка ветки на документы».
- [ ] (отложено в идеи на потом) `docs/architecture/GRAPH_ARCHITECTURE_v5.md` — переписать §6 под подход A.
- [ ] (отложено в идеи на потом) `README.md` — раздел про мульти-RAG.
### Критерий готовности
- [x] Документ, привязанный к `price_question`, появляется в retrieval только когда активна именно эта ветка.
- [x] Ветка без подписок получает в retrieval 0 чанков (для пациентских) или вся коллекция (для `_debug`).
- [x] В Песочнице видно «подписано N из M», в найденных фрагментах — название документа.
- [x] Подписка работает в обе стороны UI: и со страницы ветки (Настройки), и со страницы документа (Отладка).
---
## Спринт 7.5. Обновление промптов 4 веток (без `new_booking`)
### Цель
Применить предложения из `docs/BRANCH_MAP_AND_PROMPTS_v1.md` к четырём веткам — `reschedule`, `price_question`, `medical_question`, `general_info`. Промпты заменяются на новые более развёрнутые тексты по карте. Все 4 ветки остаются **одношаговыми** (без state machine, без слотов) — карта явно говорит, что только `new_booking` пошаговая. `escalate_human` и `_router` не трогаем.
### Статус: ⏳ Запланирован
### Задачи
**Тексты промптов (правка `prompts/intents/{code}.md`):**
- [ ] `reschedule.md` — полная замена на сценарий из 6 пунктов (BRANCH_MAP §5): сначала `action` (cancel/reschedule), потом ФИО + телефон + старое время + желаемый интервал. Условия выхода: `new_booking` / `escalate_human` / `price_question`.
- [ ] `price_question.md` — добавить 3 пункта (`+++`, BRANCH_MAP §6): про эндоскопию 1000₽ при первичном ЛОР-приёме, про лечебные процедуры (доплата), про ОМС только сурдолог.
- [ ] `medical_question.md` — добавить 1 пункт (`+++`, BRANCH_MAP §7): беременность / онкология / психиатрия → специализированная клиника, передать администратору, не предлагать запись.
- [ ] `general_info.md` — добавить 3 раздела (BRANCH_MAP §8): «Отзывы и социальное доказательство», «Преимущества клиники», «Сокращения».
**Применение в БД (вручную через UI):**
- [ ] Оператор в Настройках для каждой из 4 веток: загрузить текст из обновлённого `.md` в textarea «Системный промпт ветки» → «Сохранить как новую версию» с галочкой «Сразу сделать активной».
- [ ] Прогнать в тест-блоке «Тест-вопрос» по 1-2 кейса на каждую ветку, чтобы убедиться, что новый промпт работает с подписанными документами.
**Не делаем в этом спринте:**
- `escalate_human` — карта явно говорит «рабочий и хороший, не менять».
- `new_booking` — отдельный Спринт 7.6.
- Сквозные правила (BRANCH_MAP §2) — в идеи на потом.
- Поле `description` в `SEED_INTENTS` — текущие описания лучше карточных, не меняем.
### Критерий готовности
- [ ] 4 файла `prompts/intents/*.md` обновлены и закоммичены.
- [ ] В БД для каждой из 4 веток есть свежая активная версия с обновлённым текстом.
- [ ] Тест-блок в Настройках для каждой из 4 веток отвечает корректно на 1-2 кейса.
---
## Спринт 7.6. Оптимизация воронки `new_booking` до 4 шагов
### Цель
Сжать воронку `new_booking` с 6 шагов (`intro → qualify → present → offer_time → book → close`) до 4 (`intro → qualify → book → close`), переписать содержимое `qualify` под 5-пунктовый шаблон ответа (эмпатия → 2-3 ЛОР-причины → специалист → услуга/цена → CTA), перенести сбор имени с `intro` на `book`. Полная спецификация — в `docs/OPTIMIZATION_CONVERSION_v1.md`.
### Статус: ✅ Закрыт по коду (применение промптов в БД и ручная регрессия — за оператором)
Выбран **вариант 2** блока C — `present` убран как самостоятельный шаг. Воронка: `intro → qualify → book → close`.
### Задачи
**Блок A — сжатие воронки:**
- [x] `intro.md` — приветствие + открытый вопрос, имя НЕ спрашиваем (слот `name` со шага снят).
- [x] `book.md` — подтверждение плана + запрос телефона/имени в одной реплике.
- [x] `qualify.md` — снято требование «не уходи дальше пока нет name».
**Блок B — содержательный `qualify`:**
- [x] `qualify.md` — 5-пунктовый шаблон (эмпатия → 2–3 гипотезы из RAG → специалист → услуга/цена → CTA).
- [x] Три особые ситуации сохранены: запись ребёнка с `require_legal_rep`, конкретный врач с `waitlist_flag`, первичная жалоба на слух с `needs_surgologist_first`.
- [x] Деградация: при отсутствии гипотез/цен в RAG — пропускать пункт, не сочинять.
**Блок C — `present` (вариант 2):**
- [x] `present.md` помечен как DEPRECATED, оставлен в репозитории на случай отката.
- [x] `SEED_INTENT_STEPS` обновлён: `qualify → [qualify, book]`, `present → [book]` (изоляция), `book → [book, qualify, close]` (без `offer_time`).
- [x] `migrate_new_booking_allowed_next_v2()` — одноразовая миграция при старте сервиса. Идемпотентна. Если оператор правил `allowed_next` руками — пропускает (warning в лог).
**Блок D — регрессия:**
- [x] `eval/MANUAL_CASES.md` — чеклист на 5 конверсионных кейсов + 8 ручных сценариев из блока H Спринта 6b.
**Применение промптов в БД:**
- [x] В Спринте 7.7 (коммит `a79b6f9`) активный граф new_booking сжат до 4 шагов (`intro`, `qualify`, `book`, `close`); архивный 6-шаговый — отдельный граф v1.
- [x] Промпты `intro`, `qualify`, `book` накатаны из файлов в БД активного графа через PATCH (sync-скрипт, 2026-05-02). `close` уже совпадал.
**Регрессия (ручная, за оператором):**
- [ ] Прогнать в Песочнице 5 кейсов из `eval/MANUAL_CASES.md` §A — проверить структуру первого ответа (5 пунктов) и сжатие воронки (≤ 3 реплик до телефона).
- [ ] Прогнать 8 кейсов из `eval/MANUAL_CASES.md` §B — все должны проходить как раньше.
### Критерий готовности
- [x] Файлы промптов и `allowed_next` обновлены в коде, миграция отрабатывает.
- [ ] (за оператором) На контрольном кейсе «храп + заложенность ушей» бот отвечает по 5-пунктовому шаблону, до запроса телефона ≤ 3 реплик.
- [ ] (за оператором) Все 8 ручных сценариев из блока H Спринта 6b проходят.
- [x] Промпты `intro.md`, `qualify.md`, `book.md` обновлены и активированы в БД.
---
## Спринт 7.7. Версионирование графа шагов в БД + UI переключения
### Цель
Хранить старый 6-шаговый сценарий `new_booking` параллельно с новым 4-шаговым, чтобы можно было откатиться или сравнить варианты, не теряя историю. Оператор переключает активную версию из UI «Настройки → Шаги».
### Статус: ✅ Закрыт
### Задачи
**Модель и миграция:**
- [x] Таблица `intent_step_graphs` (id, intent_id, version, name, is_active, created_at). Активный ровно один на ветку.
- [x] `intent_steps.graph_id` FK на `intent_step_graphs`. UNIQUE сменён с `(intent_id, code)` на `(graph_id, code)`.
- [x] Alembic-миграция `j6d8c4b56g23` (batch_alter_table для SQLite).
**Сервис и сидинг:**
- [x] `services/intent_step_graph_service.py`: `ensure_seed_graphs`, `list_graphs`, `get_active_graph`, `set_active_graph`.
- [x] `ensure_seed_graphs` идемпотентен: создаёт активный граф для каждой state-machine-ветки, привязывает существующие шаги (graph_id IS NULL), для `new_booking` восстанавливает архивный 6-шаговый граф из `prompts/intents/new_booking/steps/_archived_v1/*.md` и `_PRE_SPRINT_7_6_ALLOWED_NEXT`.
- [x] Активный граф `new_booking` сжат до 4 шагов: deprecated `present` и `offer_time` удаляются (живут только в архивном v1).
- [x] `SEED_INTENT_STEPS["new_booking"]` обновлён под 4 шага.
**API (`routers/intents.py`):**
- [x] `GET /intents/{code}/step-graphs` — список графов с `steps_count` и `is_active`.
- [x] `POST /intents/{code}/step-graphs/{graph_id}/activate` — переключение активного.
- [x] Чтение шагов (`list_steps_for_intent`, `get_step_by_code`, `get_first_step`) фильтруется по активному графу.
**UI (`static/settings.html`):**
- [x] На вкладке «Шаги» вверху блок «Версии графа шагов» с карточками: имя, кол-во шагов, бейдж «активная» / кнопка «Активировать».
- [x] Заголовок вкладки «Шаги (N)» считается по активному графу: для new_booking активный = 4, после переключения на v1 = 6.
- [x] Раздел «Тест-вопрос от пациента» сделан сворачиваемым.
### Критерий готовности
- [x] В БД 2 графа для `new_booking`: активный v2 (4 шага) и архивный v1 (6 шагов).
- [x] API `/step-graphs` возвращает оба, `/steps` — только шаги активного.
- [x] Переключение через UI меняет `Шаги (4)``Шаги (6)` и список шагов.
- [x] Все остальные ветки получили один активный граф автоматически.
---
## Спринт 8a. Регрессия роутера в UI
### Цель
Дать оператору-настройщику кнопку: «после правки промпта `_router` нажми и увидь, что сломалось». Не CLI, не для разработчика — встроено в страницу «Регрессия» рядом с Настройками. Кэш ответов привязан к версии роутера: повторный прогон на той же версии — мгновенный, на новой — пересчитывается.
### Статус: ✅ Закрыт
### Задачи
**Backend:**
- [x] Таблицы `eval_runs`, `eval_run_cases` (с `is_pass`), `eval_router_predictions` (кэш `text_hash + router_config_id → predicted_intent`). Alembic-миграции `k7e9d5c67h34`, `l8f0e6d78i45`.
- [x] Сервис `services/eval_run_service.py`: `start_router_run(text_hashes)` запускает фоновую корутину, использует кэш, фиксирует активную версию `_router`. `compute_diff_vs_previous` — сравнение с предыдущим прогоном на той же версии (новые fail / новые pass).
- [x] API: `POST /eval/runs` (фон), `GET /eval/runs`, `GET /eval/runs/{id}`, `GET /eval/router-cases-with-status` (все 1573 кейса + кэш на активной версии).
**UI (`static/regression.html` + новая вкладка «Регрессия» в шапках):**
- [x] Сворачиваемый блок «Выбор кейсов»: фильтр по intent, ввод диапазона (`1-50, 200-300`), кнопки массового выбора (Все / Снять / Только без кэша / Только FAIL в кэше / Снять кэшированные).
- [x] Таблица 1573 кейсов (отсортированы по count desc): #, чекбокс, запрос, intent, частота, кэш (PASS / FAIL → predicted / —). Цветной фон строки.
- [x] Счётчик «выбрано N (новых: X, в кэше: Y)»; кнопка «Прогнать выбранное (X новых + Y из кэша)».
- [x] История прогонов с polling раз в 2 секунды, прогресс-бар, drill-down: все кейсы прогона + фильтр pass/fail + поиск + diff vs предыдущий.
### Критерий готовности
- [x] На пустой версии роутера прогон 50 кейсов за ~1 минуту, повторный — мгновенный.
- [x] При активации новой версии `_router` — кэш пуст, прогон полный.
- [x] Diff показывает «новые fail / новые pass» при сравнении с предыдущим прогоном на той же версии.
---
## Спринт 8b. Регрессия ответов веток (RAG + keywords)
### Цель
По принципу 8a, но проверяем уже не код intent-а от роутера, а **содержимое ответа конкретной ветки** на одиночную реплику. Старт — только `general_info`: «вопрос про адрес / часы / маршрут → ответ должен ссылаться на нужный документ и содержать ключевые слова». Дальше расширим на остальные ветки.
### Статус: ✅ Закрыт
### Скоуп MVP (что взяли)
- **Ветка:** `general_info`. JSONL `eval/branch_cases_general_info.jsonl` (46 кейсов).
- **Способы pass/fail:**
- **A — RAG-проверка:** среди retrieved-чанков есть кусок с `section == expected_doc_section` (точное совпадение). Если поле не задано — пропускаем.
- **B — keywords в ответе:** обязательные `expected_keywords` встречаются в `predicted_answer` (case-insensitive). По умолчанию нужны **все**; поддерживаются `keywords_min: N` и `keywords_any: true` (алиас для `keywords_min: 1`). Запрещённые `expected_must_not` — ни одного.
- **Pass = A ∧ B** (если поле задано). Незаданные поля не проверяются.
- **Кэш:** `(text_hash, branch_config_id) → {answer_text, retrieved_sections}`. При смене активной версии промпта ветки — кэш по новой версии пуст, прогон полный. При правке полей JSONL без изменения `text` — pass/fail пересчитывается без LLM.
### Что осознанно вынесено в `docs/BACKLOG.md`
- **Вариант C — LLM-judge** (отдельный LLM-вызов оценивает «подходит ли ответ»).
- **Вариант D — эталон + embeddings** (cosine similarity с эталонным ответом).
- **Diff vs предыдущий прогон** для веток (для роутера в 8a уже есть).
- **Кнопка «Сбросить кэш регрессии»** на странице (сейчас инвалидация — через создание новой версии промпта).
### Задачи
**База кейсов (от пользователя):**
- [x] `eval/branch_cases_general_info.jsonl` (46 кейсов). Схема: `{text, intent, coverage, expected_doc_section?, expected_keywords?, expected_must_not?, keywords_min?, keywords_any?, count?, note?}`.
- [x] `coverage` (`covered` / `partial` / `not_covered`) — метаинфо: есть ли материал в RAG. Для `not_covered` keywords обычно `["оператор"]` — бот должен передать живому.
**Backend:**
- [x] Таблицы `eval_branch_runs` / `eval_branch_run_cases` / `eval_branch_predictions`. Миграция `m9g1f7e89j56`.
- [x] `services/eval_branch_run_service.py`: загрузка JSONL, фоновый прогон, кэш по (`text_hash`, `branch_config_id`), оценка A+B с поддержкой `keywords_min`/`keywords_any`.
- [x] `chat_service.run_branch_single_turn` — изолированный single-turn без роутера и треда.
- [x] API: `POST /eval/branch-runs`, `GET /eval/branch-runs`, `GET /eval/branch-runs/{id}`, `GET /eval/branch-cases-with-status?intent_code=`.
**UI (`static/regression.html`):**
- [x] Селектор режима «Роутер / Ветка · general_info» в шапке страницы.
- [x] Для режима «Ветка»: фильтр по `coverage`, столбцы `секция / coverage`, `keywords` (краткая сводка), `частота`, `кэш`. Drill-down прогона: ожидание (секция / keywords / must_not), retrieved-секции, причины fail, **полный ответ ветки**.
**Связанная правка SQLite (нашли при удалении документа):**
- [x] `db/session.py` — connect-listener `PRAGMA foreign_keys=ON` на каждое подключение. Без этого `ondelete=CASCADE` в SQLite не enforced — удаление документа не очищало подписки в `intent_documents`, и регрессия валилась на пустом RAG.
- [x] Миграция `n0h2g8f9a0k67` — одноразовая чистка существующих висячих подписок.
### Критерий готовности
- [x] На стартовом наборе `general_info` (46 кейсов) прогон проходит за ~3–5 минут (последовательные LLM-вызовы). Повторный на той же версии — мгновенный.
- [x] При активации новой версии промпта ветки кэш пуст, прогон полный.
- [x] Удаление документа на «Отладка» автоматически очищает подписки веток.
---
## Спринт 8c. Дополнительные регрессионные сценарии
### Статус: ⏳ Запланирован (после 8b и накопления кейсов)
Темы: handoff между ветками (multi-turn), resumable detour-и-возврат, петли роутера, защитные условия (ребёнок, waitlist), мульти-RAG. Эти сценарии в SPRINTS.md изначально шли в одном Спринте 8 — разделили, чтобы 8a/8b закрыть быстрее.
Точечные наборы из исходного плана:
- [ ] `eval/handoff_cases.jsonl` — 5–10 многошаговых мини-диалогов.
- [ ] `eval/resumable_cases.jsonl` — 35 detour-и-возврат.
- [ ] `eval/loop_cases.jsonl` — 1–2 искусственная петля.
- [ ] `eval/guard_cases.jsonl``require_legal_rep`, `waitlist`.
- [ ] `eval/rag_cases.jsonl` — мульти-RAG.
---
## Спринт 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-запуск
---
## Бэклог
### Дальнейшие идеи
- **Сквозные правила всех веток** (из `docs/BRANCH_MAP_AND_PROMPTS_v1.md` §2): тон, что нельзя говорить, обработка сокращений, обязательное предупреждение про доп. расходы. Сейчас этого механизма нет — каждая ветка хранит свои `rules_text` отдельно. Завести «глобальный» промпт-префикс (например, поле `Intent.global_prefix_id` или общая запись в `agent_configs` с зарезервированным `intent_code = "_global"`), подмешивать в системный промпт каждой ветки до её собственного. Альтернатива — продолжать копипастить общие правила в `rules_text` каждой ветки, что хуже для поддержки.
- **Документация Спринта 7** — отложено: карточка термина «Подписка ветки на документы» в `static/docs.html`; обновление архитектуры до `GRAPH_ARCHITECTURE_v5.md` (§6 переписать под подход A — M:N через `document_id`); раздел про мульти-RAG в `README.md`. Закроется одним заходом, когда станет понятна часть Б Спринта 7 (внешняя вики).
- **Спринт 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 покажет, что нужно