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:
+138
-6
@@ -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],
|
||||
),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user