runapprentice 0.2.0__tar.gz → 0.3.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (23) hide show
  1. {runapprentice-0.2.0 → runapprentice-0.3.0}/.gitignore +17 -1
  2. {runapprentice-0.2.0 → runapprentice-0.3.0}/PKG-INFO +17 -3
  3. {runapprentice-0.2.0 → runapprentice-0.3.0}/README.md +16 -2
  4. {runapprentice-0.2.0 → runapprentice-0.3.0}/pyproject.toml +1 -1
  5. {runapprentice-0.2.0 → runapprentice-0.3.0}/src/runapprentice/__init__.py +16 -1
  6. {runapprentice-0.2.0 → runapprentice-0.3.0}/src/runapprentice/cli.py +73 -3
  7. {runapprentice-0.2.0 → runapprentice-0.3.0}/src/runapprentice/client.py +56 -5
  8. runapprentice-0.3.0/src/runapprentice/models.py +179 -0
  9. {runapprentice-0.2.0 → runapprentice-0.3.0}/tests/test_cli.py +39 -1
  10. {runapprentice-0.2.0 → runapprentice-0.3.0}/tests/test_client.py +210 -4
  11. runapprentice-0.2.0/src/runapprentice/models.py +0 -81
  12. {runapprentice-0.2.0 → runapprentice-0.3.0}/LICENSE +0 -0
  13. {runapprentice-0.2.0 → runapprentice-0.3.0}/src/runapprentice/_capture.py +0 -0
  14. {runapprentice-0.2.0 → runapprentice-0.3.0}/src/runapprentice/langchain.py +0 -0
  15. {runapprentice-0.2.0 → runapprentice-0.3.0}/src/runapprentice/local.py +0 -0
  16. {runapprentice-0.2.0 → runapprentice-0.3.0}/src/runapprentice/openai.py +0 -0
  17. {runapprentice-0.2.0 → runapprentice-0.3.0}/src/runapprentice/py.typed +0 -0
  18. {runapprentice-0.2.0 → runapprentice-0.3.0}/tests/test_capture.py +0 -0
  19. {runapprentice-0.2.0 → runapprentice-0.3.0}/tests/test_dispatcher.py +0 -0
  20. {runapprentice-0.2.0 → runapprentice-0.3.0}/tests/test_failopen.py +0 -0
  21. {runapprentice-0.2.0 → runapprentice-0.3.0}/tests/test_langchain_prompts.py +0 -0
  22. {runapprentice-0.2.0 → runapprentice-0.3.0}/tests/test_local.py +0 -0
  23. {runapprentice-0.2.0 → runapprentice-0.3.0}/tests/test_openai.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.2.0
3
+ Version: 0.3.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
@@ -230,9 +230,23 @@ count and the minimum).
230
230
  | `client.datasets.status(task)` | `DatasetStatus(gold, silver, raw, ready_for_optimization)` | same |
231
231
  | `client.prompts.register(task, template)` | dict | `ImportError` if a LangChain template is passed without the `[langchain]` extra |
232
232
  | `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) |
233
+ | `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) |
234
+ | `client.preview_optimization(task, baseline_prompt=None, budget=None, metric=None)` | `OptimizationPreview` | `ApprenticeError` on API failure |
235
+ | `client.train(task)` | `Job` | 400 if fewer gold rows than the backend's training minimum |
234
236
  | `client.job(job_id)` / `job.refresh()` / `job.wait(poll_seconds=, timeout_seconds=)` | `Job` | `ApprenticeError` on timeout |
