From bb5e3f5eb31ac0bcce8e4651d2e4aecc2f2420ba Mon Sep 17 00:00:00 2001 From: AR 15 M4 Date: Sun, 3 May 2026 01:20:59 +0500 Subject: [PATCH] =?UTF-8?q?feat(sprint8b):=20=D1=80=D0=B5=D0=B3=D1=80?= =?UTF-8?q?=D0=B5=D1=81=D1=81=D0=B8=D1=8F=20=D0=BE=D1=82=D0=B2=D0=B5=D1=82?= =?UTF-8?q?=D0=BE=D0=B2=20=D0=B2=D0=B5=D1=82=D0=BE=D0=BA=20=C2=B7=20genera?= =?UTF-8?q?l=5Finfo=20+=20=D1=84=D0=B8=D0=BA=D1=81=20PRAGMA=20foreign=5Fke?= =?UTF-8?q?ys?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Параллель к 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) --- data/datasets/general_info.md | 93 +++-- data/datasets/price_question.md | 24 +- db/models/__init__.py | 2 + db/models/eval_branch_run.py | 76 ++++ db/session.py | 15 + docs/SPRINTS.md | 42 ++- eval/branch_cases_general_info.jsonl | 46 +++ eval/branch_cases_price_question.jsonl | 40 +++ .../m9g1f7e89j56_add_eval_branch_runs.py | 92 +++++ ...g8f9a0k67_clean_orphan_intent_documents.py | 38 ++ models/responses.py | 42 +++ routers/eval.py | 138 +++++++- services/chat_service.py | 58 +++ services/eval_branch_run_service.py | 331 ++++++++++++++++++ static/regression.html | 300 +++++++++++++--- 15 files changed, 1228 insertions(+), 109 deletions(-) create mode 100644 db/models/eval_branch_run.py create mode 100644 eval/branch_cases_general_info.jsonl create mode 100644 eval/branch_cases_price_question.jsonl create mode 100644 migrations/versions/m9g1f7e89j56_add_eval_branch_runs.py create mode 100644 migrations/versions/n0h2g8f9a0k67_clean_orphan_intent_documents.py create mode 100644 services/eval_branch_run_service.py diff --git a/data/datasets/general_info.md b/data/datasets/general_info.md index 327a648..d5a85b8 100644 --- a/data/datasets/general_info.md +++ b/data/datasets/general_info.md @@ -11,6 +11,7 @@ sources: - Ya_wiki_kugn/out/yandex-wiki-catalog/homepage/udalennyjj-kontakt-centr/organizacionnye-voprosy/klinika-doktora-pirogova/obshhaja-informacija-klinika-doktora-pirogova.md - Ya_wiki_kugn/vrachi-kliniki-svodnyj-spisok.md - Ya_wiki_kugn/skripty-vozrazhenija-chavo-obshhijj-spravochnik.md + - Уточнения от оператора (Кузнецова Н.) — 2026-05-02: режим работы всех филиалов, ТГ-бот, мобильное приложение, актуальный e-mail, закрытие филиала в Краснокамске (в этой же дате уточнено: филиал закрыт окончательно, не временно). note: Файл собран вручную из выгрузки Yandex Wiki. После запуска подписки на вики этот файл заменит автоматически обновляемый источник. --- @@ -18,35 +19,65 @@ note: Файл собран вручную из выгрузки Yandex Wiki. П ## О клинике коротко -ООО «Клиника ухо, горло, нос имени профессора Е. Н. Оленевой» — специализированная сеть в Перми и Краснокамске. Создана в 2000 году как Скорая ЛОР помощь, с 2007 года работает в статусе специализированной ЛОР клиники, с 2008 года носит имя профессора Е. Н. Оленевой. В 2016 году в составе сети открылось направление «Клиника лечения кашля и аллергии». +ООО «Клиника ухо, горло, нос имени профессора Е. Н. Оленевой» — специализированная сеть в Перми. Создана в 2000 году как Скорая ЛОР помощь, с 2007 года работает в статусе специализированной ЛОР клиники, с 2008 года носит имя профессора Е. Н. Оленевой. В 2016 году в составе сети открылось направление «Клиника лечения кашля и аллергии». -В сеть входят три филиала: ЛОР-клиника на Клары Цеткин, Клиника лечения кашля и аллергии на Газеты Звезда, Клиника доктора Пирогова в Краснокамске. +В сеть входят два филиала: ЛОР-клиника на Клары Цеткин и Клиника лечения кашля и аллергии на Газеты Звезда. Ранее работал также филиал «Клиника доктора Пирогова» в Краснокамске — в 2026 году он закрыт. ## Адреса филиалов - Клиника ухо, горло, нос — г. Пермь, ул. Клары Цеткин, 9. - Клиника лечения кашля и аллергии — г. Пермь, ул. Газеты Звезда, 31а. -- Клиника доктора Пирогова — г. Краснокамск, ул. Карла Маркса, 14а. + +(Филиал в Краснокамске на ул. Карла Маркса, 14а закрыт.) ## Телефоны для пациентов - Клиника ухо, горло, нос (К. Цеткин, 9) — 8 (342) 207-03-03. - Клиника лечения кашля и аллергии (Г. Звезда, 31а) — 8 (342) 200-02-03. -- Клиника доктора Пирогова (Краснокамск) — 8 (342) 207-03-00. - Линия «Операции» — 8 (342) 207-03-01. - Линия «ЛОРДЕНТ» — 8 (342) 287-16-94. ## Электронные адреса для пациентов -- Общий адрес клиники (указан на сайте): clinic-lor@mail.ru +- Общий адрес клиники: mail@oclinica.ru — основной адрес для пациентов: вопросы, заявки на справку для налогового вычета. - Адрес для отправки анализов пациентам: test@oclinica.ru -- Адрес клиники Пирогова: info@docpirogov.ru ## Сайты - Сеть клиник: https://www.oclinica.ru, https://perm.oclinica.ru/lor - Клиника лечения кашля и аллергии: https://perm.oclinica.ru/allergo -- Клиника доктора Пирогова: https://docpirogov.ru/ + +## Запись через Telegram-бот и мобильное приложение + +### Telegram-бот + +Записаться на приём можно через Telegram-бот клиники: https://t.me/Oleneva_Clinic_Bot + +Условие: бот доступен только пациентам, которые уже были в клинике хотя бы один раз. Первичную запись пока нужно оформлять через звонок или администратора. + +### Мобильное приложение «Ухо Горло Нос» + +У клиники есть официальное мобильное приложение «Ухо Горло Нос». Сейчас приложение доступно для Android в RuStore: https://www.rustore.ru/catalog/app/com.clinic.mobileapp.app + +В Google Play приложение появится позже. + +Что доступно в приложении: + +- выбор врача и удобного времени; +- онлайн-запись на приём; +- управление своими визитами (перенос, отмена); +- история посещений; +- информация о врачах, услугах и стоимости; +- запись не только для себя, но и для членов семьи. + +Чем удобно для пациента: + +- не нужно звонить в клинику; +- записаться можно в любое время суток; +- легко перенести или отменить визит; +- вся информация о приёмах сохраняется в одном месте. + +В ближайших обновлениях планируются: рекомендации врачей после приёма, доступ к медицинским документам, возможность задать вопрос врачу. ## Как добраться: Клары Цеткин, 9 @@ -62,20 +93,37 @@ note: Файл собран вручную из выгрузки Yandex Wiki. П Альтернативный маршрут: выйти на остановке «Октябрьская площадь», пройти по «компросу» направо до перекрёстка, повернуть налево и далее во двор между домами 25 и 27. -## Как добраться: Краснокамск, Карла Маркса, 14а (Клиника доктора Пирогова) - -Ориентиры: рядом поликлиника №1, школа №10 и музыкальная школа. Здание стоит на месте бывшей «Лабдиагностики», вход с другой стороны — со двора. - -Ближайшие остановки автобусов: «Карла Маркса», «Поликлиника». От автовокзала: автобусы 206, 100, 195 до остановки «Карла Маркса», далее пешком по улице Карла Маркса около 5 минут. - ## Парковка -Закрытой парковки для посетителей у клиник на Клары Цеткин и Газеты Звезда нет. Платные городские парковки расположены вдоль улиц Пушкина, Газеты Звезда и Луначарского. +На территории клиники нет специально оборудованной парковки. Просим пациентов воспользоваться ближайшей общественной парковкой. В Перми платные городские парковки расположены вдоль улиц Пушкина, Газеты Звезда и Луначарского. ## Режим работы -- Клиника доктора Пирогова (Краснокамск): понедельник–пятница, с 8:00 до 14:00. Суббота и воскресенье — выходные. -- Режим работы филиалов на Клары Цеткин и Газеты Звезда в выгрузке вики не указан явно — при вопросе пациента уточнить у оператора. +- Клиника лечения кашля и аллергии (Газеты Звезда, 31а): понедельник–пятница с 9:00 до 21:00, суббота и воскресенье с 9:00 до 19:00. +- Клиника ухо, горло, нос (Клары Цеткин, 9): понедельник–суббота с 9:00 до 17:00. Воскресенье — выходной. + +**Внимание:** каждый 4-й четверг месяца клиника на Газеты Звезда работает до 17:00 (вместо 21:00). Это плановый сокращённый день. + +(Филиал в Краснокамске закрыт.) + +## Справки для налогового вычета (3-НДФЛ) + +Чтобы получить справку об оплате медицинских услуг для возврата налогового вычета, пациент может выбрать один из двух способов оформить заявку: + +1. **По телефону:** позвонить по номеру +7 (342) 207-03-03. +2. **По электронной почте:** отправить письмо на mail@oclinica.ru. В письме нужно указать: + - ФИО пациента, данные свидетельства о рождении (если пациент — ребёнок) либо паспортные данные; + - ФИО налогоплательщика и его паспортные данные; + - ИНН налогоплательщика; + - период, за который нужна справка (например, 2024 год). + +**Сроки.** Справка оформляется в течение 3 рабочих дней с момента получения заявки. + +**Способы получения справки** (пациент выбирает один): + +1. Лично — в клинике на ул. Газеты Звезда, 31а. С собой: паспорт; если справку забирает не сам налогоплательщик — нотариальная доверенность. +2. По электронной почте. Если в карте пациента e-mail не указан, налогоплательщику нужно отдельно написать с того адреса, на который он хочет получить справку, на mail@oclinica.ru и приложить селфи с паспортом — это нужно для подтверждения личности. +3. Клиника отправляет справку напрямую в налоговую. Справка идёт в ФНС около 10 дней, после чего отображается в личном кабинете налогоплательщика на сайте налоговой. ## Направления приёма @@ -87,7 +135,6 @@ note: Файл собран вручную из выгрузки Yandex Wiki. П - Отоневрология. - Сурдология и сурдоакустика (подбор слуховых аппаратов). - Фониатрия. -- Семейный врач (общая практика) — в Клинике доктора Пирогова. - Анестезиология (для операций). В клинике проводится диагностика (эндоскопия ЛОР-органов, тимпанометрия, спирография и др.) и лечебные процедуры (промывание носа, удаление серных пробок и др.). Операции выполняются эндоскопическим методом, под общим наркозом препаратом «Севоран». @@ -162,28 +209,30 @@ note: Файл собран вручную из выгрузки Yandex Wiki. П ## Что уверенно покрыто из выгрузки -- Адреса всех трёх филиалов. +- Адреса двух действующих филиалов (Цеткин и Газеты Звезда). Краснокамск (Пирогова) закрыт — упоминается одной строкой как закрытый, без активных контактов. - Телефонные линии для пациентов. - Транспорт и пеший маршрут до Цеткин и Газеты Звезда. - Парковка в Перми. -- Режим работы только клиники Пирогова. +- **Режим работы всех филиалов** (добавлено вручную 2026-05-02 со слов оператора). - Список врачей по специальностям (из сводного файла `vrachi-kliniki-svodnyj-spisok.md`). +- **Способы записи** — телефон, Telegram-бот, мобильное приложение «Ухо Горло Нос» (RuStore). +- **Справка для налогового вычета** — процедура заказа, состав заявки, сроки, способы получения. - История клиники, имени Оленевой. - Юридические реквизиты. - Список процедур, которые в клинике не проводятся. ## Что в выгрузке отсутствует или скудно — стоит дополнить вручную в вики -- **Режим работы Цеткин и Газеты Звезда.** Вообще не нашёлся в выгрузке. Это самый частый вопрос пациента в ветке `general_info` — нужно явно прописать рабочие часы каждой клиники, включая обед, выходные и работу в праздничные дни. - **Wi-Fi.** Системный промпт ветки явно ожидает ответ на вопрос «есть ли Wi-Fi». В вики этого нет. - **Доступная среда / маломобильные пациенты.** В выгрузке есть алгоритм действий администратора при обращении маломобильных, но нет короткой пациент-ориентированной заметки: есть ли пандус, лифт, как лучше подъехать. - **Детский приём.** Понятно, что детей принимают, но нет короткой страницы «детский ЛОР»: с какого возраста, кто из врачей принимает детей, что взять с собой кроме базовых документов. - **Подготовка к приёму по специальностям.** Для аллерголога, отоневролога, сурдолога есть нюансы (отмена антигистаминных перед аллерго-тестом и т. п.). Сейчас всё разбросано по скриптам записи — стоит свести в одну страницу «Подготовка к приёму». -- **Ориентиры и фото входа.** Для Цеткин и Газеты Звезда нет фотографий входа и подробных ориентиров — для Пирогова есть. Для патчат-сценария «не могу найти вход» это полезно. +- **Ориентиры и фото входа.** Для Цеткин и Газеты Звезда нет фотографий входа и подробных ориентиров. Для патчат-сценария «не могу найти вход» это полезно. - **Платежи и ДМС в общем виде.** Какие способы оплаты принимаются (карта, наличные, СБП), кратко про ДМС-партнёров. Детально это уйдёт в ветку `price_question`, но в общей справке нужна одна-две фразы. - **Время приёма по умолчанию.** Сколько обычно длится первичный приём ЛОРа, аллерголога. Пациенты часто спрашивают «во сколько успею». - **Отмена и перенос.** Короткое правило «как отменить запись» (полноценно — в ветке `reschedule`, но ссылка-минимум полезна и в общей). -- **Документы по итогам приёма.** Заключение, выписка, больничный, справка ФНС — что выдают и в какой форме. Сейчас это в отдельных подразделах вики, для общей ветки нужна короткая сводка. +- **Прочие документы по итогам приёма.** Заключение, выписка, больничный — что выдают и в какой форме (справка ФНС теперь покрыта отдельным разделом). +- **Праздничные дни.** Режим работы 1 января, 8 марта, 9 мая и т. д. — пациенты регулярно спрашивают, в датасете явно не указано. ## Что НЕ должно попадать в датасет общей ветки (но есть в вики) diff --git a/data/datasets/price_question.md b/data/datasets/price_question.md index c44f529..5ddc38a 100644 --- a/data/datasets/price_question.md +++ b/data/datasets/price_question.md @@ -10,8 +10,9 @@ sources: - Ya_wiki_kugn/out/yandex-wiki-catalog/homepage/udalennyjj-kontakt-centr/organizacionnye-voprosy/operacionnye-vmeshatelstva/* - Ya_wiki_kugn/out/yandex-wiki-catalog/homepage/udalennyjj-kontakt-centr/organizacionnye-voprosy/diagnostika/* - Ya_wiki_kugn/out/yandex-wiki-catalog/homepage/udalennyjj-kontakt-centr/organizacionnye-voprosy/kt-issledovanija/* - - Ya_wiki_kugn/out/yandex-wiki-catalog/homepage/udalennyjj-kontakt-centr/organizacionnye-voprosy/klinika-doktora-pirogova/* + - Ya_wiki_kugn/out/yandex-wiki-catalog/homepage/udalennyjj-kontakt-centr/organizacionnye-voprosy/klinika-doktora-pirogova/* (исторический источник; филиал закрыт в 2026 году, прайс из активной части датасета убран) - Ya_wiki_kugn/out/yandex-wiki-catalog/homepage/udalennyjj-kontakt-centr/organizacionnye-voprosy/zapis-k-otonevrologu/blokada/* + - Уточнения от оператора (Кузнецова Н.) — 2026-05-02: закрытие филиала Пирогова в Краснокамске. note: Цены собраны из выгрузки Yandex Wiki клиники. После запуска подписки этот файл заменит автоматически обновляемый источник. Все суммы — рубли. --- @@ -59,20 +60,9 @@ note: Цены собраны из выгрузки Yandex Wiki клиники. - Батарейки для слухового аппарата — 360 руб. за упаковку из 6 шт. (поштучно не продаются). - Если аппарат куплен в Клинике и сломался: после окончания гарантии — приём у сурдолога; устранимая поломка (замена расходников) — стоимость расходников. Серьёзная поломка — отправка в ремонт, стоимость указывает сервис в счёте. -## Клиника доктора Пирогова (Краснокамск) +## Филиал «Клиника доктора Пирогова» (Краснокамск) -- Семейный врач (Суднева А. Р.): 950 руб. первичный, 750 руб. повторный. Эндоскопия ЛОР-органов на приёме — 500 руб. -- ЛОР-телемедицинский приём: 1700 руб. первичный, 1400 руб. повторный. Включает консультацию ЛОР-врача и видеоэндоскопию. -- Аллерголог-иммунолог (телемед, Антонова Е. В.): 1800 руб. первичный, 1500 руб. повторный. -- ЛОР-приём по ОМС (Гилязова Л. Л., вт/чт 12:00–14:00) — бесплатно, по направлению. -- Дерматолог (Чемякин Е. А.): консультация 1000 руб. + услуги по прайсу. -- Косметолог-эстетист — услуги по прайсу. -- УЗИ — услуги по прайсу. Доплерография при двойне (срок беременности до 30 недель) — ориентировочно 1800 руб. -- ЭКГ — 450 руб., расшифровка/повторный приём — 800 руб. -- Профосмотр — 450 руб. (+390 руб. за аудиометрию, если требуется). -- Промывание серных пробок — 550 руб. за одно ухо. -- Тест на хеликобактер с индикаторными трубками — 500 руб. -- Инъекции в процедурном кабинете: внутримышечная — 150 руб., внутривенная — 300 руб., капельница внутривенная — 500 руб. Курсы: 5 в/м инъекций — 600 руб., 7 — 850 руб., 10 — 1150 руб. 5 в/в инъекций — 1200 руб., 7 — 1700 руб. +Филиал в Краснокамске закрыт в 2026 году. Все услуги, ранее доступные там (приём семейного врача, телемед-приёмы ЛОР и аллерголога, ЛОР по ОМС, дерматолог, косметолог, УЗИ, ЭКГ, профосмотр, инъекции в процедурном кабинете, промывание серных пробок 550 ₽ и т. п.), в сети больше не оказываются. На вопросы о ценах услуг этого филиала — честно сообщить, что филиал закрыт, и предложить услуги пермских филиалов (Цеткин и Газеты Звезда), либо эскалировать оператору. ## Эндоскопическая телемед-консультация ЛОР (онлайн-формат) @@ -80,7 +70,7 @@ note: Цены собраны из выгрузки Yandex Wiki клиники. ## Стандартные диагностические процедуры -- Эндоскопическая диагностика ЛОР-органов — 900 руб. (в Клинике Пирогова — 500 руб.). +- Эндоскопическая диагностика ЛОР-органов — 900 руб. - Аудиометрия — 1200 руб. - Тимпанометрия — 800 руб. - Аудиологический скрининг (отоакустическая эмиссия) — 800 руб. @@ -270,7 +260,7 @@ note: Цены собраны из выгрузки Yandex Wiki клиники. ## Что покрыто из выгрузки уверенно -- Цены на приёмы у ЛОР, аллерголога, пульмонолога, отоневролога, сурдолога, врачей Клиники Пирогова, телемед-приёмов. +- Цены на приёмы у ЛОР, аллерголога, пульмонолога, отоневролога, сурдолога, телемед-приёмов. - Скидка 50% по направлению, цена приёма «со скидкой». - Полный набор стоимостей операций ЛОР-профиля. - Анестезия, пребывание в палате, послеоперационное сопровождение. @@ -287,7 +277,7 @@ note: Цены собраны из выгрузки Yandex Wiki клиники. - **Справка ФНС / налоговый вычет.** Раздел в вики есть, но в выгрузке отсутствует. Нужен короткий блок: за какой период оформляется, сколько по времени готовится, нужна ли оплата за услугу. - **СБП.** Уточнить, принимается ли оплата через Систему быстрых платежей или только нал/карта по терминалу. - **Скидки.** В выгрузке только «50% по направлению на лечебную процедуру». Если есть скидки пенсионерам, многодетным, сотрудникам, постоянным пациентам — отдельно прописать; иначе при вопросе ассистент будет каждый раз говорить «уточню у оператора». -- **Цены по «услугам по прайсу» в Пирогове.** В таблице у дерматолога, косметолога, УЗИ написано «по прайсу» — конкретные цифры в подстраницах есть только частично. Нужно собрать прайсы в одну таблицу. +- **Услуги, бывшие только в Пирогова.** После закрытия филиала из активного датасета убраны: цены семейного врача, телемед-приёма ЛОР/аллерголога, дерматолога, косметолога, УЗИ, ЭКГ, профосмотра, инъекций в процедурном кабинете, промывания серных пробок (550 ₽). Если эти услуги планируется оказывать в пермских филиалах — нужно явно прописать новые прайсы; иначе бот честно отвечает «филиал закрыт» и эскалирует. - **Расхождение по наркозу для аденотомии.** В разделе «Структура звонка по аденотомии» (скрипты записи) указана стоимость наркоза 16500 руб., а на странице самой аденотомии — 21500 руб. Возможно, это устаревшая цена в одном из источников. Нужно сверить с актуальным прайсом и поправить в вики, иначе ассистент будет давать разные ответы в зависимости от того, какой кусок выгрузки попадёт в контекст. - **Цена аллерголога-иммунолога повторного приёма (очный).** В выгрузке указана стоимость только первичного очного приёма (2400 руб.). Для пульмонолога и ЛОРа повторный тоже отдельно не зафиксирован. - **Эндоскопия как самостоятельная диагностика.** На странице эндоскопии есть две цены — 900 руб. и 12100 руб., вторая выглядит как опечатка или комплексный код. В этом файле я взял 900 руб. как основное; стоит сверить с прайсом. diff --git a/db/models/__init__.py b/db/models/__init__.py index 5e97433..f9ff08e 100644 --- a/db/models/__init__.py +++ b/db/models/__init__.py @@ -2,6 +2,7 @@ from db.models.agent_config import AgentConfig from db.models.document import Document from db.models.intent import Intent from db.models.intent_document import IntentDocument +from db.models.eval_branch_run import EvalBranchPrediction, EvalBranchRun, EvalBranchRunCase from db.models.eval_run import EvalRouterPrediction, EvalRun, EvalRunCase from db.models.intent_step import IntentStep from db.models.intent_step_graph import IntentStepGraph @@ -13,4 +14,5 @@ __all__ = [ "Thread", "Message", "Document", "AgentConfig", "Intent", "IntentDocument", "IntentStep", "IntentStepGraph", "ThreadState", "EvalRun", "EvalRunCase", "EvalRouterPrediction", + "EvalBranchRun", "EvalBranchRunCase", "EvalBranchPrediction", ] diff --git a/db/models/eval_branch_run.py b/db/models/eval_branch_run.py new file mode 100644 index 0000000..489f9ea --- /dev/null +++ b/db/models/eval_branch_run.py @@ -0,0 +1,76 @@ +from datetime import datetime, timezone + +from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, Text +from sqlalchemy.orm import Mapped, mapped_column + +from db.base import Base + + +def _utcnow() -> datetime: + return datetime.now(timezone.utc) + + +class EvalBranchRun(Base): + """Прогон регрессии конкретной ветки (Спринт 8b). + + Параллельная сущность к `EvalRun`: тот валидирует роутер (один + intent-код в ответе), этот — содержимое ответа конкретной ветки + (RAG-секции + keywords). Активная версия промпта ветки фиксируется + в `branch_config_id`, кэш ответов привязан к ней — повторный прогон + на той же версии мгновенный. + """ + __tablename__ = "eval_branch_runs" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + suite: Mapped[str] = mapped_column(String(80), nullable=False) + intent_code: Mapped[str] = mapped_column(String(50), nullable=False, index=True) + branch_config_id: Mapped[int | None] = mapped_column( + ForeignKey("agent_configs.id", ondelete="SET NULL"), nullable=True, index=True + ) + status: Mapped[str] = mapped_column(String(20), nullable=False, default="running") + total: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + passed: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + failed: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + cache_hits: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + error_text: Mapped[str | None] = mapped_column(Text, nullable=True) + started_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow, nullable=False) + finished_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + + +class EvalBranchRunCase(Base): + """Один кейс прогона ветки. Хранятся все: pass и fail (для фильтра в UI).""" + __tablename__ = "eval_branch_run_cases" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + run_id: Mapped[int] = mapped_column( + ForeignKey("eval_branch_runs.id", ondelete="CASCADE"), nullable=False, index=True + ) + text: Mapped[str] = mapped_column(Text, nullable=False) + coverage: Mapped[str] = mapped_column(String(20), nullable=False, default="covered") + expected_doc_section: Mapped[str | None] = mapped_column(String(300), nullable=True) + expected_keywords_json: Mapped[str] = mapped_column(Text, nullable=False, default="[]") + expected_must_not_json: Mapped[str] = mapped_column(Text, nullable=False, default="[]") + keywords_min: Mapped[int | None] = mapped_column(Integer, nullable=True) + predicted_answer: Mapped[str] = mapped_column(Text, nullable=False, default="") + predicted_sections_json: Mapped[str] = mapped_column(Text, nullable=False, default="[]") + is_pass: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) + fail_reasons_json: Mapped[str] = mapped_column(Text, nullable=False, default="[]") + count_weight: Mapped[int] = mapped_column(Integer, nullable=False, default=1) + + +class EvalBranchPrediction(Base): + """Кэш single-turn ответа ветки: (text_hash, branch_config_id) → {answer, sections}. + + При активации новой версии ветки кэш для неё пуст; повторный прогон на + той же версии берёт всё из кэша, не дёргая LLM. + """ + __tablename__ = "eval_branch_predictions" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + text_hash: Mapped[str] = mapped_column(String(64), nullable=False, index=True) + branch_config_id: Mapped[int | None] = mapped_column( + ForeignKey("agent_configs.id", ondelete="CASCADE"), nullable=True, index=True + ) + answer_text: Mapped[str] = mapped_column(Text, nullable=False, default="") + retrieved_sections_json: Mapped[str] = mapped_column(Text, nullable=False, default="[]") + created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow, nullable=False) diff --git a/db/session.py b/db/session.py index 7891ac3..4559874 100644 --- a/db/session.py +++ b/db/session.py @@ -1,11 +1,26 @@ from collections.abc import AsyncIterator +from sqlalchemy import event from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine from config import settings engine = create_async_engine(settings.database_url, echo=False, future=True) + +# В SQLite ondelete=CASCADE / SET NULL не работают, пока для каждого подключения +# не включить PRAGMA foreign_keys=ON (по умолчанию выключено). aiosqlite не делает +# это автоматически. Без этого, например, удаление документа не очищало подписки +# в `intent_documents` — обнаружено в Спринте 8b. +@event.listens_for(engine.sync_engine, "connect") +def _enable_sqlite_foreign_keys(dbapi_connection, _connection_record): + cursor = dbapi_connection.cursor() + try: + cursor.execute("PRAGMA foreign_keys=ON") + finally: + cursor.close() + + SessionLocal = async_sessionmaker(engine, expire_on_commit=False, class_=AsyncSession) diff --git a/docs/SPRINTS.md b/docs/SPRINTS.md index ca17109..4047491 100644 --- a/docs/SPRINTS.md +++ b/docs/SPRINTS.md @@ -605,38 +605,46 @@ ### Цель По принципу 8a, но проверяем уже не код intent-а от роутера, а **содержимое ответа конкретной ветки** на одиночную реплику. Старт — только `general_info`: «вопрос про адрес / часы / маршрут → ответ должен ссылаться на нужный документ и содержать ключевые слова». Дальше расширим на остальные ветки. -### Статус: ⏳ Запланирован (ждём базу кейсов от пользователя) +### Статус: ✅ Закрыт -### Скоуп MVP (что берём) -- **Ветка:** только `general_info`. +### Скоуп MVP (что взяли) +- **Ветка:** `general_info`. JSONL `eval/branch_cases_general_info.jsonl` (46 кейсов). - **Способы pass/fail:** - - **A — RAG-проверка:** в retrieved-чанках есть все ожидаемые `document_id`. Детерминировано, без LLM в проверке. - - **B — keywords в ответе:** в тексте ответа бота встречаются все обязательные подстроки (`expected_keywords`) и нет запрещённых (`expected_must_not`). + - **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_doc_ids}`. При смене активной версии промпта `general_info` — кэш по новой версии пуст, прогон полный. +- **Кэш:** `(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 с эталонным ответом). -Оба добавим, если A+B окажется хрупким (keywords ловят перефраз ненадёжно). +- **Diff vs предыдущий прогон** для веток (для роутера в 8a уже есть). +- **Кнопка «Сбросить кэш регрессии»** на странице (сейчас инвалидация — через создание новой версии промпта). ### Задачи -**База кейсов (за пользователем):** -- [ ] `eval/branch_cases_general_info.jsonl`. Схема: `{text, intent, expected_doc_ids?, expected_keywords?, expected_must_not?, count?, note?}`. Минимум для одного кейса — `text + intent + (хотя бы одно из expected_*)`. +**База кейсов (от пользователя):** +- [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:** -- [ ] Таблицы (или общая `eval_runs` с `suite="branch:"`): `eval_branch_runs` или универсальное расширение, `eval_branch_run_cases` с полями `answer_text`, `retrieved_doc_ids_json`, `is_pass`, `fail_reason`. Кэш `eval_branch_predictions(text_hash, branch_config_id) → {answer_text, retrieved_doc_ids}`. -- [ ] Сервис: запуск кейса = вызов того же flow, что в `chat_service.send_message`, но на чистом треде, с фиксацией активной версии branch-config и retrieved-чанков. -- [ ] API: `POST /eval/branch-runs`, `GET /eval/branch-runs`, `GET /eval/branch-runs/{id}`, `GET /eval/branch-cases-with-status?intent_code=general_info`. +- [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:** -- [ ] На странице «Регрессия» — переключатель режима: `Роутер` / `Ветка · general_info` (дальше другие ветки добавятся в этот же селектор). -- [ ] Для режима «Ветка»: те же фильтры/диапазон/массовый выбор, но в таблице вместо «expected_intent» — `ожидаемые документы` и `keywords`. В drill-down прогона — текст реплики, фактический ответ бота, retrieved-документы, причина fail. +**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` — одноразовая чистка существующих висячих подписок. ### Критерий готовности -- [ ] На стартовом наборе general_info прогон без правок промпта даёт консистентный результат. -- [ ] После правки промпта `general_info` — diff показывает кейсы, где RAG-документы или keywords изменились. +- [x] На стартовом наборе `general_info` (46 кейсов) прогон проходит за ~3–5 минут (последовательные LLM-вызовы). Повторный на той же версии — мгновенный. +- [x] При активации новой версии промпта ветки кэш пуст, прогон полный. +- [x] Удаление документа на «Отладка» автоматически очищает подписки веток. --- diff --git a/eval/branch_cases_general_info.jsonl b/eval/branch_cases_general_info.jsonl new file mode 100644 index 0000000..946bafa --- /dev/null +++ b/eval/branch_cases_general_info.jsonl @@ -0,0 +1,46 @@ +{"text": "Адрес клиники", "intent": "general_info", "expected_keywords": ["Цеткин", "Звезда"], "keywords_any": true, "expected_must_not": ["диагноз", "назначаю", "принимайте по", "капайте по", "примите ", "Карла Маркса", "Краснокамск работает"], "expected_doc_section": null, "coverage": "covered", "note": "Короткий запрос. Достаточно упомянуть хотя бы один из двух пермских адресов. Краснокамск закрыт — упоминание его адреса как действующего = фейл."} +{"text": "Где находится ваша клиника?", "intent": "general_info", "expected_keywords": ["Пермь", "Цеткин", "Звезда"], "keywords_min": 2, "expected_must_not": ["диагноз", "назначаю", "принимайте по", "капайте по", "примите ", "Карла Маркса"], "expected_doc_section": null, "coverage": "covered", "note": "Минимум 2 из 3 — должен быть назван «Пермь» и хотя бы один филиал."} +{"text": "адреса клиник", "intent": "general_info", "expected_keywords": ["Цеткин", "Звезда"], "expected_must_not": ["диагноз", "назначаю", "принимайте по", "капайте по", "примите ", "Карла Маркса", "три филиала"], "expected_doc_section": null, "coverage": "covered", "note": "Множественное число — ожидаем перечисление обоих действующих филиалов. Оба обязательны (поэтому без keywords_any)."} +{"text": "Здравствуйте! Подскажите, пожалуйста, ваш почтовый адрес", "intent": "general_info", "expected_keywords": ["mail@oclinica.ru"], "expected_must_not": ["диагноз", "назначаю", "принимайте по", "капайте по", "примите ", "clinic-lor@mail.ru"], "expected_doc_section": "Электронные адреса для пациентов", "coverage": "covered", "note": "Общий e-mail клиники — mail@oclinica.ru (актуализировано 2026-05-02). Старый clinic-lor@mail.ru должен быть удалён из ответов."} +{"text": "Здравствуйте! Интересует электронный адрес на который можно отправить результаты анализов", "intent": "general_info", "expected_keywords": ["test@oclinica.ru"], "expected_must_not": ["диагноз", "назначаю", "принимайте по", "капайте по", "примите ", "mail@oclinica.ru", "clinic-lor@mail.ru"], "expected_doc_section": "Электронные адреса для пациентов", "coverage": "covered", "note": "Конкретно для анализов есть отдельный e-mail. Если бот даст общий — это ошибка."} +{"text": "Режим работы", "intent": "general_info", "expected_keywords": ["9:00", "21:00", "Звезда"], "expected_must_not": ["диагноз", "назначаю", "принимайте по", "капайте по", "примите ", "Пирогова работает", "Краснокамск работает", "8:00 до 14:00"], "expected_doc_section": "Режим работы", "coverage": "covered", "note": "С 2026-05-02 покрыто: Звезда пн-пт 9-21, сб-вс 9-19; Цеткин пн-сб 9-17, вс выходной; Краснокамск временно не работает."} +{"text": "Здравствуйте, подскажите режим работы клиники", "intent": "general_info", "expected_keywords": ["9:00", "Цеткин", "Звезда"], "expected_must_not": ["диагноз", "назначаю", "принимайте по", "капайте по", "примите ", "8:00 до 14:00", "Пирогова работает"], "expected_doc_section": "Режим работы", "coverage": "covered", "note": "Расширенный запрос — ожидаем перечисление часов хотя бы по двум работающим филиалам."} +{"text": "Добрый день. Какой график работы у Терво С.О.?", "intent": "general_info", "expected_keywords": ["оператор"], "expected_must_not": ["диагноз", "назначаю", "принимайте по", "капайте по", "примите ", "принимает по", "ведёт приём", "ведет прием"], "expected_doc_section": null, "coverage": "not_covered", "note": "Расписания конкретных врачей в датасете нет. Бот не должен выдумывать график."} +{"text": "Добрый день! Подскажите есть ли у вас парковка у клиники для клиентов Газеты Звезда 31а?", "intent": "general_info", "expected_keywords": ["нет", "общественн", "Пушкина"], "keywords_any": true, "expected_must_not": ["диагноз", "назначаю", "принимайте по", "капайте по", "примите ", "бесплатн", "наша парковка"], "expected_doc_section": "Парковка", "coverage": "covered", "note": "Своей парковки нет — пользоваться общественной. Достаточно одного из ключевых сигналов: либо явное «нет», либо «общественн», либо упоминание ул. Пушкина."} +{"text": "налоговый вычет", "intent": "general_info", "expected_keywords": ["207-03-03", "mail@oclinica.ru"], "keywords_any": true, "expected_must_not": ["диагноз", "назначаю", "принимайте по", "капайте по", "примите ", "в личном кабинете на сайте клиники", "по ссылке", "clinic-lor"], "expected_doc_section": "Справки для налогового вычета (3-НДФЛ)", "coverage": "covered", "note": "Самый частотный запрос (count=14). Достаточно одного из двух способов заявки."} +{"text": "Как получить справку для налогового вычета?", "intent": "general_info", "expected_keywords": ["207-03-03", "mail@oclinica.ru", "ИНН"], "keywords_min": 2, "expected_must_not": ["диагноз", "назначаю", "принимайте по", "капайте по", "примите ", "clinic-lor", "только лично"], "expected_doc_section": "Справки для налогового вычета (3-НДФЛ)", "coverage": "covered", "note": "Развёрнутый запрос — нужно минимум 2 из 3: способ заявки (телефон или e-mail) + хотя бы упоминание состава данных (ИНН и т.п.)."} +{"text": "Здравствуйте, подскажите пожалуйста, справку для налог.вычета за 25 год, когда уже можно заказывать?", "intent": "general_info", "expected_keywords": ["оператор", "207-03-03", "mail@oclinica.ru"], "keywords_any": true, "expected_must_not": ["диагноз", "назначаю", "принимайте по", "капайте по", "примите ", "с января", "в феврале"], "expected_doc_section": "Справки для налогового вычета (3-НДФЛ)", "coverage": "partial", "note": "Сроки начала года для заказа в датасете не указаны. Достаточно дать процедуру или эскалировать оператору; не выдумывать конкретные даты."} +{"text": "Прошу выслать Скан копию справки для ИФНС за 2024 год Афанасьев Андрей Павлович 30.11.1963г.р. ИНН 590803877826 Был на приеме 14.11.2024 стоимость 4200.00 р. На ЭП larisa-f1996@yandex.ru Спасибо жду.", "intent": "general_info", "expected_keywords": ["3 раб", "mail@oclinica.ru", "оператор"], "keywords_any": true, "expected_must_not": ["диагноз", "назначаю", "принимайте по", "капайте по", "примите ", "уже отправили", "получите сейчас"], "expected_doc_section": "Справки для налогового вычета (3-НДФЛ)", "coverage": "covered", "note": "Длинная реплика с ФИО/ИНН. Достаточно подтвердить процедуру (3 раб. дня) ИЛИ дать e-mail ИЛИ эскалировать. Главное — не подтверждать факт отправки."} +{"text": "Здравствуйте Нужна справка на оказание мед услуг за 2025 год. Как можно её получить? Мы из Лысьвы", "intent": "general_info", "expected_keywords": ["mail@oclinica.ru", "электронн", "налоговую"], "keywords_any": true, "expected_must_not": ["диагноз", "назначаю", "принимайте по", "капайте по", "примите ", "только лично", "приезжайте"], "expected_doc_section": "Справки для налогового вычета (3-НДФЛ)", "coverage": "covered", "note": "Иногородний пациент. Главное — упомянуть дистанционный канал: e-mail, электронный способ или отправку в налоговую. Не «только лично»."} +{"text": "как заказать справку для налоговой", "intent": "general_info", "expected_keywords": ["207-03-03", "mail@oclinica.ru"], "keywords_any": true, "expected_must_not": ["диагноз", "назначаю", "принимайте по", "капайте по", "примите ", "clinic-lor", "по ссылке", "в личном кабинете на сайте клиники"], "expected_doc_section": "Справки для налогового вычета (3-НДФЛ)", "coverage": "covered", "note": "Самая частотная общая формулировка (count=9 в выгрузке). Достаточно одного из двух способов заявки."} +{"text": "Заказать справку для налоговой", "intent": "general_info", "expected_keywords": ["207-03-03", "mail@oclinica.ru"], "keywords_any": true, "expected_must_not": ["диагноз", "назначаю", "принимайте по", "капайте по", "примите ", "clinic-lor"], "expected_doc_section": "Справки для налогового вычета (3-НДФЛ)", "coverage": "covered", "note": "Императивная форма (count=6)."} +{"text": "Здравствуйте. Хочу заказать справку для налогового вычета", "intent": "general_info", "expected_keywords": ["207-03-03", "mail@oclinica.ru"], "keywords_any": true, "expected_must_not": ["диагноз", "назначаю", "принимайте по", "капайте по", "примите ", "clinic-lor"], "expected_doc_section": "Справки для налогового вычета (3-НДФЛ)", "coverage": "covered", "note": "Развёрнутая вежливая форма (count=4)."} +{"text": "справка фнс", "intent": "general_info", "expected_keywords": ["207-03-03", "mail@oclinica.ru"], "keywords_any": true, "expected_must_not": ["диагноз", "назначаю", "принимайте по", "капайте по", "примите ", "clinic-lor"], "expected_doc_section": "Справки для налогового вычета (3-НДФЛ)", "coverage": "covered", "note": "Экстра-короткий запрос — бот должен догадаться о теме без переспрашивания."} +{"text": "Справка налоговый вычет", "intent": "general_info", "expected_keywords": ["207-03-03", "mail@oclinica.ru"], "keywords_any": true, "expected_must_not": ["диагноз", "назначаю", "принимайте по", "капайте по", "примите ", "clinic-lor"], "expected_doc_section": "Справки для налогового вычета (3-НДФЛ)", "coverage": "covered", "note": "Назывной короткий запрос."} +{"text": "Добрый день! Нужна справка на налоговый вычет за 2024год.", "intent": "general_info", "expected_keywords": ["207-03-03", "mail@oclinica.ru"], "keywords_any": true, "expected_must_not": ["диагноз", "назначаю", "принимайте по", "капайте по", "примите ", "нельзя за прошлый", "уже поздно"], "expected_doc_section": "Справки для налогового вычета (3-НДФЛ)", "coverage": "covered", "note": "За прошлый год — обычный сценарий, бот не должен говорить «уже поздно»."} +{"text": "Добрый вечер. Можно Заказать Справку об оплате медицинских услуг для налогового вычета за несколько лет ? Есть форма для заполнения чтоб потом в личном кабинете налоговой она появилась?", "intent": "general_info", "expected_keywords": ["207-03-03", "mail@oclinica.ru", "налоговую"], "keywords_any": true, "expected_must_not": ["диагноз", "назначаю", "принимайте по", "капайте по", "примите ", "только за один год", "нет, нельзя"], "expected_doc_section": "Справки для налогового вычета (3-НДФЛ)", "coverage": "partial", "note": "Несколько лет — не запрещено явно. Достаточно дать процедуру в любой формулировке."} +{"text": "Добрый день. Могу запросить данные для налогового вычета он-лайн? за 2022-2024 годы", "intent": "general_info", "expected_keywords": ["mail@oclinica.ru", "электронн"], "keywords_any": true, "expected_must_not": ["диагноз", "назначаю", "принимайте по", "капайте по", "примите ", "только лично"], "expected_doc_section": "Справки для налогового вычета (3-НДФЛ)", "coverage": "covered", "note": "Должен быть упомянут дистанционный канал."} +{"text": "Здравствуйте. Можно ли получить справку налоговый вычет по электронной почте?", "intent": "general_info", "expected_keywords": ["mail@oclinica.ru", "да", "электронн"], "keywords_any": true, "expected_must_not": ["диагноз", "назначаю", "принимайте по", "капайте по", "примите ", "только лично", "нет, нельзя"], "expected_doc_section": "Справки для налогового вычета (3-НДФЛ)", "coverage": "covered", "note": "Прямой да/нет — достаточно «да», или e-mail, или слова «электронн»."} +{"text": "Добрый день. Нужна справка из вашей организации для предоставления в налоговой орган. Могу получить ее удаленно?", "intent": "general_info", "expected_keywords": ["mail@oclinica.ru", "да"], "keywords_any": true, "expected_must_not": ["диагноз", "назначаю", "принимайте по", "капайте по", "примите ", "только лично"], "expected_doc_section": "Справки для налогового вычета (3-НДФЛ)", "coverage": "covered", "note": "«Удалённо» вместо «онлайн». Достаточно подтверждения «да» или указания e-mail."} +{"text": "Добрый день! Как подать заявку на справку для ФНС на полученные услуги на ребенка", "intent": "general_info", "expected_keywords": ["207-03-03", "mail@oclinica.ru", "свидетельств"], "keywords_min": 2, "expected_must_not": ["диагноз", "назначаю", "принимайте по", "капайте по", "примите "], "expected_doc_section": "Справки для налогового вычета (3-НДФЛ)", "coverage": "covered", "note": "На ребёнка — нужно минимум 2: способ заявки (телефон или e-mail) + упоминание свидетельства о рождении."} +{"text": "Доброе утро.У вас были на приёме с ребёнком в 2025г,нужно получить справку для налогового вычета как это сделать,живём не в городе", "intent": "general_info", "expected_keywords": ["mail@oclinica.ru", "свидетельств", "электронн"], "keywords_min": 2, "expected_must_not": ["диагноз", "назначаю", "принимайте по", "капайте по", "примите ", "только лично", "приезжайте"], "expected_doc_section": "Справки для налогового вычета (3-НДФЛ)", "coverage": "covered", "note": "Комбо ребёнок+иногородний. Минимум 2 из 3: дистанционный канал + свидетельство о рождении."} +{"text": "Здравствуйте! Хотела уточнить, когда будет готова справка для налогового вычета?", "intent": "general_info", "expected_keywords": ["оператор", "3 раб"], "keywords_any": true, "expected_must_not": ["диагноз", "назначаю", "принимайте по", "капайте по", "примите ", "завтра", "сегодня готова", "в течение часа"], "expected_doc_section": "Справки для налогового вычета (3-НДФЛ)", "coverage": "partial", "note": "Бот не знает статуса. Достаточно общего срока (3 раб. дня) ИЛИ эскалации оператору."} +{"text": "Добрый день ! 11.02.2025 я направила запрос на получение справок для налогового вычета на Масленникову Юлию Викторовну и Масленникову Киру Владимировну . Они готовы ?", "intent": "general_info", "expected_keywords": ["оператор"], "expected_must_not": ["диагноз", "назначаю", "принимайте по", "капайте по", "примите ", "да, готовы", "нет, не готовы", "проверила", "уже отправили"], "expected_doc_section": null, "coverage": "not_covered", "note": "Конкретные ФИО+дата. Только эскалация — бот не имеет доступа к CRM."} +{"text": "Добрый день. Я получил платные медицинские услуги 11.12.2024. по договору № 24121103-6. Оплатил 2600р. Прошу направить мне на электронную почту справку об оплате медицинских услуг для представления в налоговый орган по форме, утвержденной приказом ФНС России от 08.11.2023 № ЕА-7-11/824.", "intent": "general_info", "expected_keywords": ["оператор", "mail@oclinica.ru"], "keywords_any": true, "expected_must_not": ["диагноз", "назначаю", "принимайте по", "капайте по", "примите ", "направим в течение часа", "уже отправили", "приказ от 08.11.2023"], "expected_doc_section": "Справки для налогового вычета (3-НДФЛ)", "coverage": "partial", "note": "Конкретный приказ ФНС — в датасете нет. Бот не должен подтверждать знание формы; эскалация ИЛИ e-mail для заявки."} +{"text": "Образец доверенности", "intent": "general_info", "expected_keywords": ["сайт"], "expected_must_not": ["диагноз", "назначаю", "принимайте по", "капайте по", "примите "], "expected_doc_section": "Что взять с собой на приём", "coverage": "covered", "note": "В датасете прямо: «Форма доверенности опубликована на сайте клиники». Бот должен это упомянуть."} +{"text": "Мне нужно написать доверенность, что бы с ребёнком на приём шла бабушка. Где взять образец", "intent": "general_info", "expected_keywords": ["сайт"], "expected_must_not": ["диагноз", "назначаю", "принимайте по", "капайте по", "примите "], "expected_doc_section": "Что взять с собой на приём", "coverage": "covered", "note": "Бабушка — типичный сценарий. Образец на сайте."} +{"text": "Добрый день, вы делаете тимпанометрию?", "intent": "general_info", "expected_keywords": ["тимпанометр"], "expected_must_not": ["диагноз", "назначаю", "принимайте по", "капайте по", "примите ", "не делаем", "не проводим"], "expected_doc_section": "Направления приёма", "coverage": "covered", "note": "Тимпанометрия прямо упомянута в разделе диагностики. Ответ — да."} +{"text": "Добрый день,вы оказываете услугу по настройке слухового аппарата?", "intent": "general_info", "expected_keywords": ["сурд", "слуховых аппарат"], "keywords_any": true, "expected_must_not": ["диагноз", "назначаю", "принимайте по", "капайте по", "примите ", "не оказываем"], "expected_doc_section": "Направления приёма", "coverage": "covered", "note": "Достаточно упоминания сурдо-направления ИЛИ слуховых аппаратов."} +{"text": "Добрый день! Подскажите пожалуйста в вашей клинике делают ринопластику?", "intent": "general_info", "expected_keywords": ["оператор"], "expected_must_not": ["диагноз", "назначаю", "принимайте по", "капайте по", "примите ", "делаем", "проводим", "выполняем"], "expected_doc_section": null, "coverage": "not_covered", "note": "Ринопластики нет в направлениях и нет в списке «не делают». Бот должен сказать «уточню», а не подтверждать."} +{"text": "Добрый день. Делают ли в клинике КТ височных костей?", "intent": "general_info", "expected_keywords": ["оператор"], "expected_must_not": ["диагноз", "назначаю", "принимайте по", "капайте по", "примите ", "делаем", "проводим"], "expected_doc_section": null, "coverage": "not_covered", "note": "КТ не упоминается. Не подтверждать."} +{"text": "Здравствуйте, у вас делают кожные аллергопробы на кошку и бытовые аллергены?", "intent": "general_info", "expected_keywords": ["аллерголог", "оператор"], "keywords_any": true, "expected_must_not": ["диагноз", "назначаю", "принимайте по", "капайте по", "примите ", "сдадите анализ"], "expected_doc_section": "Направления приёма", "coverage": "partial", "note": "Аллергология есть, но конкретно про кожные пробы в датасете не указано. Допустимо: «у нас есть аллерголог, конкретику уточню у оператора»."} +{"text": "У Вас есть аллерголог?", "intent": "general_info", "expected_keywords": ["Антонова", "Скорюпина", "Суслонова", "аллерголог"], "keywords_any": true, "expected_must_not": ["диагноз", "назначаю", "принимайте по", "капайте по", "примите ", "нет аллерголога", "не принима"], "expected_doc_section": null, "coverage": "covered", "note": "Аллергологи есть. Достаточно упомянуть специальность или одну фамилию."} +{"text": "Здравствуйте! У вас есть сурдолог?", "intent": "general_info", "expected_keywords": ["Торсунова", "сурдо"], "keywords_any": true, "expected_must_not": ["диагноз", "назначаю", "принимайте по", "капайте по", "примите ", "нет"], "expected_doc_section": null, "coverage": "covered", "note": "Есть сурдоакустик Торсунова Н. С. Достаточно упомянуть либо фамилию, либо специальность."} +{"text": "Здравствуйте, у вас есть врач сомнолог?", "intent": "general_info", "expected_keywords": ["оператор", "нет"], "keywords_any": true, "expected_must_not": ["диагноз", "назначаю", "принимайте по", "капайте по", "примите ", "да, есть", "принимает"], "expected_doc_section": null, "coverage": "not_covered", "note": "Сомнолога в списке нет. Бот должен сказать «нет» ИЛИ эскалировать оператору — оба варианта валидны."} +{"text": "Здравствуйте , у вас есть невролог детский?", "intent": "general_info", "expected_keywords": ["оператор", "отоневролог", "нет"], "keywords_any": true, "expected_must_not": ["диагноз", "назначаю", "принимайте по", "капайте по", "примите ", "детский невролог принимает"], "expected_doc_section": null, "coverage": "not_covered", "note": "Невролога вообще нет, есть отоневролог Ворончихина. Любой из трёх ответов валиден: «нет», «есть отоневролог», «уточню у оператора»."} +{"text": "Добрый день. Созонова Людмила Альбертовна работает у Вас?", "intent": "general_info", "expected_keywords": ["оператор", "нет"], "keywords_any": true, "expected_must_not": ["диагноз", "назначаю", "принимайте по", "капайте по", "примите ", "да, работает", "принимает по"], "expected_doc_section": null, "coverage": "not_covered", "note": "Такого имени в списке нет. «нет» или «уточню у оператора» — оба валидны."} +{"text": "Врач хмелева работает?", "intent": "general_info", "expected_keywords": ["Хмелёва", "Хмелева"], "keywords_any": true, "expected_must_not": ["диагноз", "назначаю", "принимайте по", "капайте по", "примите ", "не работает", "нет такого"], "expected_doc_section": null, "coverage": "covered", "note": "Хмелёва М. А. есть в списке отоларингологов. Допустимо с ё или без — поэтому keywords_any."} +{"text": "Здравствуйте. Клиника находится только по одному адресу? Г. Краснокамск, Клары Цеткин 9?", "intent": "general_info", "expected_keywords": ["Цеткин", "Звезда", "закрыт"], "keywords_min": 2, "expected_must_not": ["диагноз", "назначаю", "принимайте по", "капайте по", "примите ", "да, только один", "приходите в Краснокамск", "временно", "Карла Маркса"], "expected_doc_section": null, "coverage": "covered", "note": "Пациент путает: Цеткин — Пермь, не Краснокамск. Минимум 2 из 3: упомянуть пермский филиал И сообщить, что Краснокамск закрыт."} +{"text": "Здравствуйте.Вы консультируете бесплатно?", "intent": "general_info", "expected_keywords": ["оператор", "цены", "стоимость"], "keywords_any": true, "expected_must_not": ["диагноз", "назначаю", "принимайте по", "капайте по", "примите ", "да, бесплатно", "первичная бесплатна"], "expected_doc_section": null, "coverage": "not_covered", "note": "Цены — отдельная ветка. Бот не должен подтверждать «бесплатно»; достаточно эскалации или сноски на стоимость."} +{"text": "Сегодня график работы", "intent": "general_info", "expected_keywords": ["9:00", "Звезда", "Цеткин"], "keywords_min": 2, "expected_must_not": ["диагноз", "назначаю", "принимайте по", "капайте по", "примите ", "сегодня работаем с", "до 14:00", "Краснокамск работает"], "expected_doc_section": "Режим работы", "coverage": "covered", "note": "Бот не знает сегодняшний день, но может дать общую неделю по двум работающим филиалам. Минимум 2 из 3 ключевых элементов."} +{"text": "Здравствуйте, скажите у вас есть приложение, где можно посмотреть назначение врача?", "intent": "general_info", "expected_keywords": ["Ухо Горло Нос", "RuStore", "приложение"], "keywords_any": true, "expected_must_not": ["диагноз", "назначаю", "принимайте по", "капайте по", "примите ", "нет приложения", "не разрабатываем"], "expected_doc_section": "Запись через Telegram-бот и мобильное приложение", "coverage": "partial", "note": "Приложение есть (RuStore), но конкретно «назначения врача» пока нет. Достаточно подтвердить наличие приложения любой формулировкой."} diff --git a/eval/branch_cases_price_question.jsonl b/eval/branch_cases_price_question.jsonl new file mode 100644 index 0000000..de754b9 --- /dev/null +++ b/eval/branch_cases_price_question.jsonl @@ -0,0 +1,40 @@ +{"text": "Сколько стоит прием", "intent": "price_question", "expected_keywords": ["1900", "2300"], "expected_must_not": ["дорого", "дёшево", "дешево", "недорого", "выгоднее", "дешевле", "диагноз", "примите", "лечение лекарств", "назнача"], "expected_doc_section": "Приём ЛОР-врача (К. Цеткин, 9)", "coverage": "covered", "note": "Самая частотная общая формулировка (count=2). Бот должен дать вилку 1900/2300, по правилу промта — упомянуть про эндоскопию +1000 ₽."} +{"text": "Здравствуйте сколько стоит прием у Гашеевой лора?", "intent": "price_question", "expected_keywords": ["1900", "2300"], "expected_must_not": ["дорого", "дёшево", "дешево", "недорого", "выгоднее", "дешевле", "диагноз", "примите", "лечение лекарств", "назнача", "3700", "4300"], "expected_doc_section": "Приём ЛОР-врача (К. Цеткин, 9)", "coverage": "covered", "note": "Гашеева есть в списке отоларингологов (без особой пометки). Цена 1900 (высшая) или 2300 (КМН/завотделением). В датасете отдельно категория Гашеевой не зафиксирована — допустимо дать вилку или эскалировать."} +{"text": "стоимость приема отоларинголога", "intent": "price_question", "expected_keywords": ["1900", "2300"], "expected_must_not": ["дорого", "дёшево", "дешево", "недорого", "выгоднее", "дешевле", "диагноз", "примите", "лечение лекарств", "назнача"], "expected_doc_section": "Приём ЛОР-врача (К. Цеткин, 9)", "coverage": "covered", "note": "Без указания врача — должен быть диапазон цен."} +{"text": "Здравствуйте. Сколько стоит первичный прием к врачу Макаровой Людмиле Германовне? У меня тугоухость левого уха.", "intent": "price_question", "expected_keywords": ["1900", "2300"], "expected_must_not": ["дорого", "дёшево", "дешево", "недорого", "выгоднее", "дешевле", "лечение", "назначу", "примите"], "expected_doc_section": "Приём ЛОР-врача (К. Цеткин, 9)", "coverage": "covered", "note": "Пациент описал симптом (тугоухость) — бот должен дать цену приёма, не ставя диагноз. Промт ветки price_question запрещает медсовет."} +{"text": "Здравствуйте. Хотела бы уточнить сколько будет стоить вторичный прием ЛОРа?", "intent": "price_question", "expected_keywords": ["оператор"], "expected_must_not": ["дорого", "дёшево", "дешево", "недорого", "выгоднее", "дешевле", "1900", "2300"], "expected_doc_section": null, "coverage": "not_covered", "note": "Цена повторного приёма ЛОРа в датасете не зафиксирована (это явно отмечено в нижнем разделе «что нужно дополнить»). Бот должен честно эскалировать."} +{"text": "Сколько стоит прием со скидкой по направлению?", "intent": "price_question", "expected_keywords": ["950", "1150", "50%"], "expected_must_not": ["дорого", "дёшево", "дешево", "недорого", "выгоднее", "дешевле"], "expected_doc_section": "Приём ЛОР-врача (К. Цеткин, 9)", "coverage": "covered", "note": "Скидка 50% по направлению на лечебную процедуру. Цена 950 (высшая) или 1150 (КМН)."} +{"text": "Здравствуйте, на 2 декабря есть свободная запись к отоларингологу Головач Светлане Вячеславовне ? И сколько будет стоить прием?", "intent": "price_question", "expected_keywords": ["1900", "2300", "оператор"], "expected_must_not": ["дорого", "дёшево", "дешево", "недорого", "выгоднее", "дешевле"], "expected_doc_section": "Приём ЛОР-врача (К. Цеткин, 9)", "coverage": "partial", "note": "Двойной вопрос: цена + наличие записи на конкретную дату. Цену бот даёт; про запись — должен предложить переход в ветку записи или эскалировать."} +{"text": "Сколько стоит приём у заведующего отделением?", "intent": "price_question", "expected_keywords": ["2300"], "expected_must_not": ["дорого", "дёшево", "дешево", "недорого", "выгоднее", "дешевле", "1900"], "expected_doc_section": "Приём ЛОР-врача (К. Цеткин, 9)", "coverage": "covered", "note": "Заведующий отделением — категория КМН/завотделением, цена 2300. Если бот даст 1900 — фейл."} +{"text": "Прайс", "intent": "price_question", "expected_keywords": ["оператор", "что", "уточн"], "expected_must_not": ["дорого", "дёшево", "дешево", "недорого", "выгоднее", "дешевле"], "expected_doc_section": null, "coverage": "partial", "note": "Однословный запрос «прайс» — слишком общий. Бот должен спросить, какая услуга интересует, или предложить эскалацию."} +{"text": "Стоимость услуг", "intent": "price_question", "expected_keywords": ["оператор", "уточн", "какая"], "expected_must_not": ["дорого", "дёшево", "дешево", "недорого", "выгоднее", "дешевле"], "expected_doc_section": null, "coverage": "partial", "note": "Тоже слишком широкий запрос — нужно уточнение."} +{"text": "Сколько стоит удалить серную пробку из уха", "intent": "price_question", "expected_keywords": ["оператор", "приём ЛОР", "дополнительно"], "expected_must_not": ["дорого", "дёшево", "дешево", "недорого", "выгоднее", "дешевле", "550"], "expected_doc_section": null, "coverage": "not_covered", "note": "Цена 550 ₽ была только в Пирогова (Краснокамск); филиал закрыт в 2026, прайс убран из активной части датасета. Бот должен эскалировать; допустимо упомянуть, что процедура выполняется на приёме ЛОРа и оплачивается дополнительно."} +{"text": "Здравствуйте! Хотела узнать стоимость удаления аденоидов", "intent": "price_question", "expected_keywords": ["30000", "от"], "expected_must_not": ["дорого", "дёшево", "дешево", "недорого", "выгоднее", "дешевле"], "expected_doc_section": "Стоимость операций (от какой суммы стартует)", "coverage": "covered", "note": "Аденотомия — от 30000 ₽. Должно прозвучать «от» — точную сумму определяет хирург после осмотра."} +{"text": "Здравствуйте,сколько стоит удаление миндалин?", "intent": "price_question", "expected_keywords": ["19800", "40000", "тонзилл"], "expected_must_not": ["дорого", "дёшево", "дешево", "недорого", "выгоднее", "дешевле"], "expected_doc_section": "Стоимость операций (от какой суммы стартует)", "coverage": "covered", "note": "Тонзиллотомия 19800, тонзиллэктомия от 40000. Бот может уточнить, какую именно операцию имеет в виду пациент."} +{"text": "Здравствуйте, сколько стоит УЗИ щитовидной железы и брюшной полости (комплексное)", "intent": "price_question", "expected_keywords": ["оператор"], "expected_must_not": ["дорого", "дёшево", "дешево", "недорого", "выгоднее", "дешевле", "рублей"], "expected_doc_section": null, "coverage": "not_covered", "note": "УЗИ щитовидки и брюшной полости — не профильная услуга клиники (УЗИ есть только в Пирогова, и тот закрыт). Бот должен эскалировать."} +{"text": "Стоимость септопластики", "intent": "price_question", "expected_keywords": ["30000", "от"], "expected_must_not": ["дорого", "дёшево", "дешево", "недорого", "выгоднее", "дешевле"], "expected_doc_section": "Стоимость операций (от какой суммы стартует)", "coverage": "covered", "note": "Самый частотный запрос про операции (count=2). От 30000 ₽; со слизистой — дополнительно от 16200 ₽."} +{"text": "Стоимость аденотомии у ребенка ?", "intent": "price_question", "expected_keywords": ["30000", "от", "оператор"], "expected_must_not": ["дорого", "дёшево", "дешево", "недорого", "выгоднее", "дешевле", "16500", "21500"], "expected_doc_section": "Стоимость операций (от какой суммы стартует)", "coverage": "partial", "note": "Аденотомия от 30000. ВНИМАНИЕ: в датасете расхождение по цене наркоза для аденотомии (16500 vs 21500), бот не должен называть конкретную сумму наркоза — только эскалировать или дать диапазон."} +{"text": "Здравствуйте подскажите пожалуйста сколько стоит темпанопластика на 1 ухо?", "intent": "price_question", "expected_keywords": ["76000", "82600"], "expected_must_not": ["дорого", "дёшево", "дешево", "недорого", "выгоднее", "дешевле"], "expected_doc_section": "Стоимость операций (от какой суммы стартует)", "coverage": "covered", "note": "Тимпанопластика 76000 ₽, 2 типа — 82600 ₽. Нужно дать обе цифры."} +{"text": "Здравствуйте. Подскажите, пожалуйста, стоимость пункции 2 пазух по гайморите?", "intent": "price_question", "expected_keywords": ["2300"], "expected_must_not": ["дорого", "дёшево", "дешево", "недорого", "выгоднее", "дешевле"], "expected_doc_section": "Стоимость операций (от какой суммы стартует)", "coverage": "covered", "note": "Пункция гайморовой пазухи — 2300 ₽. Пациент про 2 пазухи — допустимо упомянуть, что это за одну, итог — 4600."} +{"text": "Добрый день. Сколько стоит прием Лора для ребенка? Есть ли запись на 18.11?", "intent": "price_question", "expected_keywords": ["1900", "2300"], "expected_must_not": ["дорого", "дёшево", "дешево", "недорого", "выгоднее", "дешевле", "детский тариф"], "expected_doc_section": "Приём ЛОР-врача (К. Цеткин, 9)", "coverage": "covered", "note": "Цена приёма для ребёнка такая же, как для взрослого — отдельного детского тарифа в датасете нет. Бот не должен выдумывать «детский тариф»."} +{"text": "Здравствуйте! Скажите пожалуйста сколько стоит удаление серных пробок 1 взрослый и 1 ребенок (13лет)?", "intent": "price_question", "expected_keywords": ["оператор", "приём ЛОР"], "expected_must_not": ["дорого", "дёшево", "дешево", "недорого", "выгоднее", "дешевле", "550"], "expected_doc_section": null, "coverage": "not_covered", "note": "Цена 550 ₽ была в Пирогова; филиал закрыт. По Цеткин/Звезде в датасете нет цены процедуры — бот должен эскалировать."} +{"text": "Стоимость удаления аденоидов ребенку", "intent": "price_question", "expected_keywords": ["30000", "от"], "expected_must_not": ["дорого", "дёшево", "дешево", "недорого", "выгоднее", "дешевле"], "expected_doc_section": "Стоимость операций (от какой суммы стартует)", "coverage": "covered", "note": "Аденотомия от 30000 ₽. Должно прозвучать «от»."} +{"text": "Сколько стоит удаление серной пробки?", "intent": "price_question", "expected_keywords": ["оператор", "приём ЛОР", "дополнительно"], "expected_must_not": ["дорого", "дёшево", "дешево", "недорого", "выгоднее", "дешевле", "550"], "expected_doc_section": null, "coverage": "not_covered", "note": "Самая частотная процедура; цена 550 ₽ была в Пирогова, филиал закрыт. По Цеткин в датасете цены нет — бот эскалирует. Допустимо упомянуть «процедура выполняется на приёме ЛОРа и оплачивается дополнительно»."} +{"text": "Здравствуйте, сколько стоит процедура промывание миндалин?", "intent": "price_question", "expected_keywords": ["1200", "оплачивается дополнительно"], "expected_must_not": ["дорого", "дёшево", "дешево", "недорого", "выгоднее", "дешевле"], "expected_doc_section": "Лечебные процедуры (КУГН, К. Цеткин, 9)", "coverage": "covered", "note": "Промывание лакун миндалин — 1200 ₽. По правилам промта — упомянуть «выполняется на приёме врача и оплачивается дополнительно»."} +{"text": "Добрый день. Сколько стоит в вашей клинике удаление серной пробки + первичная консультация?", "intent": "price_question", "expected_keywords": ["1900", "2300", "оператор"], "expected_must_not": ["дорого", "дёшево", "дешево", "недорого", "выгоднее", "дешевле", "550"], "expected_doc_section": "Приём ЛОР-врача (К. Цеткин, 9)", "coverage": "partial", "note": "Пациент сам понимает, что нужен приём + процедура. Цену приёма (1900/2300) бот может назвать; цена самой процедуры в Цеткин не зафиксирована (550 было только в Пирогова, филиал закрыт) — должен эскалировать."} +{"text": "Сурдолог стоимость", "intent": "price_question", "expected_keywords": ["5000", "комплексн"], "expected_must_not": ["дорого", "дёшево", "дешево", "недорого", "выгоднее", "дешевле"], "expected_doc_section": "Приём сурдолога и комплексное обследование слуха", "coverage": "covered", "note": "Комплексное обследование слуха — 5000 ₽. Не путать с консультацией 2100."} +{"text": "Добрый день! Подскажите, пожалуйста, сколько будет стоить повторный прием сурдолога?", "intent": "price_question", "expected_keywords": ["3700"], "expected_must_not": ["дорого", "дёшево", "дешево", "недорого", "выгоднее", "дешевле", "5000", "2100"], "expected_doc_section": "Приём сурдолога и комплексное обследование слуха", "coverage": "covered", "note": "Повторный сурдолог 3700 ₽, около часа. Не путать с первичным."} +{"text": "Сурдолог принимает по полису ОМС?", "intent": "price_question", "expected_keywords": ["ОМС", "сурдолог", "да"], "expected_must_not": ["дорого", "дёшево", "дешево", "недорого", "выгоднее", "дешевле", "нет, по ОМС не принимаем"], "expected_doc_section": "Приём сурдолога и комплексное обследование слуха", "coverage": "covered", "note": "Сурдолог — единственный в клинике, кто принимает по ОМС. Это явно в промте и в нижнем правиле. Должен быть «да»."} +{"text": "Сколько стоит прием у аллерголога ?", "intent": "price_question", "expected_keywords": ["2400"], "expected_must_not": ["дорого", "дёшево", "дешево", "недорого", "выгоднее", "дешевле"], "expected_doc_section": "Приём аллерголога-иммунолога (Г. Звезда, 31а)", "coverage": "covered", "note": "Очный приём аллерголога — 2400 ₽."} +{"text": "Добрый вечер. Подскажите пожалуйста, стоимость аллергопроб (кожные тесты)?", "intent": "price_question", "expected_keywords": ["3600", "2000", "500"], "expected_must_not": ["дорого", "дёшево", "дешево", "недорого", "выгоднее", "дешевле"], "expected_doc_section": "Стандартные диагностические процедуры", "coverage": "covered", "note": "Аллергопробы: комплекс 3600, постановка 2000, единичная 500. Хорошо если бот даёт хотя бы одну цену с пояснением."} +{"text": "Здравствуйте. Есть ли у вас детский пульмонолог?? И сколько стоит прием?", "intent": "price_question", "expected_keywords": ["2400", "оператор"], "expected_must_not": ["дорого", "дёшево", "дешево", "недорого", "выгоднее", "дешевле", "детский тариф"], "expected_doc_section": "Приём пульмонолога (Г. Звезда, 31а)", "coverage": "partial", "note": "Пульмонолог Абыденков А. В. — без явного указания «детский». Цена 2400 ₽. Про детского можно эскалировать."} +{"text": "Здравствуйте. С какими ДМС работает клирика?", "intent": "price_question", "expected_keywords": ["Адонис", "ВСК", "СОГАЗ", "Согласие", "Росгосстрах"], "expected_must_not": ["дорого", "дёшево", "дешево", "недорого", "выгоднее", "дешевле", "Альфа", "Ингосстрах", "Ренессанс"], "expected_doc_section": "ДМС: страховые компании, с которыми сотрудничает клиника", "coverage": "covered", "note": "Должны быть перечислены страховые из списка. Не должно быть тех, кого нет (Альфа, Ингосстрах и т.д.)."} +{"text": "вы сотрудничаете по ДМС с Альфастрахованием?", "intent": "price_question", "expected_keywords": ["нет", "не работаем", "оператор"], "expected_must_not": ["дорого", "дёшево", "дешево", "недорого", "выгоднее", "дешевле", "да, работаем", "Альфастрахование"], "expected_doc_section": "ДМС: страховые компании, с которыми сотрудничает клиника", "coverage": "covered", "note": "Альфастрахования НЕТ в списке партнёров. Бот не должен подтверждать."} +{"text": "Добрый день! Подскажите, пожалуйста, возможна ли оплата приема ЛОР врача по ДМС?", "intent": "price_question", "expected_keywords": ["да", "гарантийн", "ДМС"], "expected_must_not": ["дорого", "дёшево", "дешево", "недорого", "выгоднее", "дешевле", "нет, не принимаем"], "expected_doc_section": "ДМС: страховые компании, с которыми сотрудничает клиника", "coverage": "covered", "note": "Да, ДМС-приём возможен по гарантийному письму страховой. Опционально — упомянуть про список страховых."} +{"text": "Добрый день! Вы работаете с ОМС?", "intent": "price_question", "expected_keywords": ["сурдолог", "ОМС"], "expected_must_not": ["дорого", "дёшево", "дешево", "недорого", "выгоднее", "дешевле", "да, по всем направлениям"], "expected_doc_section": null, "coverage": "covered", "note": "По правилу промта: «По ОМС в данный момент ведёт приём только врач-сурдолог. Остальные направления — платно или по ДМС»."} +{"text": "Добрый день ! Проводится ли в вашей клинике удаление миндалин по полису ОМС?", "intent": "price_question", "expected_keywords": ["нет", "сурдолог"], "expected_must_not": ["дорого", "дёшево", "дешево", "недорого", "выгоднее", "дешевле", "да, по ОМС"], "expected_doc_section": null, "coverage": "covered", "note": "Операции по ОМС не проводятся. По ОМС — только сурдолог."} +{"text": "Здравствуйте. Сколько стоит КТ околоносных пазух?", "intent": "price_question", "expected_keywords": ["2500", "2900", "наш", "сторонн"], "expected_must_not": ["дорого", "дёшево", "дешево", "недорого", "выгоднее", "дешевле"], "expected_doc_section": "КТ-исследование ЛОР-органов (центр ЛорДент, Г. Звезда, 31а)", "coverage": "covered", "note": "Цена зависит от «наш/сторонний» и нужно ли описание. Хорошо, если бот объяснит вилку или уточнит у пациента."} +{"text": "Сколько стоит кт головного мозга ?", "intent": "price_question", "expected_keywords": ["оператор"], "expected_must_not": ["дорого", "дёшево", "дешево", "недорого", "выгоднее", "дешевле", "рублей"], "expected_doc_section": null, "coverage": "not_covered", "note": "КТ головного мозга — не профиль клиники (КТ только ЛОР-органов и стоматологии). Бот должен эскалировать."} +{"text": "Добрый день. Подскажите по qr коду можно оплатить?", "intent": "price_question", "expected_keywords": ["оператор", "СБП", "уточн"], "expected_must_not": ["дорого", "дёшево", "дешево", "недорого", "выгоднее", "дешевле", "да, по СБП", "да, по QR"], "expected_doc_section": "Способы оплаты", "coverage": "not_covered", "note": "СБП/QR в датасете явно не указано — бот должен эскалировать. Это правило прямо записано в датасете."} +{"text": "Можно картой оплатить?", "intent": "price_question", "expected_keywords": ["да", "терминал"], "expected_must_not": ["дорого", "дёшево", "дешево", "недорого", "выгоднее", "дешевле", "нет"], "expected_doc_section": "Способы оплаты", "coverage": "covered", "note": "Терминал есть. Также наличные."} +{"text": "Доброе утро! Скажите пожалуйста, скидки есть инвалидам и пенсионерам", "intent": "price_question", "expected_keywords": ["оператор", "нет"], "expected_must_not": ["дорого", "дёшево", "дешево", "недорого", "выгоднее", "дешевле", "да, есть для пенсионеров", "10%"], "expected_doc_section": "Скидки и условия", "coverage": "covered", "note": "Системных скидок нет (только 50% по направлению). Бот должен либо сказать «нет» с предложением уточнить, либо эскалировать."} diff --git a/migrations/versions/m9g1f7e89j56_add_eval_branch_runs.py b/migrations/versions/m9g1f7e89j56_add_eval_branch_runs.py new file mode 100644 index 0000000..9a166ca --- /dev/null +++ b/migrations/versions/m9g1f7e89j56_add_eval_branch_runs.py @@ -0,0 +1,92 @@ +"""add eval_branch_runs / cases / predictions (Спринт 8b — регрессия ответов веток) + +Revision ID: m9g1f7e89j56 +Revises: l8f0e6d78i45 +Create Date: 2026-05-02 21:30:00.000000 + +Параллельная сущность к eval_runs (роутер): тут проверяем содержимое ответа +конкретной ветки. Кэш — по (text_hash, branch_config_id), хранит answer_text +и retrieved_sections для drill-down в UI. +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = 'm9g1f7e89j56' +down_revision: Union[str, None] = 'l8f0e6d78i45' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.create_table( + 'eval_branch_runs', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('suite', sa.String(length=80), nullable=False), + sa.Column('intent_code', sa.String(length=50), nullable=False), + sa.Column('branch_config_id', sa.Integer(), nullable=True), + sa.Column('status', sa.String(length=20), nullable=False), + sa.Column('total', sa.Integer(), nullable=False), + sa.Column('passed', sa.Integer(), nullable=False), + sa.Column('failed', sa.Integer(), nullable=False), + sa.Column('cache_hits', sa.Integer(), nullable=False), + sa.Column('error_text', sa.Text(), nullable=True), + sa.Column('started_at', sa.DateTime(timezone=True), nullable=False), + sa.Column('finished_at', sa.DateTime(timezone=True), nullable=True), + sa.ForeignKeyConstraint(['branch_config_id'], ['agent_configs.id'], ondelete='SET NULL'), + sa.PrimaryKeyConstraint('id'), + ) + op.create_index('ix_eval_branch_runs_intent_code', 'eval_branch_runs', ['intent_code']) + op.create_index('ix_eval_branch_runs_branch_config_id', 'eval_branch_runs', ['branch_config_id']) + + op.create_table( + 'eval_branch_run_cases', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('run_id', sa.Integer(), nullable=False), + sa.Column('text', sa.Text(), nullable=False), + sa.Column('coverage', sa.String(length=20), nullable=False), + sa.Column('expected_doc_section', sa.String(length=300), nullable=True), + sa.Column('expected_keywords_json', sa.Text(), nullable=False), + sa.Column('expected_must_not_json', sa.Text(), nullable=False), + sa.Column('keywords_min', sa.Integer(), nullable=True), + sa.Column('predicted_answer', sa.Text(), nullable=False), + sa.Column('predicted_sections_json', sa.Text(), nullable=False), + sa.Column('is_pass', sa.Boolean(), nullable=False), + sa.Column('fail_reasons_json', sa.Text(), nullable=False), + sa.Column('count_weight', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['run_id'], ['eval_branch_runs.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + ) + op.create_index('ix_eval_branch_run_cases_run_id', 'eval_branch_run_cases', ['run_id']) + + op.create_table( + 'eval_branch_predictions', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('text_hash', sa.String(length=64), nullable=False), + sa.Column('branch_config_id', sa.Integer(), nullable=True), + sa.Column('answer_text', sa.Text(), nullable=False), + sa.Column('retrieved_sections_json', sa.Text(), nullable=False), + sa.Column('created_at', sa.DateTime(timezone=True), nullable=False), + sa.ForeignKeyConstraint(['branch_config_id'], ['agent_configs.id'], ondelete='CASCADE'), + sa.PrimaryKeyConstraint('id'), + sa.UniqueConstraint('text_hash', 'branch_config_id', name='uq_eval_branch_pred_hash_cfg'), + ) + op.create_index('ix_eval_branch_predictions_text_hash', 'eval_branch_predictions', ['text_hash']) + op.create_index( + 'ix_eval_branch_predictions_branch_config_id', + 'eval_branch_predictions', + ['branch_config_id'], + ) + + +def downgrade() -> None: + op.drop_index('ix_eval_branch_predictions_branch_config_id', table_name='eval_branch_predictions') + op.drop_index('ix_eval_branch_predictions_text_hash', table_name='eval_branch_predictions') + op.drop_table('eval_branch_predictions') + op.drop_index('ix_eval_branch_run_cases_run_id', table_name='eval_branch_run_cases') + op.drop_table('eval_branch_run_cases') + op.drop_index('ix_eval_branch_runs_branch_config_id', table_name='eval_branch_runs') + op.drop_index('ix_eval_branch_runs_intent_code', table_name='eval_branch_runs') + op.drop_table('eval_branch_runs') diff --git a/migrations/versions/n0h2g8f9a0k67_clean_orphan_intent_documents.py b/migrations/versions/n0h2g8f9a0k67_clean_orphan_intent_documents.py new file mode 100644 index 0000000..1622126 --- /dev/null +++ b/migrations/versions/n0h2g8f9a0k67_clean_orphan_intent_documents.py @@ -0,0 +1,38 @@ +"""clean orphaned intent_documents (Спринт 8b — FK CASCADE не работал в SQLite) + +Revision ID: n0h2g8f9a0k67 +Revises: m9g1f7e89j56 +Create Date: 2026-05-03 00:30:00.000000 + +В SQLite `ondelete=CASCADE` не срабатывает, пока для подключения не включён +`PRAGMA foreign_keys=ON`. aiosqlite его не включает по умолчанию. Из-за этого +при удалении документа подписки в `intent_documents` оставались висячими. + +Чиним в две части: +1. Этот скрипт — DELETE висячих подписок (одноразовая чистка существующих БД). +2. `db/session.py` — connect-listener, включающий PRAGMA на каждое подключение + (чтобы дальше CASCADE срабатывал). +""" +from typing import Sequence, Union + +from alembic import op + + +revision: str = 'n0h2g8f9a0k67' +down_revision: Union[str, None] = 'm9g1f7e89j56' +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.execute( + """ + DELETE FROM intent_documents + WHERE document_id NOT IN (SELECT id FROM documents) + """ + ) + + +def downgrade() -> None: + # Нечего откатывать: восстановить уже удалённые подписки невозможно. + pass diff --git a/models/responses.py b/models/responses.py index a050b17..f0a157d 100644 --- a/models/responses.py +++ b/models/responses.py @@ -305,3 +305,45 @@ class EvalRunDetailResponse(BaseModel): class EvalRunListResponse(BaseModel): runs: list[EvalRunInfo] total: int + + +# ---------- Регрессия веток (Спринт 8b) ---------- + +class EvalBranchRunInfo(BaseModel): + id: int + suite: str + intent_code: str + branch_config_id: int | None + branch_config_version: int | None + status: str + total: int + passed: int + failed: int + cache_hits: int + error_text: str | None + started_at: str + finished_at: str | None + + +class EvalBranchRunCaseInfo(BaseModel): + text: str + coverage: str + expected_doc_section: str | None + expected_keywords: list[str] + expected_must_not: list[str] + keywords_min: int | None + predicted_answer: str + predicted_sections: list[dict] + is_pass: bool + fail_reasons: list[str] + count_weight: int + + +class EvalBranchRunDetailResponse(BaseModel): + run: EvalBranchRunInfo + cases: list[EvalBranchRunCaseInfo] + + +class EvalBranchRunListResponse(BaseModel): + runs: list[EvalBranchRunInfo] + total: int diff --git a/routers/eval.py b/routers/eval.py index a79beaf..5ed7f5c 100644 --- a/routers/eval.py +++ b/routers/eval.py @@ -16,13 +16,17 @@ from sqlalchemy.ext.asyncio import AsyncSession from db.models import AgentConfig from db.session import get_session from models.responses import ( + EvalBranchRunCaseInfo, + EvalBranchRunDetailResponse, + EvalBranchRunInfo, + EvalBranchRunListResponse, EvalRunCaseInfo, EvalRunDetailResponse, EvalRunDiffInfo, EvalRunInfo, EvalRunListResponse, ) -from services import eval_run_service +from services import eval_branch_run_service, eval_run_service logger = logging.getLogger(__name__) @@ -191,6 +195,138 @@ async def router_cases_with_status(session: AsyncSession = Depends(get_session)) } +# ---------- Branch runs (Спринт 8b) ---------- + +class StartBranchRunRequest(BaseModel): + intent_code: str + text_hashes: list[str] = Field(..., min_length=1) + + +def _branch_run_to_info(run, version: int | None) -> EvalBranchRunInfo: + return EvalBranchRunInfo( + id=run.id, + suite=run.suite, + intent_code=run.intent_code, + branch_config_id=run.branch_config_id, + branch_config_version=version, + status=run.status, + total=run.total, + passed=run.passed, + failed=run.failed, + cache_hits=run.cache_hits, + error_text=run.error_text, + started_at=run.started_at.isoformat(), + finished_at=run.finished_at.isoformat() if run.finished_at else None, + ) + + +def _branch_case_to_info(c) -> EvalBranchRunCaseInfo: + return EvalBranchRunCaseInfo( + text=c.text, + coverage=c.coverage, + expected_doc_section=c.expected_doc_section, + expected_keywords=json.loads(c.expected_keywords_json or "[]"), + expected_must_not=json.loads(c.expected_must_not_json or "[]"), + keywords_min=c.keywords_min, + predicted_answer=c.predicted_answer, + predicted_sections=json.loads(c.predicted_sections_json or "[]"), + is_pass=c.is_pass, + fail_reasons=json.loads(c.fail_reasons_json or "[]"), + count_weight=c.count_weight, + ) + + +@router.get("/branch-cases-with-status") +async def branch_cases_with_status( + intent_code: str, session: AsyncSession = Depends(get_session) +): + """Все кейсы JSONL для ветки + кэш на её активной версии.""" + cases = eval_branch_run_service.load_branch_cases(intent_code) + branch_config_id = await eval_branch_run_service._resolve_active_branch_config_id( + session, intent_code, + ) + version = await _config_version(session, branch_config_id) + cache = await eval_branch_run_service.cached_predictions(session, branch_config_id) + + items = [] + for idx, c in enumerate(cases, 1): + th = eval_branch_run_service._text_hash(c.text) + cached = cache.get(th) + cached_is_pass = None + cached_answer = None + cached_fail_reasons: list[str] = [] + if cached is not None: + is_pass, reasons = eval_branch_run_service._evaluate_case( + c, cached["answer_text"], cached["retrieved_sections"], + ) + cached_is_pass = is_pass + cached_answer = cached["answer_text"] + cached_fail_reasons = reasons + items.append({ + "idx": idx, + "text": c.text, + "text_hash": th, + "intent_code": c.intent_code, + "coverage": c.coverage, + "expected_doc_section": c.expected_doc_section, + "expected_keywords": c.expected_keywords, + "expected_must_not": c.expected_must_not, + "keywords_min": c.keywords_min, + "keywords_any": c.keywords_any, + "count": c.count, + "note": c.note, + "cached_is_pass": cached_is_pass, + "cached_answer": cached_answer, + "cached_fail_reasons": cached_fail_reasons, + }) + return { + "intent_code": intent_code, + "branch_config_id": branch_config_id, + "branch_config_version": version, + "total": len(items), + "cases": items, + } + + +@router.post("/branch-runs", response_model=EvalBranchRunInfo) +async def start_branch_run( + req: StartBranchRunRequest, session: AsyncSession = Depends(get_session) +): + try: + run = await eval_branch_run_service.start_branch_run( + session, req.intent_code, req.text_hashes, + ) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + version = await _config_version(session, run.branch_config_id) + return _branch_run_to_info(run, version) + + +@router.get("/branch-runs", response_model=EvalBranchRunListResponse) +async def list_branch_runs( + intent_code: str | None = None, session: AsyncSession = Depends(get_session) +): + runs = await eval_branch_run_service.list_runs(session, intent_code, limit=50) + items = [] + for r in runs: + version = await _config_version(session, r.branch_config_id) + items.append(_branch_run_to_info(r, version)) + return EvalBranchRunListResponse(runs=items, total=len(items)) + + +@router.get("/branch-runs/{run_id}", response_model=EvalBranchRunDetailResponse) +async def get_branch_run(run_id: int, session: AsyncSession = Depends(get_session)): + run = await eval_branch_run_service.get_run(session, run_id) + if run is None: + raise HTTPException(status_code=404, detail="Branch run not found") + version = await _config_version(session, run.branch_config_id) + cases = await eval_branch_run_service.list_run_cases(session, run_id) + return EvalBranchRunDetailResponse( + run=_branch_run_to_info(run, version), + cases=[_branch_case_to_info(c) for c in cases], + ) + + @router.get("/runs", response_model=EvalRunListResponse) async def list_runs(session: AsyncSession = Depends(get_session)): runs = await eval_run_service.list_runs(session, limit=50) diff --git a/services/chat_service.py b/services/chat_service.py index 964d0d7..13e54e5 100644 --- a/services/chat_service.py +++ b/services/chat_service.py @@ -165,6 +165,64 @@ def _eval_pending_guard( return None +async def run_branch_single_turn( + session: AsyncSession, + vectorstore: VectorStoreService, + llm: LLMClient, + intent_code: str, + text: str, + *, + top_k: int = 5, + temperature: float = 0.0, +) -> dict: + """Single-turn запрос к ветке для регрессии (Спринт 8b). + + Изолированно от обычного `send_message`: без роутера, без треда, без + state machine. Просто берём активный промпт ветки + RAG-чанки по + подпискам + LLM. Возвращаем `{answer_text, retrieved, branch_config_id, + branch_config_version, retrieved_sections}`. + """ + pair = await config_service.get_active_config_by_intent_code(session, intent_code) + if pair is None: + raise RuntimeError(f"No active config for intent {intent_code!r}") + intent, active_cfg = pair + + subscribed_document_ids = await intent_document_service.list_documents_for_intent_code( + session, intent_code, + ) + retrieved = vectorstore.query( + query_text=text, + top_k=top_k, + document_ids=subscribed_document_ids, + ) + base_prompt = config_service.compose_full_system_prompt(active_cfg) + + llm_result = await llm.chat( + question=text, + sources=retrieved, + history=[], + system_prompt=base_prompt, + temperature=temperature, + ) + parsed = parse_branch_response(llm_result["text"]) + answer_text = parsed["visible_text"] or llm_result["text"] + + retrieved_sections = [] + for r in retrieved or []: + meta = r.get("metadata") or {} + section = meta.get("section") or "" + document_name = meta.get("document_name") or "" + retrieved_sections.append({"section": section, "document_name": document_name}) + + return { + "answer_text": answer_text, + "retrieved": retrieved or [], + "retrieved_sections": retrieved_sections, + "branch_config_id": active_cfg.id, + "branch_config_version": active_cfg.version, + } + + async def send_message( session: AsyncSession, vectorstore: VectorStoreService, diff --git a/services/eval_branch_run_service.py b/services/eval_branch_run_service.py new file mode 100644 index 0000000..3dcb39b --- /dev/null +++ b/services/eval_branch_run_service.py @@ -0,0 +1,331 @@ +"""Регрессия ответов веток в UI (Спринт 8b). + +Параллельный сервис к `eval_run_service` (роутер): здесь оператор проверяет +содержимое ответа конкретной ветки. На старте 8b — только `general_info`, +но архитектура не привязана к коду ветки: добавление новой = положить +`eval/branch_cases_.jsonl`. + +Pass/fail для одного кейса: +- **A (RAG-секция):** среди retrieved-чанков есть кусок с + `section == expected_doc_section`. Если ожидание не задано — пропускаем. +- **B (keywords):** в `predicted_answer` встречаются обязательные подстроки + (с учётом `keywords_min` или `keywords_any`) и нет запрещённых + (`expected_must_not`). Сравнение case-insensitive. +- Pass = A ∧ B; failed_reasons собирает короткие причины для UI. + +Кэш: `(text_hash, branch_config_id) → {answer_text, retrieved_sections}`. +Привязан к версии активного промпта ветки. Смена версии = свежий прогон. +""" +import asyncio +import hashlib +import json +import logging +from dataclasses import dataclass, field +from datetime import datetime, timezone +from pathlib import Path + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from db.models import ( + AgentConfig, + EvalBranchPrediction, + EvalBranchRun, + EvalBranchRunCase, +) +from db.session import SessionLocal +from services import chat_service, config_service + +logger = logging.getLogger(__name__) + +EVAL_DIR = Path(__file__).resolve().parent.parent / "eval" + + +def _branch_cases_filename(intent_code: str) -> str: + return f"branch_cases_{intent_code}.jsonl" + + +def _text_hash(text: str) -> str: + return hashlib.sha256(text.encode("utf-8")).hexdigest() + + +@dataclass +class BranchCase: + text: str + intent_code: str + coverage: str = "covered" + expected_doc_section: str | None = None + expected_keywords: list[str] = field(default_factory=list) + expected_must_not: list[str] = field(default_factory=list) + keywords_min: int | None = None # если задан — нужно совпадение ≥ N keywords + keywords_any: bool = False # alias для keywords_min=1 + count: int = 1 + note: str | None = None + + def required_keyword_count(self) -> int: + """Сколько keywords минимум должны встретиться в ответе.""" + total = len(self.expected_keywords) + if total == 0: + return 0 + if self.keywords_min is not None: + return max(1, min(self.keywords_min, total)) + if self.keywords_any: + return 1 + return total # дефолт — все обязательны + + +def load_branch_cases(intent_code: str) -> list[BranchCase]: + """Прочитать JSONL для ветки. Если файл отсутствует — пустой список + warning.""" + path = EVAL_DIR / _branch_cases_filename(intent_code) + if not path.exists(): + logger.warning("Branch cases file not found: %s", path) + return [] + cases: list[BranchCase] = [] + with path.open(encoding="utf-8") as f: + for line in f: + line = line.strip() + if not line: + continue + try: + obj = json.loads(line) + except json.JSONDecodeError: + logger.warning("Bad JSONL line in %s: %r", path.name, line[:120]) + continue + cases.append(BranchCase( + text=str(obj["text"]), + intent_code=str(obj.get("intent", intent_code)), + coverage=str(obj.get("coverage", "covered")), + expected_doc_section=obj.get("expected_doc_section"), + expected_keywords=list(obj.get("expected_keywords") or []), + expected_must_not=list(obj.get("expected_must_not") or []), + keywords_min=obj.get("keywords_min"), + keywords_any=bool(obj.get("keywords_any", False)), + count=int(obj.get("count", 1)), + note=obj.get("note"), + )) + cases.sort(key=lambda c: (-c.count, c.text)) + return cases + + +async def _resolve_active_branch_config_id( + session: AsyncSession, intent_code: str +) -> int | None: + pair = await config_service.get_active_config_by_intent_code(session, intent_code) + if pair is None: + return None + _, cfg = pair + return cfg.id + + +async def cached_predictions( + session: AsyncSession, branch_config_id: int | None +) -> dict[str, dict]: + """{ text_hash → {answer_text, retrieved_sections} } для активной версии.""" + rows = (await session.execute( + select( + EvalBranchPrediction.text_hash, + EvalBranchPrediction.answer_text, + EvalBranchPrediction.retrieved_sections_json, + ).where(EvalBranchPrediction.branch_config_id == branch_config_id) + )).all() + out: dict[str, dict] = {} + for th, answer, sections_json in rows: + try: + sections = json.loads(sections_json) if sections_json else [] + except json.JSONDecodeError: + sections = [] + out[th] = {"answer_text": answer or "", "retrieved_sections": sections} + return out + + +def _evaluate_case( + case: BranchCase, answer_text: str, retrieved_sections: list[dict] +) -> tuple[bool, list[str]]: + """Возвращает (is_pass, fail_reasons).""" + reasons: list[str] = [] + + # A. RAG-секция. + if case.expected_doc_section: + sections_in_retrieved = {s.get("section", "") for s in retrieved_sections} + if case.expected_doc_section not in sections_in_retrieved: + reasons.append(f"section не найдена: {case.expected_doc_section!r}") + + # B. keywords. + text_lower = (answer_text or "").lower() + if case.expected_keywords: + hits = [kw for kw in case.expected_keywords if kw.lower() in text_lower] + need = case.required_keyword_count() + if len(hits) < need: + missing = [kw for kw in case.expected_keywords if kw.lower() not in text_lower] + reasons.append( + f"keywords: совпало {len(hits)}/{len(case.expected_keywords)}, нужно {need}; " + f"не нашлись: {missing[:5]}" + ) + + # B. must_not. + if case.expected_must_not: + bad = [kw for kw in case.expected_must_not if kw.lower() in text_lower] + if bad: + reasons.append(f"в ответе есть запрещённое: {bad}") + + return (len(reasons) == 0), reasons + + +async def start_branch_run( + session: AsyncSession, intent_code: str, text_hashes: list[str] +) -> EvalBranchRun: + """Создаёт run в running и стартует фоновую корутину.""" + if not text_hashes: + raise ValueError("text_hashes is empty") + branch_config_id = await _resolve_active_branch_config_id(session, intent_code) + all_cases = load_branch_cases(intent_code) + wanted = set(text_hashes) + cases = [c for c in all_cases if _text_hash(c.text) in wanted] + run = EvalBranchRun( + suite=f"branch:{intent_code}", + intent_code=intent_code, + branch_config_id=branch_config_id, + status="running", + total=len(cases), + ) + session.add(run) + await session.commit() + await session.refresh(run) + asyncio.create_task(_run_branch_suite(run.id, intent_code, branch_config_id, cases)) + return run + + +async def _run_branch_suite( + run_id: int, + intent_code: str, + branch_config_id: int | None, + cases: list[BranchCase], +) -> None: + """Фоновая корутина: своя сессия, не объекты от вызывающего.""" + # Импорт vectorstore + llm singletons из main по требованию: модуль грузится + # после lifespan, ссылки уже инициализированы. + import main as _main + + passed = failed = cache_hits = 0 + try: + async with SessionLocal() as session: + run = await session.get(EvalBranchRun, run_id) + if run is None: + logger.error("eval_branch_run %d disappeared before start", run_id) + return + for case in cases: + th = _text_hash(case.text) + cached = (await session.execute( + select(EvalBranchPrediction).where( + EvalBranchPrediction.text_hash == th, + EvalBranchPrediction.branch_config_id == branch_config_id, + ) + )).scalar_one_or_none() + + if cached is not None: + answer_text = cached.answer_text + try: + retrieved_sections = json.loads(cached.retrieved_sections_json or "[]") + except json.JSONDecodeError: + retrieved_sections = [] + cache_hits += 1 + else: + try: + result = await chat_service.run_branch_single_turn( + session=session, + vectorstore=_main.vectorstore_service, + llm=_main.llm_client, + intent_code=intent_code, + text=case.text, + ) + answer_text = result["answer_text"] + retrieved_sections = result["retrieved_sections"] + except Exception as e: + logger.warning( + "branch single-turn failed for case %r: %s", + case.text[:60], e, + ) + answer_text = "" + retrieved_sections = [] + session.add(EvalBranchPrediction( + text_hash=th, + branch_config_id=branch_config_id, + answer_text=answer_text, + retrieved_sections_json=json.dumps(retrieved_sections, ensure_ascii=False), + )) + + is_pass, reasons = _evaluate_case(case, answer_text, retrieved_sections) + if is_pass: + passed += 1 + else: + failed += 1 + session.add(EvalBranchRunCase( + run_id=run_id, + text=case.text, + coverage=case.coverage, + expected_doc_section=case.expected_doc_section, + expected_keywords_json=json.dumps(case.expected_keywords, ensure_ascii=False), + expected_must_not_json=json.dumps(case.expected_must_not, ensure_ascii=False), + keywords_min=case.keywords_min if case.keywords_min is not None + else (1 if case.keywords_any else None), + predicted_answer=answer_text, + predicted_sections_json=json.dumps(retrieved_sections, ensure_ascii=False), + is_pass=is_pass, + fail_reasons_json=json.dumps(reasons, ensure_ascii=False), + count_weight=case.count, + )) + + if (passed + failed) % 10 == 0: + run.passed = passed + run.failed = failed + run.cache_hits = cache_hits + await session.commit() + + run.passed = passed + run.failed = failed + run.cache_hits = cache_hits + run.status = "done" + run.finished_at = datetime.now(timezone.utc) + await session.commit() + logger.info( + "eval_branch_run %d done: total=%d passed=%d failed=%d cache_hits=%d", + run_id, len(cases), passed, failed, cache_hits, + ) + except Exception as e: + logger.exception("eval_branch_run %d failed: %s", run_id, e) + try: + async with SessionLocal() as session: + run = await session.get(EvalBranchRun, run_id) + if run is not None: + run.status = "error" + run.error_text = f"{type(e).__name__}: {e}" + run.finished_at = datetime.now(timezone.utc) + await session.commit() + except Exception: + logger.exception("Failed to mark eval_branch_run %d as error", run_id) + + +async def list_runs( + session: AsyncSession, intent_code: str | None = None, limit: int = 50 +) -> list[EvalBranchRun]: + stmt = select(EvalBranchRun).order_by(EvalBranchRun.id.desc()).limit(limit) + if intent_code: + stmt = stmt.where(EvalBranchRun.intent_code == intent_code) + return list((await session.execute(stmt)).scalars().all()) + + +async def get_run(session: AsyncSession, run_id: int) -> EvalBranchRun | None: + return await session.get(EvalBranchRun, run_id) + + +async def list_run_cases(session: AsyncSession, run_id: int) -> list[EvalBranchRunCase]: + stmt = ( + select(EvalBranchRunCase) + .where(EvalBranchRunCase.run_id == run_id) + .order_by( + EvalBranchRunCase.is_pass, # сначала fail + EvalBranchRunCase.count_weight.desc(), + EvalBranchRunCase.id, + ) + ) + return list((await session.execute(stmt)).scalars().all()) diff --git a/static/regression.html b/static/regression.html index a66b68e..9e2174d 100644 --- a/static/regression.html +++ b/static/regression.html @@ -215,8 +215,17 @@
-

