runapprentice 0.1.0__tar.gz → 0.2.0__tar.gz

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.
Files changed (22) hide show
  1. {runapprentice-0.1.0 → runapprentice-0.2.0}/PKG-INFO +47 -7
  2. {runapprentice-0.1.0 → runapprentice-0.2.0}/README.md +42 -6
  3. {runapprentice-0.1.0 → runapprentice-0.2.0}/pyproject.toml +6 -1
  4. {runapprentice-0.1.0 → runapprentice-0.2.0}/src/runapprentice/__init__.py +4 -0
  5. {runapprentice-0.1.0 → runapprentice-0.2.0}/src/runapprentice/_capture.py +10 -17
  6. runapprentice-0.2.0/src/runapprentice/cli.py +447 -0
  7. {runapprentice-0.1.0 → runapprentice-0.2.0}/src/runapprentice/client.py +114 -12
  8. runapprentice-0.2.0/src/runapprentice/local.py +612 -0
  9. {runapprentice-0.1.0 → runapprentice-0.2.0}/src/runapprentice/models.py +19 -0
  10. runapprentice-0.2.0/tests/test_cli.py +352 -0
  11. {runapprentice-0.1.0 → runapprentice-0.2.0}/tests/test_client.py +153 -2
  12. {runapprentice-0.1.0 → runapprentice-0.2.0}/tests/test_dispatcher.py +23 -0
  13. {runapprentice-0.1.0 → runapprentice-0.2.0}/tests/test_failopen.py +1 -1
  14. runapprentice-0.2.0/tests/test_local.py +387 -0
  15. {runapprentice-0.1.0 → runapprentice-0.2.0}/.gitignore +0 -0
  16. {runapprentice-0.1.0 → runapprentice-0.2.0}/LICENSE +0 -0
  17. {runapprentice-0.1.0 → runapprentice-0.2.0}/src/runapprentice/langchain.py +0 -0
  18. {runapprentice-0.1.0 → runapprentice-0.2.0}/src/runapprentice/openai.py +0 -0
  19. {runapprentice-0.1.0 → runapprentice-0.2.0}/src/runapprentice/py.typed +0 -0
  20. {runapprentice-0.1.0 → runapprentice-0.2.0}/tests/test_capture.py +0 -0
  21. {runapprentice-0.1.0 → runapprentice-0.2.0}/tests/test_langchain_prompts.py +0 -0
  22. {runapprentice-0.1.0 → runapprentice-0.2.0}/tests/test_openai.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: runapprentice
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: Cut LLM cost without losing quality: capture traffic, build golden datasets, and optimize prompts.
5
5
  Project-URL: Homepage, https://runapprentice.com
6
6
  Project-URL: Documentation, https://docs.runapprentice.com
@@ -21,18 +21,44 @@ Requires-Dist: httpx>=0.27
21
21
  Requires-Dist: pydantic>=2.0
22
22
  Provides-Extra: langchain
23
23
  Requires-Dist: langchain-core>=0.3; extra == 'langchain'
24
+ Provides-Extra: local
25
+ Requires-Dist: mlx-lm<0.32,>=0.31.3; extra == 'local'
26
+ Provides-Extra: optimize
27
+ Requires-Dist: dspy<4,>=3.2.1; extra == 'optimize'
24
28
  Description-Content-Type: text/markdown
25
29
 
26
30
  # runapprentice
27
31
 
