runapprentice 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.
- runapprentice/__init__.py +26 -0
- runapprentice/_capture.py +117 -0
- runapprentice/client.py +391 -0
- runapprentice/langchain.py +403 -0
- runapprentice/models.py +62 -0
- runapprentice/openai.py +126 -0
- runapprentice/py.typed +0 -0
- runapprentice-0.1.0.dist-info/METADATA +219 -0
- runapprentice-0.1.0.dist-info/RECORD +11 -0
- runapprentice-0.1.0.dist-info/WHEEL +4 -0
- runapprentice-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Apprentice SDK: capture LLM traffic, build golden datasets, and optimize prompts."""
|
|
2
|
+
|
|
3
|
+
from runapprentice.client import Apprentice, ApprenticeError, Job, enable_debug_logging
|
|
4
|
+
from runapprentice.models import (
|
|
5
|
+
DatasetRow,
|
|
6
|
+
DatasetStatus,
|
|
7
|
+
JobInfo,
|
|
8
|
+
OptimizationReport,
|
|
9
|
+
PromptVersion,
|
|
10
|
+
TraceRecord,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"Apprentice",
|
|
15
|
+
"ApprenticeError",
|
|
16
|
+
"enable_debug_logging",
|
|
17
|
+
"Job",
|
|
18
|
+
"DatasetRow",
|
|
19
|
+
"DatasetStatus",
|
|
20
|
+
"JobInfo",
|
|
21
|
+
"OptimizationReport",
|
|
22
|
+
"PromptVersion",
|
|
23
|
+
"TraceRecord",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Background capture dispatcher.
|
|
2
|
+
|
|
3
|
+
Capture runs inside the customer's production request path, so it must be both
|
|
4
|
+
NON-BLOCKING (never add latency to their call) and RESILIENT (a transient network
|
|
5
|
+
blip must not silently drop a trace on the first try). This dispatcher gives both:
|
|
6
|
+
|
|
7
|
+
- `submit()` enqueues and returns immediately (the customer's thread never waits on
|
|
8
|
+
Apprentice's network).
|
|
9
|
+
- A single daemon worker drains the queue and delivers each trace with bounded
|
|
10
|
+
retry + exponential backoff.
|
|
11
|
+
- The queue is bounded; if Apprentice's backend is down long enough to fill it, the
|
|
12
|
+
oldest pending traces are dropped (fail-open — the customer app is never affected).
|
|
13
|
+
- `flush()` (also run at interpreter exit) drains in-flight traces so short scripts
|
|
14
|
+
don't lose their last captures.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import atexit
|
|
20
|
+
import logging
|
|
21
|
+
import queue
|
|
22
|
+
import random
|
|
23
|
+
import threading
|
|
24
|
+
import time
|
|
25
|
+
from typing import Callable
|
|
26
|
+
|
|
27
|
+
from runapprentice.models import TraceRecord
|
|
28
|
+
|
|
29
|
+
logger = logging.getLogger("runapprentice")
|
|
30
|
+
|
|
31
|
+
# send(record) -> True if delivered, False if permanently rejected (do not retry);
|
|
32
|
+
# raises on a transient failure (network/5xx) that should be retried.
|
|
33
|
+
SendFn = Callable[[TraceRecord], bool]
|
|
34
|
+
_SHUTDOWN = object()
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class CaptureDispatcher:
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
send: SendFn,
|
|
41
|
+
on_exhausted: Callable[[str], None],
|
|
42
|
+
max_queue: int = 1000,
|
|
43
|
+
retries: int = 2,
|
|
44
|
+
backoff_base: float = 0.5,
|
|
45
|
+
) -> None:
|
|
46
|
+
self._send = send
|
|
47
|
+
self._on_exhausted = on_exhausted
|
|
48
|
+
self._retries = retries
|
|
49
|
+
self._backoff_base = backoff_base
|
|
50
|
+
self._queue: queue.Queue[object] = queue.Queue(maxsize=max_queue)
|
|
51
|
+
self._dropped = 0
|
|
52
|
+
self._worker = threading.Thread(
|
|
53
|
+
target=self._run, name="apprentice-capture", daemon=True
|
|
54
|
+
)
|
|
55
|
+
self._worker.start()
|
|
56
|
+
atexit.register(self.flush)
|
|
57
|
+
|
|
58
|
+
def submit(self, record: TraceRecord) -> None:
|
|
59
|
+
"""Enqueue a trace without blocking. Drops the oldest if the queue is full."""
|
|
60
|
+
try:
|
|
61
|
+
self._queue.put_nowait(record)
|
|
62
|
+
except queue.Full:
|
|
63
|
+
# Backend is unreachable and the buffer is full — drop the oldest pending
|
|
64
|
+
# trace to make room for the newest, so we keep the freshest signal.
|
|
65
|
+
try:
|
|
66
|
+
self._queue.get_nowait()
|
|
67
|
+
self._queue.task_done()
|
|
68
|
+
except queue.Empty:
|
|
69
|
+
pass
|
|
70
|
+
self._dropped += 1
|
|
71
|
+
if self._dropped == 1 or self._dropped % 100 == 0:
|
|
72
|
+
logger.warning(
|
|
73
|
+
"runapprentice: capture buffer full, dropped %d trace(s) "
|
|
74
|
+
"(backend unreachable; app unaffected)",
|
|
75
|
+
self._dropped,
|
|
76
|
+
)
|
|
77
|
+
try:
|
|
78
|
+
self._queue.put_nowait(record)
|
|
79
|
+
except queue.Full:
|
|
80
|
+
pass
|
|
81
|
+
|
|
82
|
+
def _run(self) -> None:
|
|
83
|
+
while True:
|
|
84
|
+
item = self._queue.get()
|
|
85
|
+
try:
|
|
86
|
+
if item is _SHUTDOWN:
|
|
87
|
+
return
|
|
88
|
+
assert isinstance(item, TraceRecord)
|
|
89
|
+
self._deliver(item)
|
|
90
|
+
finally:
|
|
91
|
+
self._queue.task_done()
|
|
92
|
+
|
|
93
|
+
def _deliver(self, record: TraceRecord) -> None:
|
|
94
|
+
last_error: Exception | None = None
|
|
95
|
+
for attempt in range(self._retries + 1):
|
|
96
|
+
try:
|
|
97
|
+
self._send(record) # True=delivered, False=permanent reject
|
|
98
|
+
return
|
|
99
|
+
except Exception as exc: # noqa: BLE001 — transient; retry then give up
|
|
100
|
+
last_error = exc
|
|
101
|
+
if attempt < self._retries:
|
|
102
|
+
time.sleep(self._backoff_base * (2**attempt) + random.uniform(0, 0.1))
|
|
103
|
+
if last_error is not None:
|
|
104
|
+
self._on_exhausted(f"capture failed after {self._retries + 1} attempts: {last_error}")
|
|
105
|
+
|
|
106
|
+
def flush(self, timeout: float = 2.0) -> None:
|
|
107
|
+
"""Wait up to `timeout` seconds for in-flight traces to finish delivering.
|
|
108
|
+
|
|
109
|
+
Counts items as done only after the worker calls `task_done()` (i.e. after
|
|
110
|
+
delivery + any retries complete), not merely when they leave the queue.
|
|
111
|
+
"""
|
|
112
|
+
deadline = time.monotonic() + timeout
|
|
113
|
+
while self._queue.unfinished_tasks > 0 and time.monotonic() < deadline:
|
|
114
|
+
time.sleep(0.02)
|
|
115
|
+
|
|
116
|
+
def close(self) -> None:
|
|
117
|
+
self._queue.put(_SHUTDOWN)
|
runapprentice/client.py
ADDED
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
"""Apprentice client.
|
|
2
|
+
|
|
3
|
+
Two transport disciplines coexist here, by design:
|
|
4
|
+
- Control-plane calls (upload, optimize, reports) RAISE on failure — the caller is a
|
|
5
|
+
developer at a terminal who needs to know.
|
|
6
|
+
- Capture calls (`post_trace_failopen`) NEVER raise — they run inside the customer's
|
|
7
|
+
production request path. A dead Apprentice backend must never break a customer app.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import csv
|
|
13
|
+
import json
|
|
14
|
+
import logging
|
|
15
|
+
import os
|
|
16
|
+
import time
|
|
17
|
+
import uuid
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
import httpx
|
|
22
|
+
|
|
23
|
+
from runapprentice._capture import CaptureDispatcher
|
|
24
|
+
from runapprentice.models import (
|
|
25
|
+
DatasetStatus,
|
|
26
|
+
JobInfo,
|
|
27
|
+
OptimizationReport,
|
|
28
|
+
PromptVersion,
|
|
29
|
+
TraceRecord,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
logger = logging.getLogger("runapprentice")
|
|
33
|
+
|
|
34
|
+
_DEFAULT_BASE_URL = "https://api.runapprentice.com"
|
|
35
|
+
_DEFAULT_TIMEOUT = 10.0
|
|
36
|
+
_CAPTURE_TIMEOUT = 3.0
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def enable_debug_logging(level: int = logging.DEBUG) -> None:
|
|
40
|
+
"""One-liner for development: `runapprentice.enable_debug_logging()`.
|
|
41
|
+
|
|
42
|
+
Shows every API call, capture attempt, and failure reason on stderr.
|
|
43
|
+
Also enabled by setting APPRENTICE_DEBUG=1 in the environment.
|
|
44
|
+
"""
|
|
45
|
+
handler = logging.StreamHandler()
|
|
46
|
+
handler.setFormatter(
|
|
47
|
+
logging.Formatter("%(asctime)s [apprentice] %(levelname)s %(name)s: %(message)s")
|
|
48
|
+
)
|
|
49
|
+
root = logging.getLogger("runapprentice")
|
|
50
|
+
root.addHandler(handler)
|
|
51
|
+
root.setLevel(level)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
if os.environ.get("APPRENTICE_DEBUG"): # pragma: no cover — env-dependent convenience
|
|
55
|
+
enable_debug_logging()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class ApprenticeError(Exception):
|
|
59
|
+
"""Raised for control-plane API failures."""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class Apprentice:
|
|
63
|
+
def __init__(
|
|
64
|
+
self,
|
|
65
|
+
api_key: str | None = None,
|
|
66
|
+
base_url: str | None = None,
|
|
67
|
+
timeout: float = _DEFAULT_TIMEOUT,
|
|
68
|
+
capture_async: bool = True,
|
|
69
|
+
) -> None:
|
|
70
|
+
# base_url precedence: constructor arg > APPRENTICE_BASE_URL env > hosted default.
|
|
71
|
+
# Override only for self-hosting or tests; the hosted API is the default.
|
|
72
|
+
self.base_url = (
|
|
73
|
+
base_url or os.environ.get("APPRENTICE_BASE_URL") or _DEFAULT_BASE_URL
|
|
74
|
+
).rstrip("/")
|
|
75
|
+
api_key = api_key or os.environ.get("APPRENTICE_API_KEY")
|
|
76
|
+
headers = {"Authorization": f"Bearer {api_key}"} if api_key else {}
|
|
77
|
+
self._http = httpx.Client(base_url=self.base_url, headers=headers, timeout=timeout)
|
|
78
|
+
# Capture defaults to async: traces are buffered and delivered off the caller's
|
|
79
|
+
# thread with retry, so a slow/down backend never adds latency to the customer's
|
|
80
|
+
# LLM call. Set capture_async=False for synchronous delivery (tests, short scripts).
|
|
81
|
+
self._dispatcher = (
|
|
82
|
+
CaptureDispatcher(self._send_trace_attempt, self._warn_capture_once)
|
|
83
|
+
if capture_async
|
|
84
|
+
else None
|
|
85
|
+
)
|
|
86
|
+
self.datasets = _DatasetsAPI(self)
|
|
87
|
+
self.tasks = _TasksAPI(self)
|
|
88
|
+
self.prompts = _PromptsAPI(self)
|
|
89
|
+
|
|
90
|
+
# -- control plane (raises on failure) ------------------------------------
|
|
91
|
+
|
|
92
|
+
def _request(self, method: str, path: str, json: Any | None = None) -> Any:
|
|
93
|
+
t0 = time.monotonic()
|
|
94
|
+
try:
|
|
95
|
+
resp = self._http.request(method, path, json=json)
|
|
96
|
+
except httpx.HTTPError as exc:
|
|
97
|
+
logger.error("%s %s failed after %.0fms: %s", method, path, _ms(t0), exc)
|
|
98
|
+
raise ApprenticeError(
|
|
99
|
+
f"{method} {path} failed: {exc}. "
|
|
100
|
+
f"Is the Apprentice backend reachable at {self.base_url}?"
|
|
101
|
+
) from exc
|
|
102
|
+
logger.debug("%s %s -> %s (%.0fms)", method, path, resp.status_code, _ms(t0))
|
|
103
|
+
if resp.status_code >= 400:
|
|
104
|
+
logger.error("%s %s -> %s: %s", method, path, resp.status_code, resp.text[:500])
|
|
105
|
+
raise ApprenticeError(f"{method} {path} -> {resp.status_code}: {resp.text[:500]}")
|
|
106
|
+
return resp.json()
|
|
107
|
+
|
|
108
|
+
def optimize(self, task: str) -> Job:
|
|
109
|
+
data = self._request("POST", "/v1/optimize", json={"task": task})
|
|
110
|
+
return Job(self, JobInfo.model_validate(data))
|
|
111
|
+
|
|
112
|
+
def job(self, job_id: str) -> Job:
|
|
113
|
+
data = self._request("GET", f"/v1/jobs/{job_id}")
|
|
114
|
+
return Job(self, JobInfo.model_validate(data))
|
|
115
|
+
|
|
116
|
+
def feedback(
|
|
117
|
+
self,
|
|
118
|
+
trace_id: str,
|
|
119
|
+
good: bool | None = None,
|
|
120
|
+
score: float | None = None,
|
|
121
|
+
note: str | None = None,
|
|
122
|
+
) -> None:
|
|
123
|
+
self._request(
|
|
124
|
+
"POST",
|
|
125
|
+
"/v1/feedback",
|
|
126
|
+
json={"trace_id": trace_id, "good": good, "score": score, "note": note},
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
# -- capture path (never raises) -------------------------------------------
|
|
130
|
+
|
|
131
|
+
def post_trace_failopen(self, record: TraceRecord) -> str | None:
|
|
132
|
+
"""Capture one trace. MUST NOT raise — runs in the customer's request path.
|
|
133
|
+
|
|
134
|
+
Async mode (default): enqueues and returns None immediately; delivery happens
|
|
135
|
+
off-thread with retry. Sync mode (capture_async=False): delivers inline and
|
|
136
|
+
returns the trace_id (or None on failure).
|
|
137
|
+
"""
|
|
138
|
+
if self._dispatcher is not None:
|
|
139
|
+
self._dispatcher.submit(record)
|
|
140
|
+
return None
|
|
141
|
+
try:
|
|
142
|
+
return self._deliver_trace(record)
|
|
143
|
+
except Exception as exc: # noqa: BLE001 — fail-open is the contract here
|
|
144
|
+
self._warn_capture_once(f"capture failed: {exc}")
|
|
145
|
+
return None
|
|
146
|
+
|
|
147
|
+
def capture(
|
|
148
|
+
self,
|
|
149
|
+
task: str,
|
|
150
|
+
output: str,
|
|
151
|
+
*,
|
|
152
|
+
input: str | None = None, # noqa: A002 - public API mirrors dataset field
|
|
153
|
+
inputs: dict[str, str] | None = None,
|
|
154
|
+
model: str | None = None,
|
|
155
|
+
latency_ms: float | None = None,
|
|
156
|
+
prompt_tokens: int | None = None,
|
|
157
|
+
completion_tokens: int | None = None,
|
|
158
|
+
error: str | None = None,
|
|
159
|
+
metadata: dict[str, Any] | None = None,
|
|
160
|
+
) -> str | None:
|
|
161
|
+
"""Capture one completed LLM row without raising into application code.
|
|
162
|
+
|
|
163
|
+
For RAG, pass the EXACT context string sent to the model in `inputs`;
|
|
164
|
+
do not pass only the final rendered prompt:
|
|
165
|
+
`inputs={"question": question, "context": formatted_context}`.
|
|
166
|
+
"""
|
|
167
|
+
try:
|
|
168
|
+
display_input = input
|
|
169
|
+
if display_input is None:
|
|
170
|
+
if inputs is None:
|
|
171
|
+
raise ValueError("capture requires input or inputs")
|
|
172
|
+
display_input = _display_inputs(inputs)
|
|
173
|
+
return self.post_trace_failopen(
|
|
174
|
+
TraceRecord(
|
|
175
|
+
task=task,
|
|
176
|
+
input=display_input,
|
|
177
|
+
inputs=inputs,
|
|
178
|
+
output=output,
|
|
179
|
+
model=model,
|
|
180
|
+
latency_ms=latency_ms,
|
|
181
|
+
prompt_tokens=prompt_tokens,
|
|
182
|
+
completion_tokens=completion_tokens,
|
|
183
|
+
error=error,
|
|
184
|
+
metadata=metadata or {},
|
|
185
|
+
)
|
|
186
|
+
)
|
|
187
|
+
except Exception as exc: # noqa: BLE001 — fail-open is the contract here
|
|
188
|
+
self._warn_capture_once(f"capture failed: {exc}")
|
|
189
|
+
return None
|
|
190
|
+
|
|
191
|
+
def flush_captures(self, timeout: float = 2.0) -> None:
|
|
192
|
+
"""Block until buffered traces are delivered (or timeout). No-op in sync mode."""
|
|
193
|
+
if self._dispatcher is not None:
|
|
194
|
+
self._dispatcher.flush(timeout)
|
|
195
|
+
|
|
196
|
+
def _send_trace_attempt(self, record: TraceRecord) -> bool:
|
|
197
|
+
"""One delivery attempt for the dispatcher.
|
|
198
|
+
|
|
199
|
+
Returns True on success, False on a permanent (4xx) rejection, and RAISES on a
|
|
200
|
+
transient failure (network error / 5xx) so the dispatcher retries it.
|
|
201
|
+
"""
|
|
202
|
+
return self._deliver_trace(record) is not None
|
|
203
|
+
|
|
204
|
+
def _deliver_trace(self, record: TraceRecord) -> str | None:
|
|
205
|
+
"""POST one trace. Raises httpx errors and ApprenticeError on 5xx (retryable);
|
|
206
|
+
returns trace_id on success, None on a 4xx rejection (not retryable)."""
|
|
207
|
+
t0 = time.monotonic()
|
|
208
|
+
resp = self._http.post(
|
|
209
|
+
"/v1/traces",
|
|
210
|
+
json=record.model_dump(mode="json"),
|
|
211
|
+
timeout=_CAPTURE_TIMEOUT,
|
|
212
|
+
)
|
|
213
|
+
if resp.status_code < 400:
|
|
214
|
+
trace_id = resp.json().get("trace_id")
|
|
215
|
+
logger.debug("captured trace task=%s id=%s (%.0fms)", record.task, trace_id, _ms(t0))
|
|
216
|
+
self._capture_warned = False
|
|
217
|
+
return str(trace_id) if trace_id is not None else None
|
|
218
|
+
if resp.status_code >= 500:
|
|
219
|
+
raise ApprenticeError(f"backend {resp.status_code}: {resp.text[:200]}")
|
|
220
|
+
self._warn_capture_once(f"backend rejected trace: {resp.status_code} {resp.text[:200]}")
|
|
221
|
+
return None
|
|
222
|
+
|
|
223
|
+
def _warn_capture_once(self, message: str) -> None:
|
|
224
|
+
if not getattr(self, "_capture_warned", False):
|
|
225
|
+
logger.warning(
|
|
226
|
+
"runapprentice: %s, traces are being DROPPED (app unaffected; "
|
|
227
|
+
"further drops logged at DEBUG)",
|
|
228
|
+
message,
|
|
229
|
+
)
|
|
230
|
+
self._capture_warned = True
|
|
231
|
+
else:
|
|
232
|
+
logger.debug("runapprentice: %s", message)
|
|
233
|
+
|
|
234
|
+
def close(self) -> None:
|
|
235
|
+
if self._dispatcher is not None:
|
|
236
|
+
self._dispatcher.flush()
|
|
237
|
+
self._dispatcher.close()
|
|
238
|
+
self._http.close()
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
class Job:
|
|
242
|
+
def __init__(self, client: Apprentice, info: JobInfo) -> None:
|
|
243
|
+
self._client = client
|
|
244
|
+
self.info = info
|
|
245
|
+
|
|
246
|
+
@property
|
|
247
|
+
def job_id(self) -> str:
|
|
248
|
+
return self.info.job_id
|
|
249
|
+
|
|
250
|
+
@property
|
|
251
|
+
def status(self) -> str:
|
|
252
|
+
return self.info.status
|
|
253
|
+
|
|
254
|
+
def refresh(self) -> Job:
|
|
255
|
+
self.info = self._client.job(self.info.job_id).info
|
|
256
|
+
return self
|
|
257
|
+
|
|
258
|
+
def wait(self, poll_seconds: float = 5.0, timeout_seconds: float = 3600.0) -> Job:
|
|
259
|
+
deadline = time.monotonic() + timeout_seconds
|
|
260
|
+
while self.refresh().info.status in ("queued", "running"):
|
|
261
|
+
if time.monotonic() > deadline:
|
|
262
|
+
raise ApprenticeError(f"job {self.info.job_id} timed out after {timeout_seconds}s")
|
|
263
|
+
time.sleep(poll_seconds)
|
|
264
|
+
return self
|
|
265
|
+
|
|
266
|
+
def report(self) -> OptimizationReport:
|
|
267
|
+
if self.info.report is None:
|
|
268
|
+
raise ApprenticeError(
|
|
269
|
+
f"job {self.info.job_id} has no report (status={self.info.status})"
|
|
270
|
+
)
|
|
271
|
+
return self.info.report
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
class _DatasetsAPI:
|
|
275
|
+
def __init__(self, client: Apprentice) -> None:
|
|
276
|
+
self._client = client
|
|
277
|
+
|
|
278
|
+
def upload(
|
|
279
|
+
self,
|
|
280
|
+
task: str,
|
|
281
|
+
path: str | Path | None = None,
|
|
282
|
+
rows: list[dict[str, Any]] | None = None,
|
|
283
|
+
input_col: str = "input",
|
|
284
|
+
output_col: str = "output",
|
|
285
|
+
prompt: str | None = None,
|
|
286
|
+
) -> DatasetStatus:
|
|
287
|
+
"""Upload rows.
|
|
288
|
+
|
|
289
|
+
Rows may use legacy `input`/`output` or structured `inputs`/`output`.
|
|
290
|
+
LCEL auto-capture of prompt variables is future work; v1 stays explicit.
|
|
291
|
+
"""
|
|
292
|
+
if (path is None) == (rows is None):
|
|
293
|
+
raise ApprenticeError("provide exactly one of path= or rows=")
|
|
294
|
+
if path is not None:
|
|
295
|
+
with open(path, newline="", encoding="utf-8") as f:
|
|
296
|
+
rows = list(csv.DictReader(f))
|
|
297
|
+
assert rows is not None
|
|
298
|
+
payload_rows = [_dataset_row_payload(r, input_col, output_col) for r in rows]
|
|
299
|
+
data = self._client._request(
|
|
300
|
+
"POST",
|
|
301
|
+
f"/v1/datasets/{task}/rows",
|
|
302
|
+
json={"rows": payload_rows, "prompt": prompt},
|
|
303
|
+
)
|
|
304
|
+
return DatasetStatus.model_validate(data)
|
|
305
|
+
|
|
306
|
+
def status(self, task: str) -> DatasetStatus:
|
|
307
|
+
data = self._client._request("GET", f"/v1/datasets/{task}/status")
|
|
308
|
+
return DatasetStatus.model_validate(data)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
class _TasksAPI:
|
|
312
|
+
def __init__(self, client: Apprentice) -> None:
|
|
313
|
+
self._client = client
|
|
314
|
+
|
|
315
|
+
def create(self, task: str, metric: str = "auto") -> dict[str, Any]:
|
|
316
|
+
result = self._client._request("POST", "/v1/tasks", json={"name": task, "metric": metric})
|
|
317
|
+
return dict(result)
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
class _PromptsAPI:
|
|
321
|
+
def __init__(self, client: Apprentice) -> None:
|
|
322
|
+
self._client = client
|
|
323
|
+
|
|
324
|
+
def register(self, task: str, template: Any) -> dict[str, Any]:
|
|
325
|
+
artifact = template if isinstance(template, dict) else _serialize_langchain_prompt(template)
|
|
326
|
+
return dict(self._client._request("POST", f"/v1/templates/{task}", json=artifact))
|
|
327
|
+
|
|
328
|
+
def to_langchain(self, task: str | None = None, artifact: dict[str, Any] | None = None) -> Any:
|
|
329
|
+
if artifact is None:
|
|
330
|
+
if task is None:
|
|
331
|
+
raise ApprenticeError("provide task= or artifact=")
|
|
332
|
+
artifact = dict(self._client._request("GET", f"/v1/templates/{task}"))
|
|
333
|
+
return _artifact_to_langchain(artifact)
|
|
334
|
+
|
|
335
|
+
def get(self, task: str, version: int | None = None) -> PromptVersion:
|
|
336
|
+
path = f"/v1/prompts/{task}" + (f"?version={version}" if version is not None else "")
|
|
337
|
+
return PromptVersion.model_validate(self._client._request("GET", path))
|
|
338
|
+
|
|
339
|
+
def history(self, task: str) -> list[PromptVersion]:
|
|
340
|
+
data = self._client._request("GET", f"/v1/prompts/{task}/history")
|
|
341
|
+
return [PromptVersion.model_validate(p) for p in data]
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def new_trace_id() -> str:
|
|
345
|
+
return uuid.uuid4().hex
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
def _display_inputs(inputs: dict[str, str]) -> str:
|
|
349
|
+
return json.dumps(inputs, sort_keys=True, ensure_ascii=False)
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _dataset_row_payload(
|
|
353
|
+
row: dict[str, Any], input_col: str, output_col: str
|
|
354
|
+
) -> dict[str, Any]:
|
|
355
|
+
payload: dict[str, Any] = {"output": str(row[output_col])}
|
|
356
|
+
if "inputs" in row:
|
|
357
|
+
inputs = row["inputs"]
|
|
358
|
+
if not isinstance(inputs, dict):
|
|
359
|
+
raise ApprenticeError("row['inputs'] must be a dict")
|
|
360
|
+
payload["inputs"] = inputs
|
|
361
|
+
if input_col in row:
|
|
362
|
+
payload["input"] = str(row[input_col])
|
|
363
|
+
else:
|
|
364
|
+
payload["input"] = str(row[input_col])
|
|
365
|
+
return payload
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def _serialize_langchain_prompt(template: Any) -> dict[str, Any]:
|
|
369
|
+
try:
|
|
370
|
+
from runapprentice.langchain import serialize_prompt
|
|
371
|
+
except ImportError as exc:
|
|
372
|
+
raise ImportError(
|
|
373
|
+
"LangChain support requires the optional extra: "
|
|
374
|
+
"`uv add 'runapprentice[langchain]'` or install `langchain-core>=0.3`."
|
|
375
|
+
) from exc
|
|
376
|
+
return serialize_prompt(template)
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def _artifact_to_langchain(artifact: dict[str, Any]) -> Any:
|
|
380
|
+
try:
|
|
381
|
+
from runapprentice.langchain import artifact_to_langchain
|
|
382
|
+
except ImportError as exc:
|
|
383
|
+
raise ImportError(
|
|
384
|
+
"LangChain support requires the optional extra: "
|
|
385
|
+
"`uv add 'runapprentice[langchain]'` or install `langchain-core>=0.3`."
|
|
386
|
+
) from exc
|
|
387
|
+
return artifact_to_langchain(artifact)
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def _ms(t0: float) -> float:
|
|
391
|
+
return (time.monotonic() - t0) * 1000
|