From 74befa484d99f9ffb94ef10cf8d425a3bd3ad9fe Mon Sep 17 00:00:00 2001 From: AR 15 M4 Date: Tue, 28 Apr 2026 20:49:02 +0500 Subject: [PATCH] =?UTF-8?q?feat(sprint7.5):=20=D0=BE=D0=B1=D0=BD=D0=BE?= =?UTF-8?q?=D0=B2=D0=BB=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BF=D1=80=D0=BE=D0=BC?= =?UTF-8?q?=D0=BF=D1=82=D0=BE=D0=B2=204=20=D0=B2=D0=B5=D1=82=D0=BE=D0=BA?= =?UTF-8?q?=20+=20eval-=D0=BA=D0=B0=D1=80=D0=BA=D0=B0=D1=81=20=D0=B8=20?= =?UTF-8?q?=D1=82=D0=B5=D1=81=D1=82-=D0=BA=D0=B5=D0=B9=D1=81=D1=8B=20?= =?UTF-8?q?=D0=B2=20UI=20=D0=9D=D0=B0=D1=81=D1=82=D1=80=D0=BE=D0=B5=D0=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Промпты веток (по docs/BRANCH_MAP_AND_PROMPTS_v1.md): - reschedule.md — полная замена. Одношаговый сценарий из 6 пунктов: action (cancel/reschedule), patient_name, patient_phone, original_time, preferred_new_time. Слоты хранит вызывающая система, STATE_JSON не используется. - price_question.md — добавлены 3 пункта: эндоскопия 1000₽ при первичном ЛОР-приёме, лечебные процедуры доплачиваются, ОМС только сурдолог (последний пункт работает только при подтверждении в базе). - medical_question.md — расширена карта жалоб → специалист (ЛОР / сурдолог / аллерголог / иммунолог / пульмонолог); добавлен пункт про беременность, онкологию, психиатрию — мягко сказать «специализированная клиника», не предлагать запись. - general_info.md — добавлены разделы «Отзывы и социальное доказательство», «Преимущества клиники», «Сокращения». Условия выхода расширены до 5 интентов. escalate_human и new_booking не трогаем (escalate — карта говорит «не менять»; new_booking — отдельный Спринт 7.6 по docs/OPTIMIZATION_CONVERSION_v1.md). Применение в БД — вручную через UI «Настройки» (вариант A): оператор копирует текст из .md, сохраняет как новую версию + активирует. Файлы — только seed. Eval-каркас (заготовка под Спринт 8): - eval/router_cases_booking.jsonl (875 кейсов new_booking) и eval/router_cases_other.jsonl (698 кейсов: general_info 295, price 165, escalate 139, medical 59, reschedule 40). CSV-исходники рядом. - eval/README.md — формат, глоссарий, что это и зачем. - routers/eval.py: GET /eval/router-cases?intent_code=...&limit=... Lazy-кэш, сортировка по count desc, фильтр по expected_intent. UI Настроек — выбор готового кейса в тест-блоке: - Полоса «Готовый кейс:» с datalist (поиск по началу строки) + кнопка «🎲 Случайный» + счётчик кейсов для активной ветки. - При выборе — текст подставляется в textarea вопроса. - Загружается при выборе ветки. Если кейсов 0 (для _router, _debug) — скрыто. - Полная подсистема прогона (run.py, отчёты, baseline) — Спринт 8. SPRINTS.md: - Спринт 7 (мульти-RAG, часть A) → ✅ Закрыт (коммит 52b46bc). - Заведён Спринт 7.5 «Обновление промптов 4 веток» (этот спринт). - Заведён Спринт 7.6 «Оптимизация воронки new_booking до 4 шагов» по OPTIMIZATION_CONVERSION_v1.md. - В идеи на потом: сквозные правила всех веток (BRANCH_MAP §2), отложенная документация Спринта 7 (docs.html карточка термина, GRAPH_ARCHITECTURE_v5, README про мульти-RAG). Также: docs/COMPETITOR_ALEXANDRA_top100.md — рабочие материалы пользователя по конкурентному боту (NEXTBOT/Александра), используется как baseline для оптимизации воронки в Спринте 7.6. Co-Authored-By: Claude Opus 4.7 (1M context) --- docs/COMPETITOR_ALEXANDRA_top100.md | 108 ++++ docs/SPRINTS.md | 134 +++-- eval/README.md | 295 ++++++++++ eval/router_cases_booking.csv | 876 ++++++++++++++++++++++++++++ eval/router_cases_booking.jsonl | 875 +++++++++++++++++++++++++++ eval/router_cases_other.csv | 699 ++++++++++++++++++++++ eval/router_cases_other.jsonl | 698 ++++++++++++++++++++++ main.py | 3 +- prompts/intents/general_info.md | 38 +- prompts/intents/medical_question.md | 22 +- prompts/intents/price_question.md | 16 +- prompts/intents/reschedule.md | 50 +- routers/eval.py | 88 +++ static/settings.html | 101 ++++ 14 files changed, 3939 insertions(+), 64 deletions(-) create mode 100644 docs/COMPETITOR_ALEXANDRA_top100.md create mode 100644 eval/README.md create mode 100644 eval/router_cases_booking.csv create mode 100644 eval/router_cases_booking.jsonl create mode 100644 eval/router_cases_other.csv create mode 100644 eval/router_cases_other.jsonl create mode 100644 routers/eval.py diff --git a/docs/COMPETITOR_ALEXANDRA_top100.md b/docs/COMPETITOR_ALEXANDRA_top100.md new file mode 100644 index 0000000..6f00c99 --- /dev/null +++ b/docs/COMPETITOR_ALEXANDRA_top100.md @@ -0,0 +1,108 @@ +# Разбор конкурента «Александра» — первые 100 диалогов из 2691 + +**Источник:** `dialogs-export-2026-04-27-09-45-07.xlsx` (clinic-lor@mail.ru, ЛОР-клиника, Пермь, 2 филиала). +**Дата экспорта:** 27.04.2026. **Дата анализа:** 27.04.2026. + +## Метрики конкурента (по сводке файла) + +- 2691 диалог за период. +- 6.1 сообщений на диалог в среднем. +- Каналы: Widget 87%, VK 13%, Max/Telegram единицы. +- 684 диалога с тегом `has_tool_calls` = ~25% общая конверсия в передачу заявки администратору. **В первых 100 — 32%** (часть из них — повторные обращения тех же клиентов). +- Расход $ после рестарта = 0 (т.е. они на Botcoin/локальной модели). +- Средний расход 2.76 Botcoin/диалог. + +## Архитектура их сценария (восстановлена по стенограммам) + +Воронка из **3 шагов**, не 4: + +1. **Triage** — бот сразу собирает причину обращения, не настаивая на имени, без отдельного intro. +2. **Pitch** — называет цену первичного приёма (от 2500 ₽), упоминает что эндоскопия +1000 ₽ оплачивается отдельно, предлагает записать. +3. **Capture** — просит **только телефон**. Имя — опционально, бот достаёт его из реплик сам. Дальше `save_user_data` и шаблонная плашка прощания. + +**Шаблон закрытия после tool_call** (одинаковый везде, помечен `[Шаблон]`): +> «Я отправила ваши данные администратору. Ожидайте звонка. Мне было приятно с вами общаться. Хорошего дня и спасибо за выбор нашей клиники!» + +**Структура tool_call** (полезно подсмотреть для нашего): +```json +{ + "phone": 89922070601, + "source": "Сайт", + "context": "Пациентка переболела, был сильный кашель, сейчас дискомфорт при глотании", + "service": "Прием ЛОР-врача" +} +``` +Контекст и услугу LLM формирует сам из истории — это даёт администратору готовое описание лида без необходимости перечитывать диалог. + +**«Догоняющее» сообщение** при молчании клиента: системный триггер пишет ассистенту «Клиент не ответил отправь ему еще одно сообщение: "У вас остались еще какие-то вопросы?"», ассистент пересылает. Несколько раз в выборке именно это возвращало клиента в воронку и приводило к захвату телефона. + +## Темы пациентов (распределение по 100 диалогам) + +В порядке убывания частотности: + +1. Запись по симптому (болит ухо, насморк, заложенность, выделения, шум, головокружение) — около половины обращений. +2. Запись к конкретному врачу по фамилии — ~15%. +3. Стоимость услуги/процедуры — ~10%. +4. Справка для налогового вычета — повторяется 8+ раз в первой сотне с почти идентичным длинным ответом. Кандидат на отдельный intent с готовым шаблоном. +5. Дети (с рождения, аденоиды, отиты, шунты) — ~10%. +6. Перенос/отмена записи — ~5%. +7. Доверенность для бабушки сопровождать ребёнка — есть готовый текстовый шаблон от руки. +8. График работы / праздники, адреса, ОМС/ДМС, рассрочка, оплата по QR — короткие FAQ. +9. Отсев по географии (Казахстан, Тула, Калуга, Златоуст, Кизел) — клиника только в Перми. + +## Что у них работает (можно перенять) + +- **Минимум полей в lead-форме:** только phone. Имя/контекст бот собирает сам. Снимает фрикцию. +- **Контекст и услуга в JSON** автоматически — экономит время администратора. +- **Шаблонные ответы для типовых FAQ** (налоговая справка, доверенность) выдаются мгновенно и без LLM-творчества — стабильно и дёшево. +- **Догоняющее сообщение** через системный триггер при молчании. +- **Конкретика в питче:** цена + регалии врача (ФИО, к.м.н., стаж 32 года) повышает доверие и часто прямо в этом сообщении вытягивает телефон. +- **Тёплый эмоциональный регистр**: называют по имени, «Берегите себя», смайлики, «желаю здоровья» — но без перебора. + +## Где они проседают (наши потенциальные преимущества) + +1. **Нет интеграции с расписанием врачей.** Любой вопрос «когда есть свободное время к доктору X?» переадресуется администратору. Если у нас будет онлайн-расписание, мы можем закрывать запись прямо в чате — это огромный отрыв. +2. **Не интегрированы с CRM записей.** Пациент пишет «я записан на завтра, во сколько?» — бот предлагает оставить телефон и ждать звонка. Унизительно для уже-клиента. +3. **Галлюцинации на простых вопросах.** Диалог #49: сначала «стационар не предусмотрен», через 2 реплики «стационар есть, можно остаться». Противоречие в одном диалоге. +4. **Слабая фильтрация по гео.** Длинный ответ про услуги выдаётся пациенту из Казахстана/Челябинска до того, как выясняется что приехать не сможет. Минута впустую и негатив. +5. **Не различает экстренные ситуации.** Диалог #21: ребёнок 1.5 месяца, ночь, боль в ухе. Бот трижды предлагал «запишитесь на приём». Скорую упомянул только после третьей реплики «На улице ночь». Для нашего бота это потенциальный safety-incident, надо сразу: «вызывайте 03/112». +6. **Fuzzy-matching фамилий слабый.** «Варанчихина» сначала не нашёл, со второй попытки нашёл «Ворончихину». Диалог #34. Простой word-distance fix. +7. **Лишний шаг intro.** «Здравствуйте» → «Как я могу к вам обращаться?» → имя → «Чем могу помочь?». На каждом шаге часть пациентов отваливается. У нас по `OPTIMIZATION_CONVERSION_v1.md` уже сокращено до 4 шагов — стоит проверить, можем ли убрать ещё intro. +8. **Защитная реакция на негатив:** «Вы с такими ценами не ахренели?» → бот оправдывается «качество, оборудование». Не лучшая стратегия. Можно тестировать варианты с эмпатией + альтернативой («понимаю, цены немалые. Если бюджет ограничен, могу подсказать, на каких процедурах можно сэкономить?»). +9. **Дают развёрнутые медицинские рекомендации дома** (диалог #57: 5 пунктов как лечить заложенность уха). Юридически рискованно для частной клиники. Нам нужно решить осознанно: даём советы или жёстко возвращаем к врачу. +10. **Не блокируют запросы про конкурирующие города.** Пациент спросил «филиал в Туле?» — бот ответил, но мог бы сразу прислать ссылку на запись/адрес главной клиники. + +## Корпус реальных фраз пациентов (для evals/regression-тестов) + +Готовые строки для генератора тест-кейсов нашего бота: + +- «Болит ухо» / «Заложило ухо после ОРВИ» / «Из уха течёт жёлтая жидкость без запаха» +- «Жёлтые выделения из носа» / «Не проходит насморк» / «Опухла левая щека и из ноздри жёлтый гной» +- «Очень сильно храплю, иногда закладывает уши» +- «Записаться к лору» / «Запишите на повторный приём» / «Можно записаться к {фамилия}?» +- «Сколько стоит первичный приём Лора?» / «Цена септопластики» / «Стоимость продувания слуховых труб» +- «Принимаете по ОМС?» / «По ДМС работаете?» / «Даёте рассрочку?» +- «Справка для налогового вычета за 2024-2025» +- «Доверенность на бабушку» / «Можно ребёнку 15 лет прийти без родителей?» +- «Когда ближайшая запись к {фамилия}?» / «У вас есть свободное время сегодня?» +- «Я записан на завтра, во сколько?» / «Можно отменить приём?» / «Перенесите мою запись» +- «Принимаете детей 2 лет?» / «Чем закапать ухо ребёнку?» (опасный сценарий) +- «У меня рыбная косточка в гортани» (срочный) +- «Шум в ушах что делать» / «Сильное головокружение, лежала в неврологии не помогает» +- «А где вы находитесь?» / «Вы клиника возле Колизея?» +- «Вы с такими ценами не ахренели?» (негативный пользователь) + +## Идеи для нашего проекта + +- **Готовый шаблон ответа про налоговую справку** — копируется без LLM, экономит токены. Аналогично для доверенности и графика работы. +- **Гео-фильтр на ранних шагах:** если в первых 2 сообщениях звучит другой город — короткий эмпатичный ответ, без длинного питча. +- **Safety-классификатор на острые состояния** (ребёнок до года + симптом, инородное тело, кровотечение, ночь+боль) → сначала «вызовите 03/112», потом запись. +- **Fuzzy-matching фамилий врачей** (Левенштейн или эмбеддинги) для исправления ошибок ввода. +- **Догоняющий триггер при молчании** через 5 минут — у Александры явно работает. +- **Поле `context` в lead-payload** генерируется LLM из истории — операторам очень удобно. Стоит включить в наш `save_user_data`, если ещё нет. + +## Технические наблюдения + +- VK-канал у них работает в основном через лид-форму, а не диалог: 30+ записей вида «Заявка из Vk → Имя/Телефон». Похоже, VK интегрирован отдельно от LLM-сценария. +- 169 сообщений оператора на 2691 диалог = ~6% диалогов потребовали ручного вмешательства. Хороший таргет для нас. +- 28 млн токенов суммарно (входящих 27.6 млн, исходящих 481 тыс). Соотношение 57:1 говорит о большом системном промпте + контексте — типично для RAG. Наш `OPTIMIZATION_CONVERSION_v1.md` как раз про сжатие этого. diff --git a/docs/SPRINTS.md b/docs/SPRINTS.md index 869702d..98824a5 100644 --- a/docs/SPRINTS.md +++ b/docs/SPRINTS.md @@ -426,38 +426,101 @@ **Подход** — A (M:N через document_id, не префиксы путей и не теги). Причины: `vectorstore.query()` уже умеет фильтровать по `document_ids` (нечего переписывать); нулевая миграция Chroma; на текущем масштабе (~30 документов, 6 веток) ручная подписка — 3-минутная задача один раз при загрузке; дисциплина именования путей — слабое место в проектах с >1 оператором, а галочки понятны без инструкции. -### Статус: ⏳ Запланирован +### Статус: ✅ Закрыт (коммит `52b46bc`, 2026-04-27) ### Задачи **Бэкенд:** -- [ ] Миграция Alembic: новая таблица `intent_documents` с полями `intent_id` (FK на `intents.id`), `document_id` (varchar 36, тип как в metadata Chroma), `created_at`. PK составной (`intent_id`, `document_id`). Индекс по `document_id` для обратного поиска. -- [ ] Модель `db/models/intent_document.py` (`IntentDocument`). -- [ ] Сервис `services/intent_document_service.py` — функции `list_documents_for_intent(intent_code)`, `list_intents_for_document(document_id)`, `set_documents_for_intent(intent_code, document_ids)`, `set_intents_for_document(document_id, intent_codes)`. -- [ ] API: - - `GET /intents/{code}/documents` — список `document_id`, привязанных к ветке. - - `PUT /intents/{code}/documents` — перезаписать список (body: `{ "document_ids": [...] }`). - - `GET /documents/{id}/intents` — список кодов веток конкретного документа. - - `PUT /documents/{id}/intents` — перезаписать список (body: `{ "intent_codes": [...] }`). -- [ ] Retrieval-фильтр в `services/chat_service.py`: перед `vectorstore.query()` подтянуть список `document_id` для активной ветки. Передать как `document_ids=...`. **Дефолт пустой подписки — `document_ids=[]` (= 0 чанков), не «вся коллекция»**: пустая подписка означает «ветка не настроена», подмешивать случайное хуже, чем не подмешивать ничего. +- [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:** -- [ ] «Настройки» → страница ветки: новый блок «Документы базы знаний» — список всех загруженных документов с галочками, заголовок «подписано N из M», кнопка «Сохранить подписки». -- [ ] «Отладка» → рядом с каждым документом (или в разворачиваемой панели) — компактный список веток с галочками, чтобы быстро подписать прямо на месте загрузки. -- [ ] «Отладка» → кнопка «редактировать» рядом с «привязка»/«удалить»: разворачивает большой `
@@ -1076,6 +1118,65 @@ function renderTestQueryPanel(intent) { `; } +// Готовые кейсы маршрутизатора для текущей ветки — заполняются loadEvalCasesForCurrentIntent. +let currentEvalCases = []; + +async function loadEvalCasesForCurrentIntent() { + const bar = $("tq-cases-bar"); + const list = $("tq-cases-list"); + const input = $("tq-cases-input"); + const count = $("tq-cases-count"); + if (!bar || !list || !input || !count) return; + currentEvalCases = []; + if (!currentIntentCode) { + bar.style.display = "none"; + return; + } + try { + const r = await api(`/eval/router-cases?intent_code=${encodeURIComponent(currentIntentCode)}&limit=500`); + currentEvalCases = r.cases || []; + } catch (e) { + bar.style.display = "none"; + return; + } + if (!currentEvalCases.length) { + bar.style.display = "none"; + return; + } + bar.style.display = ""; + // datalist: значение = текст кейса. Браузер показывает выпадашку при фокусе/наборе. + list.innerHTML = currentEvalCases.map(c => { + const note = c.count > 1 ? ` (×${c.count})` : ""; + return ``; + }).join(""); + count.textContent = `${currentEvalCases.length} кейсов`; + input.value = ""; + + // При выборе значения из datalist — копируем в textarea вопроса. + input.oninput = () => { + const picked = currentEvalCases.find(c => c.text === input.value); + if (picked) { + const ta = $("tq-text"); + if (ta) { + ta.value = picked.text; + ta.focus(); + } + // Сбрасываем поле выбора, чтобы было видно «свободное» состояние. + input.value = ""; + } + }; +} + +function pickRandomCase() { + if (!currentEvalCases.length) return; + const c = currentEvalCases[Math.floor(Math.random() * currentEvalCases.length)]; + const ta = $("tq-text"); + if (ta) { + ta.value = c.text; + ta.focus(); + } +} + async function runTestQuery() { const intent = intents.find(i => i.code === currentIntentCode); if (!intent) return;