Skip to content

Commit d8eec49

Browse files
aditykrisclaude
andcommitted
feat(evaluation): dataset upload, storage and deletion
`EvaluationService` gains the dataset half: an admin uploads a corpus plus a test set, both land under `<data_dir>/eval/<dataset_id>/`, and a row records what is there. - The CSV is parsed at upload, so a malformed test set is rejected while the admin is still looking at the dialog rather than after a run has already spent minutes indexing the corpus. - Uploads arrive as open binary streams, not `bytes`. Each is copied to disk in fixed-size blocks under a running byte budget, so a 512 MB corpus is a disk cost rather than a RAM one. The blocking I/O runs on a worker thread so it cannot stall the event loop. - The cap is enforced by reading one byte past it, not by trusting a client-supplied `Content-Length`. - Duplicate corpus basenames are rejected rather than silently overwriting — that would leave `corpus_file_count` overstating what a run will actually index. Path components in a filename are flattened. - A partial write is cleaned up: any failure after the directory is created removes it before the error propagates. - Deleting a dataset an active run is using is refused. The runner reads the corpus off disk for the whole indexing phase, so removing it mid-run would surface as a FileNotFoundError instead of a clear conflict. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01NxC8zdWBa33eHJpexaah2M
1 parent ac18d7c commit d8eec49

2 files changed

Lines changed: 268 additions & 0 deletions