235
- | `job.report()` | `OptimizationReport(baseline_score, optimized_score, optimized_prompt, ...)` | if job has no report |
237
+ | `client.cancel_job(job_id)` | dict (`cancel_requested`, `status`) | 409 if job is not cancellable |
238
+ | `job.cancel()` | dict (`cancel_requested`, `status`) | 409 if job is not cancellable |
239
+ | `client.jobs(task)` | `list[JobSummary]` | `ApprenticeError` on API failure |
240
+ | `client.local_runs(task)` | `list[LocalRun]` | `ApprenticeError` on API failure |
241
+ | `client.datasets.rows(task, tier, limit=200)` | `list[DatasetRow]` | `ApprenticeError` on API failure |
242
+ | `client.optimize_local(task, data=None, **options)` | dict (local GEPA result) | `ApprenticeError` if no `data` and no API key |
243
+ | `client.train_local(task, data=None, effort=None, **options)` | dict (local MLX result) | `ApprenticeError` if no `data` and no API key |
244
+ | `client.register_local_run(task, base_model, dataset_row_count, seed, config=None)` | `LocalRun` | `ApprenticeError` on API failure |
245
+ | `client.complete_local_run(run_id, payload)` | dict | `ApprenticeError` on API failure |
246
+ | `client.capture(task, output, input= or inputs=, model=, latency_ms=, ...)` | trace_id or None | **never raises** |
247
+ | `client.flush_captures(timeout=2.0)` | None (blocks until buffered traces deliver or timeout; no-op in sync mode) | nothing by design |
248
+ | `client.close()` | None (flushes and closes transport) | nothing by design |
249
+ | `job.report()` | typed optimize, train, or generate report; raw dict for future shapes | if job has no report |
236
250
  | `client.prompts.get(task, version=None)` | `PromptVersion(version, text, score)` | 404 if never optimized |
237
251
  | `client.prompts.history(task)` | `list[PromptVersion]` | `ApprenticeError` on API failure |
238
252
  | `client.feedback(trace_id, good=, score=, note=)` | None | 404 unknown trace; `ApprenticeError` if pending captures cannot drain in time, or on API failure |
@@ -201,9 +201,23 @@ count and the minimum).
201
201
  | `client.datasets.status(task)` | `DatasetStatus(gold, silver, raw, ready_for_optimization)` | same |
202
202
  | `client.prompts.register(task, template)` | dict | `ImportError` if a LangChain template is passed without the `[langchain]` extra |
203
203
  | `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) |
204
+ | `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) |
205
+ | `client.preview_optimization(task, baseline_prompt=None, budget=None, metric=None)` | `OptimizationPreview` | `ApprenticeError` on API failure |
206
+ | `client.train(task)` | `Job` | 400 if fewer gold rows than the backend's training minimum |
205
207
  | `client.job(job_id)` / `job.refresh()` / `job.wait(poll_seconds=, timeout_seconds=)` | `Job` | `ApprenticeError` on timeout |
206
- | `job.report()` | `OptimizationReport(baseline_score, optimized_score, optimized_prompt, ...)` | if job has no report |
208
+ | `client.cancel_job(job_id)` | dict (`cancel_requested`, `status`) | 409 if job is not cancellable |
209
+ | `job.cancel()` | dict (`cancel_requested`, `status`) | 409 if job is not cancellable |
210
+ | `client.jobs(task)` | `list[JobSummary]` | `ApprenticeError` on API failure |
211
+ | `client.local_runs(task)` | `list[LocalRun]` | `ApprenticeError` on API failure |
212
+ | `client.datasets.rows(task, tier, limit=200)` | `list[DatasetRow]` | `ApprenticeError` on API failure |
213
+ | `client.optimize_local(task, data=None, **options)` | dict (local GEPA result) | `ApprenticeError` if no `data` and no API key |
214
+ | `client.train_local(task, data=None, effort=None, **options)` | dict (local MLX result) | `ApprenticeError` if no `data` and no API key |
215
+ | `client.register_local_run(task, base_model, dataset_row_count, seed, config=None)` | `LocalRun` | `ApprenticeError` on API failure |
216
+ | `client.complete_local_run(run_id, payload)` | dict | `ApprenticeError` on API failure |
217
+ | `client.capture(task, output, input= or inputs=, model=, latency_ms=, ...)` | trace_id or None | **never raises** |
218
+ | `client.flush_captures(timeout=2.0)` | None (blocks until buffered traces deliver or timeout; no-op in sync mode) | nothing by design |
219
+ | `client.close()` | None (flushes and closes transport) | nothing by design |
220
+ | `job.report()` | typed optimize, train, or generate report; raw dict for future shapes | if job has no report |
207
221
  | `client.prompts.get(task, version=None)` | `PromptVersion(version, text, score)` | 404 if never optimized |
