runapprentice 0.4.2__tar.gz → 0.4.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (22) hide show
  1. {runapprentice-0.4.2 → runapprentice-0.4.3}/.gitignore +1 -0
  2. {runapprentice-0.4.2 → runapprentice-0.4.3}/PKG-INFO +1 -1
  3. {runapprentice-0.4.2 → runapprentice-0.4.3}/pyproject.toml +1 -1
  4. {runapprentice-0.4.2 → runapprentice-0.4.3}/src/runapprentice/cli.py +4 -1
  5. {runapprentice-0.4.2 → runapprentice-0.4.3}/src/runapprentice/client.py +5 -3
  6. {runapprentice-0.4.2 → runapprentice-0.4.3}/src/runapprentice/local.py +104 -31
  7. {runapprentice-0.4.2 → runapprentice-0.4.3}/src/runapprentice/models.py +16 -0
  8. {runapprentice-0.4.2 → runapprentice-0.4.3}/tests/test_cli.py +14 -13
  9. {runapprentice-0.4.2 → runapprentice-0.4.3}/tests/test_client.py +123 -0
  10. {runapprentice-0.4.2 → runapprentice-0.4.3}/LICENSE +0 -0
  11. {runapprentice-0.4.2 → runapprentice-0.4.3}/README.md +0 -0
  12. {runapprentice-0.4.2 → runapprentice-0.4.3}/src/runapprentice/__init__.py +0 -0
  13. {runapprentice-0.4.2 → runapprentice-0.4.3}/src/runapprentice/_capture.py +0 -0
  14. {runapprentice-0.4.2 → runapprentice-0.4.3}/src/runapprentice/langchain.py +0 -0
  15. {runapprentice-0.4.2 → runapprentice-0.4.3}/src/runapprentice/openai.py +0 -0
  16. {runapprentice-0.4.2 → runapprentice-0.4.3}/src/runapprentice/py.typed +0 -0
  17. {runapprentice-0.4.2 → runapprentice-0.4.3}/tests/test_capture.py +0 -0
  18. {runapprentice-0.4.2 → runapprentice-0.4.3}/tests/test_dispatcher.py +0 -0
  19. {runapprentice-0.4.2 → runapprentice-0.4.3}/tests/test_failopen.py +0 -0
  20. {runapprentice-0.4.2 → runapprentice-0.4.3}/tests/test_langchain_prompts.py +0 -0
  21. {runapprentice-0.4.2 → runapprentice-0.4.3}/tests/test_local.py +0 -0
  22. {runapprentice-0.4.2 → runapprentice-0.4.3}/tests/test_openai.py +0 -0
@@ -52,3 +52,4 @@ docs/personal_learning.md
52
52
  .uv-cache/
53
53
  spike/Capture-*.png
54
54
  apprentice-ui/ClaudeDesign/screenshots/localhost-*.png
55
+ supabase-magic-link-template.html
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: runapprentice
3
- Version: 0.4.2
3
+ Version: 0.4.3
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
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "runapprentice"
3
- version = "0.4.2"
3
+ version = "0.4.3"
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"
@@ -112,7 +112,10 @@ def build_parser() -> argparse.ArgumentParser:
112
112
  optimize.add_argument(
113
113
  "--local",
114
114
  action="store_true",
115
- help="run GEPA on this machine with your own OPENAI_API_KEY, no Apprentice account needed",
115
+ help=(
116
+ "run GEPA on this machine with your own OPENAI_API_KEY, no Apprentice account "
117
+ "needed; scores JSON extraction only, so every output must be a JSON object or array"
118
+ ),
116
119
  )
117
120
  optimize.add_argument(
118
121
  "--data",
@@ -156,7 +156,9 @@ class Apprentice:
156
156
  ) from exc
157
157
  logger.debug("%s %s -> %s (%.0fms)", method, shown_path, resp.status_code, _ms(t0))
158
158
  if resp.status_code >= 400:
159
- logger.error("%s %s -> %s: %s", method, shown_path, resp.status_code, resp.text[:500])
159
+ logger.error(
160
+ "%s %s -> %s error=http_response", method, shown_path, resp.status_code
161
+ )
160
162
  # The API sends a written reason in `detail`; lead with it so the first
