feat(sprint8b): регрессия ответов веток · general_info + фикс PRAGMA foreign_keys
Параллель к 8a, но проверяем не код intent от роутера, а содержимое ответа
конкретной ветки на одиночную реплику. Старт — general_info, 46 кейсов.
Логика pass/fail (для одного кейса):
- A — RAG-секция: среди retrieved-чанков есть кусок с
section == expected_doc_section (точное совпадение). Если поле не задано —
пропускаем.
- B — keywords: обязательные expected_keywords встречаются в predicted_answer
(case-insensitive). По умолчанию все; поддерживаются keywords_min: N
и keywords_any: true. Запрещённые expected_must_not — ни одного.
- Pass = A ∧ B. Незаданные поля не проверяются.
- Кэш: (text_hash, branch_config_id) → {answer_text, retrieved_sections}.
Привязан к версии промпта ветки. Смена версии = пустой кэш = свежий прогон.
Правка JSONL без изменения text → pass/fail пересчитывается без LLM.
Backend:
- Таблицы eval_branch_runs / eval_branch_run_cases / eval_branch_predictions.
Миграция m9g1f7e89j56.
- services/eval_branch_run_service.py: загрузка JSONL, фоновый прогон через
asyncio.create_task, кэш, оценка A+B с поддержкой keywords_min/keywords_any.
- chat_service.run_branch_single_turn — изолированный single-turn без
роутера и треда (использует существующий config_service + vectorstore + llm).
- API: POST /eval/branch-runs, GET /eval/branch-runs?intent_code=,
GET /eval/branch-runs/{id}, GET /eval/branch-cases-with-status?intent_code=.
UI (static/regression.html):
- Селектор режима «Роутер / Ветка · general_info». Логика пикера переиспользуется
(фильтры, диапазон, массовый выбор, счётчик «новые / в кэше»).
- Для режима «Ветка»: фильтр по coverage, колонки секция/coverage, keywords,
частота, кэш. Drill-down прогона: ожидание, retrieved-секции, причины fail,
полный ответ ветки.
База кейсов (eval/branch_cases_general_info.jsonl) — от пользователя, 46 кейсов
по схеме {text, intent, coverage, expected_doc_section?, expected_keywords?,
expected_must_not?, keywords_min?, keywords_any?, count?, note?}.
Связанная правка SQLite (нашли при удалении документа в этом спринте):
- db/session.py: connect-listener PRAGMA foreign_keys=ON на каждое подключение.
Без этого ondelete=CASCADE в SQLite не enforced, и удаление документа
оставляло подписки в intent_documents висячими (что давало пустой RAG
и fail регрессии).
- Миграция n0h2g8f9a0k67 — одноразовая чистка существующих висячих подписок.
docs/SPRINTS.md: Спринт 8b → ✅ Закрыт. Diff vs предыдущий прогон для веток
и кнопка «Сбросить кэш регрессии» вынесены в docs/BACKLOG.md.
Также включены обновлённые data/datasets/general_info.md и price_question.md
(рабочий материал оператора), и черновик eval/branch_cases_price_question.jsonl
для следующего захода (8b на price_question).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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 мая и т. д. — пациенты регулярно спрашивают, в датасете явно не указано.
|
||||
|
||||
## Что НЕ должно попадать в датасет общей ветки (но есть в вики)
|
||||
|
||||
|
||||
@@ -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 руб. как основное; стоит сверить с прайсом.
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
+25
-17
@@ -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:<intent_code>"`): `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] Удаление документа на «Отладка» автоматически очищает подписки веток.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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), но конкретно «назначения врача» пока нет. Достаточно подтвердить наличие приложения любой формулировкой."}
|
||||
@@ -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% по направлению). Бот должен либо сказать «нет» с предложением уточнить, либо эскалировать."}
|
||||
@@ -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')
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
+137
-1
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,331 @@
|
||||
"""Регрессия ответов веток в UI (Спринт 8b).
|
||||
|
||||
Параллельный сервис к `eval_run_service` (роутер): здесь оператор проверяет
|
||||
содержимое ответа конкретной ветки. На старте 8b — только `general_info`,
|
||||
но архитектура не привязана к коду ветки: добавление новой = положить
|
||||
`eval/branch_cases_<code>.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())
|
||||
+248
-52
@@ -215,8 +215,17 @@
|
||||
</header>
|
||||
|
||||
<main>
|
||||
<h2>Регрессия роутера</h2>
|
||||
<p class="sub">Прогон одношаговых кейсов классификатора (1573 фразы из реальных диалогов) на активной версии промпта <code>_router</code>. Pass/fail сравниваются с ожидаемой веткой. Кэш ответов привязан к версии роутера: повторный прогон на той же версии — мгновенный.</p>
|
||||
<h2 id="page-title">Регрессия роутера</h2>
|
||||
<p class="sub" id="page-sub">Прогон одношаговых кейсов классификатора на активной версии промпта <code>_router</code>. Pass/fail сравниваются с ожидаемой веткой. Кэш ответов привязан к версии роутера: повторный прогон на той же версии — мгновенный.</p>
|
||||
|
||||
<div class="panel" style="display:flex; align-items:center; gap:14px;">
|
||||
<span style="font-weight:600;">Режим:</span>
|
||||
<select id="mode-select" onchange="setMode(this.value)" style="padding:6px 10px; font-size:13px; border:1px solid var(--border); border-radius:4px;">
|
||||
<option value="router">Роутер (1573 кейса · все ветки)</option>
|
||||
<option value="branch:general_info">Ветка · general_info</option>
|
||||
</select>
|
||||
<span class="sub" id="mode-hint"></span>
|
||||
</div>
|
||||
|
||||
<details class="picker-block" id="picker-block">
|
||||
<summary class="picker-summary">
|
||||
@@ -224,12 +233,21 @@
|
||||
</summary>
|
||||
<div class="picker-body">
|
||||
<div class="picker-tools">
|
||||
<label class="field">
|
||||
<label class="field" id="picker-intent-wrap">
|
||||
<span>Ветка (intent)</span>
|
||||
<select id="picker-intent">
|
||||
<option value="">все ветки</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field" id="picker-coverage-wrap" style="display:none;">
|
||||
<span>Coverage</span>
|
||||
<select id="picker-coverage" onchange="renderPickerTable()">
|
||||
<option value="">все</option>
|
||||
<option value="covered">covered</option>
|
||||
<option value="partial">partial</option>
|
||||
<option value="not_covered">not_covered</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="field">
|
||||
<span>Диапазон (по #)</span>
|
||||
<input type="text" class="range" id="picker-range" placeholder="например: 1-50, 200-300">
|
||||
@@ -244,7 +262,7 @@
|
||||
</div>
|
||||
<div class="picker-list-wrap">
|
||||
<table class="picker-table">
|
||||
<thead>
|
||||
<thead id="picker-thead">
|
||||
<tr>
|
||||
<th class="col-idx">#</th>
|
||||
<th class="col-check"><input type="checkbox" id="picker-check-all" onchange="pickerToggleAllVisible(this.checked)"></th>
|
||||
@@ -305,6 +323,37 @@ let caseFilter = "all"; // "all" | "pass" | "fail"
|
||||
let caseSearch = "";
|
||||
let currentCases = []; // последние полученные кейсы выбранного прогона
|
||||
|
||||
// Режим страницы. "router" = классификатор; "branch:<intent_code>" = ответы ветки.
|
||||
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 запрос к ветке <code>${esc(code)}</code> на её активной версии. Pass: ожидаемая секция найдена в RAG (если задана) И ключевые слова присутствуют, запрещённые отсутствуют.`;
|
||||
$("picker-intent-wrap").style.display = "none";
|
||||
$("picker-coverage-wrap").style.display = "";
|
||||
} else {
|
||||
$("page-title").textContent = "Регрессия роутера";
|
||||
$("page-sub").innerHTML = `Прогон одношаговых кейсов классификатора (1573 фразы из реальных диалогов) на активной версии промпта <code>_router</code>. 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 = `<tr><td colspan="9" class="empty">Ошибка: ${esc(e.message)}</td></tr>`;
|
||||
$("runs-tbody").innerHTML = `<tr><td colspan="8" class="empty">Ошибка: ${esc(e.message)}</td></tr>`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 `
|
||||
<tr class="run-row ${cls}" onclick="selectRun(${r.id})">
|
||||
<td>#${r.id}</td>
|
||||
@@ -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 = `<tr><td colspan="6" class="empty">Ошибка: ${esc(e.message)}</td></tr>`;
|
||||
$("picker-tbody").innerHTML = `<tr><td colspan="7" class="empty">Ошибка: ${esc(e.message)}</td></tr>`;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = `
|
||||
<tr>
|
||||
<th class="col-idx">#</th>
|
||||
<th class="col-check"><input type="checkbox" id="picker-check-all" onchange="pickerToggleAllVisible(this.checked)"></th>
|
||||
<th class="col-text">запрос</th>
|
||||
<th class="col-intent">секция / coverage</th>
|
||||
<th class="col-count">keywords</th>
|
||||
<th class="col-cache">частота</th>
|
||||
<th class="col-cache">кэш</th>
|
||||
</tr>`;
|
||||
} else {
|
||||
thead.innerHTML = `
|
||||
<tr>
|
||||
<th class="col-idx">#</th>
|
||||
<th class="col-check"><input type="checkbox" id="picker-check-all" onchange="pickerToggleAllVisible(this.checked)"></th>
|
||||
<th class="col-text">запрос</th>
|
||||
<th class="col-intent">intent</th>
|
||||
<th class="col-count">частота</th>
|
||||
<th class="col-cache">кэш</th>
|
||||
</tr>`;
|
||||
}
|
||||
|
||||
const tbody = $("picker-tbody");
|
||||
const cols = isBranchMode() ? 7 : 6;
|
||||
if (!visible.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="6" class="empty">— нет кейсов под фильтр —</td></tr>';
|
||||
tbody.innerHTML = `<tr><td colspan="${cols}" class="empty">— нет кейсов под фильтр —</td></tr>`;
|
||||
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 `
|
||||
<tr class="${rowCls}">
|
||||
<td class="col-idx">${c.idx}</td>
|
||||
<td class="col-check"><input type="checkbox" data-hash="${c.text_hash}" ${checked} onchange="pickerToggleOne(this)"></td>
|
||||
<td class="col-text" title="${esc(c.text)}">${esc(c.text)}</td>
|
||||
<td class="col-intent">${esc(c.expected_intent)}</td>
|
||||
<td class="col-count">×${c.count}</td>
|
||||
<td class="col-cache ${cacheCellClass(c)}">${cacheCell}</td>
|
||||
</tr>
|
||||
`;
|
||||
}).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 ? `<div class="sub" style="font-size:10px;">${esc(c.coverage)}</div>` : "";
|
||||
return `
|
||||
<tr class="${rowCls}">
|
||||
<td class="col-idx">${c.idx}</td>
|
||||
<td class="col-check"><input type="checkbox" data-hash="${esc(c.text_hash)}" ${checked} onchange="pickerToggleOne(this)"></td>
|
||||
<td class="col-text" title="${esc(c.text)}">${esc(c.text)}</td>
|
||||
<td class="col-intent">${esc(sectionStr)}${covBadge}</td>
|
||||
<td class="col-count" style="text-align:left;" title="${esc((c.expected_keywords || []).join(', '))}">${esc(kwBrief || "—")}</td>
|
||||
<td class="col-cache" style="text-align:right;color:var(--muted);">×${c.count}</td>
|
||||
<td class="col-cache ${cacheCellClass(c)}">${cacheCell}</td>
|
||||
</tr>
|
||||
`;
|
||||
}
|
||||
return `
|
||||
<tr class="${rowCls}">
|
||||
<td class="col-idx">${c.idx}</td>
|
||||
<td class="col-check"><input type="checkbox" data-hash="${esc(c.text_hash)}" ${checked} onchange="pickerToggleOne(this)"></td>
|
||||
<td class="col-text" title="${esc(c.text)}">${esc(c.text)}</td>
|
||||
<td class="col-intent">${esc(c.expected_intent)}</td>
|
||||
<td class="col-count">×${c.count}</td>
|
||||
<td class="col-cache ${cacheCellClass(c)}">${cacheCell}</td>
|
||||
</tr>
|
||||
`;
|
||||
}
|
||||
|
||||
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 ? `<div class="sub" style="font-size:10px;">${esc(reasons[0].slice(0, 30))}</div>` : "";
|
||||
return `FAIL${hint}`;
|
||||
}
|
||||
return `FAIL<div class="sub" style="font-size:10px;">→ ${esc(c.cached_predicted)}</div>`;
|
||||
}
|
||||
|
||||
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 = '<div class="empty">— ничего не найдено —</div>';
|
||||
return;
|
||||
}
|
||||
if (isBranchMode()) {
|
||||
root.innerHTML = renderBranchCaseList(cases);
|
||||
} else {
|
||||
root.innerHTML = renderRouterCaseList(cases);
|
||||
}
|
||||
}
|
||||
|
||||
function renderRouterCaseList(cases) {
|
||||
const header = `
|
||||
<div class="case-list-header">
|
||||
<div>результат</div>
|
||||
@@ -726,7 +867,48 @@ function renderCaseList() {
|
||||
</div>
|
||||
`;
|
||||
}).join("");
|
||||
root.innerHTML = `<div class="case-list">${header}${rows}</div>`;
|
||||
return `<div class="case-list">${header}${rows}</div>`;
|
||||
}
|
||||
|
||||
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 => `<code style="font-size:11px;">${esc(s)}</code>`)
|
||||
.join(", ");
|
||||
const expectedSection = c.expected_doc_section
|
||||
? `<code>${esc(c.expected_doc_section)}</code>`
|
||||
: '<span style="color:var(--muted);">—</span>';
|
||||
const kw = (c.expected_keywords || []).map(k => `<code>${esc(k)}</code>`).join(" · ");
|
||||
const mustNot = (c.expected_must_not || []).map(k => `<code>${esc(k)}</code>`).join(" · ");
|
||||
const reasons = (c.fail_reasons || []).map(r => `<li>${esc(r)}</li>`).join("");
|
||||
return `
|
||||
<details class="branch-case ${cls}" style="border:1px solid var(--border); border-radius:6px; margin-bottom:10px; background:#fff;">
|
||||
<summary style="padding:10px 14px; cursor:pointer; display:flex; gap:10px; align-items:center;">
|
||||
<span class="case-status ${cls}" style="min-width:50px;">${status}</span>
|
||||
<span style="flex:1; font-weight:500;">${esc(c.text)}</span>
|
||||
<span class="sub" style="font-size:11px;">${esc(c.coverage)} · ×${c.count_weight}</span>
|
||||
</summary>
|
||||
<div style="padding:10px 14px 14px; border-top:1px solid var(--border); font-size:13px;">
|
||||
<div style="margin-bottom:8px;"><b>Ожидание:</b></div>
|
||||
<div class="sub" style="margin-left:14px; font-size:12px;">
|
||||
секция: ${expectedSection}<br>
|
||||
keywords (${c.keywords_min ?? 'все'}): ${kw || '—'}<br>
|
||||
must_not: ${mustNot || '—'}
|
||||
</div>
|
||||
<div style="margin-top:10px;"><b>RAG-секции в retrieved:</b> ${sections || '<span class="sub">—</span>'}</div>
|
||||
${reasons ? `<div style="margin-top:10px;color:var(--err);"><b>Причины fail:</b><ul style="margin:4px 0;padding-left:20px;">${reasons}</ul></div>` : ''}
|
||||
<div style="margin-top:10px;"><b>Ответ ветки:</b></div>
|
||||
<pre style="background:#f9fafb; padding:10px; border-radius:4px; white-space:pre-wrap; word-wrap:break-word; font-size:12px; margin:4px 0 0;">${esc(c.predicted_answer || '(пусто)')}</pre>
|
||||
</div>
|
||||
</details>
|
||||
`;
|
||||
}).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;
|
||||
|
||||
Reference in New Issue
Block a user