208
222
  | `client.prompts.history(task)` | `list[PromptVersion]` | `ApprenticeError` on API failure |
209
223
  | `client.feedback(trace_id, good=, score=, note=)` | None | 404 unknown trace; `ApprenticeError` if pending captures cannot drain in time, or on API failure |
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "runapprentice"
3
- version = "0.2.0"
3
+ version = "0.3.0"
4
4
  description = "Cut LLM cost without losing quality: capture traffic, build golden datasets, and optimize prompts."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -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,23 @@ __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
- __version__ = "0.1.0"
38
+ # Single source of truth is pyproject.toml; read it from package metadata so
39
+ # this string can never drift from what PyPI serves (it did once, at 0.2.0).
40
+ from importlib.metadata import PackageNotFoundError, version as _version
41
+
42
+ try:
43
+ __version__ = _version("runapprentice")
44
+ except PackageNotFoundError: # running from a source tree without installation
45
+ __version__ = "0.0.0.dev0"
@@ -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 JobInfo, JobSummary, OptimizationReport
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,7 +218,30 @@ def _optimize(args: argparse.Namespace, client: Apprentice) -> int:
187
218
  bool(args.json),
188
219
  bool(args._api_key_present),
189
220
  )
190
- job = client.optimize(args.task).wait()
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")
192
246
  return _emit(payload, _report_line(job.info.report), bool(args.json))
193
247
 
@@ -222,6 +276,11 @@ def _job(args: argparse.Namespace, client: Apprentice) -> int:
222
276
  return _emit(job.info.model_dump(mode="json"), _job_info_line(job.info), bool(args.json))
223
277
 
224
278
 
