Files
RAG_helper/migrations/versions/m9g1f7e89j56_add_eval_branch_runs.py
T
AR 15 M4 bb5e3f5eb3 feat(sprint8b): регрессия ответов веток · general_info + фикс PRAGMA foreign_keys
Параллель к 8a, но проверяем не код intent от роутера, а содержимое ответа
конкретной ветки на одиночную реплику. Старт — general_info, 46 кейсов.

Логика pass/fail (для одного кейса):
- A — RAG-секция: среди retrieved-чанков есть кусок с
  section == expected_doc_section (точное совпадение). Если поле не задано —
  пропускаем.
- B — keywords: обязательные expected_keywords встречаются в predicted_answer
  (case-insensitive). По умолчанию все; поддерживаются keywords_min: N
  и keywords_any: true. Запрещённые expected_must_not — ни одного.
- Pass = A ∧ B. Незаданные поля не проверяются.
- Кэш: (text_hash, branch_config_id) → {answer_text, retrieved_sections}.
  Привязан к версии промпта ветки. Смена версии = пустой кэш = свежий прогон.
  Правка JSONL без изменения text → pass/fail пересчитывается без LLM.

Backend:
- Таблицы eval_branch_runs / eval_branch_run_cases / eval_branch_predictions.
  Миграция m9g1f7e89j56.
- services/eval_branch_run_service.py: загрузка JSONL, фоновый прогон через
  asyncio.create_task, кэш, оценка A+B с поддержкой keywords_min/keywords_any.
- chat_service.run_branch_single_turn — изолированный single-turn без
  роутера и треда (использует существующий config_service + vectorstore + llm).
- API: POST /eval/branch-runs, GET /eval/branch-runs?intent_code=,
  GET /eval/branch-runs/{id}, GET /eval/branch-cases-with-status?intent_code=.

UI (static/regression.html):
- Селектор режима «Роутер / Ветка · general_info». Логика пикера переиспользуется
  (фильтры, диапазон, массовый выбор, счётчик «новые / в кэше»).
- Для режима «Ветка»: фильтр по coverage, колонки секция/coverage, keywords,
  частота, кэш. Drill-down прогона: ожидание, retrieved-секции, причины fail,
  полный ответ ветки.

База кейсов (eval/branch_cases_general_info.jsonl) — от пользователя, 46 кейсов
по схеме {text, intent, coverage, expected_doc_section?, expected_keywords?,
expected_must_not?, keywords_min?, keywords_any?, count?, note?}.

Связанная правка SQLite (нашли при удалении документа в этом спринте):
- db/session.py: connect-listener PRAGMA foreign_keys=ON на каждое подключение.
  Без этого ondelete=CASCADE в SQLite не enforced, и удаление документа
  оставляло подписки в intent_documents висячими (что давало пустой RAG
  и fail регрессии).
- Миграция n0h2g8f9a0k67 — одноразовая чистка существующих висячих подписок.

docs/SPRINTS.md: Спринт 8b →  Закрыт. Diff vs предыдущий прогон для веток
и кнопка «Сбросить кэш регрессии» вынесены в docs/BACKLOG.md.

Также включены обновлённые data/datasets/general_info.md и price_question.md
(рабочий материал оператора), и черновик eval/branch_cases_price_question.jsonl
для следующего захода (8b на price_question).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-03 01:20:59 +05:00

93 lines
4.5 KiB
Python

"""add eval_branch_runs / cases / predictions (Спринт 8b — регрессия ответов веток)
Revision ID: m9g1f7e89j56
Revises: l8f0e6d78i45
Create Date: 2026-05-02 21:30:00.000000
Параллельная сущность к eval_runs (роутер): тут проверяем содержимое ответа
конкретной ветки. Кэш — по (text_hash, branch_config_id), хранит answer_text
и retrieved_sections для drill-down в UI.
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
revision: str = 'm9g1f7e89j56'
down_revision: Union[str, None] = 'l8f0e6d78i45'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
op.create_table(
'eval_branch_runs',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('suite', sa.String(length=80), nullable=False),
sa.Column('intent_code', sa.String(length=50), nullable=False),
sa.Column('branch_config_id', sa.Integer(), nullable=True),
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(['branch_config_id'], ['agent_configs.id'], ondelete='SET NULL'),
sa.PrimaryKeyConstraint('id'),
)
op.create_index('ix_eval_branch_runs_intent_code', 'eval_branch_runs', ['intent_code'])
op.create_index('ix_eval_branch_runs_branch_config_id', 'eval_branch_runs', ['branch_config_id'])
op.create_table(
'eval_branch_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('coverage', sa.String(length=20), nullable=False),
sa.Column('expected_doc_section', sa.String(length=300), nullable=True),
sa.Column('expected_keywords_json', sa.Text(), nullable=False),
sa.Column('expected_must_not_json', sa.Text(), nullable=False),
sa.Column('keywords_min', sa.Integer(), nullable=True),
sa.Column('predicted_answer', sa.Text(), nullable=False),
sa.Column('predicted_sections_json', sa.Text(), nullable=False),
sa.Column('is_pass', sa.Boolean(), nullable=False),
sa.Column('fail_reasons_json', sa.Text(), nullable=False),
sa.Column('count_weight', sa.Integer(), nullable=False),
sa.ForeignKeyConstraint(['run_id'], ['eval_branch_runs.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
)
op.create_index('ix_eval_branch_run_cases_run_id', 'eval_branch_run_cases', ['run_id'])
op.create_table(
'eval_branch_predictions',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('text_hash', sa.String(length=64), nullable=False),
sa.Column('branch_config_id', sa.Integer(), nullable=True),
sa.Column('answer_text', sa.Text(), nullable=False),
sa.Column('retrieved_sections_json', sa.Text(), nullable=False),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.ForeignKeyConstraint(['branch_config_id'], ['agent_configs.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('text_hash', 'branch_config_id', name='uq_eval_branch_pred_hash_cfg'),
)
op.create_index('ix_eval_branch_predictions_text_hash', 'eval_branch_predictions', ['text_hash'])
op.create_index(
'ix_eval_branch_predictions_branch_config_id',
'eval_branch_predictions',
['branch_config_id'],
)
def downgrade() -> None:
op.drop_index('ix_eval_branch_predictions_branch_config_id', table_name='eval_branch_predictions')
op.drop_index('ix_eval_branch_predictions_text_hash', table_name='eval_branch_predictions')
op.drop_table('eval_branch_predictions')
op.drop_index('ix_eval_branch_run_cases_run_id', table_name='eval_branch_run_cases')
op.drop_table('eval_branch_run_cases')
op.drop_index('ix_eval_branch_runs_branch_config_id', table_name='eval_branch_runs')
op.drop_index('ix_eval_branch_runs_intent_code', table_name='eval_branch_runs')
op.drop_table('eval_branch_runs')