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 +1 -0
- valcore/api/__init__.py +1 -0
- valcore/api/deps.py +18 -0
- valcore/api/dtos.py +21 -0
- valcore/api/events.py +91 -0
- valcore/api/main.py +113 -0
- valcore/api/routes/__init__.py +1 -0
- valcore/api/routes/datasets.py +346 -0
- valcore/api/routes/evaluators.py +312 -0
- valcore/api/routes/runs.py +405 -0
- valcore/cli/__init__.py +5 -0
- valcore/cli/main.py +317 -0
- valcore/cli/output.py +55 -0
- valcore/cli/resolve.py +97 -0
- valcore/config.py +115 -0
- valcore/datagen.py +131 -0
- valcore/errors.py +21 -0
- valcore/export.py +230 -0
- valcore/factory.py +131 -0
- valcore/generator.py +190 -0
- valcore/metrics.py +168 -0
- valcore/models.py +335 -0
- valcore/paths.py +42 -0
- valcore/runner.py +264 -0
- valcore/settings.py +119 -0
- valcore/store.py +371 -0
- valcore/tools.py +156 -0
- valcore/web_dist/assets/index-D_kUCqnN.js +67 -0
- valcore/web_dist/assets/index-DgGwwf5t.css +1 -0
- valcore/web_dist/index.html +13 -0
- valcore-0.0.1.dist-info/METADATA +131 -0
- valcore-0.0.1.dist-info/RECORD +35 -0
- valcore-0.0.1.dist-info/WHEEL +4 -0
- valcore-0.0.1.dist-info/entry_points.txt +2 -0
- valcore-0.0.1.dist-info/licenses/LICENSE +201 -0
valcore/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""valcore: a local tool for developing, improving, and running agentic evaluations."""
|
valcore/api/__init__.py
ADDED
|
@@ -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
|
+
)
|