279
+ def _cancel(args: argparse.Namespace, client: Apprentice) -> int:
280
+ result = client.cancel_job(args.job_id)
281
+ return _emit(result, f"cancel requested: {args.job_id} ({result['status']})", bool(args.json))
282
+
283
+
225
284
  def _train_local(
226
285
  task: str,
227
286
  client: Apprentice,
@@ -424,12 +483,23 @@ def _job_info_line(job: JobInfo) -> str:
424
483
  return f"{job.job_id} {job.kind} {job.status}"
425
484
 
426
485
 
427
- def _report_line(report: OptimizationReport | None) -> str:
486
+ def _report_line(report: JobReport | None) -> str:
428
487
  if report is None:
429
488
  return "optimize finished with no report"
489
+ if not isinstance(report, OptimizationReport):
490
+ return "optimize finished with an unrecognized report"
430
491
  return f"optimize done: baseline={report.baseline_score} optimized={report.optimized_score}"
431
492
 
432
493
 
494
+ def _preview_line(preview: OptimizationPreview) -> str:
495
+ estimate = preview.estimate
496
+ return (
497
+ f"optimize preview: metric={preview.eval_metric} budget={estimate.get('budget')} "
498
+ f"rows={estimate.get('rows')} cost_usd={estimate.get('cost_usd_low')}"
499
+ f"-{estimate.get('cost_usd_high')}"
500
+ )
501
+
502
+
433
503
  def _emit(payload: dict[str, Any], text: str, as_json: bool) -> int:
434
504
  print(json.dumps(payload, sort_keys=True) if as_json else text)
435
505
  return 0
@@ -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
- OptimizationReport,
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(self, task: str) -> Job:
118
- data = self._request("POST", "/v1/optimize", json={"task": task})
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 report(self) -> OptimizationReport:
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})"
@@ -0,0 +1,179 @@
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
+ from typing import Any
7
+
8
+ from pydantic import BaseModel, Field, SkipValidation, ValidationError, model_validator
9
+
10
+
11
+ class DatasetRow(BaseModel):
12
+ id: str | None = None
13
+ input: str
14
+ inputs: dict[str, str] | None = None
15
+ output: str
16
+ tier: str | None = None
17
+ source: str | None = None
18
+ flag_label: str | None = None
19
+ feedback_score: float | None = None
20
+ expert_authored: bool | None = None
21
+ created_at: datetime | None = None
22
+ metadata: dict[str, Any] = Field(default_factory=dict)
23
+
24
+
25
+ class DatasetStatus(BaseModel):
26
+ task: str
27
+ gold: int = 0
28
+ silver: int = 0
29
+ raw: int = 0
30
+ ready_for_optimization: bool = False
31
+
32
+
33
+ class TraceRecord(BaseModel):
34
+ trace_id: str | None = Field(default=None, pattern=r"^[0-9a-f]{32}$")
35
+ task: str
36
+ input: str
37
+ inputs: dict[str, str] | None = None
38
+ output: str
39
+ model: str | None = None
40
+ latency_ms: float | None = None
41
+ prompt_tokens: int | None = None
42
+ completion_tokens: int | None = None
43
+ error: str | None = None
44
+ metadata: dict[str, Any] = Field(default_factory=dict)
45
+
46
+
47
+ class OptimizationReport(BaseModel):
48
+ task: str
49
+ baseline_score: float | None = None
50
+ optimized_score: float | None = None
51
+ examples_used: int | None = None
52
+ optimized_prompt: str | None = None
53
+ optimized_template: dict[str, Any] | None = None
54
+ detail: dict[str, Any] = Field(default_factory=dict)
55
+
56
+
57
+ class TrainingReport(BaseModel):
58
+ task: str | None = None
59
+ model_id: str | None = None
60
+ base_model: str | None = None
61
+ adapter_ref: str | None = None
62
+ champion_score: float | None = None
63
+ challenger_score: float | None = None
64
+ eval_metric: str | None = None
65
+ metric_tier: str | None = None
66
+ holdout_size: int | None = None
67
+ status: str | None = None
68
+ reason: str | None = None
69
+ config: dict[str, Any] | None = None
70
+
71
+
72
+ class GenerationReport(BaseModel):
73
+ rows_inserted: int | None = None
74
+ rows_rejected: int | None = None
75
+ reject_reasons: dict[str, int] | None = None
76
+ model: str | None = None
77
+ llm_fallback_used: bool | None = None
78
+ input_tokens: int | None = None
79
+ output_tokens: int | None = None
80
+ samples: list[dict[str, Any]] | None = None
81
+ chunks_truncated: int | None = None
82
+ response_failures: int | None = None
83
+ duplicate_inputs: int | None = None
84
+ executor_requested: str | None = None
85
+ executor_actual_models: list[str] | None = None
86
+ executor_fallback_used: bool | None = None
87
+
88
+
89
+ JobReport = OptimizationReport | TrainingReport | GenerationReport | dict[str, Any]
90
+
91
+
92
+ class JobInfo(BaseModel):
93
+ job_id: str
94
+ task: str # task ID; use task_name when calling task-scoped client methods
95
+ task_name: str | None = None
96
+ kind: str
97
+ status: str
98
+ created_at: datetime | None = None
99
+ error: str | None = None
100
+ report: SkipValidation[JobReport] | None = None
101
+ progress: dict[str, Any] | None = None
102
+ queue_position: int | None = None
103
+ baseline_score: float | None = None
104
+ optimized_score: float | None = None
105
+ baseline_from_user: bool | None = None
106
+ generation_session_id: str | None = None
107
+ round: int | None = None
108
+ predecessor_job_id: str | None = None
109
+ phase: str | None = None
110
+ guidance: str | None = None
111
+
112
+ @model_validator(mode="before")
113
+ @classmethod
114
+ def parse_report_for_kind(cls, value: Any) -> Any:
115
+ if not isinstance(value, dict) or not isinstance(value.get("report"), dict):
116
+ return value
117
+ kind = value.get("kind")
118
+ if kind == "optimize":
119
+ report_type: type[BaseModel] = OptimizationReport
120
+ elif kind == "train":
121
+ report_type = TrainingReport
122
+ elif kind == "generate":
123
+ report_type = GenerationReport
124
+ else:
125
+ return value
126
+ try:
127
+ report = report_type.model_validate(value["report"])
128
+ except ValidationError:
129
+ return value
130
+ return {**value, "report": report}
131
+
132
+
133
+ class JobSummary(BaseModel):
134
+ job_id: str
135
+ task_name: str | None = None
136
+ status: str
137
+ kind: str
138
+ created_at: datetime | None = None
139
+ baseline_score: float | None = None
140
+ optimized_score: float | None = None
141
+
142
+
143
+ class LocalRun(BaseModel):
144
+ run_id: str
145
+ task: str | None = None
146
+ task_id: str | None = None
147
+ status: str = "registered"
148
+ eval_tier: str | None = None
149
+ base_model: str | None = None
150
+ dataset_row_count: int | None = None
151
+ seed: int | None = None
152
+ baseline_score: float | None = None
153
+ final_score: float | None = None
154
+ held_out_rows: int | None = None
155
+ adapter_sha256: str | None = None
156
+ mlx_lm_version: str | None = None
157
+ error_message: str | None = None
158
+ created_at: datetime | None = None
159
+ completed_at: datetime | None = None
160
+
161
+
162
+ class OptimizationPreview(BaseModel):
163
+ eval_metric: str
164
+ metric_tier: str
165
+ requested_metric: str
166
+ recommended_metric: str
167
+ metric_reason: str
168
+ rag_candidate: bool
169
+ rows_checked: int
170
+ rows_source: str
171
+ guidance: dict[str, Any]
172
+ estimate: dict[str, Any]
173
+
174
+
175
+ class PromptVersion(BaseModel):
176
+ task: str
177
+ version: int
178
+ text: str
179
+ score: float | None = None
@@ -9,7 +9,7 @@ import pytest
9
9
 
