valcore 0.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
valcore/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """valcore: a local tool for developing, improving, and running agentic evaluations."""
@@ -0,0 +1 @@
1
+ """FastAPI application layer for valcore. Never imported by the valcore library."""
valcore/api/deps.py ADDED
@@ -0,0 +1,18 @@
1
+ """FastAPI dependencies shared across resource routers."""
2
+
3
+ import functools
4
+
5
+ from valcore.settings import get_settings
6
+ from valcore.store import Store, create_engine, init_db
7
+
8
+
9
+ @functools.lru_cache
10
+ def get_store() -> Store:
11
+ """Return the process-wide Store, creating the engine and tables on first use.
12
+
13
+ Cached so a single engine is shared across requests. Tests override this via
14
+ ``app.dependency_overrides[get_store]``.
15
+ """
16
+ engine = create_engine(get_settings().db_path)
17
+ init_db(engine)
18
+ return Store(engine)
valcore/api/dtos.py ADDED
@@ -0,0 +1,21 @@
1
+ """Pydantic request/response schemas for the API surface.
2
+
3
+ These are kept separate from the SQLModel entities so responses never leak raw SQL
4
+ objects or secrets. Only the shared error envelope lives here; resource routers define
5
+ their own request/response models.
6
+ """
7
+
8
+ from pydantic import BaseModel
9
+
10
+
11
+ class ErrorBody(BaseModel):
12
+ """The inner error payload: the exception class name and its message."""
13
+
14
+ type: str
15
+ message: str
16
+
17
+
18
+ class ErrorResponse(BaseModel):
19
+ """Uniform error envelope returned by every exception handler."""
20
+
21
+ error: ErrorBody
valcore/api/events.py ADDED
@@ -0,0 +1,91 @@
1
+ """In-process pub/sub so the runner can push run progress to SSE subscribers."""
2
+
3
+ import asyncio
4
+ from collections.abc import AsyncIterator
5
+
6
+ _QUEUE_MAXSIZE = 256
7
+ _CLOSE_SENTINEL: dict = {}
8
+
9
+
10
+ def _evict_oldest_progress(queue: "asyncio.Queue[dict]") -> bool:
11
+ """Drop the oldest ``progress`` event from a full queue, preserving order and others.
12
+
13
+ Returns True if a progress event was dropped, making room for a new event.
14
+ """
15
+ buffered: list[dict] = []
16
+ while True:
17
+ try:
18
+ buffered.append(queue.get_nowait())
19
+ except asyncio.QueueEmpty:
20
+ break
21
+ dropped = False
22
+ for event in buffered:
23
+ if not dropped and event.get("type") == "progress":
24
+ dropped = True
25
+ continue
26
+ queue.put_nowait(event)
27
+ return dropped
28
+
29
+
30
+ class EventBus:
31
+ """Fan-out of per-run events to each subscriber's own bounded queue.
32
+
33
+ Slow subscribers never block publishers: when a queue is full the oldest
34
+ ``progress`` event is dropped to make room. ``row``, ``started``, and ``finished``
35
+ events are never dropped.
36
+ """
37
+
38
+ def __init__(self) -> None:
39
+ self._subscribers: dict[str, list[asyncio.Queue[dict]]] = {}
40
+
41
+ def publish(self, run_id: str, event: dict) -> None:
42
+ """Deliver an event to every subscriber of a run without blocking."""
43
+ for queue in list(self._subscribers.get(run_id, ())):
44
+ self._offer(queue, event)
45
+
46
+ def _offer(self, queue: "asyncio.Queue[dict]", event: dict) -> None:
47
+ """Enqueue an event, evicting old progress under pressure rather than blocking."""
48
+ try:
49
+ queue.put_nowait(event)
50
+ return
51
+ except asyncio.QueueFull:
52
+ pass
53
+ if event.get("type") == "progress":
54
+ return
55
+ if _evict_oldest_progress(queue):
56
+ try:
57
+ queue.put_nowait(event)
58
+ except asyncio.QueueFull:
59
+ pass
60
+
61
+ async def subscribe(self, run_id: str) -> AsyncIterator[dict]:
62
+ """Yield events published to ``run_id`` until the run is closed."""
63
+ queue: asyncio.Queue[dict] = asyncio.Queue(maxsize=_QUEUE_MAXSIZE)
64
+ self._subscribers.setdefault(run_id, []).append(queue)
65
+ try:
66
+ while True:
67
+ event = await queue.get()
68
+ if event is _CLOSE_SENTINEL:
69
+ return
70
+ yield event
71
+ finally:
72
+ subscribers = self._subscribers.get(run_id)
73
+ if subscribers is not None and queue in subscribers:
74
+ subscribers.remove(queue)
75
+ if subscribers is not None and not subscribers:
76
+ self._subscribers.pop(run_id, None)
77
+
78
+ def close(self, run_id: str) -> None:
79
+ """Signal every subscriber of a run to stop iterating."""
80
+ for queue in list(self._subscribers.get(run_id, ())):
81
+ try:
82
+ queue.put_nowait(_CLOSE_SENTINEL)
83
+ except asyncio.QueueFull:
84
+ _evict_oldest_progress(queue)
85
+ try:
86
+ queue.put_nowait(_CLOSE_SENTINEL)
87
+ except asyncio.QueueFull:
88
+ pass
89
+
90
+
91
+ bus = EventBus()
valcore/api/main.py ADDED
@@ -0,0 +1,113 @@
1
+ """FastAPI application factory: CORS, exception handlers, health/config, routers, static SPA."""
2
+
3
+ import importlib
4
+ from importlib.resources import files as _package_files
5
+ from pathlib import Path
6
+
7
+ from fastapi import FastAPI, Request
8
+ from fastapi.middleware.cors import CORSMiddleware
9
+ from fastapi.responses import JSONResponse
10
+ from fastapi.staticfiles import StaticFiles
11
+
12
+ from valcore.api.dtos import ErrorBody, ErrorResponse
13
+ from valcore.errors import (
14
+ ConfigError,
15
+ ContractError,
16
+ FrozenVersionError,
17
+ NotFoundError,
18
+ ValcoreError,
19
+ )
20
+ from valcore.models import VALID_CAPABILITIES
21
+ from valcore.settings import MODEL_CATALOG
22
+ from valcore.tools import tool_names
23
+
24
+ _STATUS_BY_ERROR: tuple[tuple[type[ValcoreError], int], ...] = (
25
+ (NotFoundError, 404),
26
+ (ContractError, 422),
27
+ (ConfigError, 422),
28
+ (FrozenVersionError, 409),
29
+ (ValcoreError, 400),
30
+ )
31
+
32
+
33
+ def _resolve_dist_dir() -> Path | None:
34
+ """Locate the built SPA: packaged assets first, then a repo checkout, else nothing."""
35
+ try:
36
+ packaged = _package_files("valcore") / "web_dist"
37
+ if packaged.is_dir():
38
+ return Path(str(packaged))
39
+ except (ModuleNotFoundError, FileNotFoundError):
40
+ pass
41
+
42
+ for parent in Path(__file__).resolve().parents:
43
+ candidate = parent / "web" / "dist"
44
+ if candidate.is_dir():
45
+ return candidate
46
+
47
+ return None
48
+
49
+
50
+ def _error_response(status_code: int, exc: Exception) -> JSONResponse:
51
+ """Render an exception into the uniform ``{"error": {type, message}}`` envelope."""
52
+ body = ErrorResponse(error=ErrorBody(type=type(exc).__name__, message=str(exc)))
53
+ return JSONResponse(status_code=status_code, content=body.model_dump())
54
+
55
+
56
+ def _register_exception_handlers(app: FastAPI) -> None:
57
+ """Map each domain error to its documented HTTP status via the uniform envelope."""
58
+
59
+ def make_handler(status_code: int):
60
+ async def handler(_request: Request, exc: ValcoreError) -> JSONResponse:
61
+ return _error_response(status_code, exc)
62
+
63
+ return handler
64
+
65
+ for error_type, status_code in _STATUS_BY_ERROR:
66
+ app.add_exception_handler(error_type, make_handler(status_code))
67
+
68
+
69
+ def _include_routers(app: FastAPI) -> None:
70
+ """Discover and mount resource routers, tolerating ones that do not exist yet."""
71
+ for module_name in ("evaluators", "datasets", "runs"):
72
+ try:
73
+ module = importlib.import_module(f"valcore.api.routes.{module_name}")
74
+ except ImportError:
75
+ continue
76
+ app.include_router(module.router)
77
+
78
+
79
+ def create_app() -> FastAPI:
80
+ """Build and return the valcore FastAPI application."""
81
+ app = FastAPI(title="valcore")
82
+
83
+ app.add_middleware(
84
+ CORSMiddleware,
85
+ allow_origins=["http://localhost:5173"],
86
+ allow_credentials=True,
87
+ allow_methods=["*"],
88
+ allow_headers=["*"],
89
+ )
90
+
91
+ _register_exception_handlers(app)
92
+
93
+ @app.get("/api/health")
94
+ async def health() -> dict[str, str]:
95
+ """Liveness probe."""
96
+ return {"status": "ok"}
97
+
98
+ @app.get("/api/config")
99
+ async def config() -> dict[str, list[str]]:
100
+ """Return the pickers the SPA needs: models, tools, and capabilities."""
101
+ return {
102
+ "models": list(MODEL_CATALOG),
103
+ "tools": tool_names(),
104
+ "capabilities": sorted(VALID_CAPABILITIES),
105
+ }
106
+
107
+ _include_routers(app)
108
+
109
+ dist_dir = _resolve_dist_dir()
110
+ if dist_dir:
111
+ app.mount("/", StaticFiles(directory=dist_dir, html=True), name="spa")
112
+
113
+ return app
@@ -0,0 +1 @@
1
+ """Resource routers. Each module exports a module-level ``router: APIRouter``."""
@@ -0,0 +1,346 @@
1
+ """Dataset CRUD, file upload, generation, and labeling endpoints."""
2
+
3
+ import csv
4
+ import io
5
+ import json
6
+ from datetime import datetime
7
+ from typing import Annotated
8
+
9
+ from fastapi import APIRouter, Depends, File, Form, UploadFile
10
+ from pydantic import BaseModel, ConfigDict
11
+
12
+ from valcore.api.deps import get_store
13
+ from valcore.datagen import generate_rows
14
+ from valcore.errors import ContractError
15
+ from valcore.models import LabelSchema, LabelSource, ScoreKind
16
+ from valcore.store import Store
17
+
18
+ router = APIRouter(prefix="/api/datasets", tags=["datasets"])
19
+
20
+ StoreDep = Annotated[Store, Depends(get_store)]
21
+
22
+ _MAX_UPLOAD_BYTES = 10 * 1024 * 1024
23
+ _MAX_GENERATE_COUNT = 200
24
+ _JSONL_INFER_LIMIT = 50
25
+
26
+
27
+ class DatasetCreate(BaseModel):
28
+ """Request body to create an empty dataset."""
29
+
30
+ name: str
31
+ description: str = ""
32
+ columns: list[str]
33
+ label_schema: LabelSchema
34
+
35
+
36
+ class DatasetGenerate(BaseModel):
37
+ """Request body to generate a dataset and its rows."""
38
+
39
+ name: str
40
+ description: str = ""
41
+ columns: list[str]
42
+ label_schema: LabelSchema
43
+ count: int
44
+
45
+
46
+ class RowsAppend(BaseModel):
47
+ """Request body to append plain data rows to a dataset."""
48
+
49
+ rows: list[dict]
50
+
51
+
52
+ class RowPatch(BaseModel):
53
+ """Request body to relabel or annotate a single row."""
54
+
55
+ label: str | float | None = None
56
+ note: str | None = None
57
+ accept_suggestion: bool = False
58
+ clear_label: bool = False
59
+
60
+
61
+ class DatasetOut(BaseModel):
62
+ """A dataset as returned to the client."""
63
+
64
+ model_config = ConfigDict(from_attributes=True)
65
+
66
+ id: str
67
+ created_at: datetime
68
+ name: str
69
+ description: str
70
+ columns: list[str]
71
+ label_schema: dict
72
+
73
+
74
+ class DatasetCreatedOut(BaseModel):
75
+ """A newly created dataset paired with the number of rows persisted."""
76
+
77
+ dataset: DatasetOut
78
+ row_count: int
79
+
80
+
81
+ class RowOut(BaseModel):
82
+ """A dataset row with its labels as returned to the client."""
83
+
84
+ model_config = ConfigDict(from_attributes=True)
85
+
86
+ id: str
87
+ dataset_id: str
88
+ idx: int
89
+ data: dict
90
+ label: dict | None
91
+ suggested_label: dict | None
92
+ label_reasoning: str | None
93
+ label_source: LabelSource | None
94
+ note: str | None
95
+
96
+
97
+ class RowsPage(BaseModel):
98
+ """A paginated slice of dataset rows."""
99
+
100
+ rows: list[RowOut]
101
+ total: int
102
+ limit: int
103
+ offset: int
104
+
105
+
106
+ class StatsOut(BaseModel):
107
+ """Labeling progress for a dataset."""
108
+
109
+ total: int
110
+ labeled: int
111
+ unlabeled: int
112
+ label_distribution: dict[str, int]
113
+
114
+
115
+ def _label_matches_schema(value: str | float, schema: LabelSchema) -> bool:
116
+ """Return True if ``value`` is a valid label under ``schema``."""
117
+ if schema.kind is ScoreKind.CATEGORICAL:
118
+ return isinstance(value, str) and value in (schema.labels or [])
119
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
120
+ return False
121
+ below = schema.minimum is not None and value < schema.minimum
122
+ above = schema.maximum is not None and value > schema.maximum
123
+ return not (below or above)
124
+
125
+
126
+ def _parse_csv(text: str, label_column: str | None) -> tuple[list[str], list[dict]]:
127
+ """Parse CSV text into inferred data columns and prepared row dicts."""
128
+ reader = csv.DictReader(io.StringIO(text))
129
+ header = reader.fieldnames or []
130
+ if label_column is not None and label_column not in header:
131
+ raise ContractError(f"label_column {label_column!r} is not one of the columns {header}.")
132
+ columns = [name for name in header if name != label_column]
133
+ prepared = [_prepare_row(dict(record), columns, label_column) for record in reader]
134
+ return columns, prepared
135
+
136
+
137
+ def _parse_jsonl(text: str, label_column: str | None) -> tuple[list[str], list[dict]]:
138
+ """Parse JSONL text into inferred data columns and prepared row dicts."""
139
+ records: list[dict] = []
140
+ for line in text.splitlines():
141
+ line = line.strip()
142
+ if not line:
143
+ continue
144
+ try:
145
+ record = json.loads(line)
146
+ except json.JSONDecodeError as exc:
147
+ raise ContractError(f"Invalid JSON line: {exc}.") from exc
148
+ if not isinstance(record, dict):
149
+ raise ContractError("Every JSONL record must be an object.")
150
+ records.append(record)
151
+
152
+ keys: list[str] = []
153
+ for record in records[:_JSONL_INFER_LIMIT]:
154
+ for key in record:
155
+ if key != label_column and key not in keys:
156
+ keys.append(key)
157
+ prepared = [_prepare_row(record, keys, label_column) for record in records]
158
+ return keys, prepared
159
+
160
+
161
+ def _prepare_row(record: dict, columns: list[str], label_column: str | None) -> dict:
162
+ """Split a raw record into a prepared row, moving any label column into a manual label."""
163
+ fields: dict = {"data": {k: v for k, v in record.items() if k != label_column}}
164
+ if label_column is not None and record.get(label_column) is not None:
165
+ fields["label"] = {"value": record[label_column]}
166
+ fields["label_source"] = LabelSource.MANUAL
167
+ return fields
168
+
169
+
170
+ @router.get("")
171
+ async def list_datasets(store: StoreDep) -> list[DatasetOut]:
172
+ """List every dataset."""
173
+ return [DatasetOut.model_validate(ds) for ds in store.list_datasets()]
174
+
175
+
176
+ @router.post("")
177
+ async def create_dataset(body: DatasetCreate, store: StoreDep) -> DatasetOut:
178
+ """Create an empty dataset."""
179
+ dataset = store.create_dataset(
180
+ name=body.name,
181
+ description=body.description,
182
+ columns=body.columns,
183
+ label_schema=body.label_schema.model_dump(mode="json"),
184
+ )
185
+ return DatasetOut.model_validate(dataset)
186
+
187
+
188
+ @router.get("/{id}")
189
+ async def get_dataset(id: str, store: StoreDep) -> DatasetOut:
190
+ """Return a single dataset."""
191
+ return DatasetOut.model_validate(store.get_dataset(id))
192
+
193
+
194
+ @router.delete("/{id}")
195
+ async def delete_dataset(id: str, store: StoreDep) -> dict[str, str]:
196
+ """Delete a dataset and all of its rows."""
197
+ store.delete_dataset(id)
198
+ return {"status": "deleted"}
199
+
200
+
201
+ @router.post("/upload")
202
+ async def upload_dataset(
203
+ store: StoreDep,
204
+ file: Annotated[UploadFile, File()],
205
+ name: Annotated[str, Form()],
206
+ label_column: Annotated[str | None, Form()] = None,
207
+ label_schema: Annotated[str | None, Form()] = None,
208
+ ) -> DatasetCreatedOut:
209
+ """Create a dataset from an uploaded CSV or JSONL file."""
210
+ contents = await file.read()
211
+ if len(contents) > _MAX_UPLOAD_BYTES:
212
+ raise ContractError(
213
+ f"File exceeds the {_MAX_UPLOAD_BYTES // (1024 * 1024)} MB upload limit."
214
+ )
215
+
216
+ text = contents.decode("utf-8-sig")
217
+ filename = (file.filename or "").lower()
218
+ if filename.endswith(".csv"):
219
+ columns, prepared = _parse_csv(text, label_column)
220
+ elif filename.endswith((".jsonl", ".json")):
221
+ columns, prepared = _parse_jsonl(text, label_column)
222
+ else:
223
+ raise ContractError("Unsupported file type; upload a .csv or .jsonl file.")
224
+
225
+ if not prepared:
226
+ raise ContractError("File contains no data rows.")
227
+
228
+ schema_dict = _parse_label_schema(label_schema)
229
+ dataset = store.create_dataset(
230
+ name=name, description="", columns=columns, label_schema=schema_dict
231
+ )
232
+ rows = store.add_prepared_rows(dataset.id, prepared)
233
+ return DatasetCreatedOut(dataset=DatasetOut.model_validate(dataset), row_count=len(rows))
234
+
235
+
236
+ def _parse_label_schema(raw: str | None) -> dict:
237
+ """Parse an optional JSON label-schema form field, validating its shape."""
238
+ if raw is None or not raw.strip():
239
+ return {}
240
+ try:
241
+ parsed = json.loads(raw)
242
+ except json.JSONDecodeError as exc:
243
+ raise ContractError(f"Invalid label_schema JSON: {exc}.") from exc
244
+ try:
245
+ return LabelSchema.model_validate(parsed).model_dump(mode="json")
246
+ except ValueError as exc:
247
+ raise ContractError(f"Invalid label_schema: {exc}.") from exc
248
+
249
+
250
+ @router.post("/{id}/rows")
251
+ async def append_rows(id: str, body: RowsAppend, store: StoreDep) -> dict[str, int]:
252
+ """Append plain data rows to a dataset."""
253
+ store.get_dataset(id)
254
+ rows = store.add_rows(id, body.rows)
255
+ return {"row_count": len(rows)}
256
+
257
+
258
+ @router.post("/generate")
259
+ async def generate_dataset(body: DatasetGenerate, store: StoreDep) -> DatasetCreatedOut:
260
+ """Generate a dataset and its rows with suggested labels."""
261
+ if body.count > _MAX_GENERATE_COUNT:
262
+ raise ContractError(f"count may not exceed {_MAX_GENERATE_COUNT}.")
263
+
264
+ dataset = store.create_dataset(
265
+ name=body.name,
266
+ description=body.description,
267
+ columns=body.columns,
268
+ label_schema=body.label_schema.model_dump(mode="json"),
269
+ )
270
+ generated = await generate_rows(body.description, body.columns, body.label_schema, body.count)
271
+ prepared = [
272
+ {
273
+ "data": row.data,
274
+ "suggested_label": {"value": row.suggested_label},
275
+ "label_reasoning": row.reasoning,
276
+ "label_source": LabelSource.GENERATED,
277
+ }
278
+ for row in generated
279
+ ]
280
+ rows = store.add_prepared_rows(dataset.id, prepared)
281
+ return DatasetCreatedOut(dataset=DatasetOut.model_validate(dataset), row_count=len(rows))
282
+
283
+
284
+ @router.get("/{id}/rows")
285
+ async def list_dataset_rows(
286
+ id: str, store: StoreDep, limit: int = 100, offset: int = 0
287
+ ) -> RowsPage:
288
+ """Return a paginated slice of a dataset's rows."""
289
+ store.get_dataset(id)
290
+ rows = store.list_rows(id, limit=limit, offset=offset)
291
+ _, total = store.labeled_count(id)
292
+ return RowsPage(
293
+ rows=[RowOut.model_validate(row) for row in rows],
294
+ total=total,
295
+ limit=limit,
296
+ offset=offset,
297
+ )
298
+
299
+
300
+ @router.patch("/rows/{row_id}")
301
+ async def patch_row(row_id: str, body: RowPatch, store: StoreDep) -> RowOut:
302
+ """Relabel or annotate a single dataset row."""
303
+ row = store.get_row(row_id)
304
+ updates: dict = {}
305
+
306
+ if body.accept_suggestion:
307
+ if row.suggested_label is None:
308
+ raise ContractError("Row has no suggested label to accept.")
309
+ updates["label"] = row.suggested_label
310
+ updates["label_source"] = LabelSource.ACCEPTED
311
+
312
+ # A null ``label`` is indistinguishable from an omitted one, so clearing needs an
313
+ # explicit flag rather than relying on ``label=None``.
314
+ if body.clear_label:
315
+ updates["label"] = None
316
+ updates["label_source"] = None
317
+
318
+ if body.label is not None:
319
+ dataset = store.get_dataset(row.dataset_id)
320
+ schema = LabelSchema.model_validate(dataset.label_schema)
321
+ if not _label_matches_schema(body.label, schema):
322
+ raise ContractError(
323
+ f"Label {body.label!r} is not valid for this dataset's label schema."
324
+ )
325
+ updates["label"] = {"value": body.label}
326
+ updates["label_source"] = LabelSource.MANUAL
327
+
328
+ if body.note is not None:
329
+ updates["note"] = body.note
330
+
331
+ if not updates:
332
+ return RowOut.model_validate(row)
333
+ return RowOut.model_validate(store.update_row(row_id, **updates))
334
+
335
+
336
+ @router.get("/{id}/stats")
337
+ async def dataset_stats(id: str, store: StoreDep) -> StatsOut:
338
+ """Return labeling progress for a dataset."""
339
+ store.get_dataset(id)
340
+ labeled, total = store.labeled_count(id)
341
+ return StatsOut(
342
+ total=total,
343
+ labeled=labeled,
344
+ unlabeled=total - labeled,
345
+ label_distribution=store.label_distribution(id),
346
+ )