Sprint 8.5 — чанкер v2 (services/document_processor.py):
- markdown-it-py для md-входа: каждый H2 открывает свою секцию, H3 идёт в тело
- множественные H1 — штатный кейс (new_booking.md = 8 H1, шаги воронки + группы);
H1 без H2 → секция heading=H1; преамбула H1 (тело до первого H2) игнорируется
- YAML frontmatter (--- ... ---) отрезается, в индекс не попадает
- breadcrumb «## {H2}» как первая строка каждого subchunk'а
- merge коротких хвостов и sentence-overlap — только внутри одной H2-секции
- excluded_section_headings в config.py
- 17 unit-тестов на stdlib unittest (tests/test_document_processor_v2.py),
включая smoke по реальным general_info.md (тимпанометрия → правильная секция)
и new_booking.md (защита от регрессии множественных H1)
- ТЗ: docs/CHUNKER_v2_TZ.md
Sprint 8.6 — регрессия остальных 4 веток (static/regression.html):
- 4 опции в селекторе режима: branch:price_question (40 кейсов),
branch:medical_question (29), branch:escalate_human (14), branch:reschedule (16)
- бэкенд из 8b уже параметрический — правок в сервисе не потребовалось
- new_booking вне скоупа — state-machine, под него отдельный 8c (multi-turn)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
89 KiB
Спринты — 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_promptGET /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_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.
Статус: ✅ Закрыт
Задачи
Данные:
- Новая таблица
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 — в бэклоге)
Задачи
Данные:
- Таблица
thread_state(thread_id, current_intent_code, current_step, slots_json, updated_at) + миграция Alembic (batch-режим под SQLite)
State machine (первая ветка — new_booking):
- 6-шаговый скрипт: приветствие → повод → специалист → удобное время → подтверждение → запись
- Модель на каждой реплике видит блок
[ТЕКУЩЕЕ СОСТОЯНИЕ]сstepиslots - Переход шагов управляется служебным тегом
[STATE: step=N; slots={...}]в ответе модели (строковый тег, парсится балансировкой фигурных скобок)
Exit conditions и bouncing:
- В промпт
new_bookingдобавлен блок условий выхода с сигналом[INTENT_CHANGE: <code>] - Парсер в
services/chat_service._parse_assistant_signalsвырезает служебные теги из ответа - Bouncing: одна итерация (
MAX_BOUNCES=1) — ветка может передать управление другой, делаем повторный вызов LLM - Роутер на каждой реплике: если классификация ≠
thread_state.current_intent_code→ сбросstepиslots
UI:
- В «Песочнице» блок «Состояние треда»: intent, шаг, слоты (JSON), список переходов в текущей реплике
- В отладке роутера — пометка, если ветка «передала управление»
Критерий готовности
- Сценарий new_booking проходит: ФИО → повод → специалист → время → подтверждение собираются в
thread_state.slots - Переключение ветки через роутер: «Сколько стоит приём?» внутри записи → state сбрасывается в
price_question - В отладке видна вся цепочка: роутер-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_humancreason=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фиксируем результат (ок / расхождение + детали).- Базовая запись (6 шагов →
confirmed=true). - Запись → вопрос про цену (soft-insertion, без смены шага).
- Запись → перенос старой записи (hard-handoff в
reschedule,suspended=new_booking). - Запись → detour → возврат «бронируем на четверг» (восстановление из
suspended). - Запись → упоминание хирургии (
escalate_human: surgery, саммари). - Искусственная петля (
routing_loopпосле cap). - Запись ребёнка (guard блокирует переход).
- Конкретный врач (waitlist-рукав).
- Базовая запись (6 шагов →
Критерий готовности 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.
Статус: ✅ Закрыт
Задачи
- Зафиксирован словарь: «намерение» (intent) и «ветка» (branch) разнесены концептуально, в коде остаётся
intent_code(связь 1:1, см. идею в «Дальнейшие идеи»). «Маршрутизатор» вместо «роутер». «Защитное условие» вместо «guard» (буквально из v3 §3.2). «Пошаговая ветка» вместо «многошаговая». Введены: «Решение маршрутизатора», «Активная ветка», «Счётчик переключений», «Причина передачи оператору». - Документация (
static/docs.html) — карточки терминов и текст приведены к словарю. Добавлены карточки «Намерение», «Ветка» (с историческим замечанием про intent в БД), «Решение маршрутизатора», «Активная ветка», «Счётчик переключений», «Причина передачи оператору». «Guard» переименован в «Защитное условие». - Песочница (
static/sandbox.html) — «Решение роутера» → «Решение маршрутизатора». Бейдж «многошаговая» → «пошаговая ветка». Бейдж «🔒 guard X» → «🔒 защитное условие X». «Решение маршрутизатора» теперь всегда видимый бейдж (зелёный при совпадении с активной веткой, жёлтый при расхождении). Активная ветка названа явно. Счётчик переключений вынесен в визуальный элемент «N из 3» (красный при достижении капа). - Настройки (
static/settings.html) — поле «Guards (JSON)» → «Защитные условия (guards, JSON)», тост ошибки переименован. - Страницы примеров — параметризованная страница
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)
Задачи
Бэкенд:
- Миграция Alembic
i5c8b3a45f12_add_intent_documents: новая таблицаintent_documentsс полямиintent_id(FK наintents.id),document_id(varchar 36, тип как в metadata Chroma),created_at. PK составной, индекс поdocument_id. - Модель
db/models/intent_document.py(IntentDocument) с каскадом удаления. - Сервис
services/intent_document_service.py— функцииlist_documents_for_intent_code,list_intents_for_document,set_documents_for_intent,set_intents_for_document. - API:
GET/PUT /intents/{code}/documentsиGET/PUT /documents/{id}/intentsс PUT-семантикой «полный список», атомарно. - Retrieval-фильтр в
services/chat_service.py+vectorstore.query()различаетNone(нет фильтра, вся коллекция) и[](пустая подписка, 0 чанков). Дефолт для пациентских веток —[]. Для_debug—None(отладка работает из коробки).
UI:
- «Настройки» → блок «Документы базы знаний» в правом сайдбаре, всегда видим (независимо от вкладки), сортировка по имени, счётчик «N из M».
- «Отладка» → кнопка «привязка» рядом с «удалить» → раскрывашка со списком веток, быстрая привязка прямо на месте.
- «Отладка» → кнопка «редактировать» → большой textarea с raw_text,
PUT /documents/{id}/rawобновляет текст и переиндексирует в Chroma. С confirm. - Системный промпт страницы «Отладка» переехал в ветку
_debug. Удалёнprompts/system_prompt.mdиDEFAULT_SYSTEM_PROMPTвllm_client.py. info-bar на странице Отладки: версия + подписки + ссылка в Настройки. - Песочница: блок «Срез RAG», поле
rag_subscriptionвChatResponse, ворнинг при пустой подписке. - Тест-блок «Тест-вопрос от пациента» в центре Настроек (для любой выбранной ветки): 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.
Критерий готовности
- Документ, привязанный к
price_question, появляется в retrieval только когда активна именно эта ветка. - Ветка без подписок получает в retrieval 0 чанков (для пациентских) или вся коллекция (для
_debug). - В Песочнице видно «подписано N из M», в найденных фрагментах — название документа.
- Подписка работает в обе стороны 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 — сжатие воронки:
intro.md— приветствие + открытый вопрос, имя НЕ спрашиваем (слотnameсо шага снят).book.md— подтверждение плана + запрос телефона/имени в одной реплике.qualify.md— снято требование «не уходи дальше пока нет name».
Блок B — содержательный qualify:
qualify.md— 5-пунктовый шаблон (эмпатия → 2–3 гипотезы из RAG → специалист → услуга/цена → CTA).- Три особые ситуации сохранены: запись ребёнка с
require_legal_rep, конкретный врач сwaitlist_flag, первичная жалоба на слух сneeds_surgologist_first. - Деградация: при отсутствии гипотез/цен в RAG — пропускать пункт, не сочинять.
Блок C — present (вариант 2):
present.mdпомечен как DEPRECATED, оставлен в репозитории на случай отката.SEED_INTENT_STEPSобновлён:qualify → [qualify, book],present → [book](изоляция),book → [book, qualify, close](безoffer_time).migrate_new_booking_allowed_next_v2()— одноразовая миграция при старте сервиса. Идемпотентна. Если оператор правилallowed_nextруками — пропускает (warning в лог).
Блок D — регрессия:
eval/MANUAL_CASES.md— чеклист на 5 конверсионных кейсов + 8 ручных сценариев из блока H Спринта 6b.
Применение промптов в БД:
- В Спринте 7.7 (коммит
a79b6f9) активный граф new_booking сжат до 4 шагов (intro,qualify,book,close); архивный 6-шаговый — отдельный граф v1. - Промпты
intro,qualify,bookнакатаны из файлов в БД активного графа через PATCH (sync-скрипт, 2026-05-02).closeуже совпадал.
Регрессия (ручная, за оператором):
- Прогнать в Песочнице 5 кейсов из
eval/MANUAL_CASES.md§A — проверить структуру первого ответа (5 пунктов) и сжатие воронки (≤ 3 реплик до телефона). - Прогнать 8 кейсов из
eval/MANUAL_CASES.md§B — все должны проходить как раньше.
Критерий готовности
- Файлы промптов и
allowed_nextобновлены в коде, миграция отрабатывает. - (за оператором) На контрольном кейсе «храп + заложенность ушей» бот отвечает по 5-пунктовому шаблону, до запроса телефона ≤ 3 реплик.
- (за оператором) Все 8 ручных сценариев из блока H Спринта 6b проходят.
- Промпты
intro.md,qualify.md,book.mdобновлены и активированы в БД.
Спринт 7.7. Версионирование графа шагов в БД + UI переключения
Цель
Хранить старый 6-шаговый сценарий new_booking параллельно с новым 4-шаговым, чтобы можно было откатиться или сравнить варианты, не теряя историю. Оператор переключает активную версию из UI «Настройки → Шаги».
Статус: ✅ Закрыт
Задачи
Модель и миграция:
- Таблица
intent_step_graphs(id, intent_id, version, name, is_active, created_at). Активный ровно один на ветку. intent_steps.graph_idFK наintent_step_graphs. UNIQUE сменён с(intent_id, code)на(graph_id, code).- Alembic-миграция
j6d8c4b56g23(batch_alter_table для SQLite).
Сервис и сидинг:
services/intent_step_graph_service.py:ensure_seed_graphs,list_graphs,get_active_graph,set_active_graph.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.- Активный граф
new_bookingсжат до 4 шагов: deprecatedpresentиoffer_timeудаляются (живут только в архивном v1). SEED_INTENT_STEPS["new_booking"]обновлён под 4 шага.
API (routers/intents.py):
GET /intents/{code}/step-graphs— список графов сsteps_countиis_active.POST /intents/{code}/step-graphs/{graph_id}/activate— переключение активного.- Чтение шагов (
list_steps_for_intent,get_step_by_code,get_first_step) фильтруется по активному графу.
UI (static/settings.html):
- На вкладке «Шаги» вверху блок «Версии графа шагов» с карточками: имя, кол-во шагов, бейдж «активная» / кнопка «Активировать».
- Заголовок вкладки «Шаги (N)» считается по активному графу: для new_booking активный = 4, после переключения на v1 = 6.
- Раздел «Тест-вопрос от пациента» сделан сворачиваемым.
Критерий готовности
- В БД 2 графа для
new_booking: активный v2 (4 шага) и архивный v1 (6 шагов). - API
/step-graphsвозвращает оба,/steps— только шаги активного. - Переключение через UI меняет
Шаги (4)↔Шаги (6)и список шагов. - Все остальные ветки получили один активный граф автоматически.
Спринт 8a. Регрессия роутера в UI
Цель
Дать оператору-настройщику кнопку: «после правки промпта _router нажми и увидь, что сломалось». Не CLI, не для разработчика — встроено в страницу «Регрессия» рядом с Настройками. Кэш ответов привязан к версии роутера: повторный прогон на той же версии — мгновенный, на новой — пересчитывается.
Статус: ✅ Закрыт
Задачи
Backend:
- Таблицы
eval_runs,eval_run_cases(сis_pass),eval_router_predictions(кэшtext_hash + router_config_id → predicted_intent). Alembic-миграцииk7e9d5c67h34,l8f0e6d78i45. - Сервис
services/eval_run_service.py:start_router_run(text_hashes)запускает фоновую корутину, использует кэш, фиксирует активную версию_router.compute_diff_vs_previous— сравнение с предыдущим прогоном на той же версии (новые fail / новые pass). - API:
POST /eval/runs(фон),GET /eval/runs,GET /eval/runs/{id},GET /eval/router-cases-with-status(все 1573 кейса + кэш на активной версии).
UI (static/regression.html + новая вкладка «Регрессия» в шапках):
- Сворачиваемый блок «Выбор кейсов»: фильтр по intent, ввод диапазона (
1-50, 200-300), кнопки массового выбора (Все / Снять / Только без кэша / Только FAIL в кэше / Снять кэшированные). - Таблица 1573 кейсов (отсортированы по count desc): #, чекбокс, запрос, intent, частота, кэш (PASS / FAIL → predicted / —). Цветной фон строки.
- Счётчик «выбрано N (новых: X, в кэше: Y)»; кнопка «Прогнать выбранное (X новых + Y из кэша)».
- История прогонов с polling раз в 2 секунды, прогресс-бар, drill-down: все кейсы прогона + фильтр pass/fail + поиск + diff vs предыдущий.
Критерий готовности
- На пустой версии роутера прогон 50 кейсов за ~1 минуту, повторный — мгновенный.
- При активации новой версии
_router— кэш пуст, прогон полный. - Diff показывает «новые fail / новые pass» при сравнении с предыдущим прогоном на той же версии.
Спринт 8b. Регрессия ответов веток (RAG + keywords)
Цель
По принципу 8a, но проверяем уже не код intent-а от роутера, а содержимое ответа конкретной ветки на одиночную реплику. Старт — только general_info: «вопрос про адрес / часы / маршрут → ответ должен ссылаться на нужный документ и содержать ключевые слова». Дальше расширим на остальные ветки.
Статус: ✅ Закрыт
Скоуп MVP (что взяли)
- Ветка:
general_info. JSONLeval/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— ни одного.
- A — RAG-проверка: среди retrieved-чанков есть кусок с
- 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 уже есть).
- Кнопка «Сбросить кэш регрессии» на странице (сейчас инвалидация — через создание новой версии промпта).
Задачи
База кейсов (от пользователя):
eval/branch_cases_general_info.jsonl(46 кейсов). Схема:{text, intent, coverage, expected_doc_section?, expected_keywords?, expected_must_not?, keywords_min?, keywords_any?, count?, note?}.coverage(covered/partial/not_covered) — метаинфо: есть ли материал в RAG. Дляnot_coveredkeywords обычно["оператор"]— бот должен передать живому.
Backend:
- Таблицы
eval_branch_runs/eval_branch_run_cases/eval_branch_predictions. Миграцияm9g1f7e89j56. services/eval_branch_run_service.py: загрузка JSONL, фоновый прогон, кэш по (text_hash,branch_config_id), оценка A+B с поддержкойkeywords_min/keywords_any.chat_service.run_branch_single_turn— изолированный single-turn без роутера и треда.- 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):
- Селектор режима «Роутер / Ветка · general_info» в шапке страницы.
- Для режима «Ветка»: фильтр по
coverage, столбцысекция / coverage,keywords(краткая сводка),частота,кэш. Drill-down прогона: ожидание (секция / keywords / must_not), retrieved-секции, причины fail, полный ответ ветки.
Связанная правка SQLite (нашли при удалении документа):
db/session.py— connect-listenerPRAGMA foreign_keys=ONна каждое подключение. Без этогоondelete=CASCADEв SQLite не enforced — удаление документа не очищало подписки вintent_documents, и регрессия валилась на пустом RAG.- Миграция
n0h2g8f9a0k67— одноразовая чистка существующих висячих подписок.
Критерий готовности
- На стартовом наборе
general_info(46 кейсов) прогон проходит за ~3–5 минут (последовательные LLM-вызовы). Повторный на той же версии — мгновенный. - При активации новой версии промпта ветки кэш пуст, прогон полный.
- Удаление документа на «Отладка» автоматически очищает подписки веток.
Спринт 8c. Дополнительные регрессионные сценарии
Статус: ⏳ Запланирован (после 8b и накопления кейсов)
Темы: handoff между ветками (multi-turn), resumable detour-и-возврат, петли роутера, защитные условия (ребёнок, waitlist), мульти-RAG. Эти сценарии в SPRINTS.md изначально шли в одном Спринте 8 — разделили, чтобы 8a/8b закрыть быстрее.
Точечные наборы из исходного плана:
eval/handoff_cases.jsonl— 5–10 многошаговых мини-диалогов.eval/resumable_cases.jsonl— 3–5 detour-и-возврат.eval/loop_cases.jsonl— 1–2 искусственная петля.eval/guard_cases.jsonl—require_legal_rep,waitlist.eval/rag_cases.jsonl— мульти-RAG.
Спринт 8.5. Чанкер v2 (markdown с иерархией H1/H2/H3)
Цель
Сделать нарезку wiki-датасетов предсказуемой и совместимой с eval-контрактом «metadata.section чанка == заголовок H2 раздела». Триггер — фейл регрессии 8b по тимпанометрии: expected_doc_section: "Направления приёма" не находится из-за того, что текущий парсер режет markdown эвристиками без учёта иерархии, склеивает соседей через границы H2 и подмешивает overlap чужой секции. Полное ТЗ — docs/CHUNKER_v2_TZ.md.
Статус: ✅ Закрыт
Задачи
Парсинг (services/document_processor.py::parse_markdown, ветка is_markdown=True):
- Перейти на
markdown-it-py(уже в зависимостях транзитивно), регэкспные эвристикиnumbered_heading_re/faq_question_re/ ALL-CAPS — отключить для md-входа (для txt оставить как есть). - Каждый H2 открывает свою секцию; H3 и ниже идут в тело текущей H2 как строка
### {текст}. - Множественные H1 — штатный кейс (
new_booking.mdимеет 8 H1 — шаги воронки + группы). Каждый H1 группирует свои H2-секции; преамбула H1 (тело до первого H2) игнорируется. Если внутри H1 нет ни одного H2 — H1 сам становится одной секцией с heading=H1. Служебные блоки операторы держат в отдельном файлеdocs/wiki_meta_<branch>.md(внеdata/datasets/), парсеру их различать не нужно. - YAML frontmatter (
--- ... ---в самом начале файла) распарсить, вернуть отдельным полемdocument_metadata, в текст не пропускать.
Чанкинг (services/document_processor.py::chunk_sections):
- Резка длинных H2 по абзацам (
\n\n, не\n). - В каждый subchunk добавлять breadcrumb-префикс
## {heading H2}как первую строку.sectionво всех subchunk'ах одинаков. - Merge коротких хвостов — только внутри одной H2-секции. Через границу H2 склеивать запрещено.
- Sentence-overlap — только между subchunk'ами одной H2. Между разными секциями overlap'а нет.
Конфиг (config.py):
excluded_section_headings: list[str] = []— H2 из этого списка не индексируются (под будущую внешнюю вики).
Тесты (новый каталог tests/, на stdlib unittest — без новых зависимостей):
tests/test_document_processor_v2.py. Запуск:.venv/bin/python -m unittest tests.test_document_processor_v2 -v(17 кейсов, все зелёные).general_info.md→ всеsectionнепустые, нет ни одногоsection, начинающегося с цифры; чанк, содержащий «тимпанометр», имеетsection == "Направления приёма"; в каждом чанке первая строка — breadcrumb## {section}.new_booking.md(8 H1) → секции из всех H1-групп индексируются; точечно проверяем «Тон и формулировки», «Шаблон ответа (5 пунктов)», «Текст-завершение».- Файл с frontmatter → frontmatter не утекает в чанки; первый чанк начинается с
## {первый H2}. - Множественные H1 с H2 → секции из всех H1; преамбула H1 (тело до первого H2) выкидывается; WARN на втором H1 не возникает (старое правило отозвано).
- H1 без H2 → одна секция с heading=H1.
- H3 внутри H2 → один чанк с
section == H2, в теле строка### {H3}. - Длинная H2-секция → N subchunk'ов, у всех одинаковый
section, у каждого первая строка## {H2}. - Нумерованный список «1. … 2. …» в md-входе → не парсится как заголовок.
Что не делаем
- Не трогаем embeddings, reranker, гибридный retrieval, HyDE — отдельные спринты.
parse_pdf/parse_docxне трогаем; если в них всплывут аналогичные проблемы — отдельной итерацией.- Формат хранения в Chroma не меняем —
metadata.sectionостаётся строкой.
Критерий готовности
python -m unittest tests.test_document_processor_v2 -v— 17 кейсов, все зелёные.- Smoke-прогон чанкера на
data/datasets/general_info.md: чанк с «тимпанометр» имеетsection == "Направления приёма", нет чанков с пустымsection, нет чанков сsection, начинающимся с цифры. - После переиндексации документа через UI «Отладка» прогон регрессии
branch:general_infoпоказал PASS на тимпанометрии и других ранее падавших кейсах (подтверждено пользователем 2026-05-04).
Спринт 8.6. Регрессия остальных веток (price_question, medical_question, escalate_human, reschedule)
Цель
Расширить регрессию ответов веток (механика 8b) на все остальные ветки, кроме new_booking. Бэкенд из 8b уже универсальный — читает eval/branch_cases_<intent_code>.jsonl по имени, никаких правок в сервисе. Минимальная работа — добавить опции в селектор режима на странице «Регрессия».
new_booking намеренно оставлен вне скоупа: это state-machine-ветка с многошаговой воронкой, single-turn регрессия неправильно покажет результат — отдельная задача в Спринте 8c (multi-turn).
Статус: ✅ Закрыт
Задачи
UI (static/regression.html):
- В select
id="mode-select"добавлены 4 опции:<option value="branch:price_question">Ветка · price_question</option>(40 кейсов)<option value="branch:medical_question">Ветка · medical_question</option>(29 кейсов)<option value="branch:escalate_human">Ветка · escalate_human</option>(14 кейсов)<option value="branch:reschedule">Ветка · reschedule</option>(16 кейсов)
setMode/currentBranchIntent()параметричны (mode.split(":", 2)[1]) — правок не потребовалось.
База кейсов (уже в репо):
eval/branch_cases_price_question.jsonleval/branch_cases_medical_question.jsonleval/branch_cases_escalate_human.jsonleval/branch_cases_reschedule.jsonl
Критерий готовности
- На странице «Регрессия» в селекторе режима видны 5 опций веток (general_info + 4 новые).
- Smoke-прогон через UI для каждой из 4 новых веток — осмысленный ответ + retrieved-секции (подтверждено пользователем 2026-05-04).
- При активации новой версии промпта ветки кэш для неё пуст — поведение 8b сохраняется параметрически.
Спринт 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 покажет, что нужно