runapprentice 0.3.0__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.3.0 → runapprentice-0.4.0}/PKG-INFO +54 -5
- {runapprentice-0.3.0 → runapprentice-0.4.0}/README.md +53 -4
- {runapprentice-0.3.0 → runapprentice-0.4.0}/pyproject.toml +1 -1
- {runapprentice-0.3.0 → runapprentice-0.4.0}/src/runapprentice/cli.py +12 -2
- {runapprentice-0.3.0 → runapprentice-0.4.0}/src/runapprentice/client.py +6 -1
- runapprentice-0.4.0/src/runapprentice/models.py +324 -0
- {runapprentice-0.3.0 → runapprentice-0.4.0}/src/runapprentice/openai.py +60 -15
- {runapprentice-0.3.0 → runapprentice-0.4.0}/tests/test_client.py +245 -1
- runapprentice-0.4.0/tests/test_openai.py +254 -0
- runapprentice-0.3.0/src/runapprentice/models.py +0 -179
- runapprentice-0.3.0/tests/test_openai.py +0 -91
- {runapprentice-0.3.0 → runapprentice-0.4.0}/.gitignore +0 -0
- {runapprentice-0.3.0 → runapprentice-0.4.0}/LICENSE +0 -0
- {runapprentice-0.3.0 → runapprentice-0.4.0}/src/runapprentice/__init__.py +0 -0
- {runapprentice-0.3.0 → runapprentice-0.4.0}/src/runapprentice/_capture.py +0 -0
- {runapprentice-0.3.0 → runapprentice-0.4.0}/src/runapprentice/langchain.py +0 -0
- {runapprentice-0.3.0 → runapprentice-0.4.0}/src/runapprentice/local.py +0 -0
- {runapprentice-0.3.0 → runapprentice-0.4.0}/src/runapprentice/py.typed +0 -0
- {runapprentice-0.3.0 → runapprentice-0.4.0}/tests/test_capture.py +0 -0
- {runapprentice-0.3.0 → runapprentice-0.4.0}/tests/test_cli.py +0 -0
- {runapprentice-0.3.0 → runapprentice-0.4.0}/tests/test_dispatcher.py +0 -0
- {runapprentice-0.3.0 → runapprentice-0.4.0}/tests/test_failopen.py +0 -0
- {runapprentice-0.3.0 → runapprentice-0.4.0}/tests/test_langchain_prompts.py +0 -0
- {runapprentice-0.3.0 → runapprentice-0.4.0}/tests/test_local.py +0 -0
|
@@ -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,7 +273,7 @@ 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 |
|
|
@@ -247,10 +294,12 @@ count and the minimum).
|
|
|
247
294
|
| `client.flush_captures(timeout=2.0)` | None (blocks until buffered traces deliver or timeout; no-op in sync mode) | nothing by design |
|
|
248
295
|
| `client.close()` | None (flushes and closes transport) | nothing by design |
|
|
249
296
|
| `job.report()` | typed optimize, train, or generate report; raw dict for future shapes | if job has no report |
|
|
250
|
-
| `client.prompts.get(task, version=None)` | `PromptVersion(version, text, score)` | 404 if never optimized |
|
|
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` |
|
|
251
299
|
| `client.prompts.history(task)` | `list[PromptVersion]` | `ApprenticeError` on API failure |
|
|
252
300
|
| `client.feedback(trace_id, good=, score=, note=)` | None | 404 unknown trace; `ApprenticeError` if pending captures cannot drain in time, or on API failure |
|
|
253
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** |
|
|
254
303
|
| `ApprenticeCallback(task, client, redact=None)` | LangChain callback | **never raises into your chain** |
|
|
255
304
|
|
|
256
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,7 +244,7 @@ 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 |
|
|
@@ -218,10 +265,12 @@ count and the minimum).
|
|
|
218
265
|
| `client.flush_captures(timeout=2.0)` | None (blocks until buffered traces deliver or timeout; no-op in sync mode) | nothing by design |
|
|
219
266
|
| `client.close()` | None (flushes and closes transport) | nothing by design |
|
|
220
267
|
| `job.report()` | typed optimize, train, or generate report; raw dict for future shapes | if job has no report |
|
|
221
|
-
| `client.prompts.get(task, version=None)` | `PromptVersion(version, text, score)` | 404 if never optimized |
|
|
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` |
|
|
222
270
|
| `client.prompts.history(task)` | `list[PromptVersion]` | `ApprenticeError` on API failure |
|
|
223
271
|
| `client.feedback(trace_id, good=, score=, note=)` | None | 404 unknown trace; `ApprenticeError` if pending captures cannot drain in time, or on API failure |
|
|
224
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** |
|
|
225
274
|
| `ApprenticeCallback(task, client, redact=None)` | LangChain callback | **never raises into your chain** |
|
|
226
275
|
|
|
227
276
|
## Data tiers (how your rows are treated)
|
|
@@ -243,6 +243,14 @@ def _optimize(args: argparse.Namespace, client: Apprentice) -> int:
|
|
|
243
243
|
metric=args.metric,
|
|
244
244
|
).wait()
|
|
245
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
|
+
)
|
|
246
254
|
return _emit(payload, _report_line(job.info.report), bool(args.json))
|
|
247
255
|
|
|
248
256
|
|
|
@@ -505,9 +513,11 @@ def _emit(payload: dict[str, Any], text: str, as_json: bool) -> int:
|
|
|
505
513
|
return 0
|
|
506
514
|
|
|
507
515
|
|
|
508
|
-
def _fail(message: str, as_json: bool) -> int:
|
|
516
|
+
def _fail(message: str, as_json: bool, payload: dict[str, Any] | None = None) -> int:
|
|
509
517
|
if as_json:
|
|
510
|
-
|
|
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)
|
|
511
521
|
else:
|
|
512
522
|
print(message, file=sys.stderr)
|
|
513
523
|
return 1
|
|
@@ -422,10 +422,15 @@ class _DatasetsAPI:
|
|
|
422
422
|
output_col: str = "output",
|
|
423
423
|
prompt: str | None = None,
|
|
424
424
|
) -> DatasetStatus:
|
|
425
|
-
"""
|
|
425
|
+
"""Replace uploaded, unverified rows for a task.
|
|
426
426
|
|
|
427
427
|
Rows may use legacy `input`/`output` or structured `inputs`/`output`.
|
|
428
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.
|
|
429
434
|
"""
|
|
430
435
|
if (path is None) == (rows is None):
|
|
431
436
|
raise ApprenticeError("provide exactly one of path= or rows=")
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
"""Pydantic models for the Apprentice SDK. Mirrors the control-plane API contract."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
import re
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
from pydantic import BaseModel, Field, SkipValidation, ValidationError, model_validator
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class DatasetRow(BaseModel):
|
|
13
|
+
id: str | None = None
|
|
14
|
+
input: str
|
|
15
|
+
inputs: dict[str, str] | None = None
|
|
16
|
+
output: str
|
|
17
|
+
tier: str | None = None
|
|
18
|
+
source: str | None = None
|
|
19
|
+
flag_label: str | None = None
|
|
20
|
+
feedback_score: float | None = None
|
|
21
|
+
expert_authored: bool | None = None
|
|
22
|
+
created_at: datetime | None = None
|
|
23
|
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class DatasetStatus(BaseModel):
|
|
27
|
+
task: str
|
|
28
|
+
gold: int = 0
|
|
29
|
+
silver: int = 0
|
|
30
|
+
raw: int = 0
|
|
31
|
+
ready_for_optimization: bool = False
|
|
32
|
+
replaced_rows: int = 0
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class TraceRecord(BaseModel):
|
|
36
|
+
trace_id: str | None = Field(default=None, pattern=r"^[0-9a-f]{32}$")
|
|
37
|
+
task: str
|
|
38
|
+
input: str
|
|
39
|
+
inputs: dict[str, str] | None = None
|
|
40
|
+
output: str
|
|
41
|
+
model: str | None = None
|
|
42
|
+
latency_ms: float | None = None
|
|
43
|
+
prompt_tokens: int | None = None
|
|
44
|
+
completion_tokens: int | None = None
|
|
45
|
+
error: str | None = None
|
|
46
|
+
metadata: dict[str, Any] = Field(default_factory=dict)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class HoldoutEvidenceRow(BaseModel):
|
|
50
|
+
row_id: str | None = None
|
|
51
|
+
tier: str | None = None
|
|
52
|
+
bucket: str | None = None
|
|
53
|
+
input: str | None = None
|
|
54
|
+
expected: str | None = None
|
|
55
|
+
baseline_output: str | None = None
|
|
56
|
+
baseline_score: float | None = None
|
|
57
|
+
optimized_output: str | None = None
|
|
58
|
+
optimized_score: float | None = None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class OptimizationReport(BaseModel):
|
|
62
|
+
task: str | None = None
|
|
63
|
+
baseline_score: float | None = None
|
|
64
|
+
optimized_score: float | None = None
|
|
65
|
+
examples_used: int | None = None
|
|
66
|
+
baseline_prompt: str | None = None
|
|
67
|
+
baseline_from_user: bool | None = None
|
|
68
|
+
candidates_explored: int | None = None
|
|
69
|
+
duplicates_dropped: int | None = None
|
|
70
|
+
dataset_fingerprint: str | None = None
|
|
71
|
+
dataset_size: int | None = None
|
|
72
|
+
optimized_prompt: str | None = None
|
|
73
|
+
executable_prompt: dict[str, Any] | None = None
|
|
74
|
+
optimized_template: dict[str, Any] | None = None
|
|
75
|
+
template_safe: bool | None = None
|
|
76
|
+
template_placeholder_changes: dict[str, list[str]] | None = None
|
|
77
|
+
eval_metric: str | None = None
|
|
78
|
+
metric_tier: str | None = None
|
|
79
|
+
metric_version: str | None = None
|
|
80
|
+
judge_estimate: bool | None = None
|
|
81
|
+
judge_model: str | None = None
|
|
82
|
+
judge_cache: dict[str, int] | None = None
|
|
83
|
+
score_explanation: str | None = None
|
|
84
|
+
worst_examples: list[dict[str, Any]] | None = None
|
|
85
|
+
failure_count: int | None = None
|
|
86
|
+
holdout_evidence: list[HoldoutEvidenceRow] | None = None
|
|
87
|
+
improved_count: int | None = None
|
|
88
|
+
regressed_count: int | None = None
|
|
89
|
+
unchanged_count: int | None = None
|
|
90
|
+
duration_seconds: float | None = None
|
|
91
|
+
cost_usd: float | None = None
|
|
92
|
+
input_tokens: int | None = None
|
|
93
|
+
output_tokens: int | None = None
|
|
94
|
+
cached_input_tokens: int | None = None
|
|
95
|
+
reasoning_tokens: int | None = None
|
|
96
|
+
cache_replayed_calls: int | None = None
|
|
97
|
+
llm_student_model: str | None = None
|
|
98
|
+
llm_reflection_model: str | None = None
|
|
99
|
+
llm_judge_model: str | None = None
|
|
100
|
+
llm_fallback_used: bool | None = None
|
|
101
|
+
detail: dict[str, Any] = Field(default_factory=dict)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class TrainingReport(BaseModel):
|
|
105
|
+
task: str | None = None
|
|
106
|
+
model_id: str | None = None
|
|
107
|
+
base_model: str | None = None
|
|
108
|
+
adapter_ref: str | None = None
|
|
109
|
+
champion_score: float | None = None
|
|
110
|
+
challenger_score: float | None = None
|
|
111
|
+
eval_metric: str | None = None
|
|
112
|
+
metric_tier: str | None = None
|
|
113
|
+
holdout_size: int | None = None
|
|
114
|
+
status: str | None = None
|
|
115
|
+
reason: str | None = None
|
|
116
|
+
config: dict[str, Any] | None = None
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class GenerationReport(BaseModel):
|
|
120
|
+
rows_inserted: int | None = None
|
|
121
|
+
rows_rejected: int | None = None
|
|
122
|
+
reject_reasons: dict[str, int] | None = None
|
|
123
|
+
model: str | None = None
|
|
124
|
+
llm_fallback_used: bool | None = None
|
|
125
|
+
input_tokens: int | None = None
|
|
126
|
+
output_tokens: int | None = None
|
|
127
|
+
samples: list[dict[str, Any]] | None = None
|
|
128
|
+
chunks_truncated: int | None = None
|
|
129
|
+
response_failures: int | None = None
|
|
130
|
+
duplicate_inputs: int | None = None
|
|
131
|
+
executor_requested: str | None = None
|
|
132
|
+
executor_actual_models: list[str] | None = None
|
|
133
|
+
executor_fallback_used: bool | None = None
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
JobReport = OptimizationReport | TrainingReport | GenerationReport | dict[str, Any]
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class JobInfo(BaseModel):
|
|
140
|
+
job_id: str
|
|
141
|
+
task: str # task ID; use task_name when calling task-scoped client methods
|
|
142
|
+
task_name: str | None = None
|
|
143
|
+
kind: str
|
|
144
|
+
status: str
|
|
145
|
+
created_at: datetime | None = None
|
|
146
|
+
error: str | None = None
|
|
147
|
+
report: SkipValidation[JobReport] | None = None
|
|
148
|
+
progress: dict[str, Any] | None = None
|
|
149
|
+
queue_position: int | None = None
|
|
150
|
+
baseline_score: float | None = None
|
|
151
|
+
optimized_score: float | None = None
|
|
152
|
+
baseline_from_user: bool | None = None
|
|
153
|
+
generation_session_id: str | None = None
|
|
154
|
+
round: int | None = None
|
|
155
|
+
predecessor_job_id: str | None = None
|
|
156
|
+
phase: str | None = None
|
|
157
|
+
guidance: str | None = None
|
|
158
|
+
|
|
159
|
+
@model_validator(mode="before")
|
|
160
|
+
@classmethod
|
|
161
|
+
def parse_report_for_kind(cls, value: Any) -> Any:
|
|
162
|
+
if not isinstance(value, dict) or not isinstance(value.get("report"), dict):
|
|
163
|
+
return value
|
|
164
|
+
kind = value.get("kind")
|
|
165
|
+
if kind == "optimize":
|
|
166
|
+
report_type: type[BaseModel] = OptimizationReport
|
|
167
|
+
elif kind == "train":
|
|
168
|
+
report_type = TrainingReport
|
|
169
|
+
elif kind == "generate":
|
|
170
|
+
report_type = GenerationReport
|
|
171
|
+
else:
|
|
172
|
+
return value
|
|
173
|
+
try:
|
|
174
|
+
report = report_type.model_validate(value["report"])
|
|
175
|
+
except ValidationError:
|
|
176
|
+
return value
|
|
177
|
+
return {**value, "report": report}
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
class JobSummary(BaseModel):
|
|
181
|
+
job_id: str
|
|
182
|
+
task_name: str | None = None
|
|
183
|
+
status: str
|
|
184
|
+
kind: str
|
|
185
|
+
created_at: datetime | None = None
|
|
186
|
+
baseline_score: float | None = None
|
|
187
|
+
optimized_score: float | None = None
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
class LocalRun(BaseModel):
|
|
191
|
+
run_id: str
|
|
192
|
+
task: str | None = None
|
|
193
|
+
task_id: str | None = None
|
|
194
|
+
status: str = "registered"
|
|
195
|
+
eval_tier: str | None = None
|
|
196
|
+
base_model: str | None = None
|
|
197
|
+
dataset_row_count: int | None = None
|
|
198
|
+
seed: int | None = None
|
|
199
|
+
baseline_score: float | None = None
|
|
200
|
+
final_score: float | None = None
|
|
201
|
+
held_out_rows: int | None = None
|
|
202
|
+
adapter_sha256: str | None = None
|
|
203
|
+
mlx_lm_version: str | None = None
|
|
204
|
+
error_message: str | None = None
|
|
205
|
+
created_at: datetime | None = None
|
|
206
|
+
completed_at: datetime | None = None
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
class OptimizationPreview(BaseModel):
|
|
210
|
+
eval_metric: str
|
|
211
|
+
metric_tier: str
|
|
212
|
+
requested_metric: str
|
|
213
|
+
recommended_metric: str
|
|
214
|
+
metric_reason: str
|
|
215
|
+
rag_candidate: bool
|
|
216
|
+
rows_checked: int
|
|
217
|
+
rows_source: str
|
|
218
|
+
guidance: dict[str, Any]
|
|
219
|
+
estimate: dict[str, Any]
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
# LangChain roles (as stored in the artifact) -> chat-completions roles.
|
|
223
|
+
_CHAT_ROLE = {"human": "user", "ai": "assistant", "system": "system"}
|
|
224
|
+
_FORMAT_TOKEN = re.compile(r"\{\{|\}\}|\{(?P<name>[A-Za-z_][A-Za-z0-9_]*)\}")
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def _fill(template: str, values: dict[str, str]) -> str:
|
|
228
|
+
"""Substitute only declared variables, never raise.
|
|
229
|
+
|
|
230
|
+
Mirrors the backend's renderer: `{{`/`}}` are literal braces, and any other
|
|
231
|
+
brace run (a JSON example in the instruction, an unknown name) is left alone.
|
|
232
|
+
str.format would raise KeyError on the JSON braces these prompts routinely carry.
|
|
233
|
+
"""
|
|
234
|
+
|
|
235
|
+
def repl(match: re.Match[str]) -> str:
|
|
236
|
+
token = match.group(0)
|
|
237
|
+
if token in ("{{", "}}"):
|
|
238
|
+
return token[0]
|
|
239
|
+
name = match.group("name")
|
|
240
|
+
return str(values[name]) if name in values else token
|
|
241
|
+
|
|
242
|
+
return _FORMAT_TOKEN.sub(repl, template)
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
class PromptVersion(BaseModel):
|
|
246
|
+
task: str
|
|
247
|
+
version: int
|
|
248
|
+
text: str
|
|
249
|
+
score: float | None = None
|
|
250
|
+
# The message shape this version's score was measured with. Frozen server-side
|
|
251
|
+
# when the version was stored, so it does not move when the task baseline does.
|
|
252
|
+
artifact: dict[str, Any] | None = None
|
|
253
|
+
baseline_prompt: str | None = None
|
|
254
|
+
|
|
255
|
+
# `user_prompt` is positional-only: a task is free to have an input variable of
|
|
256
|
+
# that name, and a named parameter would swallow it and make the prompt unrunnable.
|
|
257
|
+
def messages(self, user_prompt: str | None = None, /, **inputs: Any) -> list[dict[str, str]]:
|
|
258
|
+
"""Chat messages that reproduce the call this version's score was measured with.
|
|
259
|
+
|
|
260
|
+
`text` alone is not runnable: it is instruction text with no input placeholder
|
|
261
|
+
and often literal JSON braces, so `text.format(...)` raises KeyError and
|
|
262
|
+
substituting into it drops the input with no error at all. This renders the
|
|
263
|
+
artifact the backend recorded when it scored the prompt, so the messages you
|
|
264
|
+
send are the messages that produced `score`.
|
|
265
|
+
|
|
266
|
+
Pass the task inputs as keywords. A first positional argument replaces the user
|
|
267
|
+
message, which changes the call away from the scored one, so `score` no longer
|
|
268
|
+
describes it. It is positional so that a task may have an input named
|
|
269
|
+
`user_prompt` without the two colliding.
|
|
270
|
+
"""
|
|
271
|
+
if self.artifact is not None and user_prompt is None:
|
|
272
|
+
# A template whose partial_variables supply every variable is complete on
|
|
273
|
+
# its own, so no keywords is the right call there, not a mistake.
|
|
274
|
+
return self._render_artifact(inputs)
|
|
275
|
+
if not inputs:
|
|
276
|
+
raise ValueError(
|
|
277
|
+
"pass the task inputs as keyword arguments, e.g. messages(input=text)"
|
|
278
|
+
)
|
|
279
|
+
return self._render_user_prompt(user_prompt, inputs)
|
|
280
|
+
|
|
281
|
+
def _render_artifact(self, inputs: dict[str, Any]) -> list[dict[str, str]]:
|
|
282
|
+
artifact = self.artifact or {}
|
|
283
|
+
declared = list(artifact.get("input_variables") or [])
|
|
284
|
+
unknown = [name for name in inputs if name not in declared]
|
|
285
|
+
if unknown:
|
|
286
|
+
raise ValueError(
|
|
287
|
+
f"task '{self.task}' takes {declared or 'no inputs'}, not "
|
|
288
|
+
f"{unknown}. Passing a name the prompt does not use would drop it "
|
|
289
|
+
"from the call."
|
|
290
|
+
)
|
|
291
|
+
values = {**(artifact.get("partial_variables") or {}), **inputs}
|
|
292
|
+
missing = [name for name in declared if name not in values]
|
|
293
|
+
if missing:
|
|
294
|
+
raise ValueError(f"task '{self.task}' also needs {missing} to build the call.")
|
|
295
|
+
return [
|
|
296
|
+
{
|
|
297
|
+
"role": _CHAT_ROLE.get(message["role"], message["role"]),
|
|
298
|
+
"content": _fill(message["template"], values),
|
|
299
|
+
}
|
|
300
|
+
for message in artifact.get("messages") or []
|
|
301
|
+
]
|
|
302
|
+
|
|
303
|
+
def _render_user_prompt(
|
|
304
|
+
self, user_prompt: str | None, inputs: dict[str, Any]
|
|
305
|
+
) -> list[dict[str, str]]:
|
|
306
|
+
"""Fallback: no recorded artifact (a version stored before they existed), or
|
|
307
|
+
the caller supplied their own user message."""
|
|
308
|
+
template = user_prompt if user_prompt is not None else self.baseline_prompt
|
|
309
|
+
if template is None:
|
|
310
|
+
raise ValueError(
|
|
311
|
+
f"task '{self.task}' has no recorded prompt shape to carry the input. "
|
|
312
|
+
"Re-run optimize, or pass a user prompt positionally, "
|
|
313
|
+
"e.g. messages('...{input}...', input=text)."
|
|
314
|
+
)
|
|
315
|
+
for name in inputs:
|
|
316
|
+
if "{" + name + "}" not in template:
|
|
317
|
+
raise ValueError(
|
|
318
|
+
f"user prompt has no {{{name}}} placeholder, so '{name}' would be "
|
|
319
|
+
"dropped from the call. Add the placeholder or fix the keyword name."
|
|
320
|
+
)
|
|
321
|
+
return [
|
|
322
|
+
{"role": "system", "content": self.text},
|
|
323
|
+
{"role": "user", "content": _fill(template, inputs)},
|
|
324
|
+
]
|