feat(sprint8a): регрессия роутера в UI с выбором кейсов и кэшем
Оператор-настройщик после правки промпта _router нажимает «Прогнать выбранное»
на странице «Регрессия» и видит, что сломалось. Не CLI, не в обход
интерфейса — встроено в верхнюю навигацию рядом с Настройками.
Backend:
- Таблицы eval_runs / eval_run_cases (с is_pass) / eval_router_predictions
(кэш text_hash + router_config_id → predicted_intent). Миграции
k7e9d5c67h34 и l8f0e6d78i45.
- services/eval_run_service.py: start_router_run(text_hashes) запускает
фоновую корутину через asyncio.create_task, фиксирует активную версию
_router. Кэш привязан к версии: повторный прогон на той же версии —
мгновенный, на новой — пересчитывается. compute_diff_vs_previous
сравнивает с предыдущим прогоном на той же версии (новые fail / pass).
- API: POST /eval/runs (фон, body text_hashes), GET /eval/runs,
GET /eval/runs/{id}, GET /eval/router-cases-with-status (все 1573 кейса
+ кэш на активной версии).
Frontend (static/regression.html — новая страница, ссылка добавлена в
шапки index/sandbox/settings/docs):
- Сворачиваемый блок «Выбор кейсов»: фильтр по intent, ввод диапазона
(1-50, 200-300), кнопки «Все видимые», «Снять все», «Только без кэша»,
«Только FAIL в кэше», «Снять кэшированные». Чекбокс в шапке.
- Таблица 1573 кейсов отсортирована по count desc: #, чекбокс, запрос,
intent, частота, кэш (PASS / FAIL → predicted / —). Цветной фон строки
по статусу кэша.
- Счётчик «выбрано N (новых: X, в кэше: Y)»; кнопка
«Прогнать выбранное (X новых + Y из кэша)» — сразу видно реальный
объём LLM-работы.
- Polling /eval/runs/{id} раз в 2 секунды, прогресс-бар, drill-down:
все кейсы прогона + фильтр pass/fail + поиск + diff vs предыдущий
(новые fail / новые pass).
docs/SPRINTS.md: Спринт 8 разбит на 8a (✅ закрыт), 8b (регрессия ответов
веток, ждёт базу кейсов от пользователя), 8c (handoff/resumable/loop/
guard/rag — позже).
docs/BACKLOG.md: новый файл для идей на потом. Записаны: просмотр
архивного графа без активации (из 7.7), варианты C (LLM-judge) и D
(эталон + embeddings) для регрессии веток в 8b.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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_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
|
||||||
from db.models.message import Message
|
from db.models.message import Message
|
||||||
@@ -11,4 +12,5 @@ from db.models.thread_state import ThreadState
|
|||||||
__all__ = [
|
__all__ = [
|
||||||
"Thread", "Message", "Document", "AgentConfig", "Intent",
|
"Thread", "Message", "Document", "AgentConfig", "Intent",
|
||||||
"IntentDocument", "IntentStep", "IntentStepGraph", "ThreadState",
|
"IntentDocument", "IntentStep", "IntentStepGraph", "ThreadState",
|
||||||
|
"EvalRun", "EvalRunCase", "EvalRouterPrediction",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,75 @@
|
|||||||
|
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 EvalRun(Base):
|
||||||
|
"""Прогон регрессионного eval-набора (Спринт 8a).
|
||||||
|
|
||||||
|
Сейчас единственный поддерживаемый suite — `router`: одношаговая классификация
|
||||||
|
реплик пациента. Прогон фиксирует активную версию роутера на момент старта
|
||||||
|
(`router_config_id`), чтобы в UI можно было сравнивать прогоны между версиями.
|
||||||
|
|
||||||
|
Кейсы-расхождения хранятся в `eval_run_cases`. Полный список pass+fail не
|
||||||
|
хранится — только статистика; кэш ответов LLM лежит в `eval_router_predictions`.
|
||||||
|
"""
|
||||||
|
__tablename__ = "eval_runs"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
suite: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
|
router_config_id: Mapped[int | None] = mapped_column(
|
||||||
|
ForeignKey("agent_configs.id", ondelete="SET NULL"), nullable=True, index=True
|
||||||
|
)
|
||||||
|
min_count: Mapped[int] = mapped_column(Integer, nullable=False, default=2)
|
||||||
|
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 EvalRunCase(Base):
|
||||||
|
"""Один кейс прогона (pass или fail).
|
||||||
|
|
||||||
|
Хранятся все кейсы — нужно для UI «таблица кейсов с фильтром pass/fail» и
|
||||||
|
для diff vs предыдущего прогона.
|
||||||
|
"""
|
||||||
|
__tablename__ = "eval_run_cases"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
run_id: Mapped[int] = mapped_column(
|
||||||
|
ForeignKey("eval_runs.id", ondelete="CASCADE"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
text: Mapped[str] = mapped_column(Text, nullable=False)
|
||||||
|
expected_intent: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
|
predicted_intent: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
|
count_weight: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
||||||
|
is_pass: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||||
|
|
||||||
|
|
||||||
|
class EvalRouterPrediction(Base):
|
||||||
|
"""Кэш LLM-предсказаний роутера: ключ (text_hash, router_config_id) → predicted_intent.
|
||||||
|
|
||||||
|
Сильно ускоряет повторные прогоны на той же версии роутера — LLM не дёргаем,
|
||||||
|
берём результат из кэша. При активации новой версии `_router` кэш для неё
|
||||||
|
пуст и первый прогон долгий.
|
||||||
|
"""
|
||||||
|
__tablename__ = "eval_router_predictions"
|
||||||
|
|
||||||
|
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||||
|
text_hash: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||||
|
router_config_id: Mapped[int | None] = mapped_column(
|
||||||
|
ForeignKey("agent_configs.id", ondelete="CASCADE"), nullable=True, index=True
|
||||||
|
)
|
||||||
|
predicted_intent: Mapped[str] = mapped_column(String(50), nullable=False)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow, nullable=False)
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# Идеи на потом (бэклог)
|
||||||
|
|
||||||
|
Это не план с дедлайнами — список идей и улучшений, которые всплыли в работе, но решено отложить, чтобы не раздувать текущий спринт. Перед стартом нового спринта — пройтись по списку, что-то взять в скоуп.
|
||||||
|
|
||||||
|
Формат записи: краткое название → **Зачем:** → **Откуда пришло** (контекст / коммит / Спринт). Если идея взята в работу — переносим в `docs/SPRINTS.md` и удаляем отсюда.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## UI
|
||||||
|
|
||||||
|
### Просмотр архивного графа шагов без активации
|
||||||
|
**Зачем.** Сейчас, чтобы увидеть содержимое архивного 6-шагового графа `new_booking`, его нужно сделать активным. Хочется кнопку «Открыть только для чтения» — посмотреть шаги архива, не переключая активный.
|
||||||
|
|
||||||
|
**Откуда.** Спринт 7.7 (коммит `a79b6f9`), обсуждение с пользователем 2026-05-02. Решено отложить до отдельного спринта.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Регрессия
|
||||||
|
|
||||||
|
### LLM-judge для регрессии ответов веток (вариант C из 8b)
|
||||||
|
**Зачем.** Дополнительный способ pass/fail для свободно-текстовых ответов веток: отдельный LLM-вызов оценивает «вот вопрос, вот ответ — подходит ли по смыслу?» Прощает перефраз, в отличие от точного совпадения слов. Дороже (× ещё один LLM-вызов на кейс) и менее детерминирован.
|
||||||
|
|
||||||
|
**Откуда.** Обсуждение Спринта 8b 2026-05-02. На старт 8b берём только A (RAG) + B (keywords), C — на потом, если хрупкость keywords станет проблемой.
|
||||||
|
|
||||||
|
### Эталонный ответ + embeddings (вариант D из 8b)
|
||||||
|
**Зачем.** Альтернатива LLM-judge: оператор пишет «правильный ответ» в кейсе, при прогоне считаем cosine similarity между фактическим ответом и эталоном. Pass если ≥ порога (например, 0.75). Дешевле LLM-judge (один embedding-вызов вместо LLM), но требует сочинять эталоны и плохо ловит фактические ошибки в цифрах/адресах.
|
||||||
|
|
||||||
|
**Откуда.** Обсуждение Спринта 8b 2026-05-02. Кандидат на 8c вместе с C, если A+B окажется недостаточно.
|
||||||
+70
-26
@@ -573,41 +573,85 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Спринт 8. Мини-eval: роутер, handoff, resumable
|
## Спринт 8a. Регрессия роутера в UI
|
||||||
|
|
||||||
### Цель
|
### Цель
|
||||||
После дотяжки v2 (Спринт 6) и мульти-RAG (Спринт 7) — зафиксировать автоматизированный тест-набор, чтобы следующие правки промптов и `wiki_sources` не ломали собранное. Формализует ручные сценарии из блока H Спринта 6.
|
Дать оператору-настройщику кнопку: «после правки промпта `_router` нажми и увидь, что сломалось». Не CLI, не для разработчика — встроено в страницу «Регрессия» рядом с Настройками. Кэш ответов привязан к версии роутера: повторный прогон на той же версии — мгновенный, на новой — пересчитывается.
|
||||||
|
|
||||||
### Статус: ⏳ Запланирован
|
### Статус: ✅ Закрыт
|
||||||
|
|
||||||
### Задачи
|
### Задачи
|
||||||
|
|
||||||
**Eval-наборы (отдельные файлы в репозитории, без БД):**
|
**Backend:**
|
||||||
|
- [x] Таблицы `eval_runs`, `eval_run_cases` (с `is_pass`), `eval_router_predictions` (кэш `text_hash + router_config_id → predicted_intent`). Alembic-миграции `k7e9d5c67h34`, `l8f0e6d78i45`.
|
||||||
|
- [x] Сервис `services/eval_run_service.py`: `start_router_run(text_hashes)` запускает фоновую корутину, использует кэш, фиксирует активную версию `_router`. `compute_diff_vs_previous` — сравнение с предыдущим прогоном на той же версии (новые fail / новые pass).
|
||||||
|
- [x] API: `POST /eval/runs` (фон), `GET /eval/runs`, `GET /eval/runs/{id}`, `GET /eval/router-cases-with-status` (все 1573 кейса + кэш на активной версии).
|
||||||
|
|
||||||
Все наборы в **JSONL** (одна строка = один кейс). Унифицированный формат, единый парсер. Схема описана в `eval/README.md`. Историческое замечание: в первой версии плана одношаговые кейсы были в CSV, многошаговые в YAML — отказались от зоопарка форматов в пользу одного JSONL.
|
**UI (`static/regression.html` + новая вкладка «Регрессия» в шапках):**
|
||||||
|
- [x] Сворачиваемый блок «Выбор кейсов»: фильтр по intent, ввод диапазона (`1-50, 200-300`), кнопки массового выбора (Все / Снять / Только без кэша / Только FAIL в кэше / Снять кэшированные).
|
||||||
- [x] `eval/router_cases_booking.jsonl` + `eval/router_cases_other.jsonl` — одношаговые кейсы маршрутизатора (875 + 698, собраны из реальных диалогов конкурента, см. `eval/README.md`). Схема: `{text, expected_intent, expected_reason?, count, note?}`. CSV-версии сохранены рядом для совместимости.
|
- [x] Таблица 1573 кейсов (отсортированы по count desc): #, чекбокс, запрос, intent, частота, кэш (PASS / FAIL → predicted / —). Цветной фон строки.
|
||||||
- [ ] `eval/handoff_cases.jsonl` — 5–10 многошаговых мини-диалогов: реплики пациента по порядку + ожидаемая активная ветка / решение маршрутизатора / приостановленная ветка / счётчик переключений на каждом шаге.
|
- [x] Счётчик «выбрано N (новых: X, в кэше: Y)»; кнопка «Прогнать выбранное (X новых + Y из кэша)».
|
||||||
- [ ] `eval/resumable_cases.jsonl` — 3–5 сценариев detour-и-возврат: реплики + ожидаемые `current_intent`, `current_step`, ключевые слоты на каждом шаге.
|
- [x] История прогонов с polling раз в 2 секунды, прогресс-бар, drill-down: все кейсы прогона + фильтр pass/fail + поиск + diff vs предыдущий.
|
||||||
- [ ] `eval/loop_cases.jsonl` — 1–2 сценария искусственной петли с проверкой `reason=routing_loop`.
|
|
||||||
- [ ] `eval/guard_cases.jsonl` — сценарии на защитные условия (ребёнок, waitlist).
|
|
||||||
- [ ] `eval/rag_cases.jsonl` — сценарии на мульти-RAG: реплика внутри ветки → проверка, что в retrieved-чанках есть фразы из ожидаемого документа (или ожидаемые `document_id`).
|
|
||||||
|
|
||||||
**Запускалка (CLI, не часть сервиса):**
|
|
||||||
- [ ] `eval/run.py` — читает JSONL-наборы, прогоняет через живой сервис. Режимы:
|
|
||||||
- `router` — прямой вызов `RouterClient.classify()` на одношаговых кейсах (быстро).
|
|
||||||
- `dialog` — полный `/chat` на чистых тредах, сверка по каждому шагу: активная ветка + решение маршрутизатора + текущий шаг + слоты + счётчик переключений + причина эскалации + retrieved-источники.
|
|
||||||
- [ ] Вывод: per-ветка accuracy, confusion matrix, список расхождений с текстом реплики.
|
|
||||||
- [ ] Отчёт: stdout + `eval/reports/{timestamp}.md` (добавлять в git для сравнения во времени).
|
|
||||||
|
|
||||||
**Документация:**
|
|
||||||
- [ ] В `README.md` — раздел «Как прогнать eval» (одна команда).
|
|
||||||
- [ ] Договорённость: перед правкой промпта роутера / ветки / `wiki_sources` — прогнать eval, зафиксировать baseline; после — сравнить.
|
|
||||||
|
|
||||||
### Критерий готовности
|
### Критерий готовности
|
||||||
- [ ] `eval/run.py` работает одной командой, режим `router` проходит за ≤ 30 секунд (на `count >= 2`), режим `dialog` — за ≤ 3 минуты.
|
- [x] На пустой версии роутера прогон 50 кейсов за ~1 минуту, повторный — мгновенный.
|
||||||
- [ ] Отчёт покрывает все 8 сценариев из блока H Спринта 6 + одношаговые кейсы маршрутизатора + RAG-проверки Спринта 7.
|
- [x] При активации новой версии `_router` — кэш пуст, прогон полный.
|
||||||
- [ ] Baseline зафиксирован в `eval/reports/{date}_baseline.md` и добавлен в git.
|
- [x] Diff показывает «новые fail / новые pass» при сравнении с предыдущим прогоном на той же версии.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Спринт 8b. Регрессия ответов веток (RAG + keywords)
|
||||||
|
|
||||||
|
### Цель
|
||||||
|
По принципу 8a, но проверяем уже не код intent-а от роутера, а **содержимое ответа конкретной ветки** на одиночную реплику. Старт — только `general_info`: «вопрос про адрес / часы / маршрут → ответ должен ссылаться на нужный документ и содержать ключевые слова». Дальше расширим на остальные ветки.
|
||||||
|
|
||||||
|
### Статус: ⏳ Запланирован (ждём базу кейсов от пользователя)
|
||||||
|
|
||||||
|
### Скоуп MVP (что берём)
|
||||||
|
- **Ветка:** только `general_info`.
|
||||||
|
- **Способы pass/fail:**
|
||||||
|
- **A — RAG-проверка:** в retrieved-чанках есть все ожидаемые `document_id`. Детерминировано, без LLM в проверке.
|
||||||
|
- **B — keywords в ответе:** в тексте ответа бота встречаются все обязательные подстроки (`expected_keywords`) и нет запрещённых (`expected_must_not`).
|
||||||
|
- **Pass = A ∧ B** (если поле задано). Незаданные поля не проверяются.
|
||||||
|
- **Кэш:** `(text_hash, branch_config_id) → {answer_text, retrieved_doc_ids}`. При смене активной версии промпта `general_info` — кэш по новой версии пуст, прогон полный.
|
||||||
|
|
||||||
|
### Что осознанно вынесено в `docs/BACKLOG.md`
|
||||||
|
- **Вариант C — LLM-judge** (отдельный LLM-вызов оценивает «подходит ли ответ»).
|
||||||
|
- **Вариант D — эталон + embeddings** (cosine similarity с эталонным ответом).
|
||||||
|
Оба добавим, если A+B окажется хрупким (keywords ловят перефраз ненадёжно).
|
||||||
|
|
||||||
|
### Задачи
|
||||||
|
|
||||||
|
**База кейсов (за пользователем):**
|
||||||
|
- [ ] `eval/branch_cases_general_info.jsonl`. Схема: `{text, intent, expected_doc_ids?, expected_keywords?, expected_must_not?, count?, note?}`. Минимум для одного кейса — `text + intent + (хотя бы одно из expected_*)`.
|
||||||
|
|
||||||
|
**Backend:**
|
||||||
|
- [ ] Таблицы (или общая `eval_runs` с `suite="branch:<intent_code>"`): `eval_branch_runs` или универсальное расширение, `eval_branch_run_cases` с полями `answer_text`, `retrieved_doc_ids_json`, `is_pass`, `fail_reason`. Кэш `eval_branch_predictions(text_hash, branch_config_id) → {answer_text, retrieved_doc_ids}`.
|
||||||
|
- [ ] Сервис: запуск кейса = вызов того же flow, что в `chat_service.send_message`, но на чистом треде, с фиксацией активной версии branch-config и retrieved-чанков.
|
||||||
|
- [ ] API: `POST /eval/branch-runs`, `GET /eval/branch-runs`, `GET /eval/branch-runs/{id}`, `GET /eval/branch-cases-with-status?intent_code=general_info`.
|
||||||
|
|
||||||
|
**UI:**
|
||||||
|
- [ ] На странице «Регрессия» — переключатель режима: `Роутер` / `Ветка · general_info` (дальше другие ветки добавятся в этот же селектор).
|
||||||
|
- [ ] Для режима «Ветка»: те же фильтры/диапазон/массовый выбор, но в таблице вместо «expected_intent» — `ожидаемые документы` и `keywords`. В drill-down прогона — текст реплики, фактический ответ бота, retrieved-документы, причина fail.
|
||||||
|
|
||||||
|
### Критерий готовности
|
||||||
|
- [ ] На стартовом наборе general_info прогон без правок промпта даёт консистентный результат.
|
||||||
|
- [ ] После правки промпта `general_info` — diff показывает кейсы, где RAG-документы или keywords изменились.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Спринт 8c. Дополнительные регрессионные сценарии
|
||||||
|
|
||||||
|
### Статус: ⏳ Запланирован (после 8b и накопления кейсов)
|
||||||
|
|
||||||
|
Темы: handoff между ветками (multi-turn), resumable detour-и-возврат, петли роутера, защитные условия (ребёнок, waitlist), мульти-RAG. Эти сценарии в SPRINTS.md изначально шли в одном Спринте 8 — разделили, чтобы 8a/8b закрыть быстрее.
|
||||||
|
|
||||||
|
Точечные наборы из исходного плана:
|
||||||
|
- [ ] `eval/handoff_cases.jsonl` — 5–10 многошаговых мини-диалогов.
|
||||||
|
- [ ] `eval/resumable_cases.jsonl` — 3–5 detour-и-возврат.
|
||||||
|
- [ ] `eval/loop_cases.jsonl` — 1–2 искусственная петля.
|
||||||
|
- [ ] `eval/guard_cases.jsonl` — `require_legal_rep`, `waitlist`.
|
||||||
|
- [ ] `eval/rag_cases.jsonl` — мульти-RAG.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,83 @@
|
|||||||
|
"""add eval_runs / eval_run_cases / eval_router_predictions (Спринт 8a)
|
||||||
|
|
||||||
|
Revision ID: k7e9d5c67h34
|
||||||
|
Revises: j6d8c4b56g23
|
||||||
|
Create Date: 2026-05-02 14:30:00.000000
|
||||||
|
|
||||||
|
Регрессия роутера в UI:
|
||||||
|
- eval_runs — прогон, привязка к версии роутера, статус, статистика.
|
||||||
|
- eval_run_cases — только расхождения (predicted != expected).
|
||||||
|
- eval_router_predictions — кэш LLM-ответов по (text_hash, router_config_id).
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision: str = 'k7e9d5c67h34'
|
||||||
|
down_revision: Union[str, None] = 'j6d8c4b56g23'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
'eval_runs',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('suite', sa.String(length=50), nullable=False),
|
||||||
|
sa.Column('router_config_id', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('min_count', sa.Integer(), nullable=False),
|
||||||
|
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(['router_config_id'], ['agent_configs.id'], ondelete='SET NULL'),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
)
|
||||||
|
op.create_index('ix_eval_runs_router_config_id', 'eval_runs', ['router_config_id'])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
'eval_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('expected_intent', sa.String(length=50), nullable=False),
|
||||||
|
sa.Column('predicted_intent', sa.String(length=50), nullable=False),
|
||||||
|
sa.Column('count_weight', sa.Integer(), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['run_id'], ['eval_runs.id'], ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
)
|
||||||
|
op.create_index('ix_eval_run_cases_run_id', 'eval_run_cases', ['run_id'])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
'eval_router_predictions',
|
||||||
|
sa.Column('id', sa.Integer(), nullable=False),
|
||||||
|
sa.Column('text_hash', sa.String(length=64), nullable=False),
|
||||||
|
sa.Column('router_config_id', sa.Integer(), nullable=True),
|
||||||
|
sa.Column('predicted_intent', sa.String(length=50), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.ForeignKeyConstraint(['router_config_id'], ['agent_configs.id'], ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('text_hash', 'router_config_id', name='uq_eval_router_pred_hash_cfg'),
|
||||||
|
)
|
||||||
|
op.create_index('ix_eval_router_predictions_text_hash', 'eval_router_predictions', ['text_hash'])
|
||||||
|
op.create_index(
|
||||||
|
'ix_eval_router_predictions_router_config_id',
|
||||||
|
'eval_router_predictions',
|
||||||
|
['router_config_id'],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index('ix_eval_router_predictions_router_config_id', table_name='eval_router_predictions')
|
||||||
|
op.drop_index('ix_eval_router_predictions_text_hash', table_name='eval_router_predictions')
|
||||||
|
op.drop_table('eval_router_predictions')
|
||||||
|
op.drop_index('ix_eval_run_cases_run_id', table_name='eval_run_cases')
|
||||||
|
op.drop_table('eval_run_cases')
|
||||||
|
op.drop_index('ix_eval_runs_router_config_id', table_name='eval_runs')
|
||||||
|
op.drop_table('eval_runs')
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
"""add eval_run_cases.is_pass (Спринт 8a — все кейсы, не только fails)
|
||||||
|
|
||||||
|
Revision ID: l8f0e6d78i45
|
||||||
|
Revises: k7e9d5c67h34
|
||||||
|
Create Date: 2026-05-02 14:50:00.000000
|
||||||
|
|
||||||
|
Расширяем eval_run_cases: храним каждый прогнанный кейс, а не только расхождения.
|
||||||
|
Это нужно для UI «Все кейсы прогона» с фильтром по pass/fail. Существующие записи
|
||||||
|
(до этой миграции) — только fails, ставим им is_pass=false.
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision: str = 'l8f0e6d78i45'
|
||||||
|
down_revision: Union[str, None] = 'k7e9d5c67h34'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
with op.batch_alter_table('eval_run_cases') as batch:
|
||||||
|
batch.add_column(sa.Column('is_pass', sa.Boolean(), nullable=True))
|
||||||
|
# Старые записи — это исключительно fails (pre-8a политика хранения).
|
||||||
|
op.execute("UPDATE eval_run_cases SET is_pass = 0 WHERE is_pass IS NULL")
|
||||||
|
with op.batch_alter_table('eval_run_cases') as batch:
|
||||||
|
batch.alter_column('is_pass', nullable=False)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
with op.batch_alter_table('eval_run_cases') as batch:
|
||||||
|
batch.drop_column('is_pass')
|
||||||
@@ -262,3 +262,46 @@ class IntentStepGraphListResponse(BaseModel):
|
|||||||
graphs: list[IntentStepGraphInfo]
|
graphs: list[IntentStepGraphInfo]
|
||||||
active_graph_id: int | None
|
active_graph_id: int | None
|
||||||
total: int
|
total: int
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- Прогоны регрессии (Спринт 8a) ----------
|
||||||
|
|
||||||
|
class EvalRunInfo(BaseModel):
|
||||||
|
id: int
|
||||||
|
suite: str
|
||||||
|
router_config_id: int | None
|
||||||
|
router_config_version: int | None
|
||||||
|
min_count: int
|
||||||
|
status: str
|
||||||
|
total: int
|
||||||
|
passed: int
|
||||||
|
failed: int
|
||||||
|
cache_hits: int
|
||||||
|
error_text: str | None
|
||||||
|
started_at: str
|
||||||
|
finished_at: str | None
|
||||||
|
|
||||||
|
|
||||||
|
class EvalRunCaseInfo(BaseModel):
|
||||||
|
text: str
|
||||||
|
expected_intent: str
|
||||||
|
predicted_intent: str
|
||||||
|
count_weight: int
|
||||||
|
is_pass: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
class EvalRunDiffInfo(BaseModel):
|
||||||
|
prev_run_id: int | None
|
||||||
|
new_fails: list[EvalRunCaseInfo]
|
||||||
|
new_passes: list[EvalRunCaseInfo]
|
||||||
|
|
||||||
|
|
||||||
|
class EvalRunDetailResponse(BaseModel):
|
||||||
|
run: EvalRunInfo
|
||||||
|
cases: list[EvalRunCaseInfo]
|
||||||
|
diff: EvalRunDiffInfo
|
||||||
|
|
||||||
|
|
||||||
|
class EvalRunListResponse(BaseModel):
|
||||||
|
runs: list[EvalRunInfo]
|
||||||
|
total: int
|
||||||
|
|||||||
+138
-6
@@ -1,15 +1,28 @@
|
|||||||
"""Эндпоинты вокруг eval-наборов (Спринт 8 — заготовка под мини-eval).
|
"""Эндпоинты eval-наборов и прогонов регрессии (Спринты 7.5 + 8a).
|
||||||
|
|
||||||
Сейчас отдаёт только готовые кейсы маршрутизатора для интеграции в тест-блок
|
- `/eval/router-cases` — список кейсов классификатора для UI Настроек (готовые
|
||||||
страницы Настроек: оператор может выбрать готовую фразу пациента из реального
|
фразы пациентов из реального корпуса).
|
||||||
корпуса вместо того, чтобы придумывать руками. Полная подсистема прогона
|
- `/eval/runs` — прогоны регрессии роутера (Спринт 8a). POST запускает фоновый
|
||||||
(`eval/run.py`, метрики, отчёты) — в Спринте 8.
|
прогон, GET возвращает историю и детали.
|
||||||
"""
|
"""
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from fastapi import APIRouter, HTTPException
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from db.models import AgentConfig
|
||||||
|
from db.session import get_session
|
||||||
|
from models.responses import (
|
||||||
|
EvalRunCaseInfo,
|
||||||
|
EvalRunDetailResponse,
|
||||||
|
EvalRunDiffInfo,
|
||||||
|
EvalRunInfo,
|
||||||
|
EvalRunListResponse,
|
||||||
|
)
|
||||||
|
from services import eval_run_service
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -86,3 +99,122 @@ def list_router_cases(intent_code: str | None = None, limit: int = 500):
|
|||||||
"total": len(items),
|
"total": len(items),
|
||||||
"cases": items,
|
"cases": items,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------- Прогоны регрессии (Спринт 8a) ----------
|
||||||
|
|
||||||
|
class StartRouterRunRequest(BaseModel):
|
||||||
|
suite: str = Field("router", description="Сейчас поддерживается только 'router'")
|
||||||
|
text_hashes: list[str] = Field(..., min_length=1, description="sha256(text) выбранных кейсов")
|
||||||
|
|
||||||
|
|
||||||
|
def _run_to_info(run, router_config_version: int | None) -> EvalRunInfo:
|
||||||
|
return EvalRunInfo(
|
||||||
|
id=run.id,
|
||||||
|
suite=run.suite,
|
||||||
|
router_config_id=run.router_config_id,
|
||||||
|
router_config_version=router_config_version,
|
||||||
|
min_count=run.min_count,
|
||||||
|
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 _case_to_info(c) -> EvalRunCaseInfo:
|
||||||
|
return EvalRunCaseInfo(
|
||||||
|
text=c.text,
|
||||||
|
expected_intent=c.expected_intent,
|
||||||
|
predicted_intent=c.predicted_intent,
|
||||||
|
count_weight=c.count_weight,
|
||||||
|
is_pass=c.is_pass,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def _config_version(session: AsyncSession, config_id: int | None) -> int | None:
|
||||||
|
if config_id is None:
|
||||||
|
return None
|
||||||
|
cfg = await session.get(AgentConfig, config_id)
|
||||||
|
return cfg.version if cfg else None
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/runs", response_model=EvalRunInfo)
|
||||||
|
async def start_run(req: StartRouterRunRequest, session: AsyncSession = Depends(get_session)):
|
||||||
|
if req.suite != "router":
|
||||||
|
raise HTTPException(status_code=400, detail="Only suite='router' is supported in 8a")
|
||||||
|
try:
|
||||||
|
run = await eval_run_service.start_router_run(session, req.text_hashes)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
version = await _config_version(session, run.router_config_id)
|
||||||
|
return _run_to_info(run, version)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/router-cases-with-status")
|
||||||
|
async def router_cases_with_status(session: AsyncSession = Depends(get_session)):
|
||||||
|
"""Все кейсы из JSONL, отсортированы по count desc, + кэш на активной версии роутера.
|
||||||
|
|
||||||
|
Под каждым кейсом — последний предсказанный intent для этой версии (если был),
|
||||||
|
и pass/fail сравнение с expected. UI строит таблицу выбора с массовыми операциями.
|
||||||
|
"""
|
||||||
|
cases = eval_run_service.load_all_router_cases()
|
||||||
|
router_config_id = await eval_run_service._resolve_active_router_config_id(session)
|
||||||
|
version = await _config_version(session, router_config_id)
|
||||||
|
cache = await eval_run_service.cached_predictions(session, router_config_id)
|
||||||
|
|
||||||
|
items = []
|
||||||
|
for idx, c in enumerate(cases, 1):
|
||||||
|
th = eval_run_service._text_hash(c.text)
|
||||||
|
cached_predicted = cache.get(th)
|
||||||
|
cached_is_pass = (
|
||||||
|
None if cached_predicted is None else cached_predicted == c.expected_intent
|
||||||
|
)
|
||||||
|
items.append({
|
||||||
|
"idx": idx,
|
||||||
|
"text": c.text,
|
||||||
|
"text_hash": th,
|
||||||
|
"expected_intent": c.expected_intent,
|
||||||
|
"count": c.count,
|
||||||
|
"cached_predicted": cached_predicted,
|
||||||
|
"cached_is_pass": cached_is_pass,
|
||||||
|
})
|
||||||
|
return {
|
||||||
|
"router_config_id": router_config_id,
|
||||||
|
"router_config_version": version,
|
||||||
|
"total": len(items),
|
||||||
|
"cases": items,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/runs", response_model=EvalRunListResponse)
|
||||||
|
async def list_runs(session: AsyncSession = Depends(get_session)):
|
||||||
|
runs = await eval_run_service.list_runs(session, limit=50)
|
||||||
|
items = []
|
||||||
|
for r in runs:
|
||||||
|
version = await _config_version(session, r.router_config_id)
|
||||||
|
items.append(_run_to_info(r, version))
|
||||||
|
return EvalRunListResponse(runs=items, total=len(items))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/runs/{run_id}", response_model=EvalRunDetailResponse)
|
||||||
|
async def get_run(run_id: int, session: AsyncSession = Depends(get_session)):
|
||||||
|
run = await eval_run_service.get_run(session, run_id)
|
||||||
|
if run is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Run not found")
|
||||||
|
version = await _config_version(session, run.router_config_id)
|
||||||
|
cases = await eval_run_service.list_run_cases(session, run_id)
|
||||||
|
diff = await eval_run_service.compute_diff_vs_previous(session, run)
|
||||||
|
return EvalRunDetailResponse(
|
||||||
|
run=_run_to_info(run, version),
|
||||||
|
cases=[_case_to_info(c) for c in cases],
|
||||||
|
diff=EvalRunDiffInfo(
|
||||||
|
prev_run_id=diff.prev_run_id,
|
||||||
|
new_fails=[_case_to_info(c) for c in diff.new_fails],
|
||||||
|
new_passes=[_case_to_info(c) for c in diff.new_passes],
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|||||||
@@ -0,0 +1,287 @@
|
|||||||
|
"""Регрессия роутера через UI (Спринт 8a).
|
||||||
|
|
||||||
|
Один прогон = одна запись в `eval_runs`. Активная версия `_router` фиксируется в
|
||||||
|
`router_config_id`, чтобы можно было сравнивать прогоны между версиями. Сами кейсы
|
||||||
|
живут в JSONL (`eval/router_cases_*.jsonl`); здесь только их прогон, кэш LLM-ответов
|
||||||
|
и расхождения.
|
||||||
|
|
||||||
|
Поток:
|
||||||
|
1. `start_router_run(min_count)` — создаёт `EvalRun(status=running)`, фиксирует
|
||||||
|
активную версию роутера, запускает фоновую корутину `_run_router_suite`.
|
||||||
|
2. `_run_router_suite` — читает кейсы по `min_count`, для каждого:
|
||||||
|
- lookup в `eval_router_predictions` → если есть, cache_hit++,
|
||||||
|
- иначе вызывает `RouterClient.classify(history=[], snapshot=None)` и пишет в кэш,
|
||||||
|
- если `predicted != expected` — пишет в `eval_run_cases`.
|
||||||
|
В конце выставляет `status=done`, `finished_at`.
|
||||||
|
3. На любой ошибке — `status=error`, `error_text`.
|
||||||
|
|
||||||
|
Кэш ключ: sha256(text) + router_config_id. Текст хранится как есть в `eval_run_cases`
|
||||||
|
для детального отчёта в UI.
|
||||||
|
"""
|
||||||
|
import asyncio
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from db.models import EvalRouterPrediction, EvalRun, EvalRunCase
|
||||||
|
from db.session import SessionLocal
|
||||||
|
from services import config_service, intent_service
|
||||||
|
from services.router_client import RouterClient
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
ROUTER_CASES_FILES = ("router_cases_booking.jsonl", "router_cases_other.jsonl")
|
||||||
|
EVAL_DIR = Path(__file__).resolve().parent.parent / "eval"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class _Case:
|
||||||
|
text: str
|
||||||
|
expected_intent: str
|
||||||
|
count: int
|
||||||
|
|
||||||
|
|
||||||
|
def _text_hash(text: str) -> str:
|
||||||
|
return hashlib.sha256(text.encode("utf-8")).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
|
def load_all_router_cases() -> list[_Case]:
|
||||||
|
"""Все кейсы из JSONL без фильтрации, отсортированы по count desc, затем text.
|
||||||
|
|
||||||
|
Сортировка стабильна — это важно для индексов в UI («диапазон 1-100»).
|
||||||
|
"""
|
||||||
|
cases: list[_Case] = []
|
||||||
|
for fname in ROUTER_CASES_FILES:
|
||||||
|
path = EVAL_DIR / fname
|
||||||
|
if not path.exists():
|
||||||
|
logger.warning("Router cases file not found: %s", path)
|
||||||
|
continue
|
||||||
|
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", fname, line[:120])
|
||||||
|
continue
|
||||||
|
cases.append(_Case(
|
||||||
|
text=str(obj["text"]),
|
||||||
|
expected_intent=str(obj["expected_intent"]),
|
||||||
|
count=int(obj.get("count", 1)),
|
||||||
|
))
|
||||||
|
cases.sort(key=lambda c: (-c.count, c.text))
|
||||||
|
return cases
|
||||||
|
|
||||||
|
|
||||||
|
def filter_cases_by_hashes(cases: list[_Case], text_hashes: list[str]) -> list[_Case]:
|
||||||
|
wanted = set(text_hashes)
|
||||||
|
return [c for c in cases if _text_hash(c.text) in wanted]
|
||||||
|
|
||||||
|
|
||||||
|
async def cached_predictions(
|
||||||
|
session: AsyncSession, router_config_id: int | None
|
||||||
|
) -> dict[str, str]:
|
||||||
|
"""{ text_hash → predicted_intent } для активной версии роутера."""
|
||||||
|
rows = (await session.execute(
|
||||||
|
select(EvalRouterPrediction.text_hash, EvalRouterPrediction.predicted_intent)
|
||||||
|
.where(EvalRouterPrediction.router_config_id == router_config_id)
|
||||||
|
)).all()
|
||||||
|
return {th: pi for th, pi in rows}
|
||||||
|
|
||||||
|
|
||||||
|
async def _resolve_active_router_config_id(session: AsyncSession) -> int | None:
|
||||||
|
pair = await config_service.get_active_config_by_intent_code(
|
||||||
|
session, intent_service.ROUTER_INTENT_CODE
|
||||||
|
)
|
||||||
|
if pair is None:
|
||||||
|
return None
|
||||||
|
_, cfg = pair
|
||||||
|
return cfg.id
|
||||||
|
|
||||||
|
|
||||||
|
async def start_router_run(
|
||||||
|
session: AsyncSession, text_hashes: list[str]
|
||||||
|
) -> EvalRun:
|
||||||
|
"""Создаёт run в status=running и запускает фоновую корутину прогона.
|
||||||
|
|
||||||
|
`text_hashes` — выбранные оператором кейсы (см. UI: диапазон / чекбоксы).
|
||||||
|
Пустой список → ValueError (бессмысленный прогон, ловим раньше валидацией).
|
||||||
|
`min_count` оставлен в схеме для обратной совместимости — пишем 0.
|
||||||
|
"""
|
||||||
|
if not text_hashes:
|
||||||
|
raise ValueError("text_hashes is empty")
|
||||||
|
router_config_id = await _resolve_active_router_config_id(session)
|
||||||
|
all_cases = load_all_router_cases()
|
||||||
|
cases = filter_cases_by_hashes(all_cases, text_hashes)
|
||||||
|
run = EvalRun(
|
||||||
|
suite="router",
|
||||||
|
router_config_id=router_config_id,
|
||||||
|
min_count=0,
|
||||||
|
status="running",
|
||||||
|
total=len(cases),
|
||||||
|
)
|
||||||
|
session.add(run)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(run)
|
||||||
|
asyncio.create_task(_run_router_suite(run.id, router_config_id, cases))
|
||||||
|
return run
|
||||||
|
|
||||||
|
|
||||||
|
async def _run_router_suite(
|
||||||
|
run_id: int, router_config_id: int | None, cases: list[_Case]
|
||||||
|
) -> None:
|
||||||
|
"""Фоновый прогон: своя сессия, никаких объектов от вызывающего."""
|
||||||
|
router = RouterClient()
|
||||||
|
passed = failed = cache_hits = 0
|
||||||
|
try:
|
||||||
|
async with SessionLocal() as session:
|
||||||
|
run = await session.get(EvalRun, run_id)
|
||||||
|
if run is None:
|
||||||
|
logger.error("eval_run %d disappeared before start", run_id)
|
||||||
|
return
|
||||||
|
for case in cases:
|
||||||
|
predicted, was_cached = await _classify_with_cache(
|
||||||
|
session, router, case.text, router_config_id
|
||||||
|
)
|
||||||
|
if was_cached:
|
||||||
|
cache_hits += 1
|
||||||
|
is_pass = predicted == case.expected_intent
|
||||||
|
if is_pass:
|
||||||
|
passed += 1
|
||||||
|
else:
|
||||||
|
failed += 1
|
||||||
|
session.add(EvalRunCase(
|
||||||
|
run_id=run_id,
|
||||||
|
text=case.text,
|
||||||
|
expected_intent=case.expected_intent,
|
||||||
|
predicted_intent=predicted,
|
||||||
|
count_weight=case.count,
|
||||||
|
is_pass=is_pass,
|
||||||
|
))
|
||||||
|
# Промежуточный commit раз в 50 кейсов — чтобы UI видел прогресс.
|
||||||
|
if (passed + failed) % 50 == 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_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_run %d failed: %s", run_id, e)
|
||||||
|
try:
|
||||||
|
async with SessionLocal() as session:
|
||||||
|
run = await session.get(EvalRun, 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_run %d as error", run_id)
|
||||||
|
|
||||||
|
|
||||||
|
async def _classify_with_cache(
|
||||||
|
session: AsyncSession,
|
||||||
|
router: RouterClient,
|
||||||
|
text: str,
|
||||||
|
router_config_id: int | None,
|
||||||
|
) -> tuple[str, bool]:
|
||||||
|
"""Возвращает (predicted_intent, was_cached). Кэшируется по (sha256(text), router_config_id)."""
|
||||||
|
text_hash = _text_hash(text)
|
||||||
|
cached = (await session.execute(
|
||||||
|
select(EvalRouterPrediction).where(
|
||||||
|
EvalRouterPrediction.text_hash == text_hash,
|
||||||
|
EvalRouterPrediction.router_config_id == router_config_id,
|
||||||
|
)
|
||||||
|
)).scalar_one_or_none()
|
||||||
|
if cached is not None:
|
||||||
|
return cached.predicted_intent, True
|
||||||
|
|
||||||
|
result = await router.classify(session, history=[], text=text, snapshot=None)
|
||||||
|
predicted = result.get("code") or "general_info"
|
||||||
|
session.add(EvalRouterPrediction(
|
||||||
|
text_hash=text_hash,
|
||||||
|
router_config_id=router_config_id,
|
||||||
|
predicted_intent=predicted,
|
||||||
|
))
|
||||||
|
return predicted, False
|
||||||
|
|
||||||
|
|
||||||
|
async def list_runs(session: AsyncSession, limit: int = 50) -> list[EvalRun]:
|
||||||
|
return list((await session.execute(
|
||||||
|
select(EvalRun).order_by(EvalRun.id.desc()).limit(limit)
|
||||||
|
)).scalars().all())
|
||||||
|
|
||||||
|
|
||||||
|
async def get_run(session: AsyncSession, run_id: int) -> EvalRun | None:
|
||||||
|
return await session.get(EvalRun, run_id)
|
||||||
|
|
||||||
|
|
||||||
|
async def list_run_cases(
|
||||||
|
session: AsyncSession, run_id: int, *, only_fails: bool = False
|
||||||
|
) -> list[EvalRunCase]:
|
||||||
|
stmt = select(EvalRunCase).where(EvalRunCase.run_id == run_id)
|
||||||
|
if only_fails:
|
||||||
|
stmt = stmt.where(EvalRunCase.is_pass.is_(False))
|
||||||
|
stmt = stmt.order_by(
|
||||||
|
EvalRunCase.is_pass, # сначала false (failed), затем true (passed)
|
||||||
|
EvalRunCase.count_weight.desc(),
|
||||||
|
EvalRunCase.id,
|
||||||
|
)
|
||||||
|
return list((await session.execute(stmt)).scalars().all())
|
||||||
|
|
||||||
|
|
||||||
|
async def list_run_fails(session: AsyncSession, run_id: int) -> list[EvalRunCase]:
|
||||||
|
return await list_run_cases(session, run_id, only_fails=True)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class RunDiff:
|
||||||
|
"""Разница с предыдущим завершённым прогоном того же router_config (если есть)."""
|
||||||
|
prev_run_id: int | None
|
||||||
|
new_fails: list[EvalRunCase] # появились в этом прогоне, не было в предыдущем
|
||||||
|
new_passes: list[EvalRunCase] # были fail в предыдущем, теперь pass — берём из prev
|
||||||
|
|
||||||
|
|
||||||
|
async def compute_diff_vs_previous(
|
||||||
|
session: AsyncSession, run: EvalRun
|
||||||
|
) -> RunDiff:
|
||||||
|
"""Сравнение с предыдущим done-прогоном на той же версии роутера."""
|
||||||
|
if run.router_config_id is None or run.status != "done":
|
||||||
|
return RunDiff(prev_run_id=None, new_fails=[], new_passes=[])
|
||||||
|
prev = (await session.execute(
|
||||||
|
select(EvalRun)
|
||||||
|
.where(
|
||||||
|
EvalRun.router_config_id == run.router_config_id,
|
||||||
|
EvalRun.status == "done",
|
||||||
|
EvalRun.id < run.id,
|
||||||
|
)
|
||||||
|
.order_by(EvalRun.id.desc())
|
||||||
|
.limit(1)
|
||||||
|
)).scalar_one_or_none()
|
||||||
|
if prev is None:
|
||||||
|
return RunDiff(prev_run_id=None, new_fails=[], new_passes=[])
|
||||||
|
|
||||||
|
cur_fails = await list_run_fails(session, run.id)
|
||||||
|
prev_fails = await list_run_fails(session, prev.id)
|
||||||
|
cur_keys = {(c.text, c.expected_intent) for c in cur_fails}
|
||||||
|
prev_keys = {(c.text, c.expected_intent) for c in prev_fails}
|
||||||
|
new_fails = [c for c in cur_fails if (c.text, c.expected_intent) not in prev_keys]
|
||||||
|
new_passes = [c for c in prev_fails if (c.text, c.expected_intent) not in cur_keys]
|
||||||
|
return RunDiff(prev_run_id=prev.id, new_fails=new_fails, new_passes=new_passes)
|
||||||
@@ -193,6 +193,7 @@
|
|||||||
<a href="/" class="nav-link">Отладка</a>
|
<a href="/" class="nav-link">Отладка</a>
|
||||||
<a href="/sandbox.html" class="nav-link">Песочница</a>
|
<a href="/sandbox.html" class="nav-link">Песочница</a>
|
||||||
<a href="/settings.html" class="nav-link">Настройки</a>
|
<a href="/settings.html" class="nav-link">Настройки</a>
|
||||||
|
<a href="/regression.html" class="nav-link">Регрессия</a>
|
||||||
<a href="/docs.html" class="nav-link active">Документация</a>
|
<a href="/docs.html" class="nav-link active">Документация</a>
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -417,6 +417,7 @@
|
|||||||
<a href="/" class="nav-link active">Отладка</a>
|
<a href="/" class="nav-link active">Отладка</a>
|
||||||
<a href="/sandbox.html" class="nav-link">Песочница</a>
|
<a href="/sandbox.html" class="nav-link">Песочница</a>
|
||||||
<a href="/settings.html" class="nav-link">Настройки</a>
|
<a href="/settings.html" class="nav-link">Настройки</a>
|
||||||
|
<a href="/regression.html" class="nav-link">Регрессия</a>
|
||||||
<a href="/docs.html" class="nav-link">Документация</a>
|
<a href="/docs.html" class="nav-link">Документация</a>
|
||||||
</nav>
|
</nav>
|
||||||
<span class="status"><span class="dot" id="dot"></span><span id="status-text">проверяю…</span></span>
|
<span class="status"><span class="dot" id="dot"></span><span id="status-text">проверяю…</span></span>
|
||||||
|
|||||||
@@ -0,0 +1,803 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||||
|
<title>Chat Agent for Patients — Регрессия</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #f6f7fb;
|
||||||
|
--panel: #ffffff;
|
||||||
|
--border: #e5e7eb;
|
||||||
|
--muted: #6b7280;
|
||||||
|
--accent: #4f6df5;
|
||||||
|
--ok: #16a34a;
|
||||||
|
--err: #dc2626;
|
||||||
|
--warn: #d97706;
|
||||||
|
--mono: ui-monospace, SFMono-Regular, Menlo, Monaco, monospace;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
html, body { margin: 0; background: var(--bg); font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; font-size: 14px; color: #111827; }
|
||||||
|
header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 18px;
|
||||||
|
padding: 12px 20px;
|
||||||
|
background: var(--panel);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
header h1 { margin: 0; font-size: 16px; font-weight: 600; }
|
||||||
|
.nav { display: flex; gap: 14px; }
|
||||||
|
.nav-link { color: var(--muted); text-decoration: none; padding: 6px 10px; border-radius: 6px; font-size: 13px; }
|
||||||
|
.nav-link:hover { background: #f3f4f6; color: #111827; }
|
||||||
|
.nav-link.active { background: var(--accent); color: #fff; }
|
||||||
|
main { padding: 20px; max-width: 1400px; margin: 0 auto; }
|
||||||
|
h2 { margin: 0 0 10px; font-size: 18px; }
|
||||||
|
.sub { color: var(--muted); font-size: 13px; margin-bottom: 16px; }
|
||||||
|
.panel { background: var(--panel); border: 1px solid var(--border); border-radius: 8px; padding: 14px 16px; margin-bottom: 16px; }
|
||||||
|
.panel h3 { margin: 0 0 10px; font-size: 14px; font-weight: 600; }
|
||||||
|
.row { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
|
||||||
|
label.field { display: inline-flex; flex-direction: column; gap: 4px; font-size: 12px; color: var(--muted); }
|
||||||
|
input.num { width: 80px; padding: 5px 8px; border: 1px solid var(--border); border-radius: 4px; font-family: var(--mono); }
|
||||||
|
button.primary { padding: 7px 14px; background: var(--accent); color: #fff; border: 0; border-radius: 4px; cursor: pointer; font-size: 13px; }
|
||||||
|
button.primary:hover { background: #3f57c4; }
|
||||||
|
button.primary:disabled { background: #9ca3af; cursor: not-allowed; }
|
||||||
|
button.secondary { padding: 5px 10px; background: #fff; color: var(--accent); border: 1px solid var(--accent); border-radius: 4px; cursor: pointer; font-size: 12px; }
|
||||||
|
button.secondary:hover { background: var(--accent); color: #fff; }
|
||||||
|
table { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||||
|
table th, table td { padding: 8px 10px; text-align: left; border-bottom: 1px solid var(--border); }
|
||||||
|
table th { font-weight: 600; color: var(--muted); font-size: 12px; text-transform: uppercase; letter-spacing: 0.04em; }
|
||||||
|
table tr.run-row { cursor: pointer; }
|
||||||
|
table tr.run-row:hover { background: #f9fafb; }
|
||||||
|
table tr.run-row.selected { background: #eff6ff; }
|
||||||
|
.badge { display: inline-block; padding: 2px 8px; border-radius: 10px; font-size: 11px; font-weight: 600; }
|
||||||
|
.badge.running { background: #fef3c7; color: var(--warn); }
|
||||||
|
.badge.done { background: #dcfce7; color: var(--ok); }
|
||||||
|
.badge.error { background: #fee2e2; color: var(--err); }
|
||||||
|
.stat { font-family: var(--mono); }
|
||||||
|
.stat.pass { color: var(--ok); }
|
||||||
|
.stat.fail { color: var(--err); }
|
||||||
|
.progress {
|
||||||
|
height: 8px;
|
||||||
|
background: #f3f4f6;
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
.progress-bar {
|
||||||
|
height: 100%;
|
||||||
|
background: var(--accent);
|
||||||
|
transition: width 0.3s;
|
||||||
|
}
|
||||||
|
.case-list { font-family: var(--mono); font-size: 12px; max-height: 600px; overflow-y: auto; border: 1px solid var(--border); border-radius: 6px; }
|
||||||
|
.case-row {
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 50px 1fr 130px 130px 60px;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.case-row:last-child { border-bottom: 0; }
|
||||||
|
.case-row.fail { background: #fef2f2; }
|
||||||
|
.case-row.pass { background: #f0fdf4; }
|
||||||
|
.case-status {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-align: center;
|
||||||
|
padding: 2px 0;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
.case-status.pass { color: var(--ok); background: #dcfce7; }
|
||||||
|
.case-status.fail { color: var(--err); background: #fee2e2; }
|
||||||
|
.case-text { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.case-expected { color: #4b5563; }
|
||||||
|
.case-predicted.match { color: var(--ok); }
|
||||||
|
.case-predicted.miss { color: var(--err); }
|
||||||
|
.case-weight { color: var(--muted); text-align: right; font-size: 11px; }
|
||||||
|
.case-controls { display: flex; gap: 12px; align-items: center; flex-wrap: wrap; margin-bottom: 10px; }
|
||||||
|
.case-controls input[type=text] { padding: 5px 8px; border: 1px solid var(--border); border-radius: 4px; font-size: 12px; min-width: 240px; font-family: inherit; }
|
||||||
|
.case-controls .filter-group { display: inline-flex; gap: 4px; }
|
||||||
|
.case-controls .filter-btn {
|
||||||
|
padding: 4px 10px; border: 1px solid var(--border); background: #fff;
|
||||||
|
border-radius: 4px; cursor: pointer; font-size: 12px; color: var(--muted);
|
||||||
|
}
|
||||||
|
.case-controls .filter-btn.active { background: var(--accent); color: #fff; border-color: var(--accent); }
|
||||||
|
.case-list-header {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 50px 1fr 130px 130px 60px;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
background: #f9fafb;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 11px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
color: var(--muted);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
border-radius: 6px 6px 0 0;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
}
|
||||||
|
.diff-block { margin-top: 12px; }
|
||||||
|
.diff-header { font-weight: 600; font-size: 13px; margin-bottom: 6px; }
|
||||||
|
.empty { color: var(--muted); font-size: 13px; padding: 12px 0; }
|
||||||
|
|
||||||
|
/* Блок выбора кейсов перед прогоном */
|
||||||
|
.picker-block {
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--panel);
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.picker-summary {
|
||||||
|
list-style: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 14px 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 14px;
|
||||||
|
position: relative;
|
||||||
|
padding-left: 36px;
|
||||||
|
}
|
||||||
|
.picker-summary::-webkit-details-marker { display: none; }
|
||||||
|
.picker-summary::before {
|
||||||
|
content: "▶";
|
||||||
|
position: absolute;
|
||||||
|
left: 16px;
|
||||||
|
transition: transform 0.15s;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.picker-block[open] > .picker-summary::before { transform: rotate(90deg); }
|
||||||
|
.picker-block[open] > .picker-summary { border-bottom: 1px solid var(--border); }
|
||||||
|
.picker-summary .sub { font-weight: 400; }
|
||||||
|
.picker-body { padding: 14px 16px; }
|
||||||
|
.picker-tools { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; margin-bottom: 12px; }
|
||||||
|
.picker-tools select, .picker-tools input[type=text] {
|
||||||
|
padding: 5px 8px; border: 1px solid var(--border); border-radius: 4px; font-size: 12px;
|
||||||
|
}
|
||||||
|
.picker-tools input[type=text].range { min-width: 200px; font-family: var(--mono); }
|
||||||
|
.picker-tools .picker-btn {
|
||||||
|
padding: 4px 10px; border: 1px solid var(--border); background: #fff;
|
||||||
|
border-radius: 4px; cursor: pointer; font-size: 12px; color: #374151;
|
||||||
|
}
|
||||||
|
.picker-tools .picker-btn:hover { background: #f3f4f6; }
|
||||||
|
.picker-counter { color: var(--muted); font-size: 12px; }
|
||||||
|
.picker-list-wrap {
|
||||||
|
max-height: 480px;
|
||||||
|
overflow-y: auto;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
.picker-table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
.picker-table thead th {
|
||||||
|
position: sticky; top: 0; background: #f9fafb;
|
||||||
|
padding: 6px 8px; text-align: left;
|
||||||
|
font-weight: 600; font-size: 11px;
|
||||||
|
text-transform: uppercase; letter-spacing: 0.04em;
|
||||||
|
color: var(--muted);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.picker-table tbody td { padding: 5px 8px; border-bottom: 1px solid var(--border); font-family: var(--mono); }
|
||||||
|
.picker-table tbody tr:hover { background: #f9fafb; }
|
||||||
|
.picker-table tbody tr.cached-fail { background: #fef2f2; }
|
||||||
|
.picker-table tbody tr.cached-pass { background: #f0fdf4; }
|
||||||
|
.picker-table tbody tr.cached-fail:hover { background: #fee2e2; }
|
||||||
|
.picker-table tbody tr.cached-pass:hover { background: #dcfce7; }
|
||||||
|
.col-idx { width: 50px; text-align: right; color: var(--muted); }
|
||||||
|
.col-check { width: 36px; text-align: center; }
|
||||||
|
.col-text { max-width: 480px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.col-intent { width: 130px; }
|
||||||
|
.col-count { width: 70px; text-align: right; color: var(--muted); }
|
||||||
|
.col-cache { width: 90px; text-align: center; font-weight: 700; }
|
||||||
|
.col-cache.pass { color: var(--ok); }
|
||||||
|
.col-cache.fail { color: var(--err); }
|
||||||
|
.col-cache.empty-c { color: var(--muted); font-weight: 400; }
|
||||||
|
.toast { position: fixed; bottom: 20px; right: 20px; background: #111827; color: #fff; padding: 10px 14px; border-radius: 6px; font-size: 13px; opacity: 0; transition: opacity 0.2s; z-index: 100; }
|
||||||
|
.toast.show { opacity: 1; }
|
||||||
|
.toast.err { background: var(--err); }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<header>
|
||||||
|
<h1>Chat Agent for Patients</h1>
|
||||||
|
<nav class="nav">
|
||||||
|
<a href="/" class="nav-link">Отладка</a>
|
||||||
|
<a href="/sandbox.html" class="nav-link">Песочница</a>
|
||||||
|
<a href="/settings.html" class="nav-link">Настройки</a>
|
||||||
|
<a href="/regression.html" class="nav-link active">Регрессия</a>
|
||||||
|
<a href="/docs.html" class="nav-link">Документация</a>
|
||||||
|
</nav>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<h2>Регрессия роутера</h2>
|
||||||
|
<p class="sub">Прогон одношаговых кейсов классификатора (1573 фразы из реальных диалогов) на активной версии промпта <code>_router</code>. Pass/fail сравниваются с ожидаемой веткой. Кэш ответов привязан к версии роутера: повторный прогон на той же версии — мгновенный.</p>
|
||||||
|
|
||||||
|
<details class="picker-block" id="picker-block">
|
||||||
|
<summary class="picker-summary">
|
||||||
|
Выбор кейсов <span class="sub" id="picker-summary-info">— загружаю…</span>
|
||||||
|
</summary>
|
||||||
|
<div class="picker-body">
|
||||||
|
<div class="picker-tools">
|
||||||
|
<label class="field">
|
||||||
|
<span>Ветка (intent)</span>
|
||||||
|
<select id="picker-intent">
|
||||||
|
<option value="">все ветки</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label class="field">
|
||||||
|
<span>Диапазон (по #)</span>
|
||||||
|
<input type="text" class="range" id="picker-range" placeholder="например: 1-50, 200-300">
|
||||||
|
</label>
|
||||||
|
<button class="picker-btn" onclick="pickerApplyRange()">Применить диапазон</button>
|
||||||
|
<button class="picker-btn" onclick="pickerSelectAllVisible()">Все (видимые)</button>
|
||||||
|
<button class="picker-btn" onclick="pickerClearAll()">Снять все</button>
|
||||||
|
<button class="picker-btn" onclick="pickerSelectByCache('none')">Только без кэша</button>
|
||||||
|
<button class="picker-btn" onclick="pickerSelectByCache('fail')">Только FAIL в кэше</button>
|
||||||
|
<button class="picker-btn" onclick="pickerDropCached()" title="Убрать галочки с тех, у которых уже есть результат в кэше">Снять кэшированные</button>
|
||||||
|
<span class="picker-counter" id="picker-counter">выбрано 0</span>
|
||||||
|
</div>
|
||||||
|
<div class="picker-list-wrap">
|
||||||
|
<table class="picker-table">
|
||||||
|
<thead>
|
||||||
|
<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>
|
||||||
|
</thead>
|
||||||
|
<tbody id="picker-tbody">
|
||||||
|
<tr><td colspan="6" class="empty">— загружаю —</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="row" style="margin-top:14px;">
|
||||||
|
<button class="primary" id="start-btn" onclick="startRun()" disabled>Прогнать выбранное (0)</button>
|
||||||
|
<span class="sub" id="start-hint">Прогон идёт в фоне, можно свернуть и вернуться.</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
|
||||||
|
<div class="panel">
|
||||||
|
<h3>История прогонов</h3>
|
||||||
|
<table>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>#</th>
|
||||||
|
<th>Стартовал</th>
|
||||||
|
<th>Версия роутера</th>
|
||||||
|
<th>Total</th>
|
||||||
|
<th>Pass</th>
|
||||||
|
<th>Fail</th>
|
||||||
|
<th>Cache</th>
|
||||||
|
<th>Статус</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="runs-tbody">
|
||||||
|
<tr><td colspan="8" class="empty">— загружаю —</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel" id="run-detail-panel" style="display:none;">
|
||||||
|
<h3 id="run-detail-title">Детали прогона</h3>
|
||||||
|
<div id="run-detail-body"></div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<div class="toast" id="toast"></div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const $ = (id) => document.getElementById(id);
|
||||||
|
const esc = (s) => String(s ?? "").replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||||
|
|
||||||
|
let selectedRunId = null;
|
||||||
|
let pollHandle = null;
|
||||||
|
let caseFilter = "all"; // "all" | "pass" | "fail"
|
||||||
|
let caseSearch = "";
|
||||||
|
let currentCases = []; // последние полученные кейсы выбранного прогона
|
||||||
|
|
||||||
|
function toast(msg, kind = "ok") {
|
||||||
|
const t = $("toast");
|
||||||
|
t.textContent = msg;
|
||||||
|
t.className = "toast show" + (kind === "err" ? " err" : "");
|
||||||
|
setTimeout(() => t.className = "toast", 2500);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function api(path, opts = {}) {
|
||||||
|
const res = await fetch(path, opts);
|
||||||
|
if (!res.ok) {
|
||||||
|
let msg = `${res.status}`;
|
||||||
|
try { const d = await res.json(); msg = d.detail || JSON.stringify(d); } catch (_) {}
|
||||||
|
throw new Error(msg);
|
||||||
|
}
|
||||||
|
if (res.status === 204) return null;
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtDate(iso) {
|
||||||
|
if (!iso) return "—";
|
||||||
|
try {
|
||||||
|
const d = new Date(iso);
|
||||||
|
return d.toLocaleString("ru-RU", { day: "2-digit", month: "2-digit", year: "2-digit", hour: "2-digit", minute: "2-digit", second: "2-digit" });
|
||||||
|
} catch (_) { return iso; }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshRuns() {
|
||||||
|
try {
|
||||||
|
const d = await api("/eval/runs");
|
||||||
|
renderRunsTable(d.runs || []);
|
||||||
|
} catch (e) {
|
||||||
|
$("runs-tbody").innerHTML = `<tr><td colspan="9" class="empty">Ошибка: ${esc(e.message)}</td></tr>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRunsTable(runs) {
|
||||||
|
const body = $("runs-tbody");
|
||||||
|
if (!runs.length) {
|
||||||
|
body.innerHTML = '<tr><td colspan="8" class="empty">Прогонов ещё не было — выберите кейсы в блоке выше и нажмите «Прогнать выбранное».</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
body.innerHTML = runs.map(r => {
|
||||||
|
const cls = r.id === selectedRunId ? "selected" : "";
|
||||||
|
const versionStr = r.router_config_version ? `v${r.router_config_version}` : "—";
|
||||||
|
return `
|
||||||
|
<tr class="run-row ${cls}" onclick="selectRun(${r.id})">
|
||||||
|
<td>#${r.id}</td>
|
||||||
|
<td>${fmtDate(r.started_at)}</td>
|
||||||
|
<td><code>${versionStr}</code></td>
|
||||||
|
<td class="stat">${r.total}</td>
|
||||||
|
<td class="stat pass">${r.passed}</td>
|
||||||
|
<td class="stat fail">${r.failed}</td>
|
||||||
|
<td class="stat" title="кэш-хитов из ${r.total}">${r.cache_hits}</td>
|
||||||
|
<td>${renderStatusBadge(r)}</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
}).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderStatusBadge(r) {
|
||||||
|
if (r.status === "running") {
|
||||||
|
const done = r.passed + r.failed;
|
||||||
|
const pct = r.total > 0 ? Math.round(100 * done / r.total) : 0;
|
||||||
|
return `<span class="badge running">${pct}%</span>`;
|
||||||
|
}
|
||||||
|
if (r.status === "done") return '<span class="badge done">готово</span>';
|
||||||
|
if (r.status === "error") return '<span class="badge error">ошибка</span>';
|
||||||
|
return `<span class="badge">${esc(r.status)}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Picker (выбор кейсов) ----------
|
||||||
|
|
||||||
|
let pickerCases = []; // полный список из /router-cases-with-status
|
||||||
|
let pickerSelected = new Set(); // text_hash выбранных
|
||||||
|
let pickerIntents = []; // уникальные intents для select
|
||||||
|
let pickerVersionLabel = "";
|
||||||
|
|
||||||
|
async function loadPicker() {
|
||||||
|
try {
|
||||||
|
const d = await api("/eval/router-cases-with-status");
|
||||||
|
pickerCases = d.cases || [];
|
||||||
|
pickerVersionLabel = d.router_config_version ? `v${d.router_config_version}` : "—";
|
||||||
|
pickerIntents = Array.from(new Set(pickerCases.map(c => c.expected_intent))).sort();
|
||||||
|
fillPickerIntentSelect();
|
||||||
|
renderPickerInfo(d);
|
||||||
|
renderPickerTable();
|
||||||
|
} catch (e) {
|
||||||
|
$("picker-tbody").innerHTML = `<tr><td colspan="6" class="empty">Ошибка: ${esc(e.message)}</td></tr>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function fillPickerIntentSelect() {
|
||||||
|
const sel = $("picker-intent");
|
||||||
|
sel.innerHTML =
|
||||||
|
'<option value="">все ветки</option>' +
|
||||||
|
pickerIntents.map(i => `<option value="${esc(i)}">${esc(i)}</option>`).join("");
|
||||||
|
sel.onchange = () => renderPickerTable();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPickerInfo(d) {
|
||||||
|
const cached = pickerCases.filter(c => c.cached_predicted !== null).length;
|
||||||
|
$("picker-summary-info").textContent =
|
||||||
|
`— активная версия роутера ${pickerVersionLabel} · ${d.total} кейсов всего · в кэше ${cached}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickerVisibleCases() {
|
||||||
|
const intent = $("picker-intent").value;
|
||||||
|
if (!intent) return pickerCases;
|
||||||
|
return pickerCases.filter(c => c.expected_intent === intent);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPickerTable() {
|
||||||
|
const visible = pickerVisibleCases();
|
||||||
|
const tbody = $("picker-tbody");
|
||||||
|
if (!visible.length) {
|
||||||
|
tbody.innerHTML = '<tr><td colspan="6" class="empty">— нет кейсов под фильтр —</td></tr>';
|
||||||
|
refreshPickerCounter();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tbody.innerHTML = visible.map(c => {
|
||||||
|
const checked = pickerSelected.has(c.text_hash) ? "checked" : "";
|
||||||
|
const cacheCell = renderCacheCell(c);
|
||||||
|
const rowCls = c.cached_predicted === null ? "" : (c.cached_is_pass ? "cached-pass" : "cached-fail");
|
||||||
|
return `
|
||||||
|
<tr class="${rowCls}">
|
||||||
|
<td class="col-idx">${c.idx}</td>
|
||||||
|
<td class="col-check"><input type="checkbox" data-hash="${c.text_hash}" ${checked} onchange="pickerToggleOne(this)"></td>
|
||||||
|
<td class="col-text" title="${esc(c.text)}">${esc(c.text)}</td>
|
||||||
|
<td class="col-intent">${esc(c.expected_intent)}</td>
|
||||||
|
<td class="col-count">×${c.count}</td>
|
||||||
|
<td class="col-cache ${cacheCellClass(c)}">${cacheCell}</td>
|
||||||
|
</tr>
|
||||||
|
`;
|
||||||
|
}).join("");
|
||||||
|
refreshPickerCounter();
|
||||||
|
syncPickerHeaderCheckbox();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCacheCell(c) {
|
||||||
|
if (c.cached_predicted === null) return "—";
|
||||||
|
if (c.cached_is_pass) return "PASS";
|
||||||
|
return `FAIL<div class="sub" style="font-size:10px;">→ ${esc(c.cached_predicted)}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cacheCellClass(c) {
|
||||||
|
if (c.cached_predicted === null) return "empty-c";
|
||||||
|
return c.cached_is_pass ? "pass" : "fail";
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickerToggleOne(cb) {
|
||||||
|
const h = cb.dataset.hash;
|
||||||
|
if (cb.checked) pickerSelected.add(h); else pickerSelected.delete(h);
|
||||||
|
refreshPickerCounter();
|
||||||
|
syncPickerHeaderCheckbox();
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickerToggleAllVisible(checked) {
|
||||||
|
const visible = pickerVisibleCases();
|
||||||
|
for (const c of visible) {
|
||||||
|
if (checked) pickerSelected.add(c.text_hash);
|
||||||
|
else pickerSelected.delete(c.text_hash);
|
||||||
|
}
|
||||||
|
renderPickerTable();
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickerSelectAllVisible() { pickerToggleAllVisible(true); }
|
||||||
|
function pickerClearAll() {
|
||||||
|
pickerSelected.clear();
|
||||||
|
renderPickerTable();
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickerSelectByCache(mode) {
|
||||||
|
const visible = pickerVisibleCases();
|
||||||
|
pickerSelected.clear();
|
||||||
|
for (const c of visible) {
|
||||||
|
if (mode === "none" && c.cached_predicted === null) pickerSelected.add(c.text_hash);
|
||||||
|
else if (mode === "fail" && c.cached_predicted !== null && !c.cached_is_pass) pickerSelected.add(c.text_hash);
|
||||||
|
}
|
||||||
|
renderPickerTable();
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickerApplyRange() {
|
||||||
|
const raw = $("picker-range").value.trim();
|
||||||
|
if (!raw) { toast("Введите диапазон, например: 1-50, 200-300", "err"); return; }
|
||||||
|
const ranges = parseRanges(raw);
|
||||||
|
if (!ranges.length) { toast("Не удалось разобрать диапазон", "err"); return; }
|
||||||
|
const visible = pickerVisibleCases();
|
||||||
|
pickerSelected.clear();
|
||||||
|
for (const c of visible) {
|
||||||
|
for (const [lo, hi] of ranges) {
|
||||||
|
if (c.idx >= lo && c.idx <= hi) { pickerSelected.add(c.text_hash); break; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
renderPickerTable();
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseRanges(s) {
|
||||||
|
// "1-50, 200-300, 5" → [[1,50],[200,300],[5,5]]
|
||||||
|
const out = [];
|
||||||
|
for (const part of s.split(",")) {
|
||||||
|
const p = part.trim();
|
||||||
|
if (!p) continue;
|
||||||
|
const m = p.match(/^(\d+)\s*-\s*(\d+)$/);
|
||||||
|
if (m) {
|
||||||
|
const a = parseInt(m[1], 10), b = parseInt(m[2], 10);
|
||||||
|
out.push([Math.min(a, b), Math.max(a, b)]);
|
||||||
|
} else if (/^\d+$/.test(p)) {
|
||||||
|
const n = parseInt(p, 10);
|
||||||
|
out.push([n, n]);
|
||||||
|
} else {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickerSelectionStats() {
|
||||||
|
// По cached_predicted делим выбранные на «новые» (LLM нужен) и «в кэше» (мгновенно).
|
||||||
|
let cached = 0;
|
||||||
|
for (const c of pickerCases) {
|
||||||
|
if (!pickerSelected.has(c.text_hash)) continue;
|
||||||
|
if (c.cached_predicted !== null) cached++;
|
||||||
|
}
|
||||||
|
return { total: pickerSelected.size, cached, fresh: pickerSelected.size - cached };
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshPickerCounter() {
|
||||||
|
const s = pickerSelectionStats();
|
||||||
|
$("picker-counter").textContent =
|
||||||
|
s.total === 0
|
||||||
|
? "выбрано 0"
|
||||||
|
: `выбрано ${s.total} (новых: ${s.fresh}, в кэше: ${s.cached})`;
|
||||||
|
|
||||||
|
const btn = $("start-btn");
|
||||||
|
btn.disabled = s.total === 0;
|
||||||
|
if (s.total === 0) {
|
||||||
|
btn.textContent = "Прогнать выбранное (0)";
|
||||||
|
} else if (s.cached === 0) {
|
||||||
|
btn.textContent = `Прогнать выбранное (${s.fresh})`;
|
||||||
|
} else if (s.fresh === 0) {
|
||||||
|
btn.textContent = `Прогнать выбранное (${s.total} из кэша)`;
|
||||||
|
} else {
|
||||||
|
btn.textContent = `Прогнать выбранное (${s.fresh} новых + ${s.cached} из кэша)`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hint = $("start-hint");
|
||||||
|
if (hint) {
|
||||||
|
if (s.fresh > 0) {
|
||||||
|
hint.textContent = `Через LLM пойдут только ${s.fresh} новых, остальные ${s.cached} возьмутся из кэша мгновенно.`;
|
||||||
|
} else if (s.cached > 0) {
|
||||||
|
hint.textContent = "Все выбранные уже в кэше на этой версии — прогон будет мгновенным.";
|
||||||
|
} else {
|
||||||
|
hint.textContent = "Прогон идёт в фоне, можно свернуть и вернуться.";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickerDropCached() {
|
||||||
|
for (const c of pickerCases) {
|
||||||
|
if (c.cached_predicted !== null) pickerSelected.delete(c.text_hash);
|
||||||
|
}
|
||||||
|
renderPickerTable();
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncPickerHeaderCheckbox() {
|
||||||
|
const visible = pickerVisibleCases();
|
||||||
|
const checked = visible.length > 0 && visible.every(c => pickerSelected.has(c.text_hash));
|
||||||
|
$("picker-check-all").checked = checked;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startRun() {
|
||||||
|
const hashes = Array.from(pickerSelected);
|
||||||
|
if (!hashes.length) { toast("Выберите хотя бы один кейс", "err"); return; }
|
||||||
|
$("start-btn").disabled = true;
|
||||||
|
try {
|
||||||
|
const r = await api("/eval/runs", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({ suite: "router", text_hashes: hashes }),
|
||||||
|
});
|
||||||
|
toast(`Прогон #${r.id} запущен (${r.total} кейсов)`);
|
||||||
|
selectedRunId = r.id;
|
||||||
|
// Свернуть пикер, чтобы показать прогресс прогона.
|
||||||
|
$("picker-block").open = false;
|
||||||
|
await refreshRuns();
|
||||||
|
await selectRun(r.id);
|
||||||
|
startPolling();
|
||||||
|
} catch (e) {
|
||||||
|
toast("Ошибка: " + e.message, "err");
|
||||||
|
} finally {
|
||||||
|
refreshPickerCounter();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectRun(runId) {
|
||||||
|
selectedRunId = runId;
|
||||||
|
await refreshRuns();
|
||||||
|
try {
|
||||||
|
const d = await api(`/eval/runs/${runId}`);
|
||||||
|
renderRunDetail(d);
|
||||||
|
} catch (e) {
|
||||||
|
toast("Ошибка: " + e.message, "err");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRunDetail(d) {
|
||||||
|
const panel = $("run-detail-panel");
|
||||||
|
const title = $("run-detail-title");
|
||||||
|
const body = $("run-detail-body");
|
||||||
|
panel.style.display = "block";
|
||||||
|
const r = d.run;
|
||||||
|
currentCases = d.cases || [];
|
||||||
|
const versionStr = r.router_config_version ? `v${r.router_config_version}` : "—";
|
||||||
|
title.textContent = `Прогон #${r.id} · роутер ${versionStr} · ${r.status}`;
|
||||||
|
|
||||||
|
let progressHtml = "";
|
||||||
|
if (r.status === "running" && r.total > 0) {
|
||||||
|
const done = r.passed + r.failed;
|
||||||
|
const pct = Math.round(100 * done / r.total);
|
||||||
|
progressHtml = `
|
||||||
|
<div class="progress"><div class="progress-bar" style="width:${pct}%"></div></div>
|
||||||
|
<div class="sub">Обработано ${done} / ${r.total}, кэш ${r.cache_hits}</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
let errorHtml = "";
|
||||||
|
if (r.status === "error" && r.error_text) {
|
||||||
|
errorHtml = `<div class="empty" style="color:var(--err)">Ошибка: ${esc(r.error_text)}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Контролы фильтра/поиска кейсов.
|
||||||
|
const total = currentCases.length;
|
||||||
|
const passes = currentCases.filter(c => c.is_pass).length;
|
||||||
|
const fails = total - passes;
|
||||||
|
const controlsHtml = `
|
||||||
|
<div class="case-controls">
|
||||||
|
<span class="diff-header" style="margin:0;">Кейсы прогона</span>
|
||||||
|
<span class="filter-group">
|
||||||
|
<button class="filter-btn ${caseFilter==='all'?'active':''}" onclick="setCaseFilter('all')">все (${total})</button>
|
||||||
|
<button class="filter-btn ${caseFilter==='pass'?'active':''}" onclick="setCaseFilter('pass')">pass (${passes})</button>
|
||||||
|
<button class="filter-btn ${caseFilter==='fail'?'active':''}" onclick="setCaseFilter('fail')">fail (${fails})</button>
|
||||||
|
</span>
|
||||||
|
<input type="text" id="case-search" placeholder="🔍 поиск по тексту…" value="${esc(caseSearch)}" oninput="onCaseSearch(this.value)">
|
||||||
|
</div>
|
||||||
|
<div id="case-list-root"></div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
let diffHtml = "";
|
||||||
|
if (d.diff && d.diff.prev_run_id) {
|
||||||
|
const newFails = renderCasesSection(d.diff.new_fails, `🔴 Новые fail vs прогон #${d.diff.prev_run_id}`, "—");
|
||||||
|
const newPasses = renderCasesSection(d.diff.new_passes, `🟢 Новые pass vs прогон #${d.diff.prev_run_id}`, "—");
|
||||||
|
diffHtml = `<div class="diff-block">${newFails}${newPasses}</div>`;
|
||||||
|
} else if (d.diff && r.status === "done") {
|
||||||
|
diffHtml = `<div class="diff-block sub">Это первый завершённый прогон на текущей версии роутера — сравнивать не с чем.</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
body.innerHTML = `
|
||||||
|
${progressHtml}
|
||||||
|
${errorHtml}
|
||||||
|
${controlsHtml}
|
||||||
|
${diffHtml}
|
||||||
|
`;
|
||||||
|
renderCaseList();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setCaseFilter(f) {
|
||||||
|
caseFilter = f;
|
||||||
|
// Перерисовываем все контролы (счётчики не меняются, но кнопки — да) и список.
|
||||||
|
const detailBody = $("run-detail-body");
|
||||||
|
if (!detailBody) return;
|
||||||
|
// Простой путь: перерисовать кнопки руками, не трогая остальное.
|
||||||
|
detailBody.querySelectorAll(".filter-btn").forEach(b => b.classList.remove("active"));
|
||||||
|
const map = {all: 0, pass: 1, fail: 2};
|
||||||
|
const idx = map[f];
|
||||||
|
const btns = detailBody.querySelectorAll(".filter-btn");
|
||||||
|
if (btns[idx]) btns[idx].classList.add("active");
|
||||||
|
renderCaseList();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onCaseSearch(value) {
|
||||||
|
caseSearch = value;
|
||||||
|
renderCaseList();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCaseList() {
|
||||||
|
const root = $("case-list-root");
|
||||||
|
if (!root) return;
|
||||||
|
const q = caseSearch.trim().toLowerCase();
|
||||||
|
let cases = currentCases;
|
||||||
|
if (caseFilter === "pass") cases = cases.filter(c => c.is_pass);
|
||||||
|
else if (caseFilter === "fail") cases = cases.filter(c => !c.is_pass);
|
||||||
|
if (q) cases = cases.filter(c => c.text.toLowerCase().includes(q));
|
||||||
|
|
||||||
|
if (!cases.length) {
|
||||||
|
root.innerHTML = '<div class="empty">— ничего не найдено —</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const header = `
|
||||||
|
<div class="case-list-header">
|
||||||
|
<div>результат</div>
|
||||||
|
<div>запрос (реплика пациента)</div>
|
||||||
|
<div>ответ роутера</div>
|
||||||
|
<div>правильный</div>
|
||||||
|
<div style="text-align:right;">вес</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
const rows = cases.map(c => {
|
||||||
|
const cls = c.is_pass ? "pass" : "fail";
|
||||||
|
const predCls = c.is_pass ? "match" : "miss";
|
||||||
|
const status = c.is_pass ? "PASS" : "FAIL";
|
||||||
|
return `
|
||||||
|
<div class="case-row ${cls}">
|
||||||
|
<div class="case-status ${cls}">${status}</div>
|
||||||
|
<div class="case-text" title="${esc(c.text)}">${esc(c.text)}</div>
|
||||||
|
<div class="case-predicted ${predCls}">${esc(c.predicted_intent)}</div>
|
||||||
|
<div class="case-expected">${esc(c.expected_intent)}</div>
|
||||||
|
<div class="case-weight">×${c.count_weight}</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}).join("");
|
||||||
|
root.innerHTML = `<div class="case-list">${header}${rows}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCasesSection(cases, title, emptyMsg) {
|
||||||
|
if (!cases || !cases.length) {
|
||||||
|
return `<div class="diff-header">${esc(title)}</div><div class="empty">${esc(emptyMsg)}</div>`;
|
||||||
|
}
|
||||||
|
const rows = cases.map(c => `
|
||||||
|
<div class="case-row fail">
|
||||||
|
<div class="case-status fail">FAIL</div>
|
||||||
|
<div class="case-text" title="${esc(c.text)}">${esc(c.text)}</div>
|
||||||
|
<div class="case-predicted miss">${esc(c.predicted_intent)}</div>
|
||||||
|
<div class="case-expected">${esc(c.expected_intent)}</div>
|
||||||
|
<div class="case-weight">×${c.count_weight}</div>
|
||||||
|
</div>
|
||||||
|
`).join("");
|
||||||
|
return `
|
||||||
|
<div class="diff-header" style="margin-top:14px;">${esc(title)} <span class="sub">(${cases.length})</span></div>
|
||||||
|
<div class="case-list">${rows}</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function startPolling() {
|
||||||
|
stopPolling();
|
||||||
|
pollHandle = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
const d = await api("/eval/runs");
|
||||||
|
const runs = d.runs || [];
|
||||||
|
renderRunsTable(runs);
|
||||||
|
if (selectedRunId) {
|
||||||
|
const cur = runs.find(r => r.id === selectedRunId);
|
||||||
|
if (cur) {
|
||||||
|
const detail = await api(`/eval/runs/${selectedRunId}`);
|
||||||
|
renderRunDetail(detail);
|
||||||
|
if (cur.status !== "running") {
|
||||||
|
stopPolling();
|
||||||
|
if (cur.status === "done") {
|
||||||
|
toast(`Прогон #${cur.id} завершён: ${cur.passed}/${cur.total}`);
|
||||||
|
} else if (cur.status === "error") {
|
||||||
|
toast(`Прогон #${cur.id} упал с ошибкой`, "err");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn("poll failed", e);
|
||||||
|
}
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopPolling() {
|
||||||
|
if (pollHandle) {
|
||||||
|
clearInterval(pollHandle);
|
||||||
|
pollHandle = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
await loadPicker();
|
||||||
|
await refreshRuns();
|
||||||
|
// Если есть «running» прогон — сразу подсветить и начать polling.
|
||||||
|
try {
|
||||||
|
const d = await api("/eval/runs");
|
||||||
|
const running = (d.runs || []).find(r => r.status === "running");
|
||||||
|
if (running) {
|
||||||
|
selectedRunId = running.id;
|
||||||
|
await selectRun(running.id);
|
||||||
|
startPolling();
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -543,6 +543,7 @@
|
|||||||
<a href="/" class="nav-link">Отладка</a>
|
<a href="/" class="nav-link">Отладка</a>
|
||||||
<a href="/sandbox.html" class="nav-link active">Песочница</a>
|
<a href="/sandbox.html" class="nav-link active">Песочница</a>
|
||||||
<a href="/settings.html" class="nav-link">Настройки</a>
|
<a href="/settings.html" class="nav-link">Настройки</a>
|
||||||
|
<a href="/regression.html" class="nav-link">Регрессия</a>
|
||||||
<a href="/docs.html" class="nav-link">Документация</a>
|
<a href="/docs.html" class="nav-link">Документация</a>
|
||||||
</nav>
|
</nav>
|
||||||
<span class="status" style="margin-left:auto;"><span class="dot" id="dot"></span><span id="status-text">проверяю…</span></span>
|
<span class="status" style="margin-left:auto;"><span class="dot" id="dot"></span><span id="status-text">проверяю…</span></span>
|
||||||
|
|||||||
@@ -865,6 +865,7 @@
|
|||||||
<a href="/" class="nav-link">Отладка</a>
|
<a href="/" class="nav-link">Отладка</a>
|
||||||
<a href="/sandbox.html" class="nav-link">Песочница</a>
|
<a href="/sandbox.html" class="nav-link">Песочница</a>
|
||||||
<a href="/settings.html" class="nav-link active">Настройки</a>
|
<a href="/settings.html" class="nav-link active">Настройки</a>
|
||||||
|
<a href="/regression.html" class="nav-link">Регрессия</a>
|
||||||
<a href="/docs.html" class="nav-link">Документация</a>
|
<a href="/docs.html" class="nav-link">Документация</a>
|
||||||
</nav>
|
</nav>
|
||||||
<span class="stats" id="stats"></span>
|
<span class="stats" id="stats"></span>
|
||||||
|
|||||||
Reference in New Issue
Block a user