161
163
  # thing a reader sees is why the call failed. The route cannot tell us
162
164
  # that: a 404 on /v1/prompts/{task} means the task has no optimized
@@ -430,8 +432,8 @@ class Apprentice:
430
432
  self._capture_warned = False
431
433
  return record.trace_id
432
434
  if resp.status_code >= 500:
433
- raise ApprenticeError(f"backend {resp.status_code}: {resp.text[:200]}")
434
- self._warn_capture_once(f"backend rejected trace: {resp.status_code} {resp.text[:200]}")
435
+ raise ApprenticeError("backend 5xx for /v1/traces")
436
+ self._warn_capture_once(f"backend rejected trace: {resp.status_code} for /v1/traces")
435
437
  return None
436
438
 
437
439
  def _warn_capture_once(self, message: str) -> None:
@@ -27,8 +27,10 @@ from runapprentice.client import ApprenticeError
27
27
  from runapprentice.models import DatasetRow
28
28
 
29
29
  LOCAL_MODEL_ID = "mlx-community/Qwen3.5-4B-OptiQ-4bit"
30
- DEFAULT_STUDENT_MODEL = "openai/gpt-5-mini"
31
- DEFAULT_REFLECTION_MODEL = "openai/gpt-5.4"
30
+ # Mirrors the API defaults (owner decision 2026-07-31). The exact id matters: the bare
31
+ # `gpt-5.6` alias routes to Sol, not Luna.
32
+ DEFAULT_STUDENT_MODEL = "openai/gpt-5.6-luna"
33
+ DEFAULT_REFLECTION_MODEL = "openai/gpt-5.6-luna"
32
34
 
33
35
  # Every tier trains the SAME model quality: 16 LoRA layers (mlx-lm's default;
34
36
  # fewer layers reduces final quality per LORA.md) and the same number of passes
@@ -117,18 +119,64 @@ def score_json_field_f1(row: DatasetRow, actual: str) -> float:
117
119
  actual_obj = json.loads(_extract_json_text(actual))
118
120
  except json.JSONDecodeError:
119
121
  return 0.0
120
- if not isinstance(expected_obj, dict) or not isinstance(actual_obj, dict):
122
+ # Objects AND arrays, matching the API scorer: a task whose gold answers are top-level
123
+ # arrays scored 0 here for a correct answer.
124
+ if not isinstance(expected_obj, (dict, list)) or not isinstance(actual_obj, (dict, list)):
121
125
  return 0.0
122
126
  expected = _json_leaf_pairs(expected_obj)
123
127
  actual_pairs = _json_leaf_pairs(actual_obj)
128
+ # Mirrors the hosted ceiling. Without it the same answer scores 1.0 here and 0.0 hosted,
129
+ # so a local run and a hosted run disagree about quality on identical data.
130
+ if len(expected) > _MAX_JSON_METRIC_LEAVES or len(actual_pairs) > _MAX_JSON_METRIC_LEAVES:
131
+ return 0.0
124
132
  f1 = _leaf_f1(expected, actual_pairs)
125
- if len(expected_obj) == 1:
133
+ if isinstance(expected_obj, dict) and isinstance(actual_obj, dict) and len(expected_obj) == 1:
126
134
  root_key, root_value = next(iter(expected_obj.items()))
127
135
  if isinstance(root_value, dict) and root_key not in actual_obj:
128
136
  f1 = max(f1, _leaf_f1(_json_leaf_pairs(root_value), actual_pairs))
129
137
  return f1
130
138
 
131
139
 