10
10
  from runapprentice import ApprenticeError, cli
11
11
  import runapprentice.local as local
12
- from runapprentice.models import DatasetRow
12
+ from runapprentice.models import DatasetRow, JobInfo, JobSummary
13
13
 
14
14
 
15
15
  def test_parser_supports_required_commands() -> None:
@@ -28,6 +28,44 @@ def test_parser_supports_required_commands() -> None:
28
28
  assert awake.fresh is True
29
29
  assert parser.parse_args(["jobs", "task"]).command == "jobs"
30
30
  assert parser.parse_args(["job", "j1"]).command == "job"
31
+ controls = parser.parse_args(
32
+ [
33
+ "optimize",
34
+ "task",
35
+ "--budget",
36
+ "thorough",
37
+ "--metric",
38
+ "json_f1",
39
+ "--prompt-file",
40
+ "prompt.txt",
41
+ "--preview",
42
+ ]
43
+ )
44
+ assert controls.budget == "thorough"
45
+ assert controls.metric == "json_f1"
46
+ assert controls.prompt_file == Path("prompt.txt")
47
+ assert controls.preview is True
48
+ assert parser.parse_args(["cancel", "j1"]).command == "cancel"
49
+
50
+
51
+ def test_cli_renders_all_job_kinds() -> None:
52
+ for kind, report in (
53
+ ("optimize", {"task": "extract", "baseline_score": 50.0}),
54
+ ("train", {"task": "extract", "champion_score": 80.0}),
55
+ ("generate", {"rows_inserted": 10, "rows_rejected": 1}),
56
+ ):
57
+ info = JobInfo.model_validate(
58
+ {
59
+ "job_id": f"j-{kind}",
60
+ "task": "task-id",
61
+ "kind": kind,
62
+ "status": "succeeded",
63
+ "report": report,
64
+ }
65
+ )
66
+ summary = JobSummary(job_id=info.job_id, kind=kind, status=info.status)
67
+ assert cli._job_info_line(info) == f"j-{kind} {kind} succeeded"
68
+ assert cli._job_summary(summary)["kind"] == kind
31
69
 
32
70
 
