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>
This commit is contained in:
@@ -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_branch_run import EvalBranchPrediction, EvalBranchRun, EvalBranchRunCase
|
||||
from db.models.eval_run import EvalRouterPrediction, EvalRun, EvalRunCase
|
||||
from db.models.intent_step import IntentStep
|
||||
from db.models.intent_step_graph import IntentStepGraph
|
||||
@@ -13,4 +14,5 @@ __all__ = [
|
||||
"Thread", "Message", "Document", "AgentConfig", "Intent",
|
||||
"IntentDocument", "IntentStep", "IntentStepGraph", "ThreadState",
|
||||
"EvalRun", "EvalRunCase", "EvalRouterPrediction",
|
||||
"EvalBranchRun", "EvalBranchRunCase", "EvalBranchPrediction",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
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 EvalBranchRun(Base):
|
||||
"""Прогон регрессии конкретной ветки (Спринт 8b).
|
||||
|
||||
Параллельная сущность к `EvalRun`: тот валидирует роутер (один
|
||||
intent-код в ответе), этот — содержимое ответа конкретной ветки
|
||||
(RAG-секции + keywords). Активная версия промпта ветки фиксируется
|
||||
в `branch_config_id`, кэш ответов привязан к ней — повторный прогон
|
||||
на той же версии мгновенный.
|
||||
"""
|
||||
__tablename__ = "eval_branch_runs"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
suite: Mapped[str] = mapped_column(String(80), nullable=False)
|
||||
intent_code: Mapped[str] = mapped_column(String(50), nullable=False, index=True)
|
||||
branch_config_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("agent_configs.id", ondelete="SET NULL"), nullable=True, index=True
|
||||
)
|
||||
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 EvalBranchRunCase(Base):
|
||||
"""Один кейс прогона ветки. Хранятся все: pass и fail (для фильтра в UI)."""
|
||||
__tablename__ = "eval_branch_run_cases"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
run_id: Mapped[int] = mapped_column(
|
||||
ForeignKey("eval_branch_runs.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
text: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
coverage: Mapped[str] = mapped_column(String(20), nullable=False, default="covered")
|
||||
expected_doc_section: Mapped[str | None] = mapped_column(String(300), nullable=True)
|
||||
expected_keywords_json: Mapped[str] = mapped_column(Text, nullable=False, default="[]")
|
||||
expected_must_not_json: Mapped[str] = mapped_column(Text, nullable=False, default="[]")
|
||||
keywords_min: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
predicted_answer: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
||||
predicted_sections_json: Mapped[str] = mapped_column(Text, nullable=False, default="[]")
|
||||
is_pass: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
fail_reasons_json: Mapped[str] = mapped_column(Text, nullable=False, default="[]")
|
||||
count_weight: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
||||
|
||||
|
||||
class EvalBranchPrediction(Base):
|
||||
"""Кэш single-turn ответа ветки: (text_hash, branch_config_id) → {answer, sections}.
|
||||
|
||||
При активации новой версии ветки кэш для неё пуст; повторный прогон на
|
||||
той же версии берёт всё из кэша, не дёргая LLM.
|
||||
"""
|
||||
__tablename__ = "eval_branch_predictions"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
text_hash: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||
branch_config_id: Mapped[int | None] = mapped_column(
|
||||
ForeignKey("agent_configs.id", ondelete="CASCADE"), nullable=True, index=True
|
||||
)
|
||||
answer_text: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
||||
retrieved_sections_json: Mapped[str] = mapped_column(Text, nullable=False, default="[]")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), default=_utcnow, nullable=False)
|
||||
@@ -1,11 +1,26 @@
|
||||
from collections.abc import AsyncIterator
|
||||
|
||||
from sqlalchemy import event
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
|
||||
from config import settings
|
||||
|
||||
engine = create_async_engine(settings.database_url, echo=False, future=True)
|
||||
|
||||
|
||||
# В SQLite ondelete=CASCADE / SET NULL не работают, пока для каждого подключения
|
||||
# не включить PRAGMA foreign_keys=ON (по умолчанию выключено). aiosqlite не делает
|
||||
# это автоматически. Без этого, например, удаление документа не очищало подписки
|
||||
# в `intent_documents` — обнаружено в Спринте 8b.
|
||||
@event.listens_for(engine.sync_engine, "connect")
|
||||
def _enable_sqlite_foreign_keys(dbapi_connection, _connection_record):
|
||||
cursor = dbapi_connection.cursor()
|
||||
try:
|
||||
cursor.execute("PRAGMA foreign_keys=ON")
|
||||
finally:
|
||||
cursor.close()
|
||||
|
||||
|
||||
SessionLocal = async_sessionmaker(engine, expire_on_commit=False, class_=AsyncSession)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user