File tree

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
"""EvaluationService — datasets on disk, runs dispatched to the worker layer.
2+
3+
This slice covers dataset storage: an admin uploads a corpus plus a test set,
4+
both land under ``<data_dir>/eval/<dataset_id>/``, and a row records what is
5+
there. Run dispatch follows.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
import asyncio
11+
import shutil
12+
import uuid
13+
from pathlib import Path
14+
from typing import TYPE_CHECKING
15+
16+
from core.evaluation import parse_testset
17+
from core.models.evaluation import EvalDataset
18+
from core.utils.exceptions import ConflictError, NotFoundError, ValidationError
19+
20+
if TYPE_CHECKING:
21+
from collections.abc import Sequence
22+
from typing import IO
23+
24+
from core.config.root import Settings
25+
from core.ports.evaluation_repo import EvaluationRepository
26+
27+
TESTSET_FILENAME = "testset.csv"
28+
CORPUS_DIRNAME = "corpus"
29+
30+
#: Block size for streaming an upload to disk.
31+
_COPY_CHUNK_BYTES = 1024 * 1024
32+
33+
34+
class EvaluationService:
35+
"""Dataset storage plus run dispatch for the admin evaluation page."""
36+
37+
def __init__(
38+
self,
39+
*,
40+
repo: EvaluationRepository,
41+
config: Settings,
42+
) -> None:
43+
self._repo = repo
44+
self._config = config
45+
self._settings = config.evaluation
46+
self._root = Path(config.paths.data_dir) / "eval"
47+
48+
# ── datasets ─────────────────────────────────────────────────────
49+
50+
def _dataset_dir(self, dataset_id: str) -> Path:
51+
return self._root / dataset_id
52+
53+
async def create_dataset(
54+
self,
55+
*,
56+
name: str,
57+
corpus: Sequence[tuple[str, IO[bytes]]],
58+
testset: IO[bytes],
59+
user_id: int | None,
60+
) -> EvalDataset:
61+
"""Validate and store a corpus + test set.
62+
63+
The CSV is parsed here so a malformed test set is rejected at upload
64+
rather than after a run has already spent minutes indexing.
65+
66+
Uploads arrive as open binary streams rather than ``bytes``: each is
67+
read under a size cap and copied to disk in fixed-size blocks, so a
68+
large corpus is never held in memory. The blocking file I/O runs on a
69+
worker thread so it cannot stall the event loop.
70+
"""
71+
if not name.strip():
72+
raise ValidationError("Dataset name is required.", status_code=400)
73+
if not corpus:
74+
raise ValidationError("At least one corpus file is required.", status_code=400)
75+
76+
testset_csv = await asyncio.to_thread(
77+
self._read_capped,
78+
testset,
79+
self._settings.max_testset_bytes,
80+
f"Test set exceeds the {self._settings.max_testset_mb} MB limit.",
81+
)
82+
cases = parse_testset(testset_csv, max_rows=self._settings.max_testset_rows)
83+
84+
dataset_id = uuid.uuid4().hex
85+
directory = self._dataset_dir(dataset_id)
86+
try:
87+
written = await asyncio.to_thread(self._store_upload, directory, corpus, testset_csv)
88+
return await self._repo.create_dataset(
89+
EvalDataset(
90+
id=dataset_id,
91+
name=name.strip(),
92+
corpus_file_count=written,
93+
testset_row_count=len(cases),
94+
created_by=user_id,
95+
)
96+
)
97+
except Exception:
98+
await asyncio.to_thread(shutil.rmtree, directory, True)
99+
raise
100+
101+
@staticmethod
102+
def _read_capped(stream: IO[bytes], limit: int, message: str) -> bytes:
103+
"""Read a stream, refusing anything past ``limit``.
104+
105+
Reads one byte beyond the cap rather than trusting a client-supplied
106+
length, so an inflated ``Content-Length`` cannot get past it.
107+
"""
108+
stream.seek(0)
109+
payload = stream.read(limit + 1)
110+
if len(payload) > limit:
111+
raise ValidationError(message, status_code=413)
112+
return payload
113+
114+
def _store_upload(
115+
self,
116+
directory: Path,
117+
corpus: Sequence[tuple[str, IO[bytes]]],
118+
testset_csv: bytes,
119+
) -> int:
120+
"""Write the corpus and test set to disk. Blocking; call in a thread."""
121+
corpus_dir = directory / CORPUS_DIRNAME
122+
corpus_dir.mkdir(parents=True, exist_ok=True)
123+
124+
written = 0
125+
budget = self._settings.max_corpus_bytes
126+
for filename, stream in corpus:
127+
# Flatten any path components a browser may have sent.
128+
target = corpus_dir / Path(filename).name
129+
if target.exists():
130+
raise ValidationError(
131+
f"Corpus contains more than one file named '{target.name}'.",
132+
status_code=400,
133+
)
134+
budget -= self._copy_within_budget(stream, target, budget)
135+
written += 1
136+
137+
(directory / TESTSET_FILENAME).write_bytes(testset_csv)
138+
return written
139+
140+
def _copy_within_budget(self, stream: IO[bytes], target: Path, budget: int) -> int:
141+
"""Copy ``stream`` into ``target``, refusing to exceed ``budget``."""
142+
stream.seek(0)
143+
written = 0
144+
with target.open("wb") as handle:
145+
while chunk := stream.read(_COPY_CHUNK_BYTES):
146+
written += len(chunk)
147+
if written > budget:
148+
raise ValidationError(
149+
f"Corpus exceeds the {self._settings.max_corpus_mb} MB limit.",
150+
status_code=413,
151+
)
152+
handle.write(chunk)
153+
return written
154+
155+
async def list_datasets(self) -> list[EvalDataset]:
156+
return await self._repo.list_datasets()
157+
158+
async def delete_dataset(self, dataset_id: str) -> None:
159+
"""Delete a dataset and its stored files.
160+
161+
Refused while a run is using it: the runner reads the corpus from disk
162+
for the whole indexing phase, so removing it mid-run would surface as a
163+
confusing FileNotFoundError instead of a clear conflict.
164+
"""
165+
active = await self._repo.active_run()
166+
if active is not None and active.dataset_id == dataset_id:
167+
raise ConflictError(f"Evaluation run '{active.id}' is still using this dataset.")
168+
169+
if not await self._repo.delete_dataset(dataset_id):
170+
raise NotFoundError(f"Evaluation dataset '{dataset_id}' not found")
171+
await asyncio.to_thread(shutil.rmtree, self._dataset_dir(dataset_id), True)
172+
173+
174+
__all__ = ["EvaluationService"]
Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
"""Tests for EvaluationService dataset storage."""
2+
3+
from __future__ import annotations
4+
5+
import pytest
6+
from core.models.evaluation import EvalDataset, EvalRun, EvalRunStatus
7+
from services.orchestrators.evaluation_service import EvaluationService
8+
9+
DATASET_ID = "ds1"
10+
11+
12+
class FakeRepo:
13+
def __init__(self, run: EvalRun | None = None) -> None:
14+
self.deleted_datasets: list[str] = []
15+
self.dataset = EvalDataset(id=DATASET_ID, name="d", corpus_file_count=1, testset_row_count=1)
16+
self.run = run
17+
18+
async def active_run(self):
19+
if self.run is not None and not self.run.status.is_terminal:
20+
return self.run
21+
return None
22+
23+
async def delete_dataset(self, dataset_id):
24+
self.deleted_datasets.append(dataset_id)
25+
return True
26+
27+
28+
def _service(repo, tmp_path=None, settings=None):
29+
from core.config.root import Settings
30+
31+
settings = settings or Settings()
32+
if tmp_path is not None:
33+
settings = settings.model_copy(update={"paths": settings.paths.model_copy(update={"data_dir": str(tmp_path)})})
34+
return EvaluationService(repo=repo, config=settings)
35+
36+
37+
def _dataset_on_disk(tmp_path):
38+
dataset_dir = tmp_path / "eval" / DATASET_ID
39+
(dataset_dir / "corpus").mkdir(parents=True)
40+
(dataset_dir / "testset.csv").write_text("question,expected_answer\nq,a\n", encoding="utf-8")
41+
42+
43+
@pytest.mark.asyncio
44+
async def test_deleting_a_dataset_in_use_is_refused(tmp_path):
45+
"""The runner reads the corpus off disk for the whole indexing phase, so
46+
removing it mid-run would surface as a FileNotFoundError."""
47+
from core.utils.exceptions import ConflictError
48+
49+
_dataset_on_disk(tmp_path)
50+
run = EvalRun(id="run-1", dataset_id=DATASET_ID, status=EvalRunStatus.INDEXING)
51+
repo = FakeRepo(run=run)
52+
service = _service(repo, tmp_path=tmp_path)
53+
54+
with pytest.raises(ConflictError):
55+
await service.delete_dataset(DATASET_ID)
56+
57+
assert repo.deleted_datasets == []
58+
assert (tmp_path / "eval" / DATASET_ID).exists(), "files must survive a refused delete"
59+
60+
61+
@pytest.mark.asyncio
62+
async def test_deleting_a_dataset_an_idle_run_used_is_allowed(tmp_path):
63+
"""Only an *active* run blocks deletion; history keeps its results."""
64+
_dataset_on_disk(tmp_path)
65+
run = EvalRun(id="run-1", dataset_id=DATASET_ID, status=EvalRunStatus.COMPLETED)
66+
repo = FakeRepo(run=run)
67+
service = _service(repo, tmp_path=tmp_path)
68+
69+
await service.delete_dataset(DATASET_ID)
70+
71+
assert repo.deleted_datasets == [DATASET_ID]
72+
assert not (tmp_path / "eval" / DATASET_ID).exists()
73+
74+
75+
@pytest.mark.asyncio
76+
async def test_an_oversized_test_set_is_rejected_without_buffering_it_all(tmp_path):
77+
"""The stream is read to one byte past the cap, not to its end."""
78+
import io
79+
80+
from core.utils.exceptions import ValidationError
81+
82+
service = _service(FakeRepo(), tmp_path=tmp_path)
83+
cap = service._settings.max_testset_bytes
84+
oversized = io.BytesIO(b"x" * (cap + 5000))
85+
86+
with pytest.raises(ValidationError) as excinfo:
87+
await service.create_dataset(
88+
name="d",
89+
corpus=[("a.txt", io.BytesIO(b"hello"))],
90+
testset=oversized,
91+
user_id=1,
92+
)
93+
assert excinfo.value.status_code == 413
94+
assert oversized.tell() <= cap + 1, "must stop reading once the cap is exceeded"

0 commit comments

Comments
 (0)