33
71
  def test_effort_explicit_overrides_hardware_detection() -> None:
@@ -13,7 +13,11 @@ import pytest
13
13
  from runapprentice import Apprentice, ApprenticeError, TraceRecord
14
14
  from runapprentice._capture import CaptureDispatcher
15
15
  import runapprentice.local as local
16
- from runapprentice.models import OptimizationReport
16
+ from runapprentice.models import (
17
+ GenerationReport,
18
+ OptimizationReport,
19
+ TrainingReport,
20
+ )
17
21
 
18
22
 
19
23
  def make_client(routes: dict[str, Any]) -> tuple[Apprentice, list[dict[str, Any]]]:
@@ -219,6 +223,179 @@ def test_optimize_and_job_lifecycle() -> None:
219
223
  assert report.optimized_score == 88.0
220
224
 
221
225
 
226
+ def test_generate_job_payload_parses_without_crashing() -> None:
227
+ payload = {
228
+ "job_id": "j-gen",
229
+ "task": "task-id",
230
+ "task_name": "invoices",
231
+ "kind": "generate",
232
+ "status": "succeeded",
233
+ "generation_session_id": "session-1",
234
+ "round": 2,
235
+ "predecessor_job_id": "j-preview",
236
+ "phase": "full",
237
+ "guidance": "Include boundary cases",
238
+ "report": {
239
+ "rows_inserted": 17,
240
+ "rows_rejected": 3,
241
+ "reject_reasons": {"duplicate": 2, "invalid_output": 1},
242
+ "model": "generator-model",
243
+ "llm_fallback_used": False,
244
+ "input_tokens": 1200,
245
+ "output_tokens": 700,
246
+ "samples": [{"input": "sample", "output": "{}"}],
247
+ "chunks_truncated": 1,
248
+ "response_failures": 1,
249
+ },
250
+ }
251
+ client, _ = make_client({"GET /v1/jobs/j-gen": payload})
252
+
253
+ job = client.job("j-gen")
254
+
255
+ report = job.report()
256
+ assert isinstance(report, GenerationReport)
257
+ assert report.rows_inserted == 17
258
+ assert job.info.generation_session_id == "session-1"
259
+ assert job.info.task_name == "invoices"
260
+
261
+
262
+ def test_train_report_keeps_training_scores() -> None:
263
+ client, _ = make_client(
264
+ {
265
+ "GET /v1/jobs/j-train": {
266
+ "job_id": "j-train",
267
+ "task": "task-id",
268
+ "kind": "train",
269
+ "status": "succeeded",
270
+ "report": {
271
+ "task": "extract",
272
+ "model_id": "model-1",
273
+ "base_model": "base-model",
274
+ "adapter_ref": "adapter-1",
275
+ "champion_score": 81.5,
276
+ "challenger_score": 84.0,
277
+ "eval_metric": "json_f1",
278
+ "metric_tier": "deterministic",
279
+ "holdout_size": 50,
280
+ "status": "ready",
281
+ "reason": "challenger met champion and minimum score",
282
+ "config": {"seed": 42},
283
+ },
284
+ }
285
+ }
286
+ )
287
+
288
+ report = client.job("j-train").report()
289
+
290
+ assert isinstance(report, TrainingReport)
291
+ assert report.model_dump()["champion_score"] == 81.5
292
+ assert report.model_dump()["challenger_score"] == 84.0
293
+
294
+
295
+ def test_unknown_or_malformed_report_stays_raw() -> None:
296
+ client, _ = make_client(
297
+ {
298
+ "GET /v1/jobs/j-future": {
299
+ "job_id": "j-future",
300
+ "task": "task-id",
301
+ "kind": "future-kind",
302
+ "status": "succeeded",
303
+ "report": {"new_field": [1, 2, 3]},
304
+ },
305
+ "GET /v1/jobs/j-bad": {
306
+ "job_id": "j-bad",
307
+ "task": "task-id",
308
+ "kind": "optimize",
309
+ "status": "succeeded",
310
+ "report": {"baseline_score": "not-a-number"},
311
+ },
312
+ }
313
+ )
314
+
315
+ assert client.job("j-future").report() == {"new_field": [1, 2, 3]}
316
+ assert client.job("j-bad").report() == {"baseline_score": "not-a-number"}
317
+
318
+
319
+ def test_optimize_sends_only_set_controls_and_preview_parses() -> None:
320
+ preview = {
321
+ "eval_metric": "json_f1",
322
+ "metric_tier": "deterministic",
323
+ "requested_metric": "json_f1",
324
+ "recommended_metric": "json_f1",
325
+ "metric_reason": "outputs are JSON objects",
326
+ "rag_candidate": False,
327
+ "rows_checked": 20,
328
+ "rows_source": "verified",
329
+ "guidance": {
330
+ "use_case": "json_extraction",
331
+ "recommended_min_rows": 30,
332
+ "verified_rows": 20,
333
+ "data_adequacy": "limited",
334
+ "best_practice": "Add more verified rows.",
335
+ },
336
+ "estimate": {
337
+ "rows": 20,
338
+ "budget": "heavy",
339
+ "cost_usd_low": 0.2,
340
+ "cost_usd_high": 0.5,
341
+ },
342
+ }
343
+ client, received = make_client(
344
+ {
345
+ "POST /v1/optimize": {
346
+ "job_id": "j1",
347
+ "task": "task-id",
348
+ "kind": "optimize",
349
+ "status": "queued",
350
+ },
351
+ "POST /v1/optimize/preview": preview,
352
+ }
353
+ )
354
+
355
+ client.optimize("extract", budget="thorough")
356
+ result = client.preview_optimization("extract", metric="json_f1")
357
+
358
+ assert received[0]["body"] == {"task": "extract", "budget": "thorough"}
359
+ assert received[1]["body"] == {"task": "extract", "metric": "json_f1"}
360
+ assert result.estimate["cost_usd_high"] == 0.5
361
+
362
+
363
+ def test_cancel_happy_path_and_job_cancel() -> None:
364
+ client, received = make_client(
365
+ {
366
+ "GET /v1/jobs/j1": {
367
+ "job_id": "j1",
368
+ "task": "task-id",
369
+ "kind": "optimize",
370
+ "status": "running",
371
+ },
372
+ "POST /v1/jobs/j1/cancel": {
373
+ "job_id": "j1",
374
+ "cancel_requested": True,
375
+ "status": "running",
376
+ },
377
+ }
378
+ )
379
+
380
+ assert client.job("j1").cancel()["cancel_requested"] is True
381
+ assert received[-1] == {"method": "POST", "path": "/v1/jobs/j1/cancel", "body": None}
382
+
383
+
384
+ def test_cancel_409_uses_normal_error_path() -> None:
385
+ client = Apprentice(api_key="test", base_url="http://mock", capture_async=False)
386
+ client._http = httpx.Client(
387
+ base_url="http://mock",
388
+ transport=httpx.MockTransport(
389
+ lambda _request: httpx.Response(
390
+ 409, json={"detail": "job j1 is succeeded, nothing to cancel"}
391
+ )
392
+ ),
393
+ )
394
+
395
+ with pytest.raises(ApprenticeError, match="nothing to cancel"):
396
+ client.cancel_job("j1")
397
+
398
+
222
399
  def test_train_jobs_rows_and_local_run_methods() -> None:
