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
+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],
),
)