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:
AR 15 M4
2026-05-02 20:39:22 +05:00
parent d5eccfc342
commit a8f7e68795
14 changed files with 1567 additions and 32 deletions
+2
View File
@@ -2,6 +2,7 @@ from db.models.agent_config import AgentConfig
from db.models.document import Document
from db.models.intent import Intent
from db.models.intent_document import IntentDocument
from db.models.eval_run import EvalRouterPrediction, EvalRun, EvalRunCase
from db.models.intent_step import IntentStep
from db.models.intent_step_graph import IntentStepGraph
from db.models.message import Message
@@ -11,4 +12,5 @@ from db.models.thread_state import ThreadState
__all__ = [
"Thread", "Message", "Document", "AgentConfig", "Intent",
"IntentDocument", "IntentStep", "IntentStepGraph", "ThreadState",
"EvalRun", "EvalRunCase", "EvalRouterPrediction",
]
+75
View File
@@ -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)
+28
View File
@@ -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
View File
@@ -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.
- [x] `eval/router_cases_booking.jsonl` + `eval/router_cases_other.jsonl` — одношаговые кейсы маршрутизатора (875 + 698, собраны из реальных диалогов конкурента, см. `eval/README.md`). Схема: `{text, expected_intent, expected_reason?, count, note?}`. CSV-версии сохранены рядом для совместимости.
- [ ] `eval/handoff_cases.jsonl` — 5–10 многошаговых мини-диалогов: реплики пациента по порядку + ожидаемая активная ветка / решение маршрутизатора / приостановленная ветка / счётчик переключений на каждом шаге.
- [ ] `eval/resumable_cases.jsonl` — 3–5 сценариев detour-и-возврат: реплики + ожидаемые `current_intent`, `current_step`, ключевые слоты на каждом шаге.
- [ ] `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; после — сравнить.
**UI (`static/regression.html` + новая вкладка «Регрессия» в шапках):**
- [x] Сворачиваемый блок «Выбор кейсов»: фильтр по intent, ввод диапазона (`1-50, 200-300`), кнопки массового выбора (Все / Снять / Только без кэша / Только FAIL в кэше / Снять кэшированные).
- [x] Таблица 1573 кейсов (отсортированы по count desc): #, чекбокс, запрос, intent, частота, кэш (PASS / FAIL → predicted / —). Цветной фон строки.
- [x] Счётчик «выбрано N (новых: X, в кэше: Y)»; кнопка «Прогнать выбранное (X новых + Y из кэша)».
- [x] История прогонов с polling раз в 2 секунды, прогресс-бар, drill-down: все кейсы прогона + фильтр pass/fail + поиск + diff vs предыдущий.
### Критерий готовности
- [ ] `eval/run.py` работает одной командой, режим `router` проходит за ≤ 30 секунд (на `count >= 2`), режим `dialog` — за ≤ 3 минуты.
- [ ] Отчёт покрывает все 8 сценариев из блока H Спринта 6 + одношаговые кейсы маршрутизатора + RAG-проверки Спринта 7.
- [ ] Baseline зафиксирован в `eval/reports/{date}_baseline.md` и добавлен в git.
- [x] На пустой версии роутера прогон 50 кейсов за ~1 минуту, повторный — мгновенный.
- [x] При активации новой версии `_router` — кэш пуст, прогон полный.
- [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` — 35 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')
+43
View File
@@ -262,3 +262,46 @@ class IntentStepGraphListResponse(BaseModel):
graphs: list[IntentStepGraphInfo]
active_graph_id: int | None
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
View File
@@ -1,15 +1,28 @@
"""Эндпоинты вокруг eval-наборов (Спринт 8 — заготовка под мини-eval).
"""Эндпоинты eval-наборов и прогонов регрессии (Спринты 7.5 + 8a).
Сейчас отдаёт только готовые кейсы маршрутизатора для интеграции в тест-блок
страницы Настроек: оператор может выбрать готовую фразу пациента из реального
корпуса вместо того, чтобы придумывать руками. Полная подсистема прогона
(`eval/run.py`, метрики, отчёты) в Спринте 8.
- `/eval/router-cases` список кейсов классификатора для UI Настроек (готовые
фразы пациентов из реального корпуса).
- `/eval/runs` прогоны регрессии роутера (Спринт 8a). POST запускает фоновый
прогон, GET возвращает историю и детали.
"""
import json
import logging
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__)
@@ -86,3 +99,122 @@ def list_router_cases(intent_code: str | None = None, limit: int = 500):
"total": len(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],
),
)
+287
View File
@@ -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)
+1
View File
@@ -193,6 +193,7 @@
<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">Регрессия</a>
<a href="/docs.html" class="nav-link active">Документация</a>
</nav>
</header>
+1
View File
@@ -417,6 +417,7 @@
<a href="/" class="nav-link active">Отладка</a>
<a href="/sandbox.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>
</nav>
<span class="status"><span class="dot" id="dot"></span><span id="status-text">проверяю…</span></span>
+803
View File
@@ -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 => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[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>
+1
View File
@@ -543,6 +543,7 @@
<a href="/" class="nav-link">Отладка</a>
<a href="/sandbox.html" class="nav-link active">Песочница</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>
</nav>
<span class="status" style="margin-left:auto;"><span class="dot" id="dot"></span><span id="status-text">проверяю…</span></span>
+1
View File
@@ -865,6 +865,7 @@
<a href="/" class="nav-link">Отладка</a>
<a href="/sandbox.html" class="nav-link">Песочница</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>
</nav>
<span class="stats" id="stats"></span>