Skip to content

Commit d04cd51

Browse files
committed
feat(evaluation): eval_datasets and eval_runs persistence
The `EvaluationRepository` port, its asyncpg implementation, the two tables, and the idempotent migration that creates them. `eval_runs` carries the metric payloads as JSONB rather than columns: the metric set is expected to grow, and the dataclasses in `core.models.evaluation` already define their shape. The store is what enforces one run at a time. The partial unique index `ux_eval_runs_single_active` admits a single row in an active status, so `create_run` raises `ConflictError` on the loser of a race rather than the orchestrator reading and then inserting. That matters because starting a run regenerates a shared service user's token: two racing starts that both passed a read check would revoke each other's credentials. The migration is idempotent per this repo's alembic rules — `Base.metadata.create_all()` runs at startup, so a freshly bootstrapped database already has these tables before alembic sees them.
1 parent 367fc99 commit d04cd51

7 files changed

Lines changed: 467 additions & 0 deletions

File tree

openrag/core/ports/catalog_store.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
from .conversation_repo import ConversationRepository
1414
from .document_repo import DocumentRepository
1515
from .entity_repo import EntityRepository
16+
from .evaluation_repo import EvaluationRepository
1617
from .idempotency_repo import IdempotencyRepository
1718
from .job_repo import JobRepository
1819
from .model_endpoint_repo import ModelEndpointRepository
@@ -70,6 +71,10 @@ def model_endpoint_repo(self) -> ModelEndpointRepository: ...
7071
@abstractmethod
7172
def preset_repo(self) -> PresetRepository: ...
7273

74+
@property
75+
@abstractmethod
76+
def evaluation_repo(self) -> EvaluationRepository: ...
77+
7378
@property
7479
@abstractmethod
7580
def chunk_repo(self) -> ChunkRepository: ...
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
"""Port for evaluation dataset and run persistence."""
2+
3+
from __future__ import annotations
4+
5+
from abc import ABC, abstractmethod
6+
7+
from core.models.evaluation import EvalDataset, EvalRun, EvalRunStatus
8+
9+
10+
class EvaluationRepository(ABC):
11+
"""Storage contract for evaluation datasets and runs."""
12+
13+
@abstractmethod
14+
async def create_dataset(self, dataset: EvalDataset) -> EvalDataset:
15+
"""Persist a new dataset row."""
16+
17+
@abstractmethod
18+
async def list_datasets(self) -> list[EvalDataset]:
19+
"""All datasets, newest first."""
20+
21+
@abstractmethod
22+
async def get_dataset(self, dataset_id: str) -> EvalDataset | None:
23+
"""One dataset, or ``None`` when it does not exist."""
24+
25+
@abstractmethod
26+
async def delete_dataset(self, dataset_id: str) -> bool:
27+
"""Delete a dataset. Returns ``False`` when nothing was deleted."""
28+
29+
@abstractmethod
30+
async def create_run(self, run: EvalRun) -> EvalRun:
31+
"""Persist a queued run.
32+
33+
Raises:
34+
ConflictError: A run is already active. At most one may be, and the
35+
store is what enforces it.
36+
"""
37+
38+
@abstractmethod
39+
async def list_runs(self, limit: int = 50) -> list[EvalRun]:
40+
"""Recent runs, newest first."""
41+
42+
@abstractmethod
43+
async def get_run(self, run_id: str) -> EvalRun | None:
44+
"""One run with its metrics, or ``None``."""
45+
46+
@abstractmethod
47+
async def active_run(self) -> EvalRun | None:
48+
"""The run currently occupying the runner, if any.
49+
50+
Advisory only — mutual exclusion between starts is enforced by the
51+
store's single-active-run index, not by reading this.
52+
"""
53+
54+
@abstractmethod
55+
async def update_run_status(self, run_id: str, status: EvalRunStatus, *, error: str | None = None) -> None:
56+
"""Move a run to a new status, stamping ``finished_at`` when terminal."""
57+
58+
@abstractmethod
59+
async def save_run_results(self, run: EvalRun) -> None:
60+
"""Write the metric payloads and terminal status of a finished run."""
61+
62+
63+
__all__ = ["EvaluationRepository"]

openrag/di/container.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
from core.ports.conversation_repo import ConversationRepository
4646
from core.ports.document_repo import DocumentRepository
4747
from core.ports.entity_repo import EntityRepository
48+
from core.ports.evaluation_repo import EvaluationRepository
4849
from core.ports.idempotency_repo import IdempotencyRepository
4950
from core.ports.job_repo import JobRepository
5051
from core.ports.model_endpoint_repo import ModelEndpointRepository
@@ -360,6 +361,10 @@ def model_endpoint_repo(self) -> ModelEndpointRepository:
360361
def preset_repo(self) -> PresetRepository:
361362
return self.catalog_store.preset_repo
362363

364+
@property
365+
def evaluation_repo(self) -> EvaluationRepository:
366+
return self.catalog_store.evaluation_repo
367+
363368
# ------------------------------------------------------------------
364369
# Orchestrators (Phase 8)
365370
# ------------------------------------------------------------------
Lines changed: 210 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,210 @@
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

Comments
 (0)