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:
@@ -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')
|
||||
Reference in New Issue
Block a user