runapprentice 0.2.1__tar.gz → 0.4.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.
- {runapprentice-0.2.1 → runapprentice-0.4.0}/.gitignore +17 -1
- {runapprentice-0.2.1 → runapprentice-0.4.0}/PKG-INFO +70 -7
- {runapprentice-0.2.1 → runapprentice-0.4.0}/README.md +69 -6
- {runapprentice-0.2.1 → runapprentice-0.4.0}/pyproject.toml +1 -1
- {runapprentice-0.2.1 → runapprentice-0.4.0}/src/runapprentice/__init__.py +8 -0
- {runapprentice-0.2.1 → runapprentice-0.4.0}/src/runapprentice/cli.py +85 -5
- {runapprentice-0.2.1 → runapprentice-0.4.0}/src/runapprentice/client.py +62 -6
- runapprentice-0.4.0/src/runapprentice/models.py +324 -0
- {runapprentice-0.2.1 → runapprentice-0.4.0}/src/runapprentice/openai.py +60 -15
- {runapprentice-0.2.1 → runapprentice-0.4.0}/tests/test_cli.py +39 -1
- runapprentice-0.4.0/tests/test_client.py +776 -0
- runapprentice-0.4.0/tests/test_openai.py +254 -0
- runapprentice-0.2.1/src/runapprentice/models.py +0 -81
- runapprentice-0.2.1/tests/test_client.py +0 -326
- runapprentice-0.2.1/tests/test_openai.py +0 -91
- {runapprentice-0.2.1 → runapprentice-0.4.0}/LICENSE +0 -0
- {runapprentice-0.2.1 → runapprentice-0.4.0}/src/runapprentice/_capture.py +0 -0
- {runapprentice-0.2.1 → runapprentice-0.4.0}/src/runapprentice/langchain.py +0 -0
- {runapprentice-0.2.1 → runapprentice-0.4.0}/src/runapprentice/local.py +0 -0
- {runapprentice-0.2.1 → runapprentice-0.4.0}/src/runapprentice/py.typed +0 -0
- {runapprentice-0.2.1 → runapprentice-0.4.0}/tests/test_capture.py +0 -0
- {runapprentice-0.2.1 → runapprentice-0.4.0}/tests/test_dispatcher.py +0 -0
- {runapprentice-0.2.1 → runapprentice-0.4.0}/tests/test_failopen.py +0 -0
- {runapprentice-0.2.1 → runapprentice-0.4.0}/tests/test_langchain_prompts.py +0 -0
- {runapprentice-0.2.1 → runapprentice-0.4.0}/tests/test_local.py +0 -0
|
@@ -34,5 +34,21 @@ optimized_program.json
|
|
|
34
34
|
apprentice-ui/codex_working.md
|
|
35
35
|
.codex/
|
|
36
36
|
|
|
37
|
-
# Local dev databases
|
|
37
|
+
# Local dev databases (and their manual backups)
|
|
38
38
|
*.db
|
|
39
|
+
*.db.bak*
|
|
40
|
+
|
|
41
|
+
# Local machine config with secrets — never commit
|
|
42
|
+
.mcp.json
|
|
43
|
+
|
|
44
|
+
# Local working artifacts, not repo content
|
|
45
|
+
# (.claude/skills holds symlinks into the ignored .agents/ dir — dangling on a fresh clone)
|
|
46
|
+
.claude/skills/
|
|
47
|
+
outputs/
|
|
48
|
+
test-sdk/
|
|
49
|
+
.agents/
|
|
50
|
+
skills-lock.json
|
|
51
|
+
docs/personal_learning.md
|
|
52
|
+
.uv-cache/
|
|
53
|
+
spike/Capture-*.png
|
|
54
|
+
apprentice-ui/ClaudeDesign/screenshots/localhost-*.png
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: runapprentice
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.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
|
|
@@ -31,7 +31,7 @@ Description-Content-Type: text/markdown
|
|
|
31
31
|
|
|
32
32
|
The Python SDK for [Apprentice](https://runapprentice.com), a tool that cuts frontier-LLM cost
|
|
33
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
|
|
34
|
+
as a CSV, or captured live from an OpenAI or LangChain app). Apprentice runs prompt
|
|
35
35
|
optimization (DSPy GEPA) against the verified rows (gold plus silver), or fine-tunes a small open model on
|
|
36
36
|
your Mac, and reports the score change on a held-out slice, so the gain is measured, never
|
|
37
37
|
claimed. Gated, eval-verified takeover of production traffic is still in development.
|
|
@@ -89,7 +89,54 @@ print(report.baseline_score, "->", report.optimized_score)
|
|
|
89
89
|
|
|
90
90
|
# 4. Pull the optimized prompt back into your code (versioned)
|
|
91
91
|
best = client.prompts.get("ticket-triage")
|
|
92
|
-
|
|
92
|
+
resp = OpenAI().chat.completions.create( # from openai import OpenAI
|
|
93
|
+
model="gpt-5-mini",
|
|
94
|
+
messages=best.messages(input=ticket_text),
|
|
95
|
+
)
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
`datasets.upload()` replaces prior rows for this task where the source is an upload and
|
|
99
|
+
the tier is silver or raw. Gold rows and SDK-captured traces stay intact. Omitting `prompt`
|
|
100
|
+
or passing `None` clears the task's stored baseline prompt. Check
|
|
101
|
+
`DatasetStatus.replaced_rows` to see how many rows were removed.
|
|
102
|
+
|
|
103
|
+
`best.text` is instruction text, not a template. It holds no input placeholder and often
|
|
104
|
+
contains literal JSON braces, so `best.text.format(input=...)` raises `KeyError` and
|
|
105
|
+
`best.text.replace("{input}", ...)` drops the ticket with no error at all. `messages()`
|
|
106
|
+
renders the message shape the backend recorded when it scored this version. Match the rest
|
|
107
|
+
of the request too if you want the reported score to describe your call: JSON metrics were
|
|
108
|
+
scored with `response_format={"type": "json_object"}` (on the Responses API, that is
|
|
109
|
+
`text={"format": {"type": "json_object"}}`), on the model in
|
|
110
|
+
`report.detail["student_model"]`. Versions optimized before 0.4.0 carry no recorded shape
|
|
111
|
+
and fall back to the task's current baseline prompt. The keyword names are the artifact's `input_variables`:
|
|
112
|
+
`input` for a plain task, `question` and `context` for RAG, your own names if you
|
|
113
|
+
registered a template.
|
|
114
|
+
|
|
115
|
+
## Live capture: OpenAI (one wrapper)
|
|
116
|
+
|
|
117
|
+
```python
|
|
118
|
+
from openai import OpenAI
|
|
119
|
+
from runapprentice.openai import wrap
|
|
120
|
+
|
|
121
|
+
openai = wrap(OpenAI(), client, task="ticket-triage")
|
|
122
|
+
|
|
123
|
+
response = openai.responses.create(
|
|
124
|
+
model="gpt-5-mini",
|
|
125
|
+
input="Triage this ticket.",
|
|
126
|
+
)
|
|
127
|
+
# create() and parse() are captured, on chat.completions and responses alike
|
|
128
|
+
# (parse posts on its own, so patching create alone would miss it).
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
Both sync `OpenAI` and async `AsyncOpenAI` clients work. Capture is fail-open and
|
|
132
|
+
returns every OpenAI result unchanged. Calls with `stream=True` and calls through
|
|
133
|
+
`responses.stream(...)` pass through uncaptured because reading them would consume
|
|
134
|
+
the caller's stream.
|
|
135
|
+
|
|
136
|
+
**PII redaction** runs in your process, before anything is transmitted:
|
|
137
|
+
|
|
138
|
+
```python
|
|
139
|
+
openai = wrap(OpenAI(), client, task="ticket-triage", redact=lambda s: my_scrubber(s))
|
|
93
140
|
```
|
|
94
141
|
|
|
95
142
|
## Live capture: LangChain (one line, zero code changes)
|
|
@@ -226,17 +273,33 @@ count and the minimum).
|
|
|
226
273
|
|---|---|---|
|
|
227
274
|
| `Apprentice(api_key=, base_url=, timeout=)` | client | `ApprenticeError` on bad config |
|
|
228
275
|
| `client.tasks.create(name, metric="auto")` | dict (`created` bool) | `ApprenticeError` on HTTP failure |
|
|
229
|
-
| `client.datasets.upload(task, path= or rows=, input_col=, output_col=, prompt=)` | `DatasetStatus` | same; also if both/neither of path/rows |
|
|
276
|
+
| `client.datasets.upload(task, path= or rows=, input_col=, output_col=, prompt=)` | `DatasetStatus` including `replaced_rows` | same; also if both/neither of path/rows |
|
|
230
277
|
| `client.datasets.status(task)` | `DatasetStatus(gold, silver, raw, ready_for_optimization)` | same |
|
|
231
278
|
| `client.prompts.register(task, template)` | dict | `ImportError` if a LangChain template is passed without the `[langchain]` extra |
|
|
232
279
|
| `client.prompts.to_langchain(task= or artifact=)` | LangChain prompt | `ImportError` without the `[langchain]` extra |
|
|
233
|
-
| `client.optimize(task)` | `Job` | 400 if fewer than the backend's configured verified-row minimum (20 by default) |
|
|
280
|
+
| `client.optimize(task, baseline_prompt=None, budget=None, metric=None)` | `Job` | 400 if fewer than the backend's configured verified-row minimum (20 by default) |
|
|
281
|
+
| `client.preview_optimization(task, baseline_prompt=None, budget=None, metric=None)` | `OptimizationPreview` | `ApprenticeError` on API failure |
|
|
282
|
+
| `client.train(task)` | `Job` | 400 if fewer gold rows than the backend's training minimum |
|
|
234
283
|
| `client.job(job_id)` / `job.refresh()` / `job.wait(poll_seconds=, timeout_seconds=)` | `Job` | `ApprenticeError` on timeout |
|
|
235
|
-
| `
|
|
236
|
-
| `
|
|
284
|
+
| `client.cancel_job(job_id)` | dict (`cancel_requested`, `status`) | 409 if job is not cancellable |
|
|
285
|
+
| `job.cancel()` | dict (`cancel_requested`, `status`) | 409 if job is not cancellable |
|
|
286
|
+
| `client.jobs(task)` | `list[JobSummary]` | `ApprenticeError` on API failure |
|
|
287
|
+
| `client.local_runs(task)` | `list[LocalRun]` | `ApprenticeError` on API failure |
|
|
288
|
+
| `client.datasets.rows(task, tier, limit=200)` | `list[DatasetRow]` | `ApprenticeError` on API failure |
|
|
289
|
+
| `client.optimize_local(task, data=None, **options)` | dict (local GEPA result) | `ApprenticeError` if no `data` and no API key |
|
|
290
|
+
| `client.train_local(task, data=None, effort=None, **options)` | dict (local MLX result) | `ApprenticeError` if no `data` and no API key |
|
|
291
|
+
| `client.register_local_run(task, base_model, dataset_row_count, seed, config=None)` | `LocalRun` | `ApprenticeError` on API failure |
|
|
292
|
+
| `client.complete_local_run(run_id, payload)` | dict | `ApprenticeError` on API failure |
|
|
293
|
+
| `client.capture(task, output, input= or inputs=, model=, latency_ms=, ...)` | trace_id or None | **never raises** |
|
|
294
|
+
| `client.flush_captures(timeout=2.0)` | None (blocks until buffered traces deliver or timeout; no-op in sync mode) | nothing by design |
|
|
295
|
+
| `client.close()` | None (flushes and closes transport) | nothing by design |
|
|
296
|
+
| `job.report()` | typed optimize, train, or generate report; raw dict for future shapes | if job has no report |
|
|
297
|
+
| `client.prompts.get(task, version=None)` | `PromptVersion(version, text, score, baseline_prompt)` | 404 if never optimized |
|
|
298
|
+
| `PromptVersion.messages(user_prompt=None, **inputs)` | `list[dict]` reproducing the scored call | `ValueError` if a keyword is not one of the artifact's `input_variables` |
|
|
237
299
|
| `client.prompts.history(task)` | `list[PromptVersion]` | `ApprenticeError` on API failure |
|
|
238
300
|
| `client.feedback(trace_id, good=, score=, note=)` | None | 404 unknown trace; `ApprenticeError` if pending captures cannot drain in time, or on API failure |
|
|
239
301
|
| `client.post_trace_failopen(record)` | trace_id or None | **never raises** |
|
|
302
|
+
| `wrap(openai_client, client, task, redact=None)` | the same OpenAI client, with chat completions and responses captured | **never raises from capture; OpenAI call errors pass through unchanged** |
|
|
240
303
|
| `ApprenticeCallback(task, client, redact=None)` | LangChain callback | **never raises into your chain** |
|
|
241
304
|
|
|
242
305
|
## Data tiers (how your rows are treated)
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
The Python SDK for [Apprentice](https://runapprentice.com), a tool that cuts frontier-LLM cost
|
|
4
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
|
|
5
|
+
as a CSV, or captured live from an OpenAI or LangChain app). Apprentice runs prompt
|
|
6
6
|
optimization (DSPy GEPA) against the verified rows (gold plus silver), or fine-tunes a small open model on
|
|
7
7
|
your Mac, and reports the score change on a held-out slice, so the gain is measured, never
|
|
8
8
|
claimed. Gated, eval-verified takeover of production traffic is still in development.
|
|
@@ -60,7 +60,54 @@ print(report.baseline_score, "->", report.optimized_score)
|
|
|
60
60
|
|
|
61
61
|
# 4. Pull the optimized prompt back into your code (versioned)
|
|
62
62
|
best = client.prompts.get("ticket-triage")
|
|
63
|
-
|
|
63
|
+
resp = OpenAI().chat.completions.create( # from openai import OpenAI
|
|
64
|
+
model="gpt-5-mini",
|
|
65
|
+
messages=best.messages(input=ticket_text),
|
|
66
|
+
)
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
`datasets.upload()` replaces prior rows for this task where the source is an upload and
|
|
70
|
+
the tier is silver or raw. Gold rows and SDK-captured traces stay intact. Omitting `prompt`
|
|
71
|
+
or passing `None` clears the task's stored baseline prompt. Check
|
|
72
|
+
`DatasetStatus.replaced_rows` to see how many rows were removed.
|
|
73
|
+
|
|
74
|
+
`best.text` is instruction text, not a template. It holds no input placeholder and often
|
|
75
|
+
contains literal JSON braces, so `best.text.format(input=...)` raises `KeyError` and
|
|
76
|
+
`best.text.replace("{input}", ...)` drops the ticket with no error at all. `messages()`
|
|
77
|
+
renders the message shape the backend recorded when it scored this version. Match the rest
|
|
78
|
+
of the request too if you want the reported score to describe your call: JSON metrics were
|
|
79
|
+
scored with `response_format={"type": "json_object"}` (on the Responses API, that is
|
|
80
|
+
`text={"format": {"type": "json_object"}}`), on the model in
|
|
81
|
+
`report.detail["student_model"]`. Versions optimized before 0.4.0 carry no recorded shape
|
|
82
|
+
and fall back to the task's current baseline prompt. The keyword names are the artifact's `input_variables`:
|
|
83
|
+
`input` for a plain task, `question` and `context` for RAG, your own names if you
|
|
84
|
+
registered a template.
|
|
85
|
+
|
|
86
|
+
## Live capture: OpenAI (one wrapper)
|
|
87
|
+
|
|
88
|
+
```python
|
|
89
|
+
from openai import OpenAI
|
|
90
|
+
from runapprentice.openai import wrap
|
|
91
|
+
|
|
92
|
+
openai = wrap(OpenAI(), client, task="ticket-triage")
|
|
93
|
+
|
|
94
|
+
response = openai.responses.create(
|
|
95
|
+
model="gpt-5-mini",
|
|
96
|
+
input="Triage this ticket.",
|
|
97
|
+
)
|
|
98
|
+
# create() and parse() are captured, on chat.completions and responses alike
|
|
99
|
+
# (parse posts on its own, so patching create alone would miss it).
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Both sync `OpenAI` and async `AsyncOpenAI` clients work. Capture is fail-open and
|
|
103
|
+
returns every OpenAI result unchanged. Calls with `stream=True` and calls through
|
|
104
|
+
`responses.stream(...)` pass through uncaptured because reading them would consume
|
|
105
|
+
the caller's stream.
|
|
106
|
+
|
|
107
|
+
**PII redaction** runs in your process, before anything is transmitted:
|
|
108
|
+
|
|
109
|
+
```python
|
|
110
|
+
openai = wrap(OpenAI(), client, task="ticket-triage", redact=lambda s: my_scrubber(s))
|
|
64
111
|
```
|
|
65
112
|
|
|
66
113
|
## Live capture: LangChain (one line, zero code changes)
|
|
@@ -197,17 +244,33 @@ count and the minimum).
|
|
|
197
244
|
|---|---|---|
|
|
198
245
|
| `Apprentice(api_key=, base_url=, timeout=)` | client | `ApprenticeError` on bad config |
|
|
199
246
|
| `client.tasks.create(name, metric="auto")` | dict (`created` bool) | `ApprenticeError` on HTTP failure |
|
|
200
|
-
| `client.datasets.upload(task, path= or rows=, input_col=, output_col=, prompt=)` | `DatasetStatus` | same; also if both/neither of path/rows |
|
|
247
|
+
| `client.datasets.upload(task, path= or rows=, input_col=, output_col=, prompt=)` | `DatasetStatus` including `replaced_rows` | same; also if both/neither of path/rows |
|
|
201
248
|
| `client.datasets.status(task)` | `DatasetStatus(gold, silver, raw, ready_for_optimization)` | same |
|
|
202
249
|
| `client.prompts.register(task, template)` | dict | `ImportError` if a LangChain template is passed without the `[langchain]` extra |
|
|
203
250
|
| `client.prompts.to_langchain(task= or artifact=)` | LangChain prompt | `ImportError` without the `[langchain]` extra |
|
|
204
|
-
| `client.optimize(task)` | `Job` | 400 if fewer than the backend's configured verified-row minimum (20 by default) |
|
|
251
|
+
| `client.optimize(task, baseline_prompt=None, budget=None, metric=None)` | `Job` | 400 if fewer than the backend's configured verified-row minimum (20 by default) |
|
|
252
|
+
| `client.preview_optimization(task, baseline_prompt=None, budget=None, metric=None)` | `OptimizationPreview` | `ApprenticeError` on API failure |
|
|
253
|
+
| `client.train(task)` | `Job` | 400 if fewer gold rows than the backend's training minimum |
|
|
205
254
|
| `client.job(job_id)` / `job.refresh()` / `job.wait(poll_seconds=, timeout_seconds=)` | `Job` | `ApprenticeError` on timeout |
|
|
206
|
-
| `
|
|
207
|
-
| `
|
|
255
|
+
| `client.cancel_job(job_id)` | dict (`cancel_requested`, `status`) | 409 if job is not cancellable |
|
|
256
|
+
| `job.cancel()` | dict (`cancel_requested`, `status`) | 409 if job is not cancellable |
|
|
257
|
+
| `client.jobs(task)` | `list[JobSummary]` | `ApprenticeError` on API failure |
|
|
258
|
+
| `client.local_runs(task)` | `list[LocalRun]` | `ApprenticeError` on API failure |
|
|
259
|
+
| `client.datasets.rows(task, tier, limit=200)` | `list[DatasetRow]` | `ApprenticeError` on API failure |
|
|
260
|
+
| `client.optimize_local(task, data=None, **options)` | dict (local GEPA result) | `ApprenticeError` if no `data` and no API key |
|
|
261
|
+
| `client.train_local(task, data=None, effort=None, **options)` | dict (local MLX result) | `ApprenticeError` if no `data` and no API key |
|
|
262
|
+
| `client.register_local_run(task, base_model, dataset_row_count, seed, config=None)` | `LocalRun` | `ApprenticeError` on API failure |
|
|
263
|
+
| `client.complete_local_run(run_id, payload)` | dict | `ApprenticeError` on API failure |
|
|
264
|
+
| `client.capture(task, output, input= or inputs=, model=, latency_ms=, ...)` | trace_id or None | **never raises** |
|
|
265
|
+
| `client.flush_captures(timeout=2.0)` | None (blocks until buffered traces deliver or timeout; no-op in sync mode) | nothing by design |
|
|
266
|
+
| `client.close()` | None (flushes and closes transport) | nothing by design |
|
|
267
|
+
| `job.report()` | typed optimize, train, or generate report; raw dict for future shapes | if job has no report |
|
|
268
|
+
| `client.prompts.get(task, version=None)` | `PromptVersion(version, text, score, baseline_prompt)` | 404 if never optimized |
|
|
269
|
+
| `PromptVersion.messages(user_prompt=None, **inputs)` | `list[dict]` reproducing the scored call | `ValueError` if a keyword is not one of the artifact's `input_variables` |
|
|
208
270
|
| `client.prompts.history(task)` | `list[PromptVersion]` | `ApprenticeError` on API failure |
|
|
209
271
|
| `client.feedback(trace_id, good=, score=, note=)` | None | 404 unknown trace; `ApprenticeError` if pending captures cannot drain in time, or on API failure |
|
|
210
272
|
| `client.post_trace_failopen(record)` | trace_id or None | **never raises** |
|
|
273
|
+
| `wrap(openai_client, client, task, redact=None)` | the same OpenAI client, with chat completions and responses captured | **never raises from capture; OpenAI call errors pass through unchanged** |
|
|
211
274
|
| `ApprenticeCallback(task, client, redact=None)` | LangChain callback | **never raises into your chain** |
|
|
212
275
|
|
|
213
276
|
## Data tiers (how your rows are treated)
|
|
@@ -4,12 +4,16 @@ from runapprentice.client import Apprentice, ApprenticeError, Job, enable_debug_
|
|
|
4
4
|
from runapprentice.models import (
|
|
5
5
|
DatasetRow,
|
|
6
6
|
DatasetStatus,
|
|
7
|
+
GenerationReport,
|
|
7
8
|
JobInfo,
|
|
9
|
+
JobReport,
|
|
8
10
|
JobSummary,
|
|
9
11
|
LocalRun,
|
|
10
12
|
OptimizationReport,
|
|
13
|
+
OptimizationPreview,
|
|
11
14
|
PromptVersion,
|
|
12
15
|
TraceRecord,
|
|
16
|
+
TrainingReport,
|
|
13
17
|
)
|
|
14
18
|
|
|
15
19
|
__all__ = [
|
|
@@ -19,12 +23,16 @@ __all__ = [
|
|
|
19
23
|
"Job",
|
|
20
24
|
"DatasetRow",
|
|
21
25
|
"DatasetStatus",
|
|
26
|
+
"GenerationReport",
|
|
22
27
|
"JobInfo",
|
|
28
|
+
"JobReport",
|
|
23
29
|
"JobSummary",
|
|
24
30
|
"LocalRun",
|
|
25
31
|
"OptimizationReport",
|
|
32
|
+
"OptimizationPreview",
|
|
26
33
|
"PromptVersion",
|
|
27
34
|
"TraceRecord",
|
|
35
|
+
"TrainingReport",
|
|
28
36
|
]
|
|
29
37
|
|
|
30
38
|
# Single source of truth is pyproject.toml; read it from package metadata so
|
|
@@ -13,7 +13,13 @@ from typing import Any
|
|
|
13
13
|
|
|
14
14
|
from runapprentice.client import Apprentice, ApprenticeError
|
|
15
15
|
from runapprentice import local as _local
|
|
16
|
-
from runapprentice.models import
|
|
16
|
+
from runapprentice.models import (
|
|
17
|
+
JobInfo,
|
|
18
|
+
JobReport,
|
|
19
|
+
JobSummary,
|
|
20
|
+
OptimizationPreview,
|
|
21
|
+
OptimizationReport,
|
|
22
|
+
)
|
|
17
23
|
|
|
18
24
|
CONFIG_DIR = Path("~/.apprentice").expanduser()
|
|
19
25
|
CONFIG_PATH = CONFIG_DIR / "config.json"
|
|
@@ -107,6 +113,22 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
107
113
|
type=Path,
|
|
108
114
|
help="CSV with input,output columns; required for --local unless an API key is set",
|
|
109
115
|
)
|
|
116
|
+
optimize.add_argument(
|
|
117
|
+
"--budget",
|
|
118
|
+
choices=("quick", "thorough"),
|
|
119
|
+
help="hosted optimization budget; omit for balanced default",
|
|
120
|
+
)
|
|
121
|
+
optimize.add_argument("--metric", help="metric used to score this hosted run")
|
|
122
|
+
optimize.add_argument(
|
|
123
|
+
"--prompt-file",
|
|
124
|
+
type=Path,
|
|
125
|
+
help="read hosted run baseline prompt from this UTF-8 text file",
|
|
126
|
+
)
|
|
127
|
+
optimize.add_argument(
|
|
128
|
+
"--preview",
|
|
129
|
+
action="store_true",
|
|
130
|
+
help="show hosted metric and cost estimate without enqueueing a run",
|
|
131
|
+
)
|
|
110
132
|
optimize.set_defaults(func=_optimize)
|
|
111
133
|
|
|
112
134
|
train = sub.add_parser(
|
|
@@ -153,6 +175,10 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
153
175
|
job = sub.add_parser("job", help="details for one hosted job")
|
|
154
176
|
job.add_argument("job_id", help="job id from `apprentice jobs`")
|
|
155
177
|
job.set_defaults(func=_job)
|
|
178
|
+
|
|
179
|
+
cancel = sub.add_parser("cancel", help="cancel a queued or running hosted job")
|
|
180
|
+
cancel.add_argument("job_id", help="job id from `apprentice jobs`")
|
|
181
|
+
cancel.set_defaults(func=_cancel)
|
|
156
182
|
return parser
|
|
157
183
|
|
|
158
184
|
|
|
@@ -180,6 +206,11 @@ def _status(args: argparse.Namespace, client: Apprentice) -> int:
|
|
|
180
206
|
|
|
181
207
|
def _optimize(args: argparse.Namespace, client: Apprentice) -> int:
|
|
182
208
|
if args.local:
|
|
209
|
+
if args.budget or args.metric or args.prompt_file or args.preview:
|
|
210
|
+
return _fail(
|
|
211
|
+
"--budget, --metric, --prompt-file, and --preview apply to hosted optimization",
|
|
212
|
+
bool(args.json),
|
|
213
|
+
)
|
|
183
214
|
return _optimize_local(
|
|
184
215
|
args.task,
|
|
185
216
|
args.data,
|
|
@@ -187,8 +218,39 @@ def _optimize(args: argparse.Namespace, client: Apprentice) -> int:
|
|
|
187
218
|
bool(args.json),
|
|
188
219
|
bool(args._api_key_present),
|
|
189
220
|
)
|
|
190
|
-
|
|
221
|
+
baseline_prompt = None
|
|
222
|
+
if args.prompt_file is not None:
|
|
223
|
+
try:
|
|
224
|
+
baseline_prompt = args.prompt_file.read_text(encoding="utf-8")
|
|
225
|
+
except OSError as exc:
|
|
226
|
+
raise ApprenticeError(f"could not read prompt file {args.prompt_file}: {exc}") from exc
|
|
227
|
+
if args.preview:
|
|
228
|
+
preview = client.preview_optimization(
|
|
229
|
+
args.task,
|
|
230
|
+
baseline_prompt=baseline_prompt,
|
|
231
|
+
budget=args.budget,
|
|
232
|
+
metric=args.metric,
|
|
233
|
+
)
|
|
234
|
+
return _emit(
|
|
235
|
+
preview.model_dump(mode="json"),
|
|
236
|
+
_preview_line(preview),
|
|
237
|
+
bool(args.json),
|
|
238
|
+
)
|
|
239
|
+
job = client.optimize(
|
|
240
|
+
args.task,
|
|
241
|
+
baseline_prompt=baseline_prompt,
|
|
242
|
+
budget=args.budget,
|
|
243
|
+
metric=args.metric,
|
|
244
|
+
).wait()
|
|
191
245
|
payload = job.info.model_dump(mode="json")
|
|
246
|
+
if job.info.status != "succeeded":
|
|
247
|
+
# Exit nonzero, or a caller that checks the exit code (every script and
|
|
248
|
+
# every agent) reads a failed run as a shipped one.
|
|
249
|
+
return _fail(
|
|
250
|
+
f"optimize {job.info.status}: {job.info.error or 'no error reported'}",
|
|
251
|
+
bool(args.json),
|
|
252
|
+
payload=payload,
|
|
253
|
+
)
|
|
192
254
|
return _emit(payload, _report_line(job.info.report), bool(args.json))
|
|
193
255
|
|
|
194
256
|
|
|
@@ -222,6 +284,11 @@ def _job(args: argparse.Namespace, client: Apprentice) -> int:
|
|
|
222
284
|
return _emit(job.info.model_dump(mode="json"), _job_info_line(job.info), bool(args.json))
|
|
223
285
|
|
|
224
286
|
|
|
287
|
+
def _cancel(args: argparse.Namespace, client: Apprentice) -> int:
|
|
288
|
+
result = client.cancel_job(args.job_id)
|
|
289
|
+
return _emit(result, f"cancel requested: {args.job_id} ({result['status']})", bool(args.json))
|
|
290
|
+
|
|
291
|
+
|
|
225
292
|
def _train_local(
|
|
226
293
|
task: str,
|
|
227
294
|
client: Apprentice,
|
|
@@ -424,20 +491,33 @@ def _job_info_line(job: JobInfo) -> str:
|
|
|
424
491
|
return f"{job.job_id} {job.kind} {job.status}"
|
|
425
492
|
|
|
426
493
|
|
|
427
|
-
def _report_line(report:
|
|
494
|
+
def _report_line(report: JobReport | None) -> str:
|
|
428
495
|
if report is None:
|
|
429
496
|
return "optimize finished with no report"
|
|
497
|
+
if not isinstance(report, OptimizationReport):
|
|
498
|
+
return "optimize finished with an unrecognized report"
|
|
430
499
|
return f"optimize done: baseline={report.baseline_score} optimized={report.optimized_score}"
|
|
431
500
|
|
|
432
501
|
|
|
502
|
+
def _preview_line(preview: OptimizationPreview) -> str:
|
|
503
|
+
estimate = preview.estimate
|
|
504
|
+
return (
|
|
505
|
+
f"optimize preview: metric={preview.eval_metric} budget={estimate.get('budget')} "
|
|
506
|
+
f"rows={estimate.get('rows')} cost_usd={estimate.get('cost_usd_low')}"
|
|
507
|
+
f"-{estimate.get('cost_usd_high')}"
|
|
508
|
+
)
|
|
509
|
+
|
|
510
|
+
|
|
433
511
|
def _emit(payload: dict[str, Any], text: str, as_json: bool) -> int:
|
|
434
512
|
print(json.dumps(payload, sort_keys=True) if as_json else text)
|
|
435
513
|
return 0
|
|
436
514
|
|
|
437
515
|
|
|
438
|
-
def _fail(message: str, as_json: bool) -> int:
|
|
516
|
+
def _fail(message: str, as_json: bool, payload: dict[str, Any] | None = None) -> int:
|
|
439
517
|
if as_json:
|
|
440
|
-
|
|
518
|
+
# Keep the job fields alongside the error so a caller parsing --json still
|
|
519
|
+
# gets the job id and status it needs to investigate.
|
|
520
|
+
print(json.dumps({**(payload or {}), "error": message}, sort_keys=True), file=sys.stderr)
|
|
441
521
|
else:
|
|
442
522
|
print(message, file=sys.stderr)
|
|
443
523
|
return 1
|
|
@@ -16,7 +16,7 @@ import os
|
|
|
16
16
|
import time
|
|
17
17
|
import uuid
|
|
18
18
|
from pathlib import Path
|
|
19
|
-
from typing import Any
|
|
19
|
+
from typing import Any, Literal
|
|
20
20
|
|
|
21
21
|
import httpx
|
|
22
22
|
|
|
@@ -25,9 +25,10 @@ from runapprentice.models import (
|
|
|
25
25
|
DatasetRow,
|
|
26
26
|
DatasetStatus,
|
|
27
27
|
JobInfo,
|
|
28
|
+
JobReport,
|
|
28
29
|
JobSummary,
|
|
29
30
|
LocalRun,
|
|
30
|
-
|
|
31
|
+
OptimizationPreview,
|
|
31
32
|
PromptVersion,
|
|
32
33
|
TraceRecord,
|
|
33
34
|
)
|
|
@@ -114,10 +115,50 @@ class Apprentice:
|
|
|
114
115
|
raise ApprenticeError(f"{method} {path} -> {resp.status_code}: {resp.text[:500]}{suffix}")
|
|
115
116
|
return resp.json()
|
|
116
117
|
|
|
117
|
-
def optimize(
|
|
118
|
-
|
|
118
|
+
def optimize(
|
|
119
|
+
self,
|
|
120
|
+
task: str,
|
|
121
|
+
baseline_prompt: str | None = None,
|
|
122
|
+
budget: Literal["quick", "thorough"] | None = None,
|
|
123
|
+
metric: str | None = None,
|
|
124
|
+
) -> Job:
|
|
125
|
+
payload = {"task": task}
|
|
126
|
+
payload.update(
|
|
127
|
+
{
|
|
128
|
+
key: value
|
|
129
|
+
for key, value in {
|
|
130
|
+
"baseline_prompt": baseline_prompt,
|
|
131
|
+
"budget": budget,
|
|
132
|
+
"metric": metric,
|
|
133
|
+
}.items()
|
|
134
|
+
if value is not None
|
|
135
|
+
}
|
|
136
|
+
)
|
|
137
|
+
data = self._request("POST", "/v1/optimize", json=payload)
|
|
119
138
|
return Job(self, JobInfo.model_validate(data))
|
|
120
139
|
|
|
140
|
+
def preview_optimization(
|
|
141
|
+
self,
|
|
142
|
+
task: str,
|
|
143
|
+
baseline_prompt: str | None = None,
|
|
144
|
+
budget: Literal["quick", "thorough"] | None = None,
|
|
145
|
+
metric: str | None = None,
|
|
146
|
+
) -> OptimizationPreview:
|
|
147
|
+
payload = {"task": task}
|
|
148
|
+
payload.update(
|
|
149
|
+
{
|
|
150
|
+
key: value
|
|
151
|
+
for key, value in {
|
|
152
|
+
"baseline_prompt": baseline_prompt,
|
|
153
|
+
"budget": budget,
|
|
154
|
+
"metric": metric,
|
|
155
|
+
}.items()
|
|
156
|
+
if value is not None
|
|
157
|
+
}
|
|
158
|
+
)
|
|
159
|
+
data = self._request("POST", "/v1/optimize/preview", json=payload)
|
|
160
|
+
return OptimizationPreview.model_validate(data)
|
|
161
|
+
|
|
121
162
|
def job(self, job_id: str) -> Job:
|
|
122
163
|
data = self._request("GET", f"/v1/jobs/{job_id}")
|
|
123
164
|
return Job(self, JobInfo.model_validate(data))
|
|
@@ -126,6 +167,13 @@ class Apprentice:
|
|
|
126
167
|
data = self._request("GET", f"/v1/tasks/{task}/jobs")
|
|
127
168
|
return [JobSummary.model_validate(item) for item in data]
|
|
128
169
|
|
|
170
|
+
def cancel_job(self, job_id: str) -> dict[str, Any]:
|
|
171
|
+
return dict(self._request("POST", f"/v1/jobs/{job_id}/cancel"))
|
|
172
|
+
|
|
173
|
+
def local_runs(self, task: str) -> list[LocalRun]:
|
|
174
|
+
data = self._request("GET", f"/v1/tasks/{task}/local-runs")
|
|
175
|
+
return [LocalRun.model_validate(item) for item in data]
|
|
176
|
+
|
|
129
177
|
def train(self, task: str) -> Job:
|
|
130
178
|
data = self._request("POST", f"/v1/tasks/{task}/train", json={})
|
|
131
179
|
return Job(self, JobInfo.model_validate(data))
|
|
@@ -350,7 +398,10 @@ class Job:
|
|
|
350
398
|
time.sleep(poll_seconds)
|
|
351
399
|
return self
|
|
352
400
|
|
|
353
|
-
def
|
|
401
|
+
def cancel(self) -> dict[str, Any]:
|
|
402
|
+
return self._client.cancel_job(self.info.job_id)
|
|
403
|
+
|
|
404
|
+
def report(self) -> JobReport:
|
|
354
405
|
if self.info.report is None:
|
|
355
406
|
raise ApprenticeError(
|
|
356
407
|
f"job {self.info.job_id} has no report (status={self.info.status})"
|
|
@@ -371,10 +422,15 @@ class _DatasetsAPI:
|
|
|
371
422
|
output_col: str = "output",
|
|
372
423
|
prompt: str | None = None,
|
|
373
424
|
) -> DatasetStatus:
|
|
374
|
-
"""
|
|
425
|
+
"""Replace uploaded, unverified rows for a task.
|
|
375
426
|
|
|
376
427
|
Rows may use legacy `input`/`output` or structured `inputs`/`output`.
|
|
377
428
|
LCEL auto-capture of prompt variables is future work; v1 stays explicit.
|
|
429
|
+
|
|
430
|
+
This is a replace, not an append: existing uploaded silver and raw rows are
|
|
431
|
+
removed first, and `replaced_rows` in the response reports how many. Gold
|
|
432
|
+
rows and captured traces survive. Omitting `prompt` (or passing `None`)
|
|
433
|
+
clears the task's stored baseline prompt.
|
|
378
434
|
"""
|
|
379
435
|
if (path is None) == (rows is None):
|
|
380
436
|
raise ApprenticeError("provide exactly one of path= or rows=")
|