Files
AR 15 M4 a8f7e68795 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>
2026-05-02 20:39:22 +05:00

76 lines
4.0 KiB
Python

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)