diff --git a/db/models/__init__.py b/db/models/__init__.py index fa53a3f..5e97433 100644 --- a/db/models/__init__.py +++ b/db/models/__init__.py @@ -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", ] diff --git a/db/models/eval_run.py b/db/models/eval_run.py new file mode 100644 index 0000000..85e0d5f --- /dev/null +++ b/db/models/eval_run.py @@ -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) diff --git a/docs/BACKLOG.md b/docs/BACKLOG.md new file mode 100644 index 0000000..249c8a2 --- /dev/null +++ b/docs/BACKLOG.md @@ -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 окажется недостаточно. diff --git a/docs/SPRINTS.md b/docs/SPRINTS.md index 7a9b3c2..ca17109 100644 --- a/docs/SPRINTS.md +++ b/docs/SPRINTS.md @@ -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:"`): `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` — 3–5 detour-и-возврат. +- [ ] `eval/loop_cases.jsonl` — 1–2 искусственная петля. +- [ ] `eval/guard_cases.jsonl` — `require_legal_rep`, `waitlist`. +- [ ] `eval/rag_cases.jsonl` — мульти-RAG. --- diff --git a/migrations/versions/k7e9d5c67h34_add_eval_runs.py b/migrations/versions/k7e9d5c67h34_add_eval_runs.py new file mode 100644 index 0000000..c2094bc --- /dev/null +++ b/migrations/versions/k7e9d5c67h34_add_eval_runs.py @@ -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') diff --git a/migrations/versions/l8f0e6d78i45_add_is_pass.py b/migrations/versions/l8f0e6d78i45_add_is_pass.py new file mode 100644 index 0000000..7d49718 --- /dev/null +++ b/migrations/versions/l8f0e6d78i45_add_is_pass.py @@ -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') diff --git a/models/responses.py b/models/responses.py index 83efeae..a050b17 100644 --- a/models/responses.py +++ b/models/responses.py @@ -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 diff --git a/routers/eval.py b/routers/eval.py index 150d9c1..a79beaf 100644 --- a/routers/eval.py +++ b/routers/eval.py @@ -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], + ), + ) diff --git a/services/eval_run_service.py b/services/eval_run_service.py new file mode 100644 index 0000000..d88b514 --- /dev/null +++ b/services/eval_run_service.py @@ -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) diff --git a/static/docs.html b/static/docs.html index a60100a..b0ab200 100644 --- a/static/docs.html +++ b/static/docs.html @@ -193,6 +193,7 @@ Отладка Песочница Настройки + Регрессия Документация diff --git a/static/index.html b/static/index.html index 53c0e09..e948c21 100644 --- a/static/index.html +++ b/static/index.html @@ -417,6 +417,7 @@ Отладка Песочница Настройки + Регрессия Документация проверяю… diff --git a/static/regression.html b/static/regression.html new file mode 100644 index 0000000..a66b68e --- /dev/null +++ b/static/regression.html @@ -0,0 +1,803 @@ + + + + + +Chat Agent for Patients — Регрессия + + + + +
+

Chat Agent for Patients

+ +
+ +
+

Регрессия роутера

+

Прогон одношаговых кейсов классификатора (1573 фразы из реальных диалогов) на активной версии промпта _router. Pass/fail сравниваются с ожидаемой веткой. Кэш ответов привязан к версии роутера: повторный прогон на той же версии — мгновенный.

+ +
+ + Выбор кейсов — загружаю… + +
+
+ + + + + + + + + выбрано 0 +
+
+ + + + + + + + + + + + + + +
#запросintentчастотакэш
— загружаю —
+
+
+ + Прогон идёт в фоне, можно свернуть и вернуться. +
+
+
+ +
+

История прогонов

+ + + + + + + + + + + + + + + + +
#СтартовалВерсия роутераTotalPassFailCacheСтатус
— загружаю —
+
+ + +
+ +
+ + + + + diff --git a/static/sandbox.html b/static/sandbox.html index d1f140b..7073ccd 100644 --- a/static/sandbox.html +++ b/static/sandbox.html @@ -543,6 +543,7 @@ Отладка Песочница Настройки + Регрессия Документация проверяю… diff --git a/static/settings.html b/static/settings.html index a936b3a..dc1a66c 100644 --- a/static/settings.html +++ b/static/settings.html @@ -865,6 +865,7 @@ Отладка Песочница Настройки + Регрессия Документация