28
- Capture your LLM traffic, build a golden dataset, and optimize your prompt against it. Training a small model to take over from the frontier model is planned for a later release.
32
+ The Python SDK for [Apprentice](https://runapprentice.com), a tool that cuts frontier-LLM cost
33
+ on repeatable tasks. You give it a dataset of inputs and correct outputs for one task (uploaded
34
+ as a CSV, or captured live from a LangChain app with one callback). Apprentice runs prompt
35
+ optimization (DSPy GEPA) against the verified rows (gold plus silver), or fine-tunes a small open model on
36
+ your Mac, and reports the score change on a held-out slice, so the gain is measured, never
37
+ claimed. Gated, eval-verified takeover of production traffic is still in development.
29
38
 
30
- This README documents the **implemented surface** (v0.1). Anything not shown here does not exist yet.
39
+ Docs: [docs.runapprentice.com](https://docs.runapprentice.com) · Reproducible benchmark:
40
+ [apprentice-benchmark](https://github.com/singhabhishekkk/apprentice-benchmark)
41
+
42
+ This README documents the **implemented surface**. Anything not shown here does not exist yet.
43
+
44
+ ## CLI (no code, no account required)
45
+
46
+ ```bash
47
+ uv add 'runapprentice[optimize]' # add [local] too for MLX fine-tuning on Apple silicon
48
+
49
+ apprentice optimize <task> --local --data golden.csv # free, your OPENAI_API_KEY
50
+ apprentice train <task> --local --data golden.csv --effort high # free, fine-tunes on your Mac
51
+ ```
52
+
53
+ Both commands work on a CSV alone, no Apprentice account. No golden dataset yet? `apprentice
54
+ login` also lets Apprentice generate a starter set and route rows to a subject-matter expert
55
+ for review, and tracks every run in the console. `apprentice --help` (or any
56
+ subcommand `--help`) documents every flag; `--json` gives clean machine-readable output.
31
57
 
32
58
  ## Install
33
59
 
34
60
  ```bash
35
- uv add runapprentice[langchain] # [langchain] extra enables zero-code capture
61
+ uv add 'runapprentice[langchain]' # [langchain] extra enables zero-code capture
36
62
  ```
37
63
 
38
64
  ## Quickstart (the whole trial journey)
@@ -163,11 +189,25 @@ want grounding + refusal optimized together.
163
189
 
164
190
  ## Feedback (your end-users' signal)
165
191
 
192
+ Capture records the call; feedback records whether it worked. That score is what the
193
+ console's Drift tab charts, and what decides when a retrain is worth doing.
194
+
166
195
  ```python
167
- client.feedback(trace_id, good=True) # thumbs
168
- client.feedback(trace_id, score=0.5, note="partial") # graded
196
+ trace_id = client.capture(task="support-triage", input=question, output=answer)
197
+ if trace_id: # None means the capture was rejected locally; capture never raises
198
+ client.feedback(trace_id, good=True) # thumbs
199
+ client.feedback(trace_id, score=0.5, note="partial") # graded
169
200
  ```
170
201
 
202
+ In async mode (the default), `feedback()` first waits up to two seconds for pending
203
+ captures to deliver, so it can never race its own trace; if the queue cannot drain in
204
+ time it raises `ApprenticeError` instead of sending a request that would 404.
205
+
206
+ Wire it to a signal you already have: a thumbs up or down, the user accepting or discarding
207
+ the output, a downstream check that passed or failed. Do not add a second model to grade the
208
+ first one, this score decides when to retrain. With no real signal, send nothing: captured
209
+ rows still become gold when a human verifies them.
210
+
171
211
  ## Debugging
172
212
 
173
213
  ```python
@@ -195,7 +235,7 @@ count and the minimum).
195
235
  | `job.report()` | `OptimizationReport(baseline_score, optimized_score, optimized_prompt, ...)` | if job has no report |
196
236
  | `client.prompts.get(task, version=None)` | `PromptVersion(version, text, score)` | 404 if never optimized |
197
237
  | `client.prompts.history(task)` | `list[PromptVersion]` | `ApprenticeError` on API failure |
198
- | `client.feedback(trace_id, good=, score=, note=)` | None | 404 unknown trace |
238
+ | `client.feedback(trace_id, good=, score=, note=)` | None | 404 unknown trace; `ApprenticeError` if pending captures cannot drain in time, or on API failure |
199
239
  | `client.post_trace_failopen(record)` | trace_id or None | **never raises** |
200
240
  | `ApprenticeCallback(task, client, redact=None)` | LangChain callback | **never raises into your chain** |
201
241
 
@@ -1,13 +1,35 @@
1
1
  # runapprentice
2
2
 
3
- Capture your LLM traffic, build a golden dataset, and optimize your prompt against it. Training a small model to take over from the frontier model is planned for a later release.
3
+ The Python SDK for [Apprentice](https://runapprentice.com), a tool that cuts frontier-LLM cost
4
+ on repeatable tasks. You give it a dataset of inputs and correct outputs for one task (uploaded
5
+ as a CSV, or captured live from a LangChain app with one callback). Apprentice runs prompt
6
+ optimization (DSPy GEPA) against the verified rows (gold plus silver), or fine-tunes a small open model on
7
+ your Mac, and reports the score change on a held-out slice, so the gain is measured, never
8
+ claimed. Gated, eval-verified takeover of production traffic is still in development.
4
9
 
5
- This README documents the **implemented surface** (v0.1). Anything not shown here does not exist yet.
10
+ Docs: [docs.runapprentice.com](https://docs.runapprentice.com) · Reproducible benchmark:
11
+ [apprentice-benchmark](https://github.com/singhabhishekkk/apprentice-benchmark)
12
+
13
+ This README documents the **implemented surface**. Anything not shown here does not exist yet.
14
+
15
+ ## CLI (no code, no account required)
16
+
17
+ ```bash
18
+ uv add 'runapprentice[optimize]' # add [local] too for MLX fine-tuning on Apple silicon
19
+
20
+ apprentice optimize <task> --local --data golden.csv # free, your OPENAI_API_KEY
21
+ apprentice train <task> --local --data golden.csv --effort high # free, fine-tunes on your Mac
22
+ ```
23
+
24
+ Both commands work on a CSV alone, no Apprentice account. No golden dataset yet? `apprentice
25
+ login` also lets Apprentice generate a starter set and route rows to a subject-matter expert
26
+ for review, and tracks every run in the console. `apprentice --help` (or any
27
+ subcommand `--help`) documents every flag; `--json` gives clean machine-readable output.
6
28
 
7
29
  ## Install
8
30
 
9
31
  ```bash
10
- uv add runapprentice[langchain] # [langchain] extra enables zero-code capture
32
+ uv add 'runapprentice[langchain]' # [langchain] extra enables zero-code capture
11
33
  ```
12
34
 
13
35
  ## Quickstart (the whole trial journey)
@@ -138,11 +160,25 @@ want grounding + refusal optimized together.
138
160
 
139
161
  ## Feedback (your end-users' signal)
140
162
 
163
+ Capture records the call; feedback records whether it worked. That score is what the
164
+ console's Drift tab charts, and what decides when a retrain is worth doing.
165
+
141
166
  ```python
142
- client.feedback(trace_id, good=True) # thumbs
143
- client.feedback(trace_id, score=0.5, note="partial") # graded
167
+ trace_id = client.capture(task="support-triage", input=question, output=answer)
168
+ if trace_id: # None means the capture was rejected locally; capture never raises
169
+ client.feedback(trace_id, good=True) # thumbs
170
+ client.feedback(trace_id, score=0.5, note="partial") # graded
144
171
  ```
145
172
 
173
+ In async mode (the default), `feedback()` first waits up to two seconds for pending
174
+ captures to deliver, so it can never race its own trace; if the queue cannot drain in
175
+ time it raises `ApprenticeError` instead of sending a request that would 404.
176
+
177
+ Wire it to a signal you already have: a thumbs up or down, the user accepting or discarding
178
+ the output, a downstream check that passed or failed. Do not add a second model to grade the
179
+ first one, this score decides when to retrain. With no real signal, send nothing: captured
180
+ rows still become gold when a human verifies them.
181
+
146
182
  ## Debugging
147
183
 
148
184
  ```python
@@ -170,7 +206,7 @@ count and the minimum).
170
206
  | `job.report()` | `OptimizationReport(baseline_score, optimized_score, optimized_prompt, ...)` | if job has no report |
171
207
  | `client.prompts.get(task, version=None)` | `PromptVersion(version, text, score)` | 404 if never optimized |
172
208
  | `client.prompts.history(task)` | `list[PromptVersion]` | `ApprenticeError` on API failure |
173
- | `client.feedback(trace_id, good=, score=, note=)` | None | 404 unknown trace |
209
+ | `client.feedback(trace_id, good=, score=, note=)` | None | 404 unknown trace; `ApprenticeError` if pending captures cannot drain in time, or on API failure |
174
210
  | `client.post_trace_failopen(record)` | trace_id or None | **never raises** |
175
211
  | `ApprenticeCallback(task, client, redact=None)` | LangChain callback | **never raises into your chain** |
176
212
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "runapprentice"
3
- version = "0.1.0"
3
+ version = "0.2.0"
4
4
  description = "Cut LLM cost without losing quality: capture traffic, build golden datasets, and optimize prompts."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -23,12 +23,17 @@ dependencies = [
23
23
  "pydantic>=2.0",
24
24
  ]
25
25
 
26
+ [project.scripts]
27
+ apprentice = "runapprentice.cli:main"
28
+
26
29
  [project.urls]
27
30
  Homepage = "https://runapprentice.com"
28
31
  Documentation = "https://docs.runapprentice.com"
29
32
 
30
33
  [project.optional-dependencies]
31
34
  langchain = ["langchain-core>=0.3"]
35
+ local = ["mlx-lm>=0.31.3,<0.32"]
36
+ optimize = ["dspy>=3.2.1,<4"]
32
37
 
33
38
  [dependency-groups]
34
39
  dev = [
@@ -5,6 +5,8 @@ from runapprentice.models import (
5
5
  DatasetRow,
6
6
  DatasetStatus,
7
7
  JobInfo,
8
+ JobSummary,
9
+ LocalRun,
8
10
  OptimizationReport,
9
11
  PromptVersion,
10
12
  TraceRecord,
@@ -18,6 +20,8 @@ __all__ = [
18
20
  "DatasetRow",
19
21
  "DatasetStatus",
20
22
  "JobInfo",
23
+ "JobSummary",
24
+ "LocalRun",
21
25
  "OptimizationReport",
22
26
  "PromptVersion",
23
27
  "TraceRecord",
@@ -8,8 +8,8 @@ blip must not silently drop a trace on the first try). This dispatcher gives bot
8
8
  Apprentice's network).
9
9
  - A single daemon worker drains the queue and delivers each trace with bounded
10
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).
11
+ - The queue is bounded; if Apprentice's backend is down long enough to fill it, new
12
+ traces are rejected (fail-open; the customer app is never affected).
13
13
  - `flush()` (also run at interpreter exit) drains in-flight traces so short scripts
14
14
  don't lose their last captures.
15
15
  """
@@ -55,29 +55,20 @@ class CaptureDispatcher:
55
55
  self._worker.start()
56
56
  atexit.register(self.flush)
57
57
 
58
- def submit(self, record: TraceRecord) -> None:
59
- """Enqueue a trace without blocking. Drops the oldest if the queue is full."""
58
+ def submit(self, record: TraceRecord) -> bool:
59
+ """Enqueue without blocking. Return whether the trace was accepted locally."""
60
60
  try:
61
61
  self._queue.put_nowait(record)
62
+ return True
62
63
  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
64
  self._dropped += 1
71
65
  if self._dropped == 1 or self._dropped % 100 == 0:
72
66
  logger.warning(
73
- "runapprentice: capture buffer full, dropped %d trace(s) "
67
+ "runapprentice: capture buffer full, rejected %d trace(s) "
74
68
  "(backend unreachable; app unaffected)",
75
69
  self._dropped,
76
70
  )
77
- try:
78
- self._queue.put_nowait(record)
79
- except queue.Full:
80
- pass
71
+ return False
81
72
 
82
73
  def _run(self) -> None:
83
74
  while True:
@@ -103,15 +94,17 @@ class CaptureDispatcher:
103
94
  if last_error is not None:
104
95
  self._on_exhausted(f"capture failed after {self._retries + 1} attempts: {last_error}")
105
96
 
106
- def flush(self, timeout: float = 2.0) -> None:
97
+ def flush(self, timeout: float = 2.0) -> bool:
107
98
  """Wait up to `timeout` seconds for in-flight traces to finish delivering.
108
99
 
109
100
  Counts items as done only after the worker calls `task_done()` (i.e. after
110
101
  delivery + any retries complete), not merely when they leave the queue.
102
+ Returns whether every accepted trace completed within the timeout.
111
103
  """
112
104
  deadline = time.monotonic() + timeout
113
105
  while self._queue.unfinished_tasks > 0 and time.monotonic() < deadline:
114
106
  time.sleep(0.02)
107
+ return self._queue.unfinished_tasks == 0
115
108
 
116
109
  def close(self) -> None:
117
110
  self._queue.put(_SHUTDOWN)