ventimath 0.1.0__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.
- ventimath/__init__.py +25 -0
- ventimath/client.py +441 -0
- ventimath-0.1.0.dist-info/METADATA +214 -0
- ventimath-0.1.0.dist-info/RECORD +6 -0
- ventimath-0.1.0.dist-info/WHEEL +5 -0
- ventimath-0.1.0.dist-info/top_level.txt +1 -0
ventimath/__init__.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""VentiMath REST client. No inference providers or runtime dependencies."""
|
|
2
|
+
|
|
3
|
+
from .client import (
|
|
4
|
+
DEFAULT_BASE_URL,
|
|
5
|
+
VentiMathAPIError,
|
|
6
|
+
VentiMathClient,
|
|
7
|
+
VentiMathConnectionError,
|
|
8
|
+
VentiMathError,
|
|
9
|
+
VentiMathImportError,
|
|
10
|
+
VentiMathProtocolError,
|
|
11
|
+
load_classification_files,
|
|
12
|
+
load_vocabulary_csv,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"DEFAULT_BASE_URL",
|
|
17
|
+
"VentiMathAPIError",
|
|
18
|
+
"VentiMathClient",
|
|
19
|
+
"VentiMathConnectionError",
|
|
20
|
+
"VentiMathError",
|
|
21
|
+
"VentiMathImportError",
|
|
22
|
+
"VentiMathProtocolError",
|
|
23
|
+
"load_classification_files",
|
|
24
|
+
"load_vocabulary_csv",
|
|
25
|
+
]
|
ventimath/client.py
ADDED
|
@@ -0,0 +1,441 @@
|
|
|
1
|
+
"""Thin JSON client for the answerKeys and problemTags Cloud Functions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import csv
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
from collections.abc import Iterable, Mapping
|
|
9
|
+
from http.client import HTTPException
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any, Literal
|
|
12
|
+
from urllib.error import HTTPError, URLError
|
|
13
|
+
from urllib.parse import quote, urlencode, urlsplit
|
|
14
|
+
from urllib.request import HTTPRedirectHandler, Request, build_opener
|
|
15
|
+
|
|
16
|
+
DEFAULT_BASE_URL = "https://us-central1-ventimath.cloudfunctions.net"
|
|
17
|
+
MAX_IMPORT_RECORDS = 200
|
|
18
|
+
MAX_IMPORT_BYTES = 2 * 1024 * 1024
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class _NoRedirectHandler(HTTPRedirectHandler):
|
|
22
|
+
def redirect_request(self, request, response, code, message, headers, new_url):
|
|
23
|
+
# Function endpoints are direct: never forward a token or change a write's method.
|
|
24
|
+
raise HTTPError(request.full_url, code, message, headers, response)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
urlopen = build_opener(_NoRedirectHandler()).open
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class VentiMathError(Exception):
|
|
31
|
+
"""Base class for transport, protocol, and aggregate import failures."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class VentiMathAPIError(VentiMathError):
|
|
35
|
+
def __init__(self, status: int, payload: Any, url: str):
|
|
36
|
+
self.status = status
|
|
37
|
+
self.payload = payload
|
|
38
|
+
self.url = url
|
|
39
|
+
super().__init__(f"HTTP {status}: {payload}")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class VentiMathConnectionError(VentiMathError):
|
|
43
|
+
"""The request could not complete; the server may have received it."""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class VentiMathProtocolError(VentiMathError):
|
|
47
|
+
"""The server returned an unexpected response shape."""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class VentiMathImportError(VentiMathError):
|
|
51
|
+
"""An import stopped; report contains counts from completed requests only."""
|
|
52
|
+
|
|
53
|
+
def __init__(self, report: dict[str, Any], cause: VentiMathError):
|
|
54
|
+
self.report = report
|
|
55
|
+
self.cause = cause
|
|
56
|
+
self.status = getattr(cause, "status", None)
|
|
57
|
+
self.payload = getattr(cause, "payload", None)
|
|
58
|
+
super().__init__(
|
|
59
|
+
f"Import stopped after {report['processed']}/{report['total']} records: {cause}. "
|
|
60
|
+
"The last request may have committed. Retry the same records safely; "
|
|
61
|
+
"unchanged classifications are not written again."
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _json_bytes(value: Any) -> bytes:
|
|
66
|
+
return json.dumps(value, ensure_ascii=False, allow_nan=False, separators=(",", ":")).encode(
|
|
67
|
+
"utf-8"
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _set_path(set_id: str) -> str:
|
|
72
|
+
if not isinstance(set_id, str) or not set_id.strip() or "/" in set_id:
|
|
73
|
+
raise ValueError("set_id must be a nonempty document ID without slashes")
|
|
74
|
+
return f"problemTags/sets/{quote(set_id, safe='')}"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _page_query(limit: int | None, cursor: str | None) -> dict[str, Any]:
|
|
78
|
+
if limit is not None and (type(limit) is not int or not 1 <= limit <= 100):
|
|
79
|
+
raise ValueError("limit must be an integer from 1 to 100")
|
|
80
|
+
return {"limit": limit, "cursor": cursor}
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def load_vocabulary_csv(path: str | os.PathLike[str]) -> list[dict[str, str]]:
|
|
84
|
+
"""Read category/skill/description CSV, including BOM and quoted newlines."""
|
|
85
|
+
with Path(path).open(encoding="utf-8-sig", newline="") as source:
|
|
86
|
+
reader = csv.reader(source, strict=True)
|
|
87
|
+
try:
|
|
88
|
+
headers = [value.strip().lower() for value in next(reader)]
|
|
89
|
+
fields = ("skill", "category", "description")
|
|
90
|
+
if any(headers.count(field) != 1 for field in fields):
|
|
91
|
+
raise ValueError("CSV needs one each of skill, category, description headers")
|
|
92
|
+
entries = []
|
|
93
|
+
for row in reader:
|
|
94
|
+
if not any(value.strip() for value in row):
|
|
95
|
+
continue
|
|
96
|
+
if len(row) != len(headers):
|
|
97
|
+
raise ValueError(f"CSV line {reader.line_num} has an incorrect column count")
|
|
98
|
+
entry = {field: row[headers.index(field)].strip() for field in fields}
|
|
99
|
+
if not entry["skill"] or not entry["category"]:
|
|
100
|
+
raise ValueError(f"CSV line {reader.line_num} needs a skill and category")
|
|
101
|
+
entries.append(entry)
|
|
102
|
+
except StopIteration as error:
|
|
103
|
+
raise ValueError("vocabulary CSV is empty") from error
|
|
104
|
+
except csv.Error as error:
|
|
105
|
+
raise ValueError(f"invalid vocabulary CSV: {error}") from error
|
|
106
|
+
if not 1 <= len(entries) <= 500:
|
|
107
|
+
raise ValueError("vocabulary CSV must contain 1–500 skills")
|
|
108
|
+
return entries
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def load_classification_files(
|
|
112
|
+
paths: Iterable[str | os.PathLike[str]],
|
|
113
|
+
) -> list[Any]:
|
|
114
|
+
"""Load individual {result} records or combined arrays with {llmResult}."""
|
|
115
|
+
records = []
|
|
116
|
+
for path in paths:
|
|
117
|
+
with Path(path).open(encoding="utf-8-sig") as source:
|
|
118
|
+
data = json.load(source)
|
|
119
|
+
if isinstance(data, list):
|
|
120
|
+
records.extend(data)
|
|
121
|
+
elif isinstance(data, dict) and "problemId" in data:
|
|
122
|
+
records.append(data)
|
|
123
|
+
else:
|
|
124
|
+
raise ValueError(f"{path} must hold a classifier record or an array of records")
|
|
125
|
+
return records
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class VentiMathClient:
|
|
129
|
+
"""Explicit-token client; token=None is anonymous and never reads credentials."""
|
|
130
|
+
|
|
131
|
+
def __init__(
|
|
132
|
+
self,
|
|
133
|
+
token: str | None = None,
|
|
134
|
+
*,
|
|
135
|
+
base_url: str = DEFAULT_BASE_URL,
|
|
136
|
+
timeout: float = 60,
|
|
137
|
+
):
|
|
138
|
+
parsed = urlsplit(base_url)
|
|
139
|
+
if parsed.scheme not in {"https", "http"} or not parsed.netloc:
|
|
140
|
+
raise ValueError("base_url must be an HTTP(S) URL")
|
|
141
|
+
if parsed.username or parsed.password or parsed.query or parsed.fragment:
|
|
142
|
+
raise ValueError("base_url cannot contain credentials, a query, or a fragment")
|
|
143
|
+
if timeout <= 0:
|
|
144
|
+
raise ValueError("timeout must be positive")
|
|
145
|
+
self.base_url = base_url.rstrip("/")
|
|
146
|
+
self.timeout = timeout
|
|
147
|
+
self._token = token
|
|
148
|
+
|
|
149
|
+
@classmethod
|
|
150
|
+
def from_env(cls, *, timeout: float = 60) -> VentiMathClient:
|
|
151
|
+
"""Read VENTI_API_KEY/VENTI_API_URL without writing a credential file."""
|
|
152
|
+
return cls(
|
|
153
|
+
token=os.environ.get("VENTI_API_KEY"),
|
|
154
|
+
base_url=os.environ.get("VENTI_API_URL") or DEFAULT_BASE_URL,
|
|
155
|
+
timeout=timeout,
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
def _request(
|
|
159
|
+
self,
|
|
160
|
+
method: str,
|
|
161
|
+
path: str,
|
|
162
|
+
*,
|
|
163
|
+
query: Mapping[str, Any] | None = None,
|
|
164
|
+
body: Any = None,
|
|
165
|
+
public: bool = False,
|
|
166
|
+
require_token: bool = False,
|
|
167
|
+
) -> dict[str, Any]:
|
|
168
|
+
if require_token and not self._token:
|
|
169
|
+
raise ValueError("this write requires your own VentiMath token with tags.write")
|
|
170
|
+
url = f"{self.base_url}/{path}"
|
|
171
|
+
if query:
|
|
172
|
+
encoded = urlencode({key: value for key, value in query.items() if value is not None})
|
|
173
|
+
if encoded:
|
|
174
|
+
url += f"?{encoded}"
|
|
175
|
+
headers = {"Accept": "application/json", "User-Agent": "ventimath-python/0.1.0"}
|
|
176
|
+
if self._token and not public:
|
|
177
|
+
headers["Authorization"] = f"Bearer {self._token}"
|
|
178
|
+
data = None
|
|
179
|
+
if body is not None:
|
|
180
|
+
data = _json_bytes(body)
|
|
181
|
+
headers["Content-Type"] = "application/json"
|
|
182
|
+
request = Request(url, data=data, headers=headers, method=method)
|
|
183
|
+
try:
|
|
184
|
+
with urlopen(request, timeout=self.timeout) as response:
|
|
185
|
+
text = response.read().decode("utf-8")
|
|
186
|
+
except HTTPError as error:
|
|
187
|
+
try:
|
|
188
|
+
with error:
|
|
189
|
+
text = error.read().decode("utf-8", errors="replace")
|
|
190
|
+
except (URLError, OSError, HTTPException) as read_error:
|
|
191
|
+
raise VentiMathConnectionError(
|
|
192
|
+
f"request to {url} failed while reading HTTP {error.code}: {read_error}"
|
|
193
|
+
) from read_error
|
|
194
|
+
try:
|
|
195
|
+
payload = json.loads(text)
|
|
196
|
+
except ValueError:
|
|
197
|
+
payload = text
|
|
198
|
+
raise VentiMathAPIError(error.code, payload, url) from error
|
|
199
|
+
except (URLError, OSError, HTTPException) as error:
|
|
200
|
+
raise VentiMathConnectionError(f"request to {url} failed: {error}") from error
|
|
201
|
+
except UnicodeError as error:
|
|
202
|
+
raise VentiMathProtocolError(f"invalid UTF-8 response from {url}") from error
|
|
203
|
+
try:
|
|
204
|
+
payload = json.loads(text)
|
|
205
|
+
except ValueError as error:
|
|
206
|
+
raise VentiMathProtocolError(f"invalid JSON response from {url}") from error
|
|
207
|
+
if not isinstance(payload, dict):
|
|
208
|
+
raise VentiMathProtocolError(f"expected a JSON object from {url}")
|
|
209
|
+
return payload
|
|
210
|
+
|
|
211
|
+
def answer_keys(
|
|
212
|
+
self, *, ids: Iterable[str] | str | None = None, exam: str | None = None
|
|
213
|
+
) -> dict[str, Any]:
|
|
214
|
+
"""Public {problems, missing, unavailable}; exactly one selector is required."""
|
|
215
|
+
if (ids is None) == (exam is None):
|
|
216
|
+
raise ValueError("provide exactly one of ids or exam")
|
|
217
|
+
query = {"exam": exam}
|
|
218
|
+
if ids is not None:
|
|
219
|
+
values = ids.split(",") if isinstance(ids, str) else list(ids)
|
|
220
|
+
if not 1 <= len(values) <= 100 or any(
|
|
221
|
+
not isinstance(value, str) or not value.strip() or "," in value for value in values
|
|
222
|
+
):
|
|
223
|
+
raise ValueError("ids must contain 1–100 nonempty canonical problem IDs")
|
|
224
|
+
query = {"ids": ",".join(value.strip() for value in values)}
|
|
225
|
+
elif not isinstance(exam, str) or not exam.strip():
|
|
226
|
+
raise ValueError("exam must be a nonempty exam ID")
|
|
227
|
+
return self._request("GET", "answerKeys", query=query, public=True)
|
|
228
|
+
|
|
229
|
+
def download_answer_key(
|
|
230
|
+
self,
|
|
231
|
+
path: str | os.PathLike[str],
|
|
232
|
+
*,
|
|
233
|
+
ids: Iterable[str] | str | None = None,
|
|
234
|
+
exam: str | None = None,
|
|
235
|
+
) -> dict[str, Any]:
|
|
236
|
+
"""Save JSON accepted by the classifier's existing local --answer-key option."""
|
|
237
|
+
payload = self.answer_keys(ids=ids, exam=exam)
|
|
238
|
+
destination = Path(path)
|
|
239
|
+
destination.parent.mkdir(parents=True, exist_ok=True)
|
|
240
|
+
destination.write_text(
|
|
241
|
+
json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
|
|
242
|
+
)
|
|
243
|
+
return payload
|
|
244
|
+
|
|
245
|
+
def list_tag_sets(
|
|
246
|
+
self,
|
|
247
|
+
*,
|
|
248
|
+
owner: str | None = None,
|
|
249
|
+
limit: int | None = None,
|
|
250
|
+
cursor: str | None = None,
|
|
251
|
+
public: bool = False,
|
|
252
|
+
) -> dict[str, Any]:
|
|
253
|
+
"""One {sets, nextCursor} page; omit vocabulary until get_tag_set is called."""
|
|
254
|
+
return self._request(
|
|
255
|
+
"GET",
|
|
256
|
+
"problemTags/sets",
|
|
257
|
+
query={"owner": owner, **_page_query(limit, cursor)},
|
|
258
|
+
public=public,
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
def list_public_tag_sets(self, **kwargs: Any) -> dict[str, Any]:
|
|
262
|
+
"""Discover public sets anonymously, even if this client has a token."""
|
|
263
|
+
return self.list_tag_sets(**{**kwargs, "public": True})
|
|
264
|
+
|
|
265
|
+
def get_tag_set(self, set_id: str, *, public: bool = False) -> dict[str, Any]:
|
|
266
|
+
"""Return {set}, including vocabulary."""
|
|
267
|
+
return self._request("GET", _set_path(set_id), public=public)
|
|
268
|
+
|
|
269
|
+
def create_tag_set(
|
|
270
|
+
self,
|
|
271
|
+
slug: str,
|
|
272
|
+
name: str,
|
|
273
|
+
*,
|
|
274
|
+
public: bool = True,
|
|
275
|
+
vocabulary: Iterable[Mapping[str, str]] | None = None,
|
|
276
|
+
) -> dict[str, Any]:
|
|
277
|
+
"""Create a public set on the token owner's profile; use public=False for private."""
|
|
278
|
+
if type(public) is not bool:
|
|
279
|
+
raise ValueError("public must be a boolean")
|
|
280
|
+
body: dict[str, Any] = {
|
|
281
|
+
"slug": slug,
|
|
282
|
+
"name": name,
|
|
283
|
+
"visibility": "public" if public else "private",
|
|
284
|
+
}
|
|
285
|
+
if vocabulary is not None:
|
|
286
|
+
body["vocabulary"] = list(vocabulary)
|
|
287
|
+
return self._request("POST", "problemTags/sets", body=body, require_token=True)
|
|
288
|
+
|
|
289
|
+
def update_tag_set(
|
|
290
|
+
self,
|
|
291
|
+
set_id: str,
|
|
292
|
+
*,
|
|
293
|
+
name: str | None = None,
|
|
294
|
+
public: Literal[True] | None = None,
|
|
295
|
+
vocabulary: Iterable[Mapping[str, str]] | None = None,
|
|
296
|
+
) -> dict[str, Any]:
|
|
297
|
+
"""Update metadata or publish with public=True; publication is permanent.
|
|
298
|
+
|
|
299
|
+
Vocabulary replaces the list. Private visibility is only chosen at creation.
|
|
300
|
+
"""
|
|
301
|
+
body: dict[str, Any] = {}
|
|
302
|
+
if name is not None:
|
|
303
|
+
body["name"] = name
|
|
304
|
+
if public is not None:
|
|
305
|
+
if public is not True:
|
|
306
|
+
raise ValueError("public must be True to publish; choose private at creation")
|
|
307
|
+
body["visibility"] = "public"
|
|
308
|
+
if vocabulary is not None:
|
|
309
|
+
body["vocabulary"] = list(vocabulary)
|
|
310
|
+
if not body:
|
|
311
|
+
raise ValueError("provide name, public, or vocabulary to update")
|
|
312
|
+
return self._request("PATCH", _set_path(set_id), body=body, require_token=True)
|
|
313
|
+
|
|
314
|
+
def import_classifications(
|
|
315
|
+
self,
|
|
316
|
+
set_id: str,
|
|
317
|
+
records: Iterable[Any] | Mapping[str, Any],
|
|
318
|
+
*,
|
|
319
|
+
vocabulary: Iterable[Mapping[str, str]] | None = None,
|
|
320
|
+
dry_run: bool = False,
|
|
321
|
+
model: str | None = None,
|
|
322
|
+
reasoning_effort: str | None = None,
|
|
323
|
+
) -> dict[str, Any]:
|
|
324
|
+
"""Import saved records with bounded requests and one aggregate report.
|
|
325
|
+
|
|
326
|
+
Individual `result` and combined `llmResult` shapes pass through unchanged.
|
|
327
|
+
Row-level rejection details pass through, with indices adjusted globally.
|
|
328
|
+
Dry runs preview each batch against currently persisted state.
|
|
329
|
+
"""
|
|
330
|
+
if not self._token:
|
|
331
|
+
raise ValueError("imports require your own VentiMath token with tags.write")
|
|
332
|
+
if type(dry_run) is not bool:
|
|
333
|
+
raise ValueError("dry_run must be a boolean")
|
|
334
|
+
path = f"{_set_path(set_id)}/import"
|
|
335
|
+
items = [records] if isinstance(records, Mapping) else list(records)
|
|
336
|
+
if not items:
|
|
337
|
+
raise ValueError("no classifier records supplied")
|
|
338
|
+
options: dict[str, Any] = {"dryRun": dry_run}
|
|
339
|
+
if vocabulary is not None:
|
|
340
|
+
options["vocabulary"] = list(vocabulary)
|
|
341
|
+
if model is not None:
|
|
342
|
+
options["model"] = model
|
|
343
|
+
if reasoning_effort is not None:
|
|
344
|
+
options["reasoningEffort"] = reasoning_effort
|
|
345
|
+
batches: list[list[Any]] = []
|
|
346
|
+
batch: list[Any] = []
|
|
347
|
+
for index, record in enumerate(items):
|
|
348
|
+
if len(_json_bytes({**options, "records": [record]})) > MAX_IMPORT_BYTES:
|
|
349
|
+
raise ValueError(f"record {index} plus vocabulary exceeds the 2 MiB request limit")
|
|
350
|
+
candidate = [*batch, record]
|
|
351
|
+
if batch and (
|
|
352
|
+
len(candidate) > MAX_IMPORT_RECORDS
|
|
353
|
+
or len(_json_bytes({**options, "records": candidate})) > MAX_IMPORT_BYTES
|
|
354
|
+
):
|
|
355
|
+
batches.append(batch)
|
|
356
|
+
batch = []
|
|
357
|
+
batch.append(record)
|
|
358
|
+
batches.append(batch)
|
|
359
|
+
report: dict[str, Any] = {
|
|
360
|
+
"imported": 0,
|
|
361
|
+
"unchanged": 0,
|
|
362
|
+
"rejected": [],
|
|
363
|
+
"dryRun": dry_run,
|
|
364
|
+
"total": len(items),
|
|
365
|
+
"processed": 0,
|
|
366
|
+
"remaining": len(items),
|
|
367
|
+
}
|
|
368
|
+
for chunk in batches:
|
|
369
|
+
try:
|
|
370
|
+
result = self._request(
|
|
371
|
+
"POST", path, body={**options, "records": chunk}, require_token=True
|
|
372
|
+
)
|
|
373
|
+
if (
|
|
374
|
+
type(result.get("imported")) is not int
|
|
375
|
+
or type(result.get("unchanged")) is not int
|
|
376
|
+
or result["imported"] < 0
|
|
377
|
+
or result["unchanged"] < 0
|
|
378
|
+
or not isinstance(result.get("rejected"), list)
|
|
379
|
+
or result["imported"] + result["unchanged"] + len(result["rejected"])
|
|
380
|
+
!= len(chunk)
|
|
381
|
+
or any(
|
|
382
|
+
not isinstance(rejected, dict)
|
|
383
|
+
or type(rejected.get("index")) is not int
|
|
384
|
+
or not 0 <= rejected["index"] < len(chunk)
|
|
385
|
+
for rejected in result["rejected"]
|
|
386
|
+
)
|
|
387
|
+
):
|
|
388
|
+
raise VentiMathProtocolError(
|
|
389
|
+
"invalid import response; inspect server state before retrying"
|
|
390
|
+
)
|
|
391
|
+
except VentiMathError as error:
|
|
392
|
+
raise VentiMathImportError(report, error) from error
|
|
393
|
+
report["imported"] += result["imported"]
|
|
394
|
+
report["unchanged"] += result["unchanged"]
|
|
395
|
+
report["rejected"].extend(
|
|
396
|
+
{**rejected, "index": report["processed"] + rejected["index"]}
|
|
397
|
+
for rejected in result["rejected"]
|
|
398
|
+
)
|
|
399
|
+
report["processed"] += len(chunk)
|
|
400
|
+
report["remaining"] = len(items) - report["processed"]
|
|
401
|
+
return report
|
|
402
|
+
|
|
403
|
+
def tagged_problems(
|
|
404
|
+
self,
|
|
405
|
+
set_id: str,
|
|
406
|
+
*,
|
|
407
|
+
skill: str | None = None,
|
|
408
|
+
importance: str | None = None,
|
|
409
|
+
limit: int | None = None,
|
|
410
|
+
cursor: str | None = None,
|
|
411
|
+
public: bool = False,
|
|
412
|
+
) -> dict[str, Any]:
|
|
413
|
+
"""One {set, problems, nextCursor} page with each problem's classification."""
|
|
414
|
+
if importance is not None and importance not in {"essential", "time-saving", "helpful"}:
|
|
415
|
+
raise ValueError("importance must be essential, time-saving, or helpful")
|
|
416
|
+
if importance and not skill:
|
|
417
|
+
raise ValueError("importance requires a skill")
|
|
418
|
+
return self._request(
|
|
419
|
+
"GET",
|
|
420
|
+
f"{_set_path(set_id)}/problems",
|
|
421
|
+
query={"skill": skill, "importance": importance, **_page_query(limit, cursor)},
|
|
422
|
+
public=public,
|
|
423
|
+
)
|
|
424
|
+
|
|
425
|
+
def iter_tagged_problems(self, set_id: str, **kwargs: Any) -> Iterable[dict[str, Any]]:
|
|
426
|
+
"""Yield problems across pages, including empty pages with a next cursor."""
|
|
427
|
+
cursor = kwargs.pop("cursor", None)
|
|
428
|
+
seen = {cursor} if cursor else set()
|
|
429
|
+
while True:
|
|
430
|
+
page = self.tagged_problems(set_id, cursor=cursor, **kwargs)
|
|
431
|
+
if not isinstance(page.get("problems"), list):
|
|
432
|
+
raise VentiMathProtocolError("tagged-problem response needs a problems list")
|
|
433
|
+
yield from page["problems"]
|
|
434
|
+
cursor = page.get("nextCursor")
|
|
435
|
+
if not cursor:
|
|
436
|
+
break
|
|
437
|
+
if not isinstance(cursor, str) or cursor in seen:
|
|
438
|
+
raise VentiMathProtocolError(
|
|
439
|
+
"server returned an invalid or repeated pagination cursor"
|
|
440
|
+
)
|
|
441
|
+
seen.add(cursor)
|
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: ventimath
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Small standard-library client for VentiMath answer keys and profile skill tags
|
|
5
|
+
Project-URL: Repository, https://github.com/VentiMath/venti-sdk
|
|
6
|
+
Requires-Python: >=3.10
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
|
|
9
|
+
# VentiMath Python SDK
|
|
10
|
+
|
|
11
|
+
Download answer keys, import your problem classifications, and find problems by skill.
|
|
12
|
+
Requires Python 3.10 or newer, with no runtime dependencies.
|
|
13
|
+
|
|
14
|
+
## Install
|
|
15
|
+
|
|
16
|
+
```sh
|
|
17
|
+
pip install ventimath
|
|
18
|
+
# Or add it to a uv project:
|
|
19
|
+
uv add ventimath
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
The default API base URL is
|
|
23
|
+
`https://us-central1-ventimath.cloudfunctions.net`; use `base_url=` for a deployed
|
|
24
|
+
test environment or functions emulator.
|
|
25
|
+
|
|
26
|
+
## Download answer keys for the existing classifier
|
|
27
|
+
|
|
28
|
+
Answer-key reads are public and always omit authentication, even when the client
|
|
29
|
+
has a token. Download a file; the classifier's existing `--answer-key` reads a
|
|
30
|
+
local JSON path, not a URL.
|
|
31
|
+
|
|
32
|
+
```python
|
|
33
|
+
from ventimath import VentiMathClient
|
|
34
|
+
|
|
35
|
+
public = VentiMathClient()
|
|
36
|
+
result = public.download_answer_key("answer-key.json", exam="EXAM_ID")
|
|
37
|
+
print("Missing problems:", result["missing"])
|
|
38
|
+
print("No usable key:", result["unavailable"])
|
|
39
|
+
|
|
40
|
+
# Or select up to 100 canonical problem IDs:
|
|
41
|
+
keys = public.answer_keys(ids=["PROBLEM_ID_1", "PROBLEM_ID_2"])
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
```sh
|
|
45
|
+
python classify_skills.py PROBLEM_ID --answer-key answer-key.json
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
The response and saved file have the same shape:
|
|
49
|
+
|
|
50
|
+
```json
|
|
51
|
+
{
|
|
52
|
+
"problems": {
|
|
53
|
+
"PROBLEM_ID": {"correctChoice": "B", "answerText": "2"}
|
|
54
|
+
},
|
|
55
|
+
"missing": [],
|
|
56
|
+
"unavailable": []
|
|
57
|
+
}
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
Text-answer records carry `answerText`; accepted alternatives may also appear in
|
|
61
|
+
`allowedAnswers`. Exam requests are bounded to 500 placements. Unknown canonical
|
|
62
|
+
IDs appear in `missing`; proofs, absent keys, and placeholders appear in
|
|
63
|
+
`unavailable`.
|
|
64
|
+
|
|
65
|
+
## Import classifications on your own profile
|
|
66
|
+
|
|
67
|
+
Create your VentiMath account and mint **your own** personal access token in
|
|
68
|
+
Settings → Developer. Select `tags.read` and `tags.write`. Keep the token in your
|
|
69
|
+
environment rather than in source files. The authenticated account owns the set;
|
|
70
|
+
there is no option to assign your upload to someone else's account.
|
|
71
|
+
|
|
72
|
+
`VentiMathClient(token=...)` uses an explicit token. `VentiMathClient.from_env()`
|
|
73
|
+
reads `VENTI_API_KEY` and optionally `VENTI_API_URL`. Neither stores credentials.
|
|
74
|
+
The ordinary `VentiMathClient()` constructor remains anonymous even if a token
|
|
75
|
+
environment variable exists.
|
|
76
|
+
|
|
77
|
+
Sets are **public by default**. Pass `public=False` at creation to keep a set
|
|
78
|
+
private to your account. Publishing a private set is permanent; public sets cannot
|
|
79
|
+
be made private. This import example creates a private set:
|
|
80
|
+
|
|
81
|
+
```python
|
|
82
|
+
from pathlib import Path
|
|
83
|
+
from ventimath import (
|
|
84
|
+
VentiMathClient,
|
|
85
|
+
load_classification_files,
|
|
86
|
+
load_vocabulary_csv,
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
client = VentiMathClient.from_env()
|
|
90
|
+
vocabulary = load_vocabulary_csv("skill-lists/skills-merged.csv")
|
|
91
|
+
created = client.create_tag_set(
|
|
92
|
+
"amc-skills",
|
|
93
|
+
"My AMC skill classifications",
|
|
94
|
+
public=False,
|
|
95
|
+
vocabulary=vocabulary,
|
|
96
|
+
)
|
|
97
|
+
set_id = created["set"]["id"]
|
|
98
|
+
|
|
99
|
+
# Use saved per-problem output from a single classifier run folder.
|
|
100
|
+
records = load_classification_files(sorted(Path("output/skills/MODEL_DIRECTORY").glob("*.json")))
|
|
101
|
+
|
|
102
|
+
# Or use the classifier's combined output instead:
|
|
103
|
+
# records = load_classification_files(["combined.json"])
|
|
104
|
+
|
|
105
|
+
preview = client.import_classifications(set_id, records, dry_run=True)
|
|
106
|
+
print(preview)
|
|
107
|
+
|
|
108
|
+
# After inspecting the preview, upload the same records.
|
|
109
|
+
report = client.import_classifications(set_id, records)
|
|
110
|
+
print("Imported:", report["imported"], "unchanged:", report["unchanged"])
|
|
111
|
+
for rejected in report["rejected"]:
|
|
112
|
+
print(rejected)
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Individual records use `{problemId, result, model, reasoningEffort}`. Combined
|
|
116
|
+
files use `{problemId, llmResult}` and usually omit model provenance; supply
|
|
117
|
+
`model="MODEL_USED"` and `reasoning_effort="EFFORT_USED"` to `import_classifications`
|
|
118
|
+
when those values are known. Per-record values take precedence. Both shapes pass
|
|
119
|
+
through to the API unchanged. The server validates each classification and
|
|
120
|
+
discards copied prompts, raw replies, statements, and answer fields.
|
|
121
|
+
|
|
122
|
+
Vocabulary CSV headers are `category`, `skill`, and `description`, in any order.
|
|
123
|
+
Quoted commas/newlines, escaped quotes, and a UTF-8 BOM are supported. On import,
|
|
124
|
+
`vocabulary=` merges by skill name; supplied entries update matching descriptions
|
|
125
|
+
and categories. Accepted records add unseen skill names. To explicitly replace
|
|
126
|
+
the vocabulary, use `update_tag_set(set_id, vocabulary=...)`.
|
|
127
|
+
|
|
128
|
+
## Retrieve problems by skill
|
|
129
|
+
|
|
130
|
+
```python
|
|
131
|
+
# Your own sets, including private sets; needs tags.read.
|
|
132
|
+
mine = client.list_tag_sets()
|
|
133
|
+
|
|
134
|
+
# Public discovery always omits the token, even on an authenticated client.
|
|
135
|
+
shared = client.list_public_tag_sets(owner="OWNER_NUMERIC_ID", limit=20)
|
|
136
|
+
# Pass shared["nextCursor"] as cursor= to request the next page.
|
|
137
|
+
|
|
138
|
+
# Set listing returns metadata. Fetch a set to get its vocabulary.
|
|
139
|
+
tag_set = client.get_tag_set(set_id)
|
|
140
|
+
print(tag_set["set"]["vocabulary"])
|
|
141
|
+
|
|
142
|
+
# One page, preserving the response envelope and pagination cursor:
|
|
143
|
+
page = client.tagged_problems(
|
|
144
|
+
set_id, skill="Casework", importance="essential", limit=25
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
# Or iterate across every page:
|
|
148
|
+
for problem in client.iter_tagged_problems(set_id, skill="Casework"):
|
|
149
|
+
print(problem["id"], problem["details"]["statement"])
|
|
150
|
+
print(problem["classification"])
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
Skill names match exactly. Importance is `essential`, `time-saving`, or `helpful`
|
|
154
|
+
and requires a skill filter.
|
|
155
|
+
Use `public=True` on `get_tag_set`, `tagged_problems`, or `iter_tagged_problems`
|
|
156
|
+
to read a public set without sending the client's token. Public problem records
|
|
157
|
+
omit catalog answer fields; their attached classifications remain attributed to
|
|
158
|
+
the set owner.
|
|
159
|
+
|
|
160
|
+
## Reports, errors, and retries
|
|
161
|
+
|
|
162
|
+
Imports are split into requests of at most 200 records and 2 MiB of UTF-8 JSON.
|
|
163
|
+
All input is checked for JSON encodability and individual request size before
|
|
164
|
+
any request is sent. The aggregate response contains `imported`, `unchanged`,
|
|
165
|
+
`rejected`, `dryRun`, `total`, `processed`, and `remaining`. Rejected entries retain
|
|
166
|
+
the server's error code and problem ID; their zero-based `index` refers to your
|
|
167
|
+
complete input sequence. Row rejections do not raise an exception; inspect them.
|
|
168
|
+
|
|
169
|
+
Dry runs validate each batch against the currently stored set without writing.
|
|
170
|
+
For imports spanning batches, repeated problem IDs or the cumulative vocabulary
|
|
171
|
+
limit can make actual counts differ from the preview. Inspect the final report.
|
|
172
|
+
|
|
173
|
+
```python
|
|
174
|
+
from ventimath import VentiMathAPIError, VentiMathImportError
|
|
175
|
+
|
|
176
|
+
try:
|
|
177
|
+
report = client.import_classifications(set_id, records)
|
|
178
|
+
except VentiMathImportError as error:
|
|
179
|
+
print("Completed requests:", error.report)
|
|
180
|
+
print("HTTP status, if available:", error.status)
|
|
181
|
+
print("Server payload, if available:", error.payload)
|
|
182
|
+
# A failed response does not prove the last request was uncommitted.
|
|
183
|
+
# Retrying the identical input is safe; unchanged records are not rewritten.
|
|
184
|
+
except VentiMathAPIError as error:
|
|
185
|
+
print(error.status, error.payload)
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
Other methods raise `VentiMathAPIError` with the original HTTP status and server
|
|
189
|
+
payload for HTTP failures. `VentiMathConnectionError` covers transport failures,
|
|
190
|
+
including interrupted response bodies;
|
|
191
|
+
`VentiMathProtocolError` covers unexpected responses. Invalid local arguments
|
|
192
|
+
raise `ValueError`. Imports wrap transport/protocol/HTTP failures in
|
|
193
|
+
`VentiMathImportError` to preserve the counts from completed requests.
|
|
194
|
+
|
|
195
|
+
These methods call the actual REST routes: `GET /answerKeys`,
|
|
196
|
+
`GET|POST /problemTags/sets`, `GET|PATCH /problemTags/sets/{id}`,
|
|
197
|
+
`POST /problemTags/sets/{id}/import`, and
|
|
198
|
+
`GET /problemTags/sets/{id}/problems`. You can call those endpoints directly from
|
|
199
|
+
your existing Python HTTP code; the SDK is optional.
|
|
200
|
+
|
|
201
|
+
## Development
|
|
202
|
+
|
|
203
|
+
Install from a source checkout with `python -m pip install .`.
|
|
204
|
+
|
|
205
|
+
From this package directory, without installing or contacting a server:
|
|
206
|
+
|
|
207
|
+
```sh
|
|
208
|
+
PYTHONPATH=src python -B -m unittest discover -s tests -v
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
All transport calls in these tests are mocked. They exercise exact URLs and
|
|
212
|
+
headers, anonymous discovery, private ownership request shaping, both classifier
|
|
213
|
+
record formats, CSV parsing, Unicode batch limits, pagination, rejected redirects,
|
|
214
|
+
and partial-failure reports.
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
ventimath/__init__.py,sha256=m7hDvQqqguH6xPRMU-OdLEuSKy8TL2qt57PBmnPMoUg,587
|
|
2
|
+
ventimath/client.py,sha256=9Un81c8Q-Al77T6sRclDnrb3NViCKu-pXJM9uJVf15w,18305
|
|
3
|
+
ventimath-0.1.0.dist-info/METADATA,sha256=I8FLapqHmBI1ZBq4Katu2Fnm7aT2b_2LY6cXTTTcu7I,8283
|
|
4
|
+
ventimath-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
5
|
+
ventimath-0.1.0.dist-info/top_level.txt,sha256=0G5b1NL6nlsVycAkb2myss6CWJymFE0j7eRzzI6mor8,10
|
|
6
|
+
ventimath-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
ventimath
|