223
400
  client, received = make_client(
224
401
  {
@@ -232,20 +409,49 @@ def test_train_jobs_rows_and_local_run_methods() -> None:
232
409
  {"job_id": "j1", "kind": "optimize", "status": "succeeded"}
233
410
  ],
234
411
  "GET /v1/datasets/t1/rows": {
235
- "rows": [{"id": "r1", "input": "i", "output": "o", "tier": "gold"}],
412
+ "rows": [
413
+ {
414
+ "id": "r1",
415
+ "input": "i",
416
+ "output": "o",
417
+ "tier": "gold",
418
+ "source": "review",
419
+ "flag_label": "edge_case",
420
+ "feedback_score": 1.0,
421
+ "expert_authored": True,
422
+ "created_at": "2026-07-18T00:00:00+00:00",
423
+ }
424
+ ],
236
425
  "total": 1,
237
426
  },
238
427
  "POST /v1/local-runs": {"run_id": "lr1", "task": "t1", "status": "registered"},
239
428
  "POST /v1/local-runs/lr1/complete": {"ok": True},
429
+ "GET /v1/tasks/t1/local-runs": [
430
+ {
431
+ "run_id": "lr1",
432
+ "task_id": "task-id",
433
+ "status": "completed",
434
+ "eval_tier": "self_reported",
435
+ "base_model": "m",
436
+ "dataset_row_count": 3,
437
+ "seed": 42,
438
+ "final_score": 91.0,
439
+ }
440
+ ],
240
441
  }
