"""Эндпоинты вокруг eval-наборов (Спринт 8 — заготовка под мини-eval). Сейчас отдаёт только готовые кейсы маршрутизатора для интеграции в тест-блок страницы Настроек: оператор может выбрать готовую фразу пациента из реального корпуса вместо того, чтобы придумывать руками. Полная подсистема прогона (`eval/run.py`, метрики, отчёты) — в Спринте 8. """ import json import logging from pathlib import Path from fastapi import APIRouter, HTTPException logger = logging.getLogger(__name__) router = APIRouter(prefix="/eval", tags=["eval"]) EVAL_DIR = Path(__file__).resolve().parent.parent / "eval" ROUTER_CASE_FILES = [ EVAL_DIR / "router_cases_booking.jsonl", EVAL_DIR / "router_cases_other.jsonl", ] # Кэш загруженных кейсов: грузим один раз при первом запросе. Файлы JSONL не # меняются на лету (это часть репо), поэтому горячая перезагрузка не нужна. _router_cases_cache: list[dict] | None = None def _load_router_cases() -> list[dict]: global _router_cases_cache if _router_cases_cache is not None: return _router_cases_cache all_cases: list[dict] = [] for path in ROUTER_CASE_FILES: if not path.is_file(): logger.warning("Router case file not found: %s", path) continue with path.open(encoding="utf-8") as f: for line_no, raw in enumerate(f, 1): line = raw.strip() if not line: continue try: data = json.loads(line) except json.JSONDecodeError as e: logger.warning("%s:%d JSON decode error: %s", path.name, line_no, e) continue all_cases.append(data) _router_cases_cache = all_cases logger.info("Loaded %d router eval cases from %d file(s)", len(all_cases), len(ROUTER_CASE_FILES)) return all_cases @router.get("/router-cases") def list_router_cases(intent_code: str | None = None, limit: int = 500): """Список кейсов маршрутизатора, опционально с фильтром по `expected_intent`. Сортировка — по `count` desc (самые частотные фразы вверху). `limit` ограничивает объём, чтобы UI не давился на 800+ опциях datalist. """ cases = _load_router_cases() if intent_code: filtered = [c for c in cases if c.get("expected_intent") == intent_code] else: filtered = list(cases) filtered.sort(key=lambda c: c.get("count", 0), reverse=True) filtered = filtered[:max(1, min(limit, 5000))] items = [ { "text": c.get("text", ""), "expected_intent": c.get("expected_intent", ""), "count": int(c.get("count", 0) or 0), "note": c.get("note") or None, } for c in filtered ] return { "intent_code": intent_code, "total": len(items), "cases": items, }