140
+ def _reject_unscoreable_rows(rows: Sequence[DatasetRow]) -> None:
141
+ """Stop before spending if the scorer cannot read this data.
142
+
143
+ Local optimize scores with `score_json_field_f1` and nothing else -- the CLI refuses
144
+ `--metric` in local mode. That scorer returns 0.0 for an answer it cannot parse as a JSON
145
+ object or array, so a dataset of plain labels ("yes"/"no") used to run to completion on the
146
+ user's own OpenAI key and report 0.0 to 0.0: money and minutes spent measuring nothing.
147
+ The same shape of bug reached production hosted-side on 2026-08-02.
148
+
149
+ The test mirrors the scorer's own accept rule rather than restating it, so the two cannot
150
+ drift apart.
151
+ """
152
+ bad = [
153
+ index
154
+ for index, row in enumerate(rows, start=1)
155
+ if not _is_json_container(row.output)
156
+ ]
157
+ if not bad:
158
+ return
159
+ shown = ", ".join(str(index) for index in bad[:5])
160
+ more = f" (and {len(bad) - 5} more)" if len(bad) > 5 else ""
161
+ # One action, and it is true on every path that reaches this message. An earlier draft
162
+ # also offered "run without --local", which is false advice here: the hosted branch of
163
+ # `_optimize` never reads --data, so dropping the flag silently ignores the CSV and
164
+ # optimizes whatever rows already exist server-side. The CLI has no upload command, so
165
+ # there is no hosted path for THIS file to name.
166
+ raise ApprenticeError(
167
+ f"{len(bad)} of {len(rows)} rows have an output that is not a JSON object or array, "
168
+ f"starting at row {shown}{more}. Local optimize scores JSON extraction only, so these "
169
+ "rows would all score zero. Give each row's output column a JSON object or array."
170
+ )
171
+
172
+
173
+ def _is_json_container(output: str) -> bool:
174
+ try:
175
+ return isinstance(json.loads(output), (dict, list))
176
+ except json.JSONDecodeError:
177
+ return False
178
+
179
+
132
180
  def optimize_prompt_local(
133
181
  rows: Sequence[DatasetRow],
134
182
  *,
@@ -141,6 +189,7 @@ def optimize_prompt_local(
141
189
  raise ApprenticeError("OPENAI_API_KEY is required for local prompt optimization")
142
190
  if len(rows) < 2:
143
191
  raise ApprenticeError("local optimize needs at least two rows for train and held-out scoring")
192
+ _reject_unscoreable_rows(rows)
144
193
  dspy = _load_dspy()
145
194
  student_model = student_model or os.environ.get("APPRENTICE_STUDENT_MODEL", DEFAULT_STUDENT_MODEL)
146
195
  reflection_model = reflection_model or os.environ.get(
@@ -525,51 +574,75 @@ def _generate(row: DatasetRow, adapter_dir: Path, model_id: str) -> str:
525
574
 
526
575
 
527
576
  def _extract_json_text(text: str) -> str:
577
+ # Objects AND arrays, mirroring the API. Scanning for "{" alone returned the first inner
578
+ # OBJECT of a wrapped array, so an array answer was compared as an object and scored 0.
528
579
  candidate = text.strip()
529
- start = candidate.find("{")
530
- while start != -1:
531
- in_string = False
532
- escaped = False
533
- depth = 0
534
- for i, char in enumerate(candidate[start:], start=start):
535
- if in_string:
536
- if escaped:
537
- escaped = False
538
- elif char == "\\":
539
- escaped = True
540
- elif char == '"':
541
- in_string = False
542
- continue
543
- if char == '"':
544
- in_string = True
545
- elif char == "{":
546
- depth += 1
547
- elif char == "}":
548
- depth -= 1
549
- if depth == 0:
550
- return candidate[start : i + 1]
551
- start = candidate.find("{", start + 1)
580
+ openers = [(candidate.find(o), o, c) for o, c in (("{", "}"), ("[", "]")) if candidate.find(o) != -1]
581
+ for _, opener, closer in sorted(openers):
582
+ start = candidate.find(opener)
583
+ while start != -1:
584
+ in_string = False
585
+ escaped = False
586
+ depth = 0
587
+ for i, char in enumerate(candidate[start:], start=start):
588
+ if in_string:
589
+ if escaped:
590
+ escaped = False
591
+ elif char == "\\":
592
+ escaped = True
593
+ elif char == '"':
594
+ in_string = False
595
+ continue
596
+ if char == '"':
597
+ in_string = True
598
+ elif char == opener:
599
+ depth += 1
600
+ elif char == closer:
601
+ depth -= 1
602
+ if depth == 0:
603
+ span = candidate[start : i + 1]
604
+ try:
605
+ json.loads(span)
606
+ return span
607
+ except json.JSONDecodeError:
608
+ break
609
+ start = candidate.find(opener, start + 1)
552
610
  return candidate
553
611
 
554
612
 
555
- def _json_leaf_pairs(value: Any, path: str = "") -> set[tuple[str, str]]:
556
- leaves: set[tuple[str, str]] = set()
613
+ # Leaf kinds, mirroring the API. The kind is a separate field so an empty container can
614
+ # never be confused with a scalar that spells one.
615
+ # Mirrors apprentice-api optimizer._MAX_JSON_METRIC_LEAVES.
616
+ _MAX_JSON_METRIC_LEAVES = 2000
617
+ _LEAF_SCALAR = "scalar"
618
+ _LEAF_EMPTY_OBJECT = "empty-object"
619
+ _LEAF_EMPTY_ARRAY = "empty-array"
620
+
621
+
622
+ def _json_leaf_pairs(value: Any, path: str = "") -> set[tuple[str, str, str]]:
623
+ leaves: set[tuple[str, str, str]] = set()
557
624
 
558
625
  def walk(node: Any, current_path: str) -> None:
559
626
  if isinstance(node, dict):
627
+ if not node:
628
+ leaves.add((current_path or "$", _LEAF_EMPTY_OBJECT, ""))
629
+ return
560
630
  for key in sorted(node):
561
631
  walk(node[key], f"{current_path}.{key}" if current_path else str(key))
562
632
  elif isinstance(node, list):
633
+ if not node:
634
+ leaves.add((current_path or "$", _LEAF_EMPTY_ARRAY, ""))
635
+ return
563
636
  for i, item in enumerate(node):
564
637
  walk(item, f"{current_path}[{i}]")
565
638
  else:
566
- leaves.add((current_path or "$", _normalize_scalar(node)))
639
+ leaves.add((current_path or "$", _LEAF_SCALAR, _normalize_scalar(node)))
567
640
 
568
641
  walk(value, path)
569
642
  return leaves
570
643
 
571
644
 
572
- def _leaf_f1(expected: set[tuple[str, str]], actual: set[tuple[str, str]]) -> float:
645
+ def _leaf_f1(expected: set[tuple[str, str, str]], actual: set[tuple[str, str, str]]) -> float:
573
646
  tp = len(expected & actual)
574
647
  precision = tp / len(actual) if actual else 0.0
575
648
  recall = tp / len(expected) if expected else 0.0
@@ -64,6 +64,9 @@ class HoldoutEvidenceRow(BaseModel):
64
64
  class OptimizationReport(BaseModel):
65
65
  task: str | None = None
66
66
  baseline_score: float | None = None
67
+ # Set only when the baseline scored 0 on every held-out row, derived from the outputs it
68
+ # produced: output_not_structured | empty_output | evaluation_error | values_differ.
69
+ baseline_zero_reason: str | None = None
67
70
  optimized_score: float | None = None
68
71
  examples_used: int | None = None
69
72
  baseline_prompt: str | None = None
@@ -85,7 +88,16 @@ class OptimizationReport(BaseModel):
85
88
  judge_cache: dict[str, int] | None = None
86
89
  score_explanation: str | None = None
87
90
  worst_examples: list[dict[str, Any]] | None = None
91
+ # Deprecated, None on runs from 2026-08 onward: it counted rows scoring under 1.0, which
92
+ # on a continuous metric makes 0.97 a "failure". Older reports keep whatever they stored.
93
+ # Remove at 1.0.
88
94
  failure_count: int | None = None
95
+ label_distribution: dict[str, int] | None = None
96
+ constant_baseline: dict[str, str | float] | None = None
97
+ constant_baseline_reason: str | None = None
98
+ per_class_result: list[dict[str, str | int]] | None = None
99
+ holdout_tier_counts: dict[str, int] | None = None
100
+ evaluation_errors: int | None = None
89
101
  holdout_evidence: list[HoldoutEvidenceRow] | None = None
90
102
  improved_count: int | None = None
91
103
  regressed_count: int | None = None
@@ -149,6 +161,10 @@ class JobInfo(BaseModel):
149
161
  error: str | None = None
150
162
  report: SkipValidation[JobReport] | None = None
151
163
  progress: dict[str, Any] | None = None
164
+ # Deprecated, always None: the API stopped reporting a queue rank because every rank it
165
+ # could compute was false (it ignored run_after and the worker's own --queues subset).
166
+ # Kept so `job.queue_position` still reads as "no rank" for anyone on 0.4.2 or earlier
167
+ # instead of raising AttributeError. Remove at 1.0.
152
168
  queue_position: int | None = None
153
169
  baseline_score: float | None = None
154
170
  optimized_score: float | None = None
@@ -3,6 +3,7 @@ from __future__ import annotations
3
3
  import json
4
4
  import os
5
5
  import stat
6
+ import sys
6
7
  from io import StringIO
7
8
  from pathlib import Path
8
9
  from typing import Any, Callable
@@ -333,7 +334,7 @@ def test_login_writes_config_0600(
333
334
  config_path = config_dir / "config.json"
334
335
  monkeypatch.setattr(cli, "CONFIG_DIR", config_dir)
335
336
  monkeypatch.setattr(cli, "CONFIG_PATH", config_path)
336
- monkeypatch.setattr(cli.sys.stdin, "isatty", lambda: True)
337
+ monkeypatch.setattr(sys.stdin, "isatty", lambda: True)
337
338
  monkeypatch.setattr("runapprentice.cli.getpass.getpass", lambda _: "secret-key")
338
339
  _mock_login_api(monkeypatch, lambda _request: httpx.Response(200, json=[]))
339
340
 
@@ -351,7 +352,7 @@ def test_login_rejects_invalid_key_without_writing(
351
352
  ) -> None:
352
353
  config_path = tmp_path / "config.json"
353
354
  monkeypatch.setattr(cli, "CONFIG_PATH", config_path)
354
- monkeypatch.setattr(cli.sys, "stdin", StringIO("invalid-key\n"))
355
+ monkeypatch.setattr(sys, "stdin", StringIO("invalid-key\n"))
355
356
  _mock_login_api(
356
357
  monkeypatch,
357
358
  lambda _request: httpx.Response(401, json={"detail": "invalid API key"}),
@@ -369,7 +370,7 @@ def test_failed_login_does_not_clobber_existing_config(
369
370
  original = '{"api_key": "working-key"}\n'
370
371
  config_path.write_text(original, encoding="utf-8")
371
372
  monkeypatch.setattr(cli, "CONFIG_PATH", config_path)
372
- monkeypatch.setattr(cli.sys, "stdin", StringIO("invalid-key\n"))
373
+ monkeypatch.setattr(sys, "stdin", StringIO("invalid-key\n"))
373
374
  _mock_login_api(monkeypatch, lambda _request: httpx.Response(403, json={"detail": "forbidden"}))
374
375
 
375
376
  assert cli.main(["login"]) == 1
@@ -380,7 +381,7 @@ def test_login_reports_unreachable_api_differently_from_rejected_key(
380
381
  tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
381
382
  ) -> None:
382
383
  monkeypatch.setattr(cli, "CONFIG_PATH", tmp_path / "config.json")
383
- monkeypatch.setattr(cli.sys, "stdin", StringIO("candidate-key\n"))
384
+ monkeypatch.setattr(sys, "stdin", StringIO("candidate-key\n"))
384
385
 
385
386
  def unreachable(request: httpx.Request) -> httpx.Response:
386
387
  raise httpx.ConnectError("connection refused", request=request)
@@ -398,7 +399,7 @@ def test_login_reports_server_error_as_unavailable(
398
399
  ) -> None:
399
400
  config_path = tmp_path / "config.json"
400
401
  monkeypatch.setattr(cli, "CONFIG_PATH", config_path)
401
- monkeypatch.setattr(cli.sys, "stdin", StringIO("candidate-key\n"))
402
+ monkeypatch.setattr(sys, "stdin", StringIO("candidate-key\n"))
402
403
  _mock_login_api(monkeypatch, lambda _request: httpx.Response(503))
403
404
 
404
405
  assert cli.main(["login"]) == 1
@@ -425,7 +426,7 @@ def test_login_reads_piped_stdin_and_validates_it(
425
426
  ) -> None:
426
427
  config_path = tmp_path / "config.json"
427
428
  monkeypatch.setattr(cli, "CONFIG_PATH", config_path)
428
- monkeypatch.setattr(cli.sys, "stdin", StringIO(" piped-key \n"))
429
+ monkeypatch.setattr(sys, "stdin", StringIO(" piped-key \n"))
429
430
 
430
431
  def validate(request: httpx.Request) -> httpx.Response:
431
432
  assert request.url.path == "/v1/tasks"
@@ -547,7 +548,7 @@ def test_login_rejects_a_key_containing_whitespace(
547
548
  """
548
549
  config_path = tmp_path / "config.json"
549
550
  monkeypatch.setattr(cli, "CONFIG_PATH", config_path)
550
- monkeypatch.setattr(cli.sys, "stdin", StringIO("good-key\nJUNK-SECOND-LINE\n"))
551
+ monkeypatch.setattr(sys, "stdin", StringIO("good-key\nJUNK-SECOND-LINE\n"))
551
552
 
552
553
  def explode(_request: httpx.Request) -> httpx.Response:
553
554
  raise AssertionError("a malformed key must never reach the network")
@@ -569,7 +570,7 @@ def test_login_directory_is_not_world_writable_under_a_permissive_umask(
569
570
  """
570
571
  config_path = tmp_path / "nested" / "config.json"
571
572
  monkeypatch.setattr(cli, "CONFIG_PATH", config_path)
572
- monkeypatch.setattr(cli.sys, "stdin", StringIO("good-key\n"))
573
+ monkeypatch.setattr(sys, "stdin", StringIO("good-key\n"))
573
574
  _mock_login_api(monkeypatch, lambda _request: httpx.Response(200, json=[]))
574
575
 
575
576
  previous = os.umask(0)
@@ -609,7 +610,7 @@ def test_login_rejects_a_key_that_is_not_printable_ascii(
609
610
  """
610
611
  config_path = tmp_path / "config.json"
611
612
  monkeypatch.setattr(cli, "CONFIG_PATH", config_path)
612
- monkeypatch.setattr(cli.sys, "stdin", StringIO(key + "\n"))
613
+ monkeypatch.setattr(sys, "stdin", StringIO(key + "\n"))
613
614
 
614
615
  def explode(_request: httpx.Request) -> httpx.Response:
615
616
  raise AssertionError("a malformed key must never reach the network")
@@ -632,7 +633,7 @@ def test_login_never_leaves_the_key_world_readable(
632
633
  """
633
634
  config_path = tmp_path / "nested" / "config.json"
634
635
  monkeypatch.setattr(cli, "CONFIG_PATH", config_path)
635
- monkeypatch.setattr(cli.sys, "stdin", StringIO("good-key\n"))
636
+ monkeypatch.setattr(sys, "stdin", StringIO("good-key\n"))
636
637
  _mock_login_api(monkeypatch, lambda _request: httpx.Response(200, json=[]))
637
638
 
638
639
  seen_modes: list[int] = []
@@ -643,7 +644,7 @@ def test_login_never_leaves_the_key_world_readable(
643
644
  seen_modes.append(stat.S_IMODE(os.stat(src).st_mode))
644
645
  real_replace(src, dst)
645
646
 
646
- monkeypatch.setattr(cli.os, "replace", spy)
647
+ monkeypatch.setattr(os, "replace", spy)
647
648
 
648
649
  previous = os.umask(0)
649
650
  try:
@@ -663,7 +664,7 @@ def test_login_replaces_a_symlinked_config_instead_of_its_target(
663
664
  config_path = tmp_path / "config.json"
664
665
  config_path.symlink_to(victim)
665
666
  monkeypatch.setattr(cli, "CONFIG_PATH", config_path)
666
- monkeypatch.setattr(cli.sys, "stdin", StringIO("good-key\n"))
667
+ monkeypatch.setattr(sys, "stdin", StringIO("good-key\n"))
667
668
  _mock_login_api(monkeypatch, lambda _request: httpx.Response(200, json=[]))
668
669
 
669
670
  assert cli.main(["login"]) == 0
@@ -683,7 +684,7 @@ def test_login_reports_a_filesystem_failure_instead_of_a_traceback(
683
684
  """
684
685
  # A path whose parent cannot exist: /dev/null is a file, not a directory.
685
686
  monkeypatch.setattr(cli, "CONFIG_PATH", Path("/dev/null/nope/config.json"))
686
- monkeypatch.setattr(cli.sys, "stdin", StringIO("good-key\n"))
687
+ monkeypatch.setattr(sys, "stdin", StringIO("good-key\n"))
687
688
  _mock_login_api(monkeypatch, lambda _request: httpx.Response(200, json=[]))
688
689
 
689
690
  assert cli.main(["--json", "login"]) == 1
@@ -252,10 +252,20 @@ def test_job_error_is_reachable() -> None:
252
252
  assert job.error == "training failed"
253
253
 
254
254
 
255
+ def test_queue_position_still_reads_as_absent_after_the_api_stopped_sending_it() -> None:
256
+ """0.4.2 published this attribute; dropping it would raise AttributeError on upgrade."""
257
+ info = JobInfo.model_validate({"job_id": "j1", "task": "t1", "kind": "optimize", "status": "queued"})
258
+
259
+ assert info.queue_position is None
260
+
261
+
255
262
  def test_optimization_report_round_trips_every_api_field() -> None:
256
263
  payload = {
257
264
  "task": "extract",
258
265
  "baseline_score": 41.0,
266
+ # Set only on a run whose baseline scored zero everywhere; carried here so this
267
+ # round-trip keeps covering every field the API can send.
268
+ "baseline_zero_reason": None,
259
269
  "optimized_score": 88.0,
260
270
  "examples_used": 20,
261
271
  "baseline_prompt": "Extract fields.",
@@ -290,6 +300,15 @@ def test_optimization_report_round_trips_every_api_field() -> None:
290
300
  {"input": "invoice", "expected": "{}", "actual": "{}", "score": 1.0}
291
301
  ],
292
302
  "failure_count": 1,
303
+ "label_distribution": {"false": 12, "true": 4},
304
+ "constant_baseline": {"value": "false", "score": 75.0},
305
+ "constant_baseline_reason": None,
306
+ "per_class_result": [
307
+ {"label": "false", "correct": 12, "total": 12},
308
+ {"label": "true", "correct": 0, "total": 4},
309
+ ],
310
+ "holdout_tier_counts": {"gold": 3, "silver": 13},
311
+ "evaluation_errors": 0,
293
312
  "holdout_evidence": [
294
313
  {
295
314
  "row_id": "row-1",
@@ -632,6 +651,42 @@ def test_error_leads_with_the_reason_the_api_gave() -> None:
632
651
  )
633
652
 
634
653
 
654
+ def test_http_error_log_omits_response_body(caplog: pytest.LogCaptureFixture) -> None:
655
+ secret = "customer SSN 123-45-6789"
656
+ client = Apprentice(api_key="k", base_url="http://mock", capture_async=False)
657
+ client._http = httpx.Client(
658
+ base_url="http://mock",
659
+ transport=httpx.MockTransport(
660
+ lambda _request: httpx.Response(422, json={"detail": secret})
661
+ ),
662
+ )
663
+
664
+ with caplog.at_level(logging.ERROR, logger="runapprentice"):
665
+ with pytest.raises(ApprenticeError):
666
+ client._request("POST", "/v1/datasets/t1/rows", json={"input": secret})
667
+
668
+ assert "/v1/datasets/t1/rows" in caplog.text
669
+ assert "422" in caplog.text
670
+ assert secret not in caplog.text
671
+
672
+
673
+ def test_capture_rejection_log_omits_response_body(caplog: pytest.LogCaptureFixture) -> None:
674
+ secret = "customer prompt in rejected body"
675
+ client = Apprentice(api_key="k", base_url="http://mock", capture_async=False)
676
+ client._http = httpx.Client(
677
+ base_url="http://mock",
678
+ transport=httpx.MockTransport(
679
+ lambda _request: httpx.Response(422, json={"detail": secret})
680
+ ),
681
+ )
682
+
683
+ with caplog.at_level(logging.WARNING, logger="runapprentice"):
684
+ assert client.capture("t1", input="i", output="o") is None
685
+
686
+ assert "backend rejected trace: 422 for /v1/traces" in caplog.text
687
+ assert secret not in caplog.text
688
+
689
+
635
690
  @pytest.mark.parametrize(
636
691
  "body",
637
692
  [["upstream failed"], "plain string", 42, None],
@@ -965,3 +1020,71 @@ def test_a_hostile_server_message_cannot_repaint_the_terminal() -> None:
965
1020
  message = str(exc_info.value)
966
1021
  assert "\x1b" not in message and "\x07" not in message and "\r" not in message
967
1022
  assert "FAKE: login succeeded" in message # kept, but inert
1023
+
1024
+
1025
+ def test_local_scorer_matches_the_hosted_one_on_arrays_and_empty_containers() -> None:
1026
+ """local.py names the API scorer as its source of truth, so it has to keep up.
1027
+
1028
+ It rejected top-level arrays and ignored empty containers after the API stopped doing
1029
+ both, so the same answer scored differently depending on where it ran.
1030
+ """
1031
+ from runapprentice.local import score_json_field_f1
1032
+ from runapprentice.models import DatasetRow
1033
+
1034
+ def score(expected: str, actual: str) -> float:
1035
+ return round(score_json_field_f1(DatasetRow(input="x", output=expected), actual), 3)
1036
+
1037
+ assert score("[1]", "[1]") == 1.0
1038
+ assert score('[{"a": 1}]', '[{"a": 1}]') == 1.0
1039
+ assert score('{"a": {}}', '{"a": {}}') == 1.0
1040
+ # A missing empty field is counted, not ignored.
1041
+ assert score('{"a": 1, "i": []}', '{"a": 1}') < 1.0
1042
+ # The leaf kind is not forgeable by a string that spells it.
1043
+ assert score('{"a": {}}', '{"a": "{}"}') == 0.0
1044
+ # Unchanged behaviour.
1045
+ assert score('{"a": 1, "b": 2}', '{"a": 1, "b": 2}') == 1.0
1046
+ assert score('{"a": 1, "b": 2}', '{"a": 1, "b": 9}') == 0.5
1047
+ assert score('{"root": {"a": 1}}', '{"a": 1}') == 1.0
1048
+
1049
+
1050
+ def test_local_optimize_refuses_data_its_scorer_cannot_read(
1051
+ monkeypatch: pytest.MonkeyPatch,
1052
+ ) -> None:
1053
+ """Non-JSON rows must fail BEFORE any model call, not score 0.0 after paying.
1054
+
1055
+ Local optimize scores with score_json_field_f1 and the CLI refuses --metric in local
1056
+ mode, so a dataset of plain labels used to run to completion on the user's own OpenAI
1057
+ key and report 0.0 to 0.0. The guard has to fire before the key check does any work.
1058
+ """
1059
+ from runapprentice import local
1060
+ from runapprentice.client import ApprenticeError
1061
+ from runapprentice.models import DatasetRow
1062
+
1063
+ monkeypatch.setenv("OPENAI_API_KEY", "sk-test")
1064
+ # Would blow up loudly if the guard let execution through to the optimizer.
1065
+ monkeypatch.setattr(local, "_load_dspy", lambda: pytest.fail("spent before validating"))
1066
+
1067
+ labels = [DatasetRow(input=f"email {i}", output="yes" if i % 2 else "no") for i in range(6)]
1068
+ with pytest.raises(ApprenticeError) as caught:
1069
+ local.optimize_prompt_local(labels)
1070
+
1071
+ message = str(caught.value)
1072
+ assert "6 of 6 rows" in message, "the count tells the user how much of their data is wrong"
1073
+ assert "not a JSON object or array" in message
1074
+ assert "output column a JSON object or array" in message, "one action, and a true one"
1075
+ # No hosted suggestion: the hosted branch never reads --data, so "run without --local"
1076
+ # would silently ignore this CSV.
1077
+ assert "--local" not in message
1078
+
1079
+ # A scalar is still not a container, and neither is a bare JSON string.
1080
+ with pytest.raises(ApprenticeError):
1081
+ local.optimize_prompt_local([DatasetRow(input="a", output="42")] * 2)
1082
+ with pytest.raises(ApprenticeError):
1083
+ local.optimize_prompt_local([DatasetRow(input="a", output='"yes"')] * 2)
1084
+
1085
+ # Objects and arrays both pass the guard: it must not narrow what the scorer accepts.
1086
+ mixed = [
1087
+ DatasetRow(input="a", output='{"label": "yes"}'),
1088
+ DatasetRow(input="b", output='[{"label": "no"}]'),
1089
+ ]
1090
+ local._reject_unscoreable_rows(mixed)
File without changes
File without changes