runapprentice 0.3.0__tar.gz → 0.4.1__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 (24) hide show
  1. {runapprentice-0.3.0 → runapprentice-0.4.1}/PKG-INFO +54 -5
  2. {runapprentice-0.3.0 → runapprentice-0.4.1}/README.md +53 -4
  3. {runapprentice-0.3.0 → runapprentice-0.4.1}/pyproject.toml +1 -1
  4. {runapprentice-0.3.0 → runapprentice-0.4.1}/src/runapprentice/cli.py +12 -2
  5. {runapprentice-0.3.0 → runapprentice-0.4.1}/src/runapprentice/client.py +17 -11
  6. runapprentice-0.4.1/src/runapprentice/models.py +327 -0
  7. {runapprentice-0.3.0 → runapprentice-0.4.1}/src/runapprentice/openai.py +68 -18
  8. {runapprentice-0.3.0 → runapprentice-0.4.1}/tests/test_client.py +266 -1
  9. runapprentice-0.4.1/tests/test_openai.py +288 -0
  10. runapprentice-0.3.0/src/runapprentice/models.py +0 -179
  11. runapprentice-0.3.0/tests/test_openai.py +0 -91
  12. {runapprentice-0.3.0 → runapprentice-0.4.1}/.gitignore +0 -0
  13. {runapprentice-0.3.0 → runapprentice-0.4.1}/LICENSE +0 -0
  14. {runapprentice-0.3.0 → runapprentice-0.4.1}/src/runapprentice/__init__.py +0 -0
  15. {runapprentice-0.3.0 → runapprentice-0.4.1}/src/runapprentice/_capture.py +0 -0
  16. {runapprentice-0.3.0 → runapprentice-0.4.1}/src/runapprentice/langchain.py +0 -0
  17. {runapprentice-0.3.0 → runapprentice-0.4.1}/src/runapprentice/local.py +0 -0
  18. {runapprentice-0.3.0 → runapprentice-0.4.1}/src/runapprentice/py.typed +0 -0
  19. {runapprentice-0.3.0 → runapprentice-0.4.1}/tests/test_capture.py +0 -0
  20. {runapprentice-0.3.0 → runapprentice-0.4.1}/tests/test_cli.py +0 -0
  21. {runapprentice-0.3.0 → runapprentice-0.4.1}/tests/test_dispatcher.py +0 -0
  22. {runapprentice-0.3.0 → runapprentice-0.4.1}/tests/test_failopen.py +0 -0
  23. {runapprentice-0.3.0 → runapprentice-0.4.1}/tests/test_langchain_prompts.py +0 -0
  24. {runapprentice-0.3.0 → runapprentice-0.4.1}/tests/test_local.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: runapprentice
3
- Version: 0.3.0
3
+ Version: 0.4.1
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 a LangChain app with one callback). Apprentice runs prompt
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
- print(best.version, best.text)
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 a LangChain app with one callback). Apprentice runs prompt
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
- print(best.version, best.text)
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)
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "runapprentice"
3
- version = "0.3.0"
3
+ version = "0.4.1"
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"
@@ -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
- print(json.dumps({"error": message}, sort_keys=True), file=sys.stderr)
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
@@ -16,6 +16,7 @@ import os
16
16
  import time
17
17
  import uuid
18
18
  from pathlib import Path
19
+ from urllib.parse import quote
19
20
  from typing import Any, Literal
20
21
 
21
22
  import httpx
@@ -164,18 +165,18 @@ class Apprentice:
164
165
  return Job(self, JobInfo.model_validate(data))
165
166
 
166
167
  def jobs(self, task: str) -> list[JobSummary]:
167
- data = self._request("GET", f"/v1/tasks/{task}/jobs")
168
+ data = self._request("GET", f"/v1/tasks/{quote(task, safe='')}/jobs")
168
169
  return [JobSummary.model_validate(item) for item in data]
169
170
 
170
171
  def cancel_job(self, job_id: str) -> dict[str, Any]:
171
172
  return dict(self._request("POST", f"/v1/jobs/{job_id}/cancel"))
