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:
AR 15 M4
2026-05-03 01:20:59 +05:00
parent a8f7e68795
commit bb5e3f5eb3
15 changed files with 1228 additions and 109 deletions
+71 -22
View File
@@ -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/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/vrachi-kliniki-svodnyj-spisok.md
- Ya_wiki_kugn/skripty-vozrazhenija-chavo-obshhijj-spravochnik.md - Ya_wiki_kugn/skripty-vozrazhenija-chavo-obshhijj-spravochnik.md
- Уточнения от оператора (Кузнецова Н.) — 2026-05-02: режим работы всех филиалов, ТГ-бот, мобильное приложение, актуальный e-mail, закрытие филиала в Краснокамске (в этой же дате уточнено: филиал закрыт окончательно, не временно).
note: Файл собран вручную из выгрузки Yandex Wiki. После запуска подписки на вики этот файл заменит автоматически обновляемый источник. note: Файл собран вручную из выгрузки Yandex Wiki. После запуска подписки на вики этот файл заменит автоматически обновляемый источник.
--- ---
@@ -18,35 +19,65 @@ note: Файл собран вручную из выгрузки Yandex Wiki. П
## О клинике коротко ## О клинике коротко
ООО «Клиника ухо, горло, нос имени профессора Е. Н. Оленевой» — специализированная сеть в Перми и Краснокамске. Создана в 2000 году как Скорая ЛОР помощь, с 2007 года работает в статусе специализированной ЛОР клиники, с 2008 года носит имя профессора Е. Н. Оленевой. В 2016 году в составе сети открылось направление «Клиника лечения кашля и аллергии». ООО «Клиника ухо, горло, нос имени профессора Е. Н. Оленевой» — специализированная сеть в Перми. Создана в 2000 году как Скорая ЛОР помощь, с 2007 года работает в статусе специализированной ЛОР клиники, с 2008 года носит имя профессора Е. Н. Оленевой. В 2016 году в составе сети открылось направление «Клиника лечения кашля и аллергии».
В сеть входят три филиала: ЛОР-клиника на Клары Цеткин, Клиника лечения кашля и аллергии на Газеты Звезда, Клиника доктора Пирогова в Краснокамске. В сеть входят два филиала: ЛОР-клиника на Клары Цеткин и Клиника лечения кашля и аллергии на Газеты Звезда. Ранее работал также филиал «Клиника доктора Пирогова» в Краснокамске — в 2026 году он закрыт.
## Адреса филиалов ## Адреса филиалов
- Клиника ухо, горло, нос — г. Пермь, ул. Клары Цеткин, 9. - Клиника ухо, горло, нос — г. Пермь, ул. Клары Цеткин, 9.
- Клиника лечения кашля и аллергии — г. Пермь, ул. Газеты Звезда, 31а. - Клиника лечения кашля и аллергии — г. Пермь, ул. Газеты Звезда, 31а.
- Клиника доктора Пирогова — г. Краснокамск, ул. Карла Маркса, 14а.
(Филиал в Краснокамске на ул. Карла Маркса, 14а закрыт.)
## Телефоны для пациентов ## Телефоны для пациентов
- Клиника ухо, горло, нос (К. Цеткин, 9) — 8 (342) 207-03-03. - Клиника ухо, горло, нос (К. Цеткин, 9) — 8 (342) 207-03-03.
- Клиника лечения кашля и аллергии (Г. Звезда, 31а) — 8 (342) 200-02-03. - Клиника лечения кашля и аллергии (Г. Звезда, 31а) — 8 (342) 200-02-03.
- Клиника доктора Пирогова (Краснокамск) — 8 (342) 207-03-00.
- Линия «Операции» — 8 (342) 207-03-01. - Линия «Операции» — 8 (342) 207-03-01.
- Линия «ЛОРДЕНТ» — 8 (342) 287-16-94. - Линия «ЛОРДЕНТ» — 8 (342) 287-16-94.
## Электронные адреса для пациентов ## Электронные адреса для пациентов
- Общий адрес клиники (указан на сайте): clinic-lor@mail.ru - Общий адрес клиники: mail@oclinica.ru — основной адрес для пациентов: вопросы, заявки на справку для налогового вычета.
- Адрес для отправки анализов пациентам: test@oclinica.ru - Адрес для отправки анализов пациентам: test@oclinica.ru
- Адрес клиники Пирогова: info@docpirogov.ru
## Сайты ## Сайты
- Сеть клиник: https://www.oclinica.ru, https://perm.oclinica.ru/lor - Сеть клиник: https://www.oclinica.ru, https://perm.oclinica.ru/lor
- Клиника лечения кашля и аллергии: https://perm.oclinica.ru/allergo - Клиника лечения кашля и аллергии: 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 ## Как добраться: Клары Цеткин, 9
@@ -62,20 +93,37 @@ note: Файл собран вручную из выгрузки Yandex Wiki. П
Альтернативный маршрут: выйти на остановке «Октябрьская площадь», пройти по «компросу» направо до перекрёстка, повернуть налево и далее во двор между домами 25 и 27. Альтернативный маршрут: выйти на остановке «Октябрьская площадь», пройти по «компросу» направо до перекрёстка, повернуть налево и далее во двор между домами 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`). - Список врачей по специальностям (из сводного файла `vrachi-kliniki-svodnyj-spisok.md`).
- **Способы записи** — телефон, Telegram-бот, мобильное приложение «Ухо Горло Нос» (RuStore).
- **Справка для налогового вычета** — процедура заказа, состав заявки, сроки, способы получения.
- История клиники, имени Оленевой. - История клиники, имени Оленевой.
- Юридические реквизиты. - Юридические реквизиты.
- Список процедур, которые в клинике не проводятся. - Список процедур, которые в клинике не проводятся.
## Что в выгрузке отсутствует или скудно — стоит дополнить вручную в вики ## Что в выгрузке отсутствует или скудно — стоит дополнить вручную в вики
- **Режим работы Цеткин и Газеты Звезда.** Вообще не нашёлся в выгрузке. Это самый частый вопрос пациента в ветке `general_info` — нужно явно прописать рабочие часы каждой клиники, включая обед, выходные и работу в праздничные дни.
- **Wi-Fi.** Системный промпт ветки явно ожидает ответ на вопрос «есть ли Wi-Fi». В вики этого нет. - **Wi-Fi.** Системный промпт ветки явно ожидает ответ на вопрос «есть ли Wi-Fi». В вики этого нет.
- **Доступная среда / маломобильные пациенты.** В выгрузке есть алгоритм действий администратора при обращении маломобильных, но нет короткой пациент-ориентированной заметки: есть ли пандус, лифт, как лучше подъехать. - **Доступная среда / маломобильные пациенты.** В выгрузке есть алгоритм действий администратора при обращении маломобильных, но нет короткой пациент-ориентированной заметки: есть ли пандус, лифт, как лучше подъехать.
- **Детский приём.** Понятно, что детей принимают, но нет короткой страницы «детский ЛОР»: с какого возраста, кто из врачей принимает детей, что взять с собой кроме базовых документов. - **Детский приём.** Понятно, что детей принимают, но нет короткой страницы «детский ЛОР»: с какого возраста, кто из врачей принимает детей, что взять с собой кроме базовых документов.
- **Подготовка к приёму по специальностям.** Для аллерголога, отоневролога, сурдолога есть нюансы (отмена антигистаминных перед аллерго-тестом и т. п.). Сейчас всё разбросано по скриптам записи — стоит свести в одну страницу «Подготовка к приёму». - **Подготовка к приёму по специальностям.** Для аллерголога, отоневролога, сурдолога есть нюансы (отмена антигистаминных перед аллерго-тестом и т. п.). Сейчас всё разбросано по скриптам записи — стоит свести в одну страницу «Подготовка к приёму».
- **Ориентиры и фото входа.** Для Цеткин и Газеты Звезда нет фотографий входа и подробных ориентиров — для Пирогова есть. Для патчат-сценария «не могу найти вход» это полезно. - **Ориентиры и фото входа.** Для Цеткин и Газеты Звезда нет фотографий входа и подробных ориентиров. Для патчат-сценария «не могу найти вход» это полезно.
- **Платежи и ДМС в общем виде.** Какие способы оплаты принимаются (карта, наличные, СБП), кратко про ДМС-партнёров. Детально это уйдёт в ветку `price_question`, но в общей справке нужна одна-две фразы. - **Платежи и ДМС в общем виде.** Какие способы оплаты принимаются (карта, наличные, СБП), кратко про ДМС-партнёров. Детально это уйдёт в ветку `price_question`, но в общей справке нужна одна-две фразы.
- **Время приёма по умолчанию.** Сколько обычно длится первичный приём ЛОРа, аллерголога. Пациенты часто спрашивают «во сколько успею». - **Время приёма по умолчанию.** Сколько обычно длится первичный приём ЛОРа, аллерголога. Пациенты часто спрашивают «во сколько успею».
- **Отмена и перенос.** Короткое правило «как отменить запись» (полноценно — в ветке `reschedule`, но ссылка-минимум полезна и в общей). - **Отмена и перенос.** Короткое правило «как отменить запись» (полноценно — в ветке `reschedule`, но ссылка-минимум полезна и в общей).
- **Документы по итогам приёма.** Заключение, выписка, больничный, справка ФНС — что выдают и в какой форме. Сейчас это в отдельных подразделах вики, для общей ветки нужна короткая сводка. - **Прочие документы по итогам приёма.** Заключение, выписка, больничный — что выдают и в какой форме (справка ФНС теперь покрыта отдельным разделом).
- **Праздничные дни.** Режим работы 1 января, 8 марта, 9 мая и т. д. — пациенты регулярно спрашивают, в датасете явно не указано.
## Что НЕ должно попадать в датасет общей ветки (но есть в вики) ## Что НЕ должно попадать в датасет общей ветки (но есть в вики)
+7 -17
View File
@@ -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/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/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/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/* - Ya_wiki_kugn/out/yandex-wiki-catalog/homepage/udalennyjj-kontakt-centr/organizacionnye-voprosy/zapis-k-otonevrologu/blokada/*
- Уточнения от оператора (Кузнецова Н.) — 2026-05-02: закрытие филиала Пирогова в Краснокамске.
note: Цены собраны из выгрузки Yandex Wiki клиники. После запуска подписки этот файл заменит автоматически обновляемый источник. Все суммы — рубли. note: Цены собраны из выгрузки Yandex Wiki клиники. После запуска подписки этот файл заменит автоматически обновляемый источник. Все суммы — рубли.
--- ---
@@ -59,20 +60,9 @@ note: Цены собраны из выгрузки Yandex Wiki клиники.
- Батарейки для слухового аппарата — 360 руб. за упаковку из 6 шт. (поштучно не продаются). - Батарейки для слухового аппарата — 360 руб. за упаковку из 6 шт. (поштучно не продаются).
- Если аппарат куплен в Клинике и сломался: после окончания гарантии — приём у сурдолога; устранимая поломка (замена расходников) — стоимость расходников. Серьёзная поломка — отправка в ремонт, стоимость указывает сервис в счёте. - Если аппарат куплен в Клинике и сломался: после окончания гарантии — приём у сурдолога; устранимая поломка (замена расходников) — стоимость расходников. Серьёзная поломка — отправка в ремонт, стоимость указывает сервис в счёте.
## Клиника доктора Пирогова (Краснокамск) ## Филиал «Клиника доктора Пирогова» (Краснокамск)
- Семейный врач (Суднева А. Р.): 950 руб. первичный, 750 руб. повторный. Эндоскопия ЛОР-органов на приёме — 500 руб. Филиал в Краснокамске закрыт в 2026 году. Все услуги, ранее доступные там (приём семейного врача, телемед-приёмы ЛОР и аллерголога, ЛОР по ОМС, дерматолог, косметолог, УЗИ, ЭКГ, профосмотр, инъекции в процедурном кабинете, промывание серных пробок 550 ₽ и т. п.), в сети больше не оказываются. На вопросы о ценах услуг этого филиала — честно сообщить, что филиал закрыт, и предложить услуги пермских филиалов (Цеткин и Газеты Звезда), либо эскалировать оператору.
- ЛОР-телемедицинский приём: 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 руб.
## Эндоскопическая телемед-консультация ЛОР (онлайн-формат) ## Эндоскопическая телемед-консультация ЛОР (онлайн-формат)
@@ -80,7 +70,7 @@ note: Цены собраны из выгрузки Yandex Wiki клиники.
## Стандартные диагностические процедуры ## Стандартные диагностические процедуры
- Эндоскопическая диагностика ЛОР-органов — 900 руб. (в Клинике Пирогова — 500 руб.). - Эндоскопическая диагностика ЛОР-органов — 900 руб.
- Аудиометрия — 1200 руб. - Аудиометрия — 1200 руб.
- Тимпанометрия — 800 руб. - Тимпанометрия — 800 руб.
- Аудиологический скрининг (отоакустическая эмиссия) — 800 руб. - Аудиологический скрининг (отоакустическая эмиссия) — 800 руб.
@@ -270,7 +260,7 @@ note: Цены собраны из выгрузки Yandex Wiki клиники.
## Что покрыто из выгрузки уверенно ## Что покрыто из выгрузки уверенно
- Цены на приёмы у ЛОР, аллерголога, пульмонолога, отоневролога, сурдолога, врачей Клиники Пирогова, телемед-приёмов. - Цены на приёмы у ЛОР, аллерголога, пульмонолога, отоневролога, сурдолога, телемед-приёмов.
- Скидка 50% по направлению, цена приёма «со скидкой». - Скидка 50% по направлению, цена приёма «со скидкой».
- Полный набор стоимостей операций ЛОР-профиля. - Полный набор стоимостей операций ЛОР-профиля.
- Анестезия, пребывание в палате, послеоперационное сопровождение. - Анестезия, пребывание в палате, послеоперационное сопровождение.
@@ -287,7 +277,7 @@ note: Цены собраны из выгрузки Yandex Wiki клиники.
- **Справка ФНС / налоговый вычет.** Раздел в вики есть, но в выгрузке отсутствует. Нужен короткий блок: за какой период оформляется, сколько по времени готовится, нужна ли оплата за услугу. - **Справка ФНС / налоговый вычет.** Раздел в вики есть, но в выгрузке отсутствует. Нужен короткий блок: за какой период оформляется, сколько по времени готовится, нужна ли оплата за услугу.
- **СБП.** Уточнить, принимается ли оплата через Систему быстрых платежей или только нал/карта по терминалу. - **СБП.** Уточнить, принимается ли оплата через Систему быстрых платежей или только нал/карта по терминалу.
- **Скидки.** В выгрузке только «50% по направлению на лечебную процедуру». Если есть скидки пенсионерам, многодетным, сотрудникам, постоянным пациентам — отдельно прописать; иначе при вопросе ассистент будет каждый раз говорить «уточню у оператора». - **Скидки.** В выгрузке только «50% по направлению на лечебную процедуру». Если есть скидки пенсионерам, многодетным, сотрудникам, постоянным пациентам — отдельно прописать; иначе при вопросе ассистент будет каждый раз говорить «уточню у оператора».
- **Цены по «услугам по прайсу» в Пирогове.** В таблице у дерматолога, косметолога, УЗИ написано «по прайсу» — конкретные цифры в подстраницах есть только частично. Нужно собрать прайсы в одну таблицу. - **Услуги, бывшие только в Пирогова.** После закрытия филиала из активного датасета убраны: цены семейного врача, телемед-приёма ЛОР/аллерголога, дерматолога, косметолога, УЗИ, ЭКГ, профосмотра, инъекций в процедурном кабинете, промывания серных пробок (550 ₽). Если эти услуги планируется оказывать в пермских филиалах — нужно явно прописать новые прайсы; иначе бот честно отвечает «филиал закрыт» и эскалирует.
- **Расхождение по наркозу для аденотомии.** В разделе «Структура звонка по аденотомии» (скрипты записи) указана стоимость наркоза 16500 руб., а на странице самой аденотомии — 21500 руб. Возможно, это устаревшая цена в одном из источников. Нужно сверить с актуальным прайсом и поправить в вики, иначе ассистент будет давать разные ответы в зависимости от того, какой кусок выгрузки попадёт в контекст. - **Расхождение по наркозу для аденотомии.** В разделе «Структура звонка по аденотомии» (скрипты записи) указана стоимость наркоза 16500 руб., а на странице самой аденотомии — 21500 руб. Возможно, это устаревшая цена в одном из источников. Нужно сверить с актуальным прайсом и поправить в вики, иначе ассистент будет давать разные ответы в зависимости от того, какой кусок выгрузки попадёт в контекст.
- **Цена аллерголога-иммунолога повторного приёма (очный).** В выгрузке указана стоимость только первичного очного приёма (2400 руб.). Для пульмонолога и ЛОРа повторный тоже отдельно не зафиксирован. - **Цена аллерголога-иммунолога повторного приёма (очный).** В выгрузке указана стоимость только первичного очного приёма (2400 руб.). Для пульмонолога и ЛОРа повторный тоже отдельно не зафиксирован.
- **Эндоскопия как самостоятельная диагностика.** На странице эндоскопии есть две цены — 900 руб. и 12100 руб., вторая выглядит как опечатка или комплексный код. В этом файле я взял 900 руб. как основное; стоит сверить с прайсом. - **Эндоскопия как самостоятельная диагностика.** На странице эндоскопии есть две цены — 900 руб. и 12100 руб., вторая выглядит как опечатка или комплексный код. В этом файле я взял 900 руб. как основное; стоит сверить с прайсом.
+2
View File
@@ -2,6 +2,7 @@ from db.models.agent_config import AgentConfig
from db.models.document import Document from db.models.document import Document
from db.models.intent import Intent from db.models.intent import Intent
from db.models.intent_document import IntentDocument 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.eval_run import EvalRouterPrediction, EvalRun, EvalRunCase
from db.models.intent_step import IntentStep from db.models.intent_step import IntentStep
from db.models.intent_step_graph import IntentStepGraph from db.models.intent_step_graph import IntentStepGraph
@@ -13,4 +14,5 @@ __all__ = [
"Thread", "Message", "Document", "AgentConfig", "Intent", "Thread", "Message", "Document", "AgentConfig", "Intent",
"IntentDocument", "IntentStep", "IntentStepGraph", "ThreadState", "IntentDocument", "IntentStep", "IntentStepGraph", "ThreadState",
"EvalRun", "EvalRunCase", "EvalRouterPrediction", "EvalRun", "EvalRunCase", "EvalRouterPrediction",
"EvalBranchRun", "EvalBranchRunCase", "EvalBranchPrediction",
] ]
+76
View File
@@ -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)
+15
View File
@@ -1,11 +1,26 @@
from collections.abc import AsyncIterator from collections.abc import AsyncIterator
from sqlalchemy import event
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from config import settings from config import settings
engine = create_async_engine(settings.database_url, echo=False, future=True) 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) SessionLocal = async_sessionmaker(engine, expire_on_commit=False, class_=AsyncSession)
+25 -17
View File
@@ -605,38 +605,46 @@
### Цель ### Цель
По принципу 8a, но проверяем уже не код intent-а от роутера, а **содержимое ответа конкретной ветки** на одиночную реплику. Старт — только `general_info`: «вопрос про адрес / часы / маршрут → ответ должен ссылаться на нужный документ и содержать ключевые слова». Дальше расширим на остальные ветки. По принципу 8a, но проверяем уже не код intent-а от роутера, а **содержимое ответа конкретной ветки** на одиночную реплику. Старт — только `general_info`: «вопрос про адрес / часы / маршрут → ответ должен ссылаться на нужный документ и содержать ключевые слова». Дальше расширим на остальные ветки.
### Статус: Запланирован (ждём базу кейсов от пользователя) ### Статус: Закрыт
### Скоуп MVP (что берём) ### Скоуп MVP (что взяли)
- **Ветка:** только `general_info`. - **Ветка:** `general_info`. JSONL `eval/branch_cases_general_info.jsonl` (46 кейсов).
- **Способы pass/fail:** - **Способы pass/fail:**
- **A — RAG-проверка:** в retrieved-чанках есть все ожидаемые `document_id`. Детерминировано, без LLM в проверке. - **A — RAG-проверка:** среди retrieved-чанков есть кусок с `section == expected_doc_section` (точное совпадение). Если поле не задано — пропускаем.
- **B — keywords в ответе:** в тексте ответа бота встречаются все обязательные подстроки (`expected_keywords`) и нет запрещённых (`expected_must_not`). - **B — keywords в ответе:** обязательные `expected_keywords` встречаются в `predicted_answer` (case-insensitive). По умолчанию нужны **все**; поддерживаются `keywords_min: N` и `keywords_any: true` (алиас для `keywords_min: 1`). Запрещённые `expected_must_not` — ни одного.
- **Pass = A ∧ B** (если поле задано). Незаданные поля не проверяются. - **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` ### Что осознанно вынесено в `docs/BACKLOG.md`
- **Вариант C — LLM-judge** (отдельный LLM-вызов оценивает «подходит ли ответ»). - **Вариант C — LLM-judge** (отдельный LLM-вызов оценивает «подходит ли ответ»).
- **Вариант D — эталон + embeddings** (cosine similarity с эталонным ответом). - **Вариант 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:** **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}`. - [x] Таблицы `eval_branch_runs` / `eval_branch_run_cases` / `eval_branch_predictions`. Миграция `m9g1f7e89j56`.
- [ ] Сервис: запуск кейса = вызов того же flow, что в `chat_service.send_message`, но на чистом треде, с фиксацией активной версии branch-config и retrieved-чанков. - [x] `services/eval_branch_run_service.py`: загрузка JSONL, фоновый прогон, кэш по (`text_hash`, `branch_config_id`), оценка A+B с поддержкой `keywords_min`/`keywords_any`.
- [ ] 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] `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:** **UI (`static/regression.html`):**
- [ ] На странице «Регрессия» — переключатель режима: `Роутер` / `Ветка · general_info` (дальше другие ветки добавятся в этот же селектор). - [x] Селектор режима «Роутер / Ветка · general_info» в шапке страницы.
- [ ] Для режима «Ветка»: те же фильтры/диапазон/массовый выбор, но в таблице вместо «expected_intent» — `ожидаемые документы` и `keywords`. В drill-down прогона — текст реплики, фактический ответ бота, retrieved-документы, причина fail. - [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 прогон без правок промпта даёт консистентный результат. - [x] На стартовом наборе `general_info` (46 кейсов) прогон проходит за ~3–5 минут (последовательные LLM-вызовы). Повторный на той же версии — мгновенный.
- [ ] После правки промпта `general_info` — diff показывает кейсы, где RAG-документы или keywords изменились. - [x] При активации новой версии промпта ветки кэш пуст, прогон полный.
- [x] Удаление документа на «Отладка» автоматически очищает подписки веток.
--- ---
+46
View File
@@ -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), но конкретно «назначения врача» пока нет. Достаточно подтвердить наличие приложения любой формулировкой."}
+40
View File
@@ -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
+42
View File
@@ -305,3 +305,45 @@ class EvalRunDetailResponse(BaseModel):
class EvalRunListResponse(BaseModel): class EvalRunListResponse(BaseModel):
runs: list[EvalRunInfo] runs: list[EvalRunInfo]
total: int 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
View File
@@ -16,13 +16,17 @@ from sqlalchemy.ext.asyncio import AsyncSession
from db.models import AgentConfig from db.models import AgentConfig
from db.session import get_session from db.session import get_session
from models.responses import ( from models.responses import (
EvalBranchRunCaseInfo,
EvalBranchRunDetailResponse,
EvalBranchRunInfo,
EvalBranchRunListResponse,
EvalRunCaseInfo, EvalRunCaseInfo,
EvalRunDetailResponse, EvalRunDetailResponse,
EvalRunDiffInfo, EvalRunDiffInfo,
EvalRunInfo, EvalRunInfo,
EvalRunListResponse, EvalRunListResponse,
) )
from services import eval_run_service from services import eval_branch_run_service, eval_run_service
logger = logging.getLogger(__name__) 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) @router.get("/runs", response_model=EvalRunListResponse)
async def list_runs(session: AsyncSession = Depends(get_session)): async def list_runs(session: AsyncSession = Depends(get_session)):
runs = await eval_run_service.list_runs(session, limit=50) runs = await eval_run_service.list_runs(session, limit=50)
+58
View File
@@ -165,6 +165,64 @@ def _eval_pending_guard(
return None 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( async def send_message(
session: AsyncSession, session: AsyncSession,
vectorstore: VectorStoreService, vectorstore: VectorStoreService,
+331
View File
@@ -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())
+233 -37
View File
@@ -215,8 +215,17 @@
</header> </header>
<main> <main>
<h2>Регрессия роутера</h2> <h2 id="page-title">Регрессия роутера</h2>
<p class="sub">Прогон одношаговых кейсов классификатора (1573 фразы из реальных диалогов) на активной версии промпта <code>_router</code>. Pass/fail сравниваются с ожидаемой веткой. Кэш ответов привязан к версии роутера: повторный прогон на той же версии — мгновенный.</p> <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"> <details class="picker-block" id="picker-block">
<summary class="picker-summary"> <summary class="picker-summary">
@@ -224,12 +233,21 @@
</summary> </summary>
<div class="picker-body"> <div class="picker-body">
<div class="picker-tools"> <div class="picker-tools">
<label class="field"> <label class="field" id="picker-intent-wrap">
<span>Ветка (intent)</span> <span>Ветка (intent)</span>
<select id="picker-intent"> <select id="picker-intent">
<option value="">все ветки</option> <option value="">все ветки</option>
</select> </select>
</label> </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"> <label class="field">
<span>Диапазон (по #)</span> <span>Диапазон (по #)</span>
<input type="text" class="range" id="picker-range" placeholder="например: 1-50, 200-300"> <input type="text" class="range" id="picker-range" placeholder="например: 1-50, 200-300">
@@ -244,7 +262,7 @@
</div> </div>
<div class="picker-list-wrap"> <div class="picker-list-wrap">
<table class="picker-table"> <table class="picker-table">
<thead> <thead id="picker-thead">
<tr> <tr>
<th class="col-idx">#</th> <th class="col-idx">#</th>
<th class="col-check"><input type="checkbox" id="picker-check-all" onchange="pickerToggleAllVisible(this.checked)"></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 caseSearch = "";
let currentCases = []; // последние полученные кейсы выбранного прогона 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") { function toast(msg, kind = "ok") {
const t = $("toast"); const t = $("toast");
t.textContent = msg; t.textContent = msg;
@@ -333,10 +382,13 @@ function fmtDate(iso) {
async function refreshRuns() { async function refreshRuns() {
try { 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 || []); renderRunsTable(d.runs || []);
} catch (e) { } 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 => { body.innerHTML = runs.map(r => {
const cls = r.id === selectedRunId ? "selected" : ""; 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 ` return `
<tr class="run-row ${cls}" onclick="selectRun(${r.id})"> <tr class="run-row ${cls}" onclick="selectRun(${r.id})">
<td>#${r.id}</td> <td>#${r.id}</td>
@@ -384,15 +437,23 @@ let pickerVersionLabel = "";
async function loadPicker() { async function loadPicker() {
try { try {
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"); const d = await api("/eval/router-cases-with-status");
pickerCases = d.cases || []; pickerCases = d.cases || [];
pickerVersionLabel = d.router_config_version ? `v${d.router_config_version}` : "—"; pickerVersionLabel = d.router_config_version ? `v${d.router_config_version}` : "—";
pickerIntents = Array.from(new Set(pickerCases.map(c => c.expected_intent))).sort(); pickerIntents = Array.from(new Set(pickerCases.map(c => c.expected_intent))).sort();
fillPickerIntentSelect(); fillPickerIntentSelect();
renderPickerInfo(d); renderPickerInfo(d);
}
renderPickerTable(); renderPickerTable();
} catch (e) { } 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) { 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 = $("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() { 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; const intent = $("picker-intent").value;
if (!intent) return pickerCases; if (!intent) return pickerCases;
return pickerCases.filter(c => c.expected_intent === intent); return pickerCases.filter(c => c.expected_intent === intent);
@@ -418,40 +498,91 @@ function pickerVisibleCases() {
function renderPickerTable() { function renderPickerTable() {
const visible = pickerVisibleCases(); 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 tbody = $("picker-tbody");
const cols = isBranchMode() ? 7 : 6;
if (!visible.length) { if (!visible.length) {
tbody.innerHTML = '<tr><td colspan="6" class="empty">— нет кейсов под фильтр —</td></tr>'; tbody.innerHTML = `<tr><td colspan="${cols}" class="empty">— нет кейсов под фильтр —</td></tr>`;
refreshPickerCounter(); refreshPickerCounter();
return; return;
} }
tbody.innerHTML = visible.map(c => { tbody.innerHTML = visible.map(c => renderPickerRow(c)).join("");
refreshPickerCounter();
syncPickerHeaderCheckbox();
}
function renderPickerRow(c) {
const checked = pickerSelected.has(c.text_hash) ? "checked" : ""; const checked = pickerSelected.has(c.text_hash) ? "checked" : "";
const cacheCell = renderCacheCell(c); const cacheCell = renderCacheCell(c);
const rowCls = c.cached_predicted === null ? "" : (c.cached_is_pass ? "cached-pass" : "cached-fail"); 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 ` return `
<tr class="${rowCls}"> <tr class="${rowCls}">
<td class="col-idx">${c.idx}</td> <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-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-text" title="${esc(c.text)}">${esc(c.text)}</td>
<td class="col-intent">${esc(c.expected_intent)}</td> <td class="col-intent">${esc(c.expected_intent)}</td>
<td class="col-count">×${c.count}</td> <td class="col-count">×${c.count}</td>
<td class="col-cache ${cacheCellClass(c)}">${cacheCell}</td> <td class="col-cache ${cacheCellClass(c)}">${cacheCell}</td>
</tr> </tr>
`; `;
}).join("");
refreshPickerCounter();
syncPickerHeaderCheckbox();
} }
function renderCacheCell(c) { function renderCacheCell(c) {
if (c.cached_predicted === null) return "—"; if (!isCaseCached(c)) return "—";
if (c.cached_is_pass) return "PASS"; 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>`; return `FAIL<div class="sub" style="font-size:10px;">→ ${esc(c.cached_predicted)}</div>`;
} }
function cacheCellClass(c) { function cacheCellClass(c) {
if (c.cached_predicted === null) return "empty-c"; if (!isCaseCached(c)) return "empty-c";
return c.cached_is_pass ? "pass" : "fail"; return caseIsPass(c) ? "pass" : "fail";
} }
function pickerToggleOne(cb) { function pickerToggleOne(cb) {
@@ -480,8 +611,8 @@ function pickerSelectByCache(mode) {
const visible = pickerVisibleCases(); const visible = pickerVisibleCases();
pickerSelected.clear(); pickerSelected.clear();
for (const c of visible) { for (const c of visible) {
if (mode === "none" && c.cached_predicted === null) pickerSelected.add(c.text_hash); if (mode === "none" && !isCaseCached(c)) pickerSelected.add(c.text_hash);
else if (mode === "fail" && c.cached_predicted !== null && !c.cached_is_pass) pickerSelected.add(c.text_hash); else if (mode === "fail" && isCaseCached(c) && !caseIsPass(c)) pickerSelected.add(c.text_hash);
} }
renderPickerTable(); renderPickerTable();
} }
@@ -522,11 +653,10 @@ function parseRanges(s) {
} }
function pickerSelectionStats() { function pickerSelectionStats() {
// По cached_predicted делим выбранные на «новые» (LLM нужен) и «в кэше» (мгновенно).
let cached = 0; let cached = 0;
for (const c of pickerCases) { for (const c of pickerCases) {
if (!pickerSelected.has(c.text_hash)) continue; 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 }; return { total: pickerSelected.size, cached, fresh: pickerSelected.size - cached };
} }
@@ -564,7 +694,7 @@ function refreshPickerCounter() {
function pickerDropCached() { function pickerDropCached() {
for (const c of pickerCases) { for (const c of pickerCases) {
if (c.cached_predicted !== null) pickerSelected.delete(c.text_hash); if (isCaseCached(c)) pickerSelected.delete(c.text_hash);
} }
renderPickerTable(); renderPickerTable();
} }
@@ -580,14 +710,17 @@ async function startRun() {
if (!hashes.length) { toast("Выберите хотя бы один кейс", "err"); return; } if (!hashes.length) { toast("Выберите хотя бы один кейс", "err"); return; }
$("start-btn").disabled = true; $("start-btn").disabled = true;
try { 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", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ suite: "router", text_hashes: hashes }), body: JSON.stringify(body),
}); });
toast(`Прогон #${r.id} запущен (${r.total} кейсов)`); toast(`Прогон #${r.id} запущен (${r.total} кейсов)`);
selectedRunId = r.id; selectedRunId = r.id;
// Свернуть пикер, чтобы показать прогресс прогона.
$("picker-block").open = false; $("picker-block").open = false;
await refreshRuns(); await refreshRuns();
await selectRun(r.id); await selectRun(r.id);
@@ -603,7 +736,8 @@ async function selectRun(runId) {
selectedRunId = runId; selectedRunId = runId;
await refreshRuns(); await refreshRuns();
try { 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); renderRunDetail(d);
} catch (e) { } catch (e) {
toast("Ошибка: " + e.message, "err"); toast("Ошибка: " + e.message, "err");
@@ -702,7 +836,14 @@ function renderCaseList() {
root.innerHTML = '<div class="empty">— ничего не найдено —</div>'; root.innerHTML = '<div class="empty">— ничего не найдено —</div>';
return; return;
} }
if (isBranchMode()) {
root.innerHTML = renderBranchCaseList(cases);
} else {
root.innerHTML = renderRouterCaseList(cases);
}
}
function renderRouterCaseList(cases) {
const header = ` const header = `
<div class="case-list-header"> <div class="case-list-header">
<div>результат</div> <div>результат</div>
@@ -726,7 +867,48 @@ function renderCaseList() {
</div> </div>
`; `;
}).join(""); }).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) { function renderCasesSection(cases, title, emptyMsg) {
@@ -752,13 +934,19 @@ function startPolling() {
stopPolling(); stopPolling();
pollHandle = setInterval(async () => { pollHandle = setInterval(async () => {
try { 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 || []; const runs = d.runs || [];
renderRunsTable(runs); renderRunsTable(runs);
if (selectedRunId) { if (selectedRunId) {
const cur = runs.find(r => r.id === selectedRunId); const cur = runs.find(r => r.id === selectedRunId);
if (cur) { 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); renderRunDetail(detail);
if (cur.status !== "running") { if (cur.status !== "running") {
stopPolling(); stopPolling();
@@ -784,11 +972,19 @@ function stopPolling() {
} }
(async () => { (async () => {
await loadPicker(); // Изначально открываем в режиме роутера; если в URL ?mode=branch:general_info — переключаем.
await refreshRuns(); const params = new URLSearchParams(location.search);
// Если есть «running» прогон — сразу подсветить и начать polling. 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 { 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"); const running = (d.runs || []).find(r => r.status === "running");
if (running) { if (running) {
selectedRunId = running.id; selectedRunId = running.id;