Регрессия роутера

-

Прогон одношаговых кейсов классификатора (1573 фразы из реальных диалогов) на активной версии промпта _router. Pass/fail сравниваются с ожидаемой веткой. Кэш ответов привязан к версии роутера: повторный прогон на той же версии — мгновенный.

+

Регрессия роутера

+

Прогон одношаговых кейсов классификатора на активной версии промпта _router. Pass/fail сравниваются с ожидаемой веткой. Кэш ответов привязан к версии роутера: повторный прогон на той же версии — мгновенный.

+ +
+ Режим: + + +
@@ -224,12 +233,21 @@
-
- + @@ -305,6 +323,37 @@ let caseFilter = "all"; // "all" | "pass" | "fail" let caseSearch = ""; let currentCases = []; // последние полученные кейсы выбранного прогона +// Режим страницы. "router" = классификатор; "branch:" = ответы ветки. +let currentMode = "router"; + +function isBranchMode() { return currentMode.startsWith("branch:"); } +function currentBranchIntent() { + return isBranchMode() ? currentMode.split(":", 2)[1] : null; +} + +async function setMode(mode) { + currentMode = mode; + selectedRunId = null; + stopPolling(); + pickerSelected.clear(); + // Заголовок и подсказка. + if (isBranchMode()) { + const code = currentBranchIntent(); + $("page-title").textContent = `Регрессия ветки · ${code}`; + $("page-sub").innerHTML = `Single-turn запрос к ветке ${esc(code)} на её активной версии. Pass: ожидаемая секция найдена в RAG (если задана) И ключевые слова присутствуют, запрещённые отсутствуют.`; + $("picker-intent-wrap").style.display = "none"; + $("picker-coverage-wrap").style.display = ""; + } else { + $("page-title").textContent = "Регрессия роутера"; + $("page-sub").innerHTML = `Прогон одношаговых кейсов классификатора (1573 фразы из реальных диалогов) на активной версии промпта _router. Pass/fail сравниваются с ожидаемой веткой. Кэш ответов привязан к версии роутера: повторный прогон на той же версии — мгновенный.`; + $("picker-intent-wrap").style.display = ""; + $("picker-coverage-wrap").style.display = "none"; + } + $("run-detail-panel").style.display = "none"; + await loadPicker(); + await refreshRuns(); +} + function toast(msg, kind = "ok") { const t = $("toast"); t.textContent = msg; @@ -333,10 +382,13 @@ function fmtDate(iso) { async function refreshRuns() { try { - const d = await api("/eval/runs"); + const url = isBranchMode() + ? `/eval/branch-runs?intent_code=${encodeURIComponent(currentBranchIntent())}` + : "/eval/runs"; + const d = await api(url); renderRunsTable(d.runs || []); } catch (e) { - $("runs-tbody").innerHTML = ``; + $("runs-tbody").innerHTML = ``; } } @@ -348,7 +400,8 @@ function renderRunsTable(runs) { } body.innerHTML = runs.map(r => { const cls = r.id === selectedRunId ? "selected" : ""; - const versionStr = r.router_config_version ? `v${r.router_config_version}` : "—"; + const ver = isBranchMode() ? r.branch_config_version : r.router_config_version; + const versionStr = ver ? `v${ver}` : "—"; return ` @@ -384,15 +437,23 @@ let pickerVersionLabel = ""; async function loadPicker() { try { - const d = await api("/eval/router-cases-with-status"); - pickerCases = d.cases || []; - pickerVersionLabel = d.router_config_version ? `v${d.router_config_version}` : "—"; - pickerIntents = Array.from(new Set(pickerCases.map(c => c.expected_intent))).sort(); - fillPickerIntentSelect(); - renderPickerInfo(d); + if (isBranchMode()) { + const code = currentBranchIntent(); + const d = await api(`/eval/branch-cases-with-status?intent_code=${encodeURIComponent(code)}`); + pickerCases = d.cases || []; + pickerVersionLabel = d.branch_config_version ? `v${d.branch_config_version}` : "—"; + renderPickerInfo({ ...d, branch: true }); + } else { + const d = await api("/eval/router-cases-with-status"); + pickerCases = d.cases || []; + pickerVersionLabel = d.router_config_version ? `v${d.router_config_version}` : "—"; + pickerIntents = Array.from(new Set(pickerCases.map(c => c.expected_intent))).sort(); + fillPickerIntentSelect(); + renderPickerInfo(d); + } renderPickerTable(); } catch (e) { - $("picker-tbody").innerHTML = ``; + $("picker-tbody").innerHTML = ``; } } @@ -405,12 +466,31 @@ function fillPickerIntentSelect() { } function renderPickerInfo(d) { - const cached = pickerCases.filter(c => c.cached_predicted !== null).length; + const cached = pickerCases.filter(c => isCaseCached(c)).length; + const label = isBranchMode() + ? `активная версия ветки ${pickerVersionLabel}` + : `активная версия роутера ${pickerVersionLabel}`; $("picker-summary-info").textContent = - `— активная версия роутера ${pickerVersionLabel} · ${d.total} кейсов всего · в кэше ${cached}`; + `— ${label} · ${d.total} кейсов · в кэше ${cached}`; +} + +function isCaseCached(c) { + if (isBranchMode()) return c.cached_is_pass !== null && c.cached_is_pass !== undefined; + return c.cached_predicted !== null && c.cached_predicted !== undefined; +} + +function caseIsPass(c) { + // Унифицированный pass-флаг для текущего mode. + if (isBranchMode()) return c.cached_is_pass === true; + return c.cached_is_pass === true; // роутер также имеет cached_is_pass } function pickerVisibleCases() { + if (isBranchMode()) { + const cov = $("picker-coverage").value; + if (!cov) return pickerCases; + return pickerCases.filter(c => c.coverage === cov); + } const intent = $("picker-intent").value; if (!intent) return pickerCases; return pickerCases.filter(c => c.expected_intent === intent); @@ -418,40 +498,91 @@ function pickerVisibleCases() { function renderPickerTable() { const visible = pickerVisibleCases(); + // Шапка зависит от режима. + const thead = $("picker-thead"); + if (isBranchMode()) { + thead.innerHTML = ` + + + + + + + + + `; + } else { + thead.innerHTML = ` + + + + + + + + `; + } + const tbody = $("picker-tbody"); + const cols = isBranchMode() ? 7 : 6; if (!visible.length) { - tbody.innerHTML = ''; + tbody.innerHTML = ``; refreshPickerCounter(); return; } - tbody.innerHTML = visible.map(c => { - const checked = pickerSelected.has(c.text_hash) ? "checked" : ""; - const cacheCell = renderCacheCell(c); - const rowCls = c.cached_predicted === null ? "" : (c.cached_is_pass ? "cached-pass" : "cached-fail"); - return ` - - - - - - - - - `; - }).join(""); + tbody.innerHTML = visible.map(c => renderPickerRow(c)).join(""); refreshPickerCounter(); syncPickerHeaderCheckbox(); } +function renderPickerRow(c) { + const checked = pickerSelected.has(c.text_hash) ? "checked" : ""; + const cacheCell = renderCacheCell(c); + const cached = isCaseCached(c); + const rowCls = !cached ? "" : (caseIsPass(c) ? "cached-pass" : "cached-fail"); + if (isBranchMode()) { + const sectionStr = c.expected_doc_section || "—"; + const kwBrief = (c.expected_keywords || []).slice(0, 3).join(", ") + + ((c.expected_keywords || []).length > 3 ? "…" : ""); + const covBadge = c.coverage ? `
${esc(c.coverage)}
` : ""; + return ` + + + + + + + + + + `; + } + return ` + + + + + + + + + `; +} + function renderCacheCell(c) { - if (c.cached_predicted === null) return "—"; - if (c.cached_is_pass) return "PASS"; + if (!isCaseCached(c)) return "—"; + if (caseIsPass(c)) return "PASS"; + if (isBranchMode()) { + const reasons = c.cached_fail_reasons || []; + const hint = reasons.length ? `
${esc(reasons[0].slice(0, 30))}
` : ""; + return `FAIL${hint}`; + } return `FAIL
→ ${esc(c.cached_predicted)}
`; } function cacheCellClass(c) { - if (c.cached_predicted === null) return "empty-c"; - return c.cached_is_pass ? "pass" : "fail"; + if (!isCaseCached(c)) return "empty-c"; + return caseIsPass(c) ? "pass" : "fail"; } function pickerToggleOne(cb) { @@ -480,8 +611,8 @@ function pickerSelectByCache(mode) { const visible = pickerVisibleCases(); pickerSelected.clear(); for (const c of visible) { - if (mode === "none" && c.cached_predicted === null) pickerSelected.add(c.text_hash); - else if (mode === "fail" && c.cached_predicted !== null && !c.cached_is_pass) pickerSelected.add(c.text_hash); + if (mode === "none" && !isCaseCached(c)) pickerSelected.add(c.text_hash); + else if (mode === "fail" && isCaseCached(c) && !caseIsPass(c)) pickerSelected.add(c.text_hash); } renderPickerTable(); } @@ -522,11 +653,10 @@ function parseRanges(s) { } function pickerSelectionStats() { - // По cached_predicted делим выбранные на «новые» (LLM нужен) и «в кэше» (мгновенно). let cached = 0; for (const c of pickerCases) { if (!pickerSelected.has(c.text_hash)) continue; - if (c.cached_predicted !== null) cached++; + if (isCaseCached(c)) cached++; } return { total: pickerSelected.size, cached, fresh: pickerSelected.size - cached }; } @@ -564,7 +694,7 @@ function refreshPickerCounter() { function pickerDropCached() { for (const c of pickerCases) { - if (c.cached_predicted !== null) pickerSelected.delete(c.text_hash); + if (isCaseCached(c)) pickerSelected.delete(c.text_hash); } renderPickerTable(); } @@ -580,14 +710,17 @@ async function startRun() { if (!hashes.length) { toast("Выберите хотя бы один кейс", "err"); return; } $("start-btn").disabled = true; try { - const r = await api("/eval/runs", { + const url = isBranchMode() ? "/eval/branch-runs" : "/eval/runs"; + const body = isBranchMode() + ? { intent_code: currentBranchIntent(), text_hashes: hashes } + : { suite: "router", text_hashes: hashes }; + const r = await api(url, { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ suite: "router", text_hashes: hashes }), + body: JSON.stringify(body), }); toast(`Прогон #${r.id} запущен (${r.total} кейсов)`); selectedRunId = r.id; - // Свернуть пикер, чтобы показать прогресс прогона. $("picker-block").open = false; await refreshRuns(); await selectRun(r.id); @@ -603,7 +736,8 @@ async function selectRun(runId) { selectedRunId = runId; await refreshRuns(); try { - const d = await api(`/eval/runs/${runId}`); + const url = isBranchMode() ? `/eval/branch-runs/${runId}` : `/eval/runs/${runId}`; + const d = await api(url); renderRunDetail(d); } catch (e) { toast("Ошибка: " + e.message, "err"); @@ -702,7 +836,14 @@ function renderCaseList() { root.innerHTML = '
— ничего не найдено —
'; return; } + if (isBranchMode()) { + root.innerHTML = renderBranchCaseList(cases); + } else { + root.innerHTML = renderRouterCaseList(cases); + } +} +function renderRouterCaseList(cases) { const header = `
результат
@@ -726,7 +867,48 @@ function renderCaseList() {
`; }).join(""); - root.innerHTML = `
${header}${rows}
`; + return `
${header}${rows}
`; +} + +function renderBranchCaseList(cases) { + // Для веток показываем кейс блоком: запрос + статус + ожидаемое + фактическое + причины. + const rows = cases.map(c => { + const cls = c.is_pass ? "pass" : "fail"; + const status = c.is_pass ? "PASS" : "FAIL"; + const sections = (c.predicted_sections || []) + .map(s => s.section) + .filter(Boolean) + .map(s => `${esc(s)}`) + .join(", "); + const expectedSection = c.expected_doc_section + ? `${esc(c.expected_doc_section)}` + : ''; + const kw = (c.expected_keywords || []).map(k => `${esc(k)}`).join(" · "); + const mustNot = (c.expected_must_not || []).map(k => `${esc(k)}`).join(" · "); + const reasons = (c.fail_reasons || []).map(r => `
  • ${esc(r)}
  • `).join(""); + return ` +
    + + ${status} + ${esc(c.text)} + ${esc(c.coverage)} · ×${c.count_weight} + +
    +
    Ожидание:
    +
    + секция: ${expectedSection}
    + keywords (${c.keywords_min ?? 'все'}): ${kw || '—'}
    + must_not: ${mustNot || '—'} +
    +
    RAG-секции в retrieved: ${sections || ''}
    + ${reasons ? `
    Причины fail:
      ${reasons}
    ` : ''} +
    Ответ ветки:
    +
    ${esc(c.predicted_answer || '(пусто)')}
    +
    +
    + `; + }).join(""); + return rows; } function renderCasesSection(cases, title, emptyMsg) { @@ -752,13 +934,19 @@ function startPolling() { stopPolling(); pollHandle = setInterval(async () => { try { - const d = await api("/eval/runs"); + const listUrl = isBranchMode() + ? `/eval/branch-runs?intent_code=${encodeURIComponent(currentBranchIntent())}` + : "/eval/runs"; + const d = await api(listUrl); const runs = d.runs || []; renderRunsTable(runs); if (selectedRunId) { const cur = runs.find(r => r.id === selectedRunId); if (cur) { - const detail = await api(`/eval/runs/${selectedRunId}`); + const detailUrl = isBranchMode() + ? `/eval/branch-runs/${selectedRunId}` + : `/eval/runs/${selectedRunId}`; + const detail = await api(detailUrl); renderRunDetail(detail); if (cur.status !== "running") { stopPolling(); @@ -784,11 +972,19 @@ function stopPolling() { } (async () => { - await loadPicker(); - await refreshRuns(); - // Если есть «running» прогон — сразу подсветить и начать polling. + // Изначально открываем в режиме роутера; если в URL ?mode=branch:general_info — переключаем. + const params = new URLSearchParams(location.search); + const initMode = params.get("mode") || "router"; + if ($("mode-select").querySelector(`option[value="${initMode}"]`)) { + $("mode-select").value = initMode; + } + await setMode($("mode-select").value); + // Если в текущем режиме есть «running» прогон — подсветить и начать polling. try { - const d = await api("/eval/runs"); + const url = isBranchMode() + ? `/eval/branch-runs?intent_code=${encodeURIComponent(currentBranchIntent())}` + : "/eval/runs"; + const d = await api(url); const running = (d.runs || []).find(r => r.status === "running"); if (running) { selectedRunId = running.id;
    #
    Ошибка: ${esc(e.message)}
    Ошибка: ${esc(e.message)}
    #${r.id}
    Ошибка: ${esc(e.message)}
    Ошибка: ${esc(e.message)}
    #запроссекция / coveragekeywordsчастотакэш
    #запросintentчастотакэш
    — нет кейсов под фильтр —
    — нет кейсов под фильтр —
    ${c.idx}${esc(c.text)}${esc(c.expected_intent)}×${c.count}${cacheCell}
    ${c.idx}${esc(c.text)}${esc(sectionStr)}${covBadge}${esc(kwBrief || "—")}×${c.count}${cacheCell}
    ${c.idx}${esc(c.text)}${esc(c.expected_intent)}×${c.count}${cacheCell}