|
| 1 | +"""asyncpg-backed :class:`EvaluationRepository`. |
| 2 | +
|
| 3 | +Backs the ``eval_datasets`` and ``eval_runs`` tables. Metric payloads round-trip |
| 4 | +as JSONB; the dataclasses in ``core.models.evaluation`` define their shape. |
| 5 | +""" |
| 6 | + |
| 7 | +from __future__ import annotations |
| 8 | + |
| 9 | +import json |
| 10 | +from collections.abc import Callable |
| 11 | +from dataclasses import asdict |
| 12 | +from typing import TYPE_CHECKING, Any |
| 13 | + |
| 14 | +from core.models.evaluation import ( |
| 15 | + AnswerMetrics, |
| 16 | + EvalCaseResult, |
| 17 | + EvalDataset, |
| 18 | + EvalRun, |
| 19 | + EvalRunStatus, |
| 20 | + FileIndexingSample, |
| 21 | + IndexingMetrics, |
| 22 | + RetrievalMetrics, |
| 23 | +) |
| 24 | +from core.ports.evaluation_repo import EvaluationRepository |
| 25 | +from core.utils.exceptions import ConflictError |
| 26 | + |
| 27 | +if TYPE_CHECKING: |
| 28 | + import asyncpg |
| 29 | + |
| 30 | +_ACTIVE_STATUSES = ("QUEUED", "INDEXING", "EVALUATING") |
| 31 | + |
| 32 | + |
| 33 | +def _dump(payload: Any) -> str | None: |
| 34 | + """Serialise a metrics dataclass — or a list of them — for a JSONB column.""" |
| 35 | + if payload is None: |
| 36 | + return None |
| 37 | + if isinstance(payload, list): |
| 38 | + return json.dumps([asdict(item) for item in payload]) |
| 39 | + return json.dumps(asdict(payload) if hasattr(payload, "__dataclass_fields__") else payload) |
| 40 | + |
| 41 | + |
| 42 | +def _load(raw: Any) -> Any: |
| 43 | + """asyncpg returns JSONB as ``str`` unless a codec is registered.""" |
| 44 | + if raw is None: |
| 45 | + return None |
| 46 | + return json.loads(raw) if isinstance(raw, str | bytes) else raw |
| 47 | + |
| 48 | + |
| 49 | +class PgEvaluationRepository(EvaluationRepository): |
| 50 | + """asyncpg-backed implementation of :class:`EvaluationRepository`.""" |
| 51 | + |
| 52 | + def __init__(self, pool_getter: Callable[[], asyncpg.Pool]) -> None: |
| 53 | + self._pool_getter = pool_getter |
| 54 | + |
| 55 | + @property |
| 56 | + def pool(self) -> asyncpg.Pool: |
| 57 | + return self._pool_getter() |
| 58 | + |
| 59 | + # ── datasets ───────────────────────────────────────────────────── |
| 60 | + |
| 61 | + async def create_dataset(self, dataset: EvalDataset) -> EvalDataset: |
| 62 | + row = await self.pool.fetchrow( |
| 63 | + """ |
| 64 | + INSERT INTO eval_datasets (id, name, corpus_file_count, |
| 65 | + testset_row_count, created_by) |
| 66 | + VALUES ($1, $2, $3, $4, $5) |
| 67 | + RETURNING * |
| 68 | + """, |
| 69 | + dataset.id, |
| 70 | + dataset.name, |
| 71 | + dataset.corpus_file_count, |
| 72 | + dataset.testset_row_count, |
| 73 | + dataset.created_by, |
| 74 | + ) |
| 75 | + return self._row_to_dataset(row) |
| 76 | + |
| 77 | + async def list_datasets(self) -> list[EvalDataset]: |
| 78 | + rows = await self.pool.fetch("SELECT * FROM eval_datasets ORDER BY created_at DESC") |
| 79 | + return [self._row_to_dataset(row) for row in rows] |
| 80 | + |
| 81 | + async def get_dataset(self, dataset_id: str) -> EvalDataset | None: |
| 82 | + row = await self.pool.fetchrow("SELECT * FROM eval_datasets WHERE id = $1", dataset_id) |
| 83 | + return self._row_to_dataset(row) if row else None |
| 84 | + |
| 85 | + async def delete_dataset(self, dataset_id: str) -> bool: |
| 86 | + result = await self.pool.execute("DELETE FROM eval_datasets WHERE id = $1", dataset_id) |
| 87 | + return result.endswith(" 1") |
| 88 | + |
| 89 | + # ── runs ───────────────────────────────────────────────────────── |
| 90 | + |
| 91 | + async def create_run(self, run: EvalRun) -> EvalRun: |
| 92 | + import asyncpg |
| 93 | + |
| 94 | + try: |
| 95 | + row = await self.pool.fetchrow( |
| 96 | + """ |
| 97 | + INSERT INTO eval_runs (id, dataset_id, status, created_by) |
| 98 | + VALUES ($1, $2, $3, $4) |
| 99 | + RETURNING * |
| 100 | + """, |
| 101 | + run.id, |
| 102 | + run.dataset_id, |
| 103 | + run.status.value, |
| 104 | + run.created_by, |
| 105 | + ) |
| 106 | + except asyncpg.UniqueViolationError as exc: |
| 107 | + # ux_eval_runs_single_active: a run is already in flight. |
| 108 | + raise ConflictError("An evaluation run is already in progress.") from exc |
| 109 | + return self._row_to_run(row) |
| 110 | + |
| 111 | + async def list_runs(self, limit: int = 50) -> list[EvalRun]: |
| 112 | + rows = await self.pool.fetch("SELECT * FROM eval_runs ORDER BY started_at DESC LIMIT $1", limit) |
| 113 | + return [self._row_to_run(row) for row in rows] |
| 114 | + |
| 115 | + async def get_run(self, run_id: str) -> EvalRun | None: |
| 116 | + row = await self.pool.fetchrow("SELECT * FROM eval_runs WHERE id = $1", run_id) |
| 117 | + return self._row_to_run(row) if row else None |
| 118 | + |
| 119 | + async def active_run(self) -> EvalRun | None: |
| 120 | + row = await self.pool.fetchrow( |
| 121 | + """ |
| 122 | + SELECT * FROM eval_runs |
| 123 | + WHERE status = ANY($1::text[]) |
| 124 | + ORDER BY started_at DESC |
| 125 | + LIMIT 1 |
| 126 | + """, |
| 127 | + list(_ACTIVE_STATUSES), |
| 128 | + ) |
| 129 | + return self._row_to_run(row) if row else None |
| 130 | + |
| 131 | + async def update_run_status(self, run_id: str, status: EvalRunStatus, *, error: str | None = None) -> None: |
| 132 | + await self.pool.execute( |
| 133 | + """ |
| 134 | + UPDATE eval_runs |
| 135 | + SET status = $2, |
| 136 | + error = COALESCE($3, error), |
| 137 | + finished_at = CASE WHEN $4 THEN now() ELSE finished_at END |
| 138 | + WHERE id = $1 |
| 139 | + """, |
| 140 | + run_id, |
| 141 | + status.value, |
| 142 | + error, |
| 143 | + status.is_terminal, |
| 144 | + ) |
| 145 | + |
| 146 | + async def save_run_results(self, run: EvalRun) -> None: |
| 147 | + await self.pool.execute( |
| 148 | + """ |
| 149 | + UPDATE eval_runs |
| 150 | + SET status = $2, |
| 151 | + indexing = $3::jsonb, |
| 152 | + retrieval = $4::jsonb, |
| 153 | + answer = $5::jsonb, |
| 154 | + cases = $6::jsonb, |
| 155 | + error = $7, |
| 156 | + finished_at = now() |
| 157 | + WHERE id = $1 |
| 158 | + """, |
| 159 | + run.id, |
| 160 | + run.status.value, |
| 161 | + _dump(run.indexing), |
| 162 | + _dump(run.retrieval), |
| 163 | + _dump(run.answer), |
| 164 | + _dump(run.cases), |
| 165 | + run.error, |
| 166 | + ) |
| 167 | + |
| 168 | + # ── row mapping ────────────────────────────────────────────────── |
| 169 | + |
| 170 | + @staticmethod |
| 171 | + def _row_to_dataset(row: Any) -> EvalDataset: |
| 172 | + return EvalDataset( |
| 173 | + id=row["id"], |
| 174 | + name=row["name"], |
| 175 | + corpus_file_count=row["corpus_file_count"], |
| 176 | + testset_row_count=row["testset_row_count"], |
| 177 | + created_at=row["created_at"], |
| 178 | + created_by=row["created_by"], |
| 179 | + ) |
| 180 | + |
| 181 | + @staticmethod |
| 182 | + def _row_to_run(row: Any) -> EvalRun: |
| 183 | + indexing = _load(row["indexing"]) |
| 184 | + retrieval = _load(row["retrieval"]) |
| 185 | + answer = _load(row["answer"]) |
| 186 | + cases = _load(row["cases"]) or [] |
| 187 | + samples = indexing.pop("samples", []) if indexing else [] |
| 188 | + return EvalRun( |
| 189 | + id=row["id"], |
| 190 | + dataset_id=row["dataset_id"], |
| 191 | + status=EvalRunStatus(row["status"]), |
| 192 | + started_at=row["started_at"], |
| 193 | + finished_at=row["finished_at"], |
| 194 | + indexing=( |
| 195 | + IndexingMetrics( |
| 196 | + **indexing, |
| 197 | + samples=[FileIndexingSample(**sample) for sample in samples], |
| 198 | + ) |
| 199 | + if indexing |
| 200 | + else None |
| 201 | + ), |
| 202 | + retrieval=RetrievalMetrics(**retrieval) if retrieval else None, |
| 203 | + answer=AnswerMetrics(**answer) if answer else None, |
| 204 | + cases=[EvalCaseResult(**case) for case in cases], |
| 205 | + error=row["error"], |
| 206 | + created_by=row["created_by"], |
| 207 | + ) |
| 208 | + |
| 209 | + |
| 210 | +__all__ = ["PgEvaluationRepository"] |
0 commit comments