172
173
 
173
174
  def local_runs(self, task: str) -> list[LocalRun]:
174
- data = self._request("GET", f"/v1/tasks/{task}/local-runs")
175
+ data = self._request("GET", f"/v1/tasks/{quote(task, safe='')}/local-runs")
175
176
  return [LocalRun.model_validate(item) for item in data]
176
177
 
177
178
  def train(self, task: str) -> Job:
178
- data = self._request("POST", f"/v1/tasks/{task}/train", json={})
179
+ data = self._request("POST", f"/v1/tasks/{quote(task, safe='')}/train", json={})
179
180
  return Job(self, JobInfo.model_validate(data))
180
181
 
181
182
  def optimize_local(
@@ -422,10 +423,15 @@ class _DatasetsAPI:
422
423
  output_col: str = "output",
423
424
  prompt: str | None = None,
424
425
  ) -> DatasetStatus:
425
- """Upload rows.
426
+ """Replace uploaded, unverified rows for a task.
426
427
 
427
428
  Rows may use legacy `input`/`output` or structured `inputs`/`output`.
428
429
  LCEL auto-capture of prompt variables is future work; v1 stays explicit.
430
+
431
+ This is a replace, not an append: existing uploaded silver and raw rows are
432
+ removed first, and `replaced_rows` in the response reports how many. Gold
433
+ rows and captured traces survive. Omitting `prompt` (or passing `None`)
434
+ clears the task's stored baseline prompt.
429
435
  """
430
436
  if (path is None) == (rows is None):
431
437
  raise ApprenticeError("provide exactly one of path= or rows=")
@@ -436,13 +442,13 @@ class _DatasetsAPI:
436
442
  payload_rows = [_dataset_row_payload(r, input_col, output_col) for r in rows]
437
443
  data = self._client._request(
438
444
  "POST",
439
- f"/v1/datasets/{task}/rows",
445
+ f"/v1/datasets/{quote(task, safe='')}/rows",
440
446
  json={"rows": payload_rows, "prompt": prompt},
441
447
  )
442
448
  return DatasetStatus.model_validate(data)
443
449
 
444
450
  def status(self, task: str) -> DatasetStatus:
445
- data = self._client._request("GET", f"/v1/datasets/{task}/status")
451
+ data = self._client._request("GET", f"/v1/datasets/{quote(task, safe='')}/status")
446
452
  return DatasetStatus.model_validate(data)
447
453
 
448
454
  def rows(self, task: str, tier: str, limit: int = 200) -> list[DatasetRow]:
@@ -451,7 +457,7 @@ class _DatasetsAPI:
451
457
  while True:
452
458
  data = self._client._request(
453
459
  "GET",
454
- f"/v1/datasets/{task}/rows?tier={tier}&limit={limit}&offset={offset}",
460
+ f"/v1/datasets/{quote(task, safe='')}/rows?tier={tier}&limit={limit}&offset={offset}",
455
461
  )
456
462
  batch = [DatasetRow.model_validate(item) for item in data.get("rows", [])]
457
463
  rows.extend(batch)
@@ -476,21 +482,21 @@ class _PromptsAPI:
476
482
 
477
483
  def register(self, task: str, template: Any) -> dict[str, Any]:
478
484
  artifact = template if isinstance(template, dict) else _serialize_langchain_prompt(template)
479
- return dict(self._client._request("POST", f"/v1/templates/{task}", json=artifact))
485
+ return dict(self._client._request("POST", f"/v1/templates/{quote(task, safe='')}", json=artifact))
480
486
 
481
487
  def to_langchain(self, task: str | None = None, artifact: dict[str, Any] | None = None) -> Any:
482
488
  if artifact is None:
483
489
  if task is None:
484
490
  raise ApprenticeError("provide task= or artifact=")
485
- artifact = dict(self._client._request("GET", f"/v1/templates/{task}"))
491
+ artifact = dict(self._client._request("GET", f"/v1/templates/{quote(task, safe='')}"))
486
492
  return _artifact_to_langchain(artifact)
487
493
 
488
494
  def get(self, task: str, version: int | None = None) -> PromptVersion:
489
- path = f"/v1/prompts/{task}" + (f"?version={version}" if version is not None else "")
495
+ path = f"/v1/prompts/{quote(task, safe='')}" + (f"?version={version}" if version is not None else "")
490
496
  return PromptVersion.model_validate(self._client._request("GET", path))
491
497
 
492
498
  def history(self, task: str) -> list[PromptVersion]:
493
- data = self._client._request("GET", f"/v1/prompts/{task}/history")
499
+ data = self._client._request("GET", f"/v1/prompts/{quote(task, safe='')}/history")
494
500
  return [PromptVersion.model_validate(p) for p in data]
495
501
 
496
502
 
@@ -0,0 +1,327 @@
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
+ # Set when the row's 0 came from an evaluation error, not a wrong answer.
60
+ baseline_error: str | None = None
61
+ optimized_error: str | None = None
62
+
63
+
64
+ class OptimizationReport(BaseModel):
65
+ task: str | None = None
66
+ baseline_score: float | None = None
67
+ optimized_score: float | None = None
68
+ examples_used: int | None = None
69
+ baseline_prompt: str | None = None
70
+ baseline_from_user: bool | None = None
71
+ candidates_explored: int | None = None
72
+ duplicates_dropped: int | None = None
73
+ dataset_fingerprint: str | None = None
74
+ dataset_size: int | None = None
75
+ optimized_prompt: str | None = None
76
+ executable_prompt: dict[str, Any] | None = None
77
+ optimized_template: dict[str, Any] | None = None
78
+ template_safe: bool | None = None
79
+ template_placeholder_changes: dict[str, list[str]] | None = None
80
+ eval_metric: str | None = None
81
+ metric_tier: str | None = None
82
+ metric_version: str | None = None
83
+ judge_estimate: bool | None = None
84
+ judge_model: str | None = None
85
+ judge_cache: dict[str, int] | None = None
86
+ score_explanation: str | None = None
87
+ worst_examples: list[dict[str, Any]] | None = None
88
+ failure_count: int | None = None
89
+ holdout_evidence: list[HoldoutEvidenceRow] | None = None
90
+ improved_count: int | None = None
91
+ regressed_count: int | None = None
92
+ unchanged_count: int | None = None
93
+ duration_seconds: float | None = None
94
+ cost_usd: float | None = None
95
+ input_tokens: int | None = None
96
+ output_tokens: int | None = None
97
+ cached_input_tokens: int | None = None
98
+ reasoning_tokens: int | None = None
99
+ cache_replayed_calls: int | None = None
100
+ llm_student_model: str | None = None
101
+ llm_reflection_model: str | None = None
102
+ llm_judge_model: str | None = None
103
+ llm_fallback_used: bool | None = None
104
+ detail: dict[str, Any] = Field(default_factory=dict)
105
+
106
+
107
+ class TrainingReport(BaseModel):
108
+ task: str | None = None
109
+ model_id: str | None = None
110
+ base_model: str | None = None
111
+ adapter_ref: str | None = None
112
+ champion_score: float | None = None
113
+ challenger_score: float | None = None
114
+ eval_metric: str | None = None
115
+ metric_tier: str | None = None
116
+ holdout_size: int | None = None
117
+ status: str | None = None
118
+ reason: str | None = None
119
+ config: dict[str, Any] | None = None
120
+
121
+
122
+ class GenerationReport(BaseModel):
123
+ rows_inserted: int | None = None
124
+ rows_rejected: int | None = None
125
+ reject_reasons: dict[str, int] | None = None
126
+ model: str | None = None
127
+ llm_fallback_used: bool | None = None
128
+ input_tokens: int | None = None
129
+ output_tokens: int | None = None
130
+ samples: list[dict[str, Any]] | None = None
131
+ chunks_truncated: int | None = None
132
+ response_failures: int | None = None
133
+ duplicate_inputs: int | None = None
134
+ executor_requested: str | None = None
135
+ executor_actual_models: list[str] | None = None
136
+ executor_fallback_used: bool | None = None
137
+
138
+
139
+ JobReport = OptimizationReport | TrainingReport | GenerationReport | dict[str, Any]
140
+
141
+
142
+ class JobInfo(BaseModel):
143
+ job_id: str
144
+ task: str # task ID; use task_name when calling task-scoped client methods
145
+ task_name: str | None = None
146
+ kind: str
147
+ status: str
148
+ created_at: datetime | None = None
149
+ error: str | None = None
150
+ report: SkipValidation[JobReport] | None = None
151
+ progress: dict[str, Any] | None = None
152
+ queue_position: int | None = None
153
+ baseline_score: float | None = None
154
+ optimized_score: float | None = None
155
+ baseline_from_user: bool | None = None
156
+ generation_session_id: str | None = None
157
+ round: int | None = None
158
+ predecessor_job_id: str | None = None
159
+ phase: str | None = None
160
+ guidance: str | None = None
161
+
162
+ @model_validator(mode="before")
163
+ @classmethod
164
+ def parse_report_for_kind(cls, value: Any) -> Any:
165
+ if not isinstance(value, dict) or not isinstance(value.get("report"), dict):
166
+ return value
167
+ kind = value.get("kind")
168
+ if kind == "optimize":
169
+ report_type: type[BaseModel] = OptimizationReport
170
+ elif kind == "train":
171
+ report_type = TrainingReport
172
+ elif kind == "generate":
173
+ report_type = GenerationReport
174
+ else:
175
+ return value
176
+ try:
177
+ report = report_type.model_validate(value["report"])
178
+ except ValidationError:
179
+ return value
180
+ return {**value, "report": report}
181
+
182
+
183
+ class JobSummary(BaseModel):
184
+ job_id: str
185
+ task_name: str | None = None
186
+ status: str
187
+ kind: str
188
+ created_at: datetime | None = None
189
+ baseline_score: float | None = None
190
+ optimized_score: float | None = None
191
+
192
+
193
+ class LocalRun(BaseModel):
194
+ run_id: str
195
+ task: str | None = None
196
+ task_id: str | None = None
197
+ status: str = "registered"
198
+ eval_tier: str | None = None
199
+ base_model: str | None = None
200
+ dataset_row_count: int | None = None
201
+ seed: int | None = None
202
+ baseline_score: float | None = None
203
+ final_score: float | None = None
204
+ held_out_rows: int | None = None
205
+ adapter_sha256: str | None = None
206
+ mlx_lm_version: str | None = None
207
+ error_message: str | None = None
208
+ created_at: datetime | None = None
209
+ completed_at: datetime | None = None
210
+
211
+
212
+ class OptimizationPreview(BaseModel):
213
+ eval_metric: str
214
+ metric_tier: str
215
+ requested_metric: str
216
+ recommended_metric: str
217
+ metric_reason: str
218
+ rag_candidate: bool
219
+ rows_checked: int
220
+ rows_source: str
221
+ guidance: dict[str, Any]
222
+ estimate: dict[str, Any]
223
+
224
+
225
+ # LangChain roles (as stored in the artifact) -> chat-completions roles.
226
+ _CHAT_ROLE = {"human": "user", "ai": "assistant", "system": "system"}
227
+ _FORMAT_TOKEN = re.compile(r"\{\{|\}\}|\{(?P<name>[A-Za-z_][A-Za-z0-9_]*)\}")
228
+
229
+
230
+ def _fill(template: str, values: dict[str, str]) -> str:
231
+ """Substitute only declared variables, never raise.
232
+
233
+ Mirrors the backend's renderer: `{{`/`}}` are literal braces, and any other
234
+ brace run (a JSON example in the instruction, an unknown name) is left alone.
235
+ str.format would raise KeyError on the JSON braces these prompts routinely carry.
236
+ """
237
+
238
+ def repl(match: re.Match[str]) -> str:
239
+ token = match.group(0)
240
+ if token in ("{{", "}}"):
241
+ return token[0]
242
+ name = match.group("name")
243
+ return str(values[name]) if name in values else token
244
+
245
+ return _FORMAT_TOKEN.sub(repl, template)
246
+
247
+
248
+ class PromptVersion(BaseModel):
249
+ task: str
250
+ version: int
251
+ text: str
252
+ score: float | None = None
253
+ # The message shape this version's score was measured with. Frozen server-side
254
+ # when the version was stored, so it does not move when the task baseline does.
255
+ artifact: dict[str, Any] | None = None
256
+ baseline_prompt: str | None = None
257
+
258
+ # `user_prompt` is positional-only: a task is free to have an input variable of
259
+ # that name, and a named parameter would swallow it and make the prompt unrunnable.
260
+ def messages(self, user_prompt: str | None = None, /, **inputs: Any) -> list[dict[str, str]]:
261
+ """Chat messages that reproduce the call this version's score was measured with.
262
+
263
+ `text` alone is not runnable: it is instruction text with no input placeholder
264
+ and often literal JSON braces, so `text.format(...)` raises KeyError and
265
+ substituting into it drops the input with no error at all. This renders the
266
+ artifact the backend recorded when it scored the prompt, so the messages you
267
+ send are the messages that produced `score`.
268
+
269
+ Pass the task inputs as keywords. A first positional argument replaces the user
270
+ message, which changes the call away from the scored one, so `score` no longer
271
+ describes it. It is positional so that a task may have an input named
272
+ `user_prompt` without the two colliding.
273
+ """
274
+ if self.artifact is not None and user_prompt is None:
275
+ # A template whose partial_variables supply every variable is complete on
276
+ # its own, so no keywords is the right call there, not a mistake.
277
+ return self._render_artifact(inputs)
278
+ if not inputs:
279
+ raise ValueError(
280
+ "pass the task inputs as keyword arguments, e.g. messages(input=text)"
281
+ )
282
+ return self._render_user_prompt(user_prompt, inputs)
283
+
284
+ def _render_artifact(self, inputs: dict[str, Any]) -> list[dict[str, str]]:
285
+ artifact = self.artifact or {}
286
+ declared = list(artifact.get("input_variables") or [])
287
+ unknown = [name for name in inputs if name not in declared]
288
+ if unknown:
289
+ raise ValueError(
290
+ f"task '{self.task}' takes {declared or 'no inputs'}, not "
291
+ f"{unknown}. Passing a name the prompt does not use would drop it "
292
+ "from the call."
293
+ )
294
+ values = {**(artifact.get("partial_variables") or {}), **inputs}
295
+ missing = [name for name in declared if name not in values]
296
+ if missing:
297
+ raise ValueError(f"task '{self.task}' also needs {missing} to build the call.")
298
+ return [
299
+ {
300
+ "role": _CHAT_ROLE.get(message["role"], message["role"]),
301
+ "content": _fill(message["template"], values),
302
+ }
303
+ for message in artifact.get("messages") or []
304
+ ]
305
+
306
+ def _render_user_prompt(
307
+ self, user_prompt: str | None, inputs: dict[str, Any]
308
+ ) -> list[dict[str, str]]:
309
+ """Fallback: no recorded artifact (a version stored before they existed), or
310
+ the caller supplied their own user message."""
311
+ template = user_prompt if user_prompt is not None else self.baseline_prompt
312
+ if template is None:
313
+ raise ValueError(
314
+ f"task '{self.task}' has no recorded prompt shape to carry the input. "
315
+ "Re-run optimize, or pass a user prompt positionally, "
316
+ "e.g. messages('...{input}...', input=text)."
317
+ )
318
+ for name in inputs:
319
+ if "{" + name + "}" not in template:
320
+ raise ValueError(
321
+ f"user prompt has no {{{name}}} placeholder, so '{name}' would be "
322
+ "dropped from the call. Add the placeholder or fix the keyword name."
323
+ )
324
+ return [
325
+ {"role": "system", "content": self.text},
326
+ {"role": "user", "content": _fill(template, inputs)},
327
+ ]