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.
@@ -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)
@@ -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