241
442
  )
242
443
 
243
444
  assert client.train("t1").job_id == "j2"
244
445
  assert client.jobs("t1")[0].job_id == "j1"
245
- assert client.datasets.rows("t1", "gold")[0].id == "r1"
446
+ row = client.datasets.rows("t1", "gold")[0]
447
+ assert row.id == "r1"
448
+ assert row.source == "review"
449
+ assert row.expert_authored is True
246
450
  assert client.register_local_run("t1", "m", 3, 42).run_id == "lr1"
247
451
  assert client.complete_local_run("lr1", {"final_score": 1.0}) == {"ok": True}
248
- assert received[-2]["body"] == {
452
+ assert client.local_runs("t1")[0].final_score == 91.0
453
+ registration = next(item for item in received if item["path"] == "/v1/local-runs")
454
+ assert registration["body"] == {
249
455
  "task": "t1",
250
456
  "base_model": "m",
251
457
  "dataset_row_count": 3,
@@ -1,81 +0,0 @@
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
- from typing import Any
7
-
8
- from pydantic import BaseModel, Field
9
-
10
-
11
- class DatasetRow(BaseModel):
12
- id: str | None = None
13
- input: str
14
- inputs: dict[str, str] | None = None
15
- output: str
16
- tier: str | None = None
17
- metadata: dict[str, Any] = Field(default_factory=dict)
18
-
19
-
20
- class DatasetStatus(BaseModel):
21
- task: str
22
- gold: int = 0
23
- silver: int = 0
24
- raw: int = 0
25
- ready_for_optimization: bool = False
26
-
27
-
28
- class TraceRecord(BaseModel):
29
- trace_id: str | None = Field(default=None, pattern=r"^[0-9a-f]{32}$")
30
- task: str
31
- input: str
32
- inputs: dict[str, str] | None = None
33
- output: str
34
- model: str | None = None
35
- latency_ms: float | None = None
36
- prompt_tokens: int | None = None
37
- completion_tokens: int | None = None
38
- error: str | None = None
39
- metadata: dict[str, Any] = Field(default_factory=dict)
40
-
41
-
42
- class OptimizationReport(BaseModel):
43
- task: str
44
- baseline_score: float | None = None
45
- optimized_score: float | None = None
46
- examples_used: int | None = None
47
- optimized_prompt: str | None = None
48
- optimized_template: dict[str, Any] | None = None
49
- detail: dict[str, Any] = Field(default_factory=dict)
50
-
51
-
52
- class JobInfo(BaseModel):
53
- job_id: str
54
- task: str
55
- kind: str
56
- status: str # queued | running | succeeded | failed
57
- created_at: datetime | None = None
58
- error: str | None = None
59
- report: OptimizationReport | None = None
60
-
61
-
62
- class JobSummary(BaseModel):
63
- job_id: str
64
- status: str
65
- kind: str
66
- created_at: datetime | None = None
67
- baseline_score: float | None = None
68
- optimized_score: float | None = None
69
-
70
-
71
- class LocalRun(BaseModel):
72
- run_id: str
73
- task: str
74
- status: str = "registered"
75
-
76
-
77
- class PromptVersion(BaseModel):
78
- task: str
79
- version: int
80
- text: str
81
- score: float | None = None
File without changes