nabla-cli 0.6.3__tar.gz → 0.7.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 (36) hide show
  1. {nabla_cli-0.6.3 → nabla_cli-0.7.0}/PKG-INFO +18 -1
  2. {nabla_cli-0.6.3 → nabla_cli-0.7.0}/README.md +17 -0
  3. {nabla_cli-0.6.3 → nabla_cli-0.7.0}/nabla/__init__.py +1 -1
  4. nabla_cli-0.7.0/nabla/branding.py +139 -0
  5. {nabla_cli-0.6.3 → nabla_cli-0.7.0}/nabla/cli.py +89 -25
  6. {nabla_cli-0.6.3 → nabla_cli-0.7.0}/nabla/recorder.py +8 -0
  7. {nabla_cli-0.6.3 → nabla_cli-0.7.0}/nabla/ui.py +53 -44
  8. {nabla_cli-0.6.3 → nabla_cli-0.7.0}/nabla_cli.egg-info/PKG-INFO +18 -1
  9. {nabla_cli-0.6.3 → nabla_cli-0.7.0}/pyproject.toml +1 -1
  10. {nabla_cli-0.6.3 → nabla_cli-0.7.0}/tests/test_cli_ux.py +69 -0
  11. nabla_cli-0.6.3/nabla/branding.py +0 -101
  12. {nabla_cli-0.6.3 → nabla_cli-0.7.0}/nabla/__main__.py +0 -0
  13. {nabla_cli-0.6.3 → nabla_cli-0.7.0}/nabla/config.py +0 -0
  14. {nabla_cli-0.6.3 → nabla_cli-0.7.0}/nabla/contract.py +0 -0
  15. {nabla_cli-0.6.3 → nabla_cli-0.7.0}/nabla/induce.py +0 -0
  16. {nabla_cli-0.6.3 → nabla_cli-0.7.0}/nabla/profile.py +0 -0
  17. {nabla_cli-0.6.3 → nabla_cli-0.7.0}/nabla/prompt_recon.py +0 -0
  18. {nabla_cli-0.6.3 → nabla_cli-0.7.0}/nabla/samplegen.py +0 -0
  19. {nabla_cli-0.6.3 → nabla_cli-0.7.0}/nabla/scanner.py +0 -0
  20. {nabla_cli-0.6.3 → nabla_cli-0.7.0}/nabla/schema_resolver.py +0 -0
  21. {nabla_cli-0.6.3 → nabla_cli-0.7.0}/nabla/schemas/site_profile.schema.json +0 -0
  22. {nabla_cli-0.6.3 → nabla_cli-0.7.0}/nabla/term.py +0 -0
  23. {nabla_cli-0.6.3 → nabla_cli-0.7.0}/nabla_cli.egg-info/SOURCES.txt +0 -0
  24. {nabla_cli-0.6.3 → nabla_cli-0.7.0}/nabla_cli.egg-info/dependency_links.txt +0 -0
  25. {nabla_cli-0.6.3 → nabla_cli-0.7.0}/nabla_cli.egg-info/entry_points.txt +0 -0
  26. {nabla_cli-0.6.3 → nabla_cli-0.7.0}/nabla_cli.egg-info/requires.txt +0 -0
  27. {nabla_cli-0.6.3 → nabla_cli-0.7.0}/nabla_cli.egg-info/top_level.txt +0 -0
  28. {nabla_cli-0.6.3 → nabla_cli-0.7.0}/setup.cfg +0 -0
  29. {nabla_cli-0.6.3 → nabla_cli-0.7.0}/tests/test_config.py +0 -0
  30. {nabla_cli-0.6.3 → nabla_cli-0.7.0}/tests/test_e2e_profile.py +0 -0
  31. {nabla_cli-0.6.3 → nabla_cli-0.7.0}/tests/test_induce.py +0 -0
  32. {nabla_cli-0.6.3 → nabla_cli-0.7.0}/tests/test_prompt_recon.py +0 -0
  33. {nabla_cli-0.6.3 → nabla_cli-0.7.0}/tests/test_recorder.py +0 -0
  34. {nabla_cli-0.6.3 → nabla_cli-0.7.0}/tests/test_samplegen.py +0 -0
  35. {nabla_cli-0.6.3 → nabla_cli-0.7.0}/tests/test_scanner.py +0 -0
  36. {nabla_cli-0.6.3 → nabla_cli-0.7.0}/tests/test_schema_resolver.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nabla-cli
3
- Version: 0.6.3
3
+ Version: 0.7.0
4
4
  Summary: Call-site harvester: scan OpenAI call sites, record real traffic, emit site profiles
5
5
  License: MIT
6
6
  Requires-Python: >=3.11
@@ -162,6 +162,23 @@ returns a goal description, implicit constraints, and difficulty
162
162
  dimensions with cited evidence. That's the goal line printed above, and
163
163
  it's what lands in the profile's `goal` block.
164
164
 
165
+ ### `nabla jeremy`
166
+
167
+ An alternative to `build` with the same inputs and site handling: instead
168
+ of the full site profile, it emits `<site>.jeremy.json` — a seed spec for
169
+ a problem generator:
170
+
171
+ ```json
172
+ {
173
+ "goal": "You are generating new problems to test and eventually fine-tune a small student model on the following task: ...",
174
+ "system_prompt": "<the site's own system prompt>",
175
+ "examples": [ {"problem": "...", "solution": "..."}, ... ]
176
+ }
177
+ ```
178
+
179
+ The goal text is composed deterministically from the same inferred goal
180
+ and constraints that `build` uses; the examples are the captured pairs.
181
+
165
182
  ## Providers
166
183
 
167
184
  `nabla init` supports three providers. Pick whichever you have a key
@@ -148,6 +148,23 @@ returns a goal description, implicit constraints, and difficulty
148
148
  dimensions with cited evidence. That's the goal line printed above, and
149
149
  it's what lands in the profile's `goal` block.
150
150
 
151
+ ### `nabla jeremy`
152
+
153
+ An alternative to `build` with the same inputs and site handling: instead
154
+ of the full site profile, it emits `<site>.jeremy.json` — a seed spec for
155
+ a problem generator:
156
+
157
+ ```json
158
+ {
159
+ "goal": "You are generating new problems to test and eventually fine-tune a small student model on the following task: ...",
160
+ "system_prompt": "<the site's own system prompt>",
161
+ "examples": [ {"problem": "...", "solution": "..."}, ... ]
162
+ }
163
+ ```
164
+
165
+ The goal text is composed deterministically from the same inferred goal
166
+ and constraints that `build` uses; the examples are the captured pairs.
167
+
151
168
  ## Providers
152
169
 
153
170
  `nabla init` supports three providers. Pick whichever you have a key
@@ -1,3 +1,3 @@
1
1
  """nabla — call-site harvester CLI (pre-Phase-0)."""
2
2
 
3
- __version__ = "0.6.3"
3
+ __version__ = "0.7.0"
@@ -0,0 +1,139 @@
1
+ """nabla terminal branding: the ∇ logo and startup banner.
2
+
3
+ The banner prints only on a real terminal — piped output, tests, and
4
+ --json consumers never see it. Block glyphs render via the Windows
5
+ console's UTF-16 writer, so no legacy-codepage fallback is needed on an
6
+ interactive console.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from rich.console import Console
12
+ from rich.text import Text
13
+
14
+ from . import __version__
15
+
16
+ # Uniform thick strokes like the wordmark: solid bar, solid diagonals,
17
+ # single-block apex. Terminal cells are ~2:1 tall, so 6 rows x 11 cols
18
+ # reads close to the mark's near-equilateral proportions.
19
+ _LOGO = [
20
+ "█▀▀▀▀▀▀▀▀▀█",
21
+ " █ █",
22
+ " █ █",
23
+ " █ █",
24
+ " █ █",
25
+ " █",
26
+ ]
27
+
28
+ _BRAND_STYLE = "bold cyan"
29
+
30
+
31
+ def banner_lines() -> list[tuple[str, str, str]]:
32
+ """(logo_line, tagline, tagline_style) triples; outer rows have no tagline."""
33
+ taglines = [
34
+ ("", ""),
35
+ ("nabla", "bold"),
36
+ (f"v{__version__} · OpenAI call-site harvester", ""),
37
+ ("scan · capture · build", "cyan"),
38
+ ("", ""),
39
+ ("", ""),
40
+ ]
41
+ return [(logo, text, style) for logo, (text, style) in zip(_LOGO, taglines)]
42
+
43
+
44
+ def _compose() -> Text:
45
+ """The complete banner, final form."""
46
+ frame = Text()
47
+ for logo_line, tagline, tag_style in banner_lines():
48
+ frame.append(f" {logo_line:<14}", style=_BRAND_STYLE)
49
+ if tagline:
50
+ frame.append(tagline, style=tag_style)
51
+ frame.append("\n")
52
+ return frame
53
+
54
+
55
+ def _stroke_frames() -> list[set[tuple[int, int]]]:
56
+ """Reveal order that traces the mark like a pen: the top bar sweeps
57
+ left to right, then the two diagonals descend together to the apex."""
58
+ frames: list[set[tuple[int, int]]] = []
59
+ mask: set[tuple[int, int]] = set()
60
+ top_cells = [(0, c) for c, ch in enumerate(_LOGO[0]) if ch != " "]
61
+ for i in range(0, len(top_cells), 2):
62
+ mask.update(top_cells[i:i + 2])
63
+ frames.append(set(mask))
64
+ for r in range(1, len(_LOGO)):
65
+ mask.update((r, c) for c, ch in enumerate(_LOGO[r]) if ch != " ")
66
+ frames.append(set(mask))
67
+ return frames
68
+
69
+
70
+ def print_banner(console: Console | None = None) -> None:
71
+ """Print the complete banner instantly. Animation, when wanted, happens
72
+ afterwards via animate_in_place() — over this already-rendered region —
73
+ so downstream UI (rails, menus) is on screen before a single frame."""
74
+ console = console or Console()
75
+ if not console.is_terminal:
76
+ return
77
+ console.print()
78
+ console.print(_compose(), end="")
79
+ console.print()
80
+
81
+
82
+ # Raw ANSI for the in-place replay: rich Live owns whole-screen regions,
83
+ # but here the cursor must slip above content that is already printed
84
+ # below the banner, so escape codes are driven directly.
85
+ _ANSI = {"brand": "\x1b[1;36m", "bold": "\x1b[1m", "cyan": "\x1b[36m",
86
+ "": "", "reset": "\x1b[0m"}
87
+
88
+
89
+ def _ansi_row(r: int, mask: set[tuple[int, int]]) -> str:
90
+ logo_line, tagline, tag_style = banner_lines()[r]
91
+ revealed = "".join(
92
+ ch if ch == " " or (r, c) in mask else " "
93
+ for c, ch in enumerate(logo_line)
94
+ )
95
+ row = f" {_ANSI['brand']}{revealed:<14}{_ANSI['reset']}"
96
+ if tagline:
97
+ row += f"{_ANSI[tag_style]}{tagline}{_ANSI['reset']}"
98
+ return row + "\x1b[0K" # clear any residue right of the rewritten text
99
+
100
+
101
+ def animate_in_place(console: Console, lines_below: int) -> None:
102
+ """Replay the logo's stroke-order draw over the banner already on
103
+ screen. ``lines_below`` = rows printed after the banner's trailing
104
+ blank line (rails, menu, ...); the cursor is assumed to sit at the
105
+ start of the row just below them. Everything is rewritten line-for-line
106
+ (taglines included), so the layout cannot shift."""
107
+ if not console.is_terminal:
108
+ return
109
+ import sys as _sys
110
+ import time
111
+
112
+ up = len(_LOGO) + 1 + lines_below # +1: banner's trailing blank line
113
+ write = _sys.stdout.write
114
+ try:
115
+ for mask in _stroke_frames():
116
+ write(f"\x1b[s\x1b[{up}A") # save cursor, jump to logo top
117
+ for r in range(len(_LOGO)):
118
+ write("\r" + _ansi_row(r, mask))
119
+ if r < len(_LOGO) - 1:
120
+ write("\x1b[1B") # down one row
121
+ write("\x1b[u") # restore cursor
122
+ _sys.stdout.flush()
123
+ time.sleep(0.09)
124
+ except Exception: # noqa: BLE001 - cosmetic replay must never break init
125
+ pass
126
+ finally:
127
+ # Terminal state is guaranteed sane: land on the finished mark.
128
+ try:
129
+ full = {(r, c) for r, line in enumerate(_LOGO)
130
+ for c, ch in enumerate(line) if ch != " "}
131
+ write(f"\x1b[s\x1b[{up}A")
132
+ for r in range(len(_LOGO)):
133
+ write("\r" + _ansi_row(r, full))
134
+ if r < len(_LOGO) - 1:
135
+ write("\x1b[1B")
136
+ write("\x1b[u")
137
+ _sys.stdout.flush()
138
+ except Exception: # noqa: BLE001
139
+ pass
@@ -132,6 +132,29 @@ def _build_parser() -> argparse.ArgumentParser:
132
132
  p_profile.add_argument("--out", default=None,
133
133
  help="output path (default: <site>.profile.json)")
134
134
 
135
+ p_jeremy = sub.add_parser(
136
+ "jeremy",
137
+ help="assemble a seed spec (goal + system_prompt + problem/solution examples)",
138
+ formatter_class=argparse.RawDescriptionHelpFormatter,
139
+ epilog=(
140
+ "an alternative to `build`: same inputs, but emits <site>.jeremy.json —\n"
141
+ "a generator-framed goal, the site's system prompt, and the captured\n"
142
+ "pairs as {problem, solution} reference examples.\n\n"
143
+ "examples:\n"
144
+ " nabla jeremy auto: pick site, find <site>.captured.jsonl\n"
145
+ " nabla jeremy path/to/repo --site extract_invoice --captured captured.jsonl\n"
146
+ ),
147
+ )
148
+ p_jeremy.add_argument("repo", nargs="?", default=".", help="path to target repo (default: current directory)")
149
+ p_jeremy.add_argument(
150
+ "--site",
151
+ help="site symbol (auto-picked when omitted and the repo has exactly one supported call site)",
152
+ )
153
+ p_jeremy.add_argument("--captured", "--examples", dest="examples",
154
+ help="captured JSONL from `nabla capture` (default: <site>.captured.jsonl)")
155
+ p_jeremy.add_argument("--out", default=None,
156
+ help="output path (default: <site>.jeremy.json)")
157
+
135
158
  return parser
136
159
 
137
160
 
@@ -193,6 +216,7 @@ def main(argv: list[str] | None = None) -> int:
193
216
  "record": _cmd_record, # legacy alias
194
217
  "build": _cmd_profile,
195
218
  "profile": _cmd_profile, # legacy alias
219
+ "jeremy": lambda a: _cmd_profile(a, mode="jeremy"),
196
220
  }
197
221
  try:
198
222
  return commands[args.command](args)
@@ -264,9 +288,12 @@ def _read_api_key(prompt: str) -> str:
264
288
 
265
289
 
266
290
  def _cmd_init(args) -> int:
291
+ from .branding import animate_in_place
267
292
  from .ui import RAIL, rail, rail_end, rail_ok, rail_start, select
268
293
 
269
- print_banner(_out, animate=True)
294
+ # Everything renders first — banner (final form), rail, menu — then the
295
+ # logo replays its stroke-order draw in place. Nothing ever shifts.
296
+ print_banner(_out)
270
297
  rail_start(_out, "nabla init")
271
298
  rail(_out)
272
299
 
@@ -274,7 +301,10 @@ def _cmd_init(args) -> int:
274
301
  if provider is None:
275
302
  names = list(PROVIDERS)
276
303
  options = [(name, _PROVIDER_DESCRIPTIONS.get(name, "")) for name in names]
277
- provider = names[select(_out, "Provider", options)]
304
+ provider = names[select(
305
+ _out, "Provider", options,
306
+ on_first_draw=lambda menu_lines: animate_in_place(_out, menu_lines + 2),
307
+ )]
278
308
  else:
279
309
  rail(_out, f"Provider {provider}")
280
310
 
@@ -772,21 +802,23 @@ def _find_captured_file(site) -> Path | None:
772
802
  )
773
803
 
774
804
 
775
- def _cmd_profile(args) -> int:
805
+ def _cmd_profile(args, mode: str = "profile") -> int:
776
806
  from .ui import rail, rail_end, rail_start
777
807
 
778
- rail_start(_out, "nabla build")
808
+ command = "jeremy" if mode == "jeremy" else "build"
809
+ noun = "seed spec(s)" if mode == "jeremy" else "profile(s)"
810
+ rail_start(_out, f"nabla {command}")
779
811
  sites = scan(args.repo)
780
812
 
781
- # Multi-site: no --site/--captured, several supported sites — build a
782
- # profile for every site that has captured pairs on disk.
813
+ # Multi-site: no --site/--captured, several supported sites — build the
814
+ # artifact for every site that has captured pairs on disk.
783
815
  if args.site is None and args.examples is None:
784
816
  supported = [s for s in sites
785
817
  if is_supported(resolve_schema(s, args.repo)[1], s.flags)]
786
818
  if len(supported) > 1:
787
819
  with_captures = [s for s in supported if _find_captured_file(s) is not None]
788
820
  if not with_captures:
789
- print(f"nabla build: no captured pairs found for any of the "
821
+ print(f"nabla {command}: no captured pairs found for any of the "
790
822
  f"{len(supported)} supported sites. Run first: nabla capture {args.repo}",
791
823
  file=sys.stderr)
792
824
  return 2
@@ -795,11 +827,11 @@ def _cmd_profile(args) -> int:
795
827
  for i, site in enumerate(with_captures):
796
828
  if i:
797
829
  rail(_out)
798
- rc = _build_one(args, site) or rc
830
+ rc = _build_one(args, site, mode=mode) or rc
799
831
  if skipped:
800
832
  rail(_out, f"skipped (no captured pairs): {', '.join(skipped)}", style="yellow")
801
833
  rail(_out)
802
- rail_end(_out, f"done — built {len(with_captures)} profile(s)", style="green")
834
+ rail_end(_out, f"done — built {len(with_captures)} {noun}", style="green")
803
835
  return rc
804
836
 
805
837
  site, err = _resolve_site(args, sites)
@@ -807,16 +839,32 @@ def _cmd_profile(args) -> int:
807
839
  return err
808
840
  if args.site:
809
841
  rail(_out, f"site {site.symbol} · {site.file}")
810
- rc = _build_one(args, site, final_rail=True)
842
+ rc = _build_one(args, site, final_rail=True, mode=mode)
811
843
  return rc
812
844
 
813
845
 
814
- def _build_one(args, site, final_rail: bool = False) -> int:
846
+ def _compose_seed_goal(goal) -> str:
847
+ """Generator-framed goal text for the seed spec, composed in pure Python
848
+ from the induced GoalBlock — no extra LLM call, no invented facts."""
849
+ parts = [
850
+ "You are generating new problems to test and eventually fine-tune a "
851
+ "small student model on the following task: " + goal.description.rstrip(".") + "."
852
+ ]
853
+ if goal.constraints:
854
+ parts.append("Every generated problem's solution must satisfy: "
855
+ + " ".join(c.rstrip(".") + "." for c in goal.constraints))
856
+ parts.append("Match the style, difficulty range, and output format of the "
857
+ "reference examples below.")
858
+ return " ".join(parts)
859
+
860
+
861
+ def _build_one(args, site, final_rail: bool = False, mode: str = "profile") -> int:
815
862
  from .induce import induce
816
863
  from .prompt_recon import reconstruct
817
864
  from .schema_resolver import resolve_schema as _resolve
818
865
  from .ui import rail, rail_end, rail_ok
819
866
 
867
+ command = "jeremy" if mode == "jeremy" else "build"
820
868
  examples_path = args.examples
821
869
  if examples_path is None:
822
870
  found = _find_captured_file(site) or (
@@ -825,7 +873,7 @@ def _build_one(args, site, final_rail: bool = False) -> int:
825
873
  capture_cmd = f"nabla capture {args.repo}"
826
874
  if args.site:
827
875
  capture_cmd += f" --site {site.symbol}"
828
- print(f"nabla build: no captured pairs found ({site.symbol}.captured.jsonl). "
876
+ print(f"nabla {command}: no captured pairs found ({site.symbol}.captured.jsonl). "
829
877
  f"Run first: {capture_cmd}", file=sys.stderr)
830
878
  return 2
831
879
  examples_path = str(found)
@@ -851,21 +899,37 @@ def _build_one(args, site, final_rail: bool = False) -> int:
851
899
  else:
852
900
  goal = induce(template, schema, examples)
853
901
 
854
- profile = build_profile(site, args.repo, examples, goal, recorded_from=recorded_from)
855
- out = (args.out if final_rail else None) or f"{site.symbol}.profile.json"
856
- Path(out).write_text(json.dumps(profile, indent=2), encoding="utf-8")
857
- supported = profile["classification"]["supported"]
858
- rail(_out)
859
- rail_ok(_out, f"wrote {out}")
860
- rail(_out, f"site {site.symbol} · {profile['site_id'][:16]}…")
861
- rail(_out, f"supported {supported}")
862
- rail(_out, f"examples {len(examples)} ({recorded_from})")
863
- rail(_out, f"goal {goal.description[:90]}")
864
- dims = ", ".join(d["name"] for d in goal.difficulty_dimensions[:4])
865
- rail(_out, f"difficulty {dims}")
902
+ if mode == "jeremy":
903
+ seed = {
904
+ "goal": _compose_seed_goal(goal),
905
+ "system_prompt": template.system_prompt or "",
906
+ "examples": [
907
+ {"problem": e.prompt, "solution": e.completion} for e in examples
908
+ ],
909
+ }
910
+ out = (args.out if final_rail else None) or f"{site.symbol}.jeremy.json"
911
+ Path(out).write_text(json.dumps(seed, indent=2), encoding="utf-8")
912
+ rail(_out)
913
+ rail_ok(_out, f"wrote {out}")
914
+ rail(_out, f"site {site.symbol}")
915
+ rail(_out, f"examples {len(examples)} ({recorded_from})")
916
+ rail(_out, f"goal {seed['goal'][:90]}")
917
+ else:
918
+ profile = build_profile(site, args.repo, examples, goal, recorded_from=recorded_from)
919
+ out = (args.out if final_rail else None) or f"{site.symbol}.profile.json"
920
+ Path(out).write_text(json.dumps(profile, indent=2), encoding="utf-8")
921
+ supported = profile["classification"]["supported"]
922
+ rail(_out)
923
+ rail_ok(_out, f"wrote {out}")
924
+ rail(_out, f"site {site.symbol} · {profile['site_id'][:16]}…")
925
+ rail(_out, f"supported {supported}")
926
+ rail(_out, f"examples {len(examples)} ({recorded_from})")
927
+ rail(_out, f"goal {goal.description[:90]}")
928
+ dims = ", ".join(d["name"] for d in goal.difficulty_dimensions[:4])
929
+ rail(_out, f"difficulty {dims}")
866
930
  if len(examples) < 20:
867
931
  rail(_out, f"only {len(examples)} examples (< 20) — enough for a look, but a "
868
- "handoff-quality profile wants 20+ real-traffic pairs", style="yellow")
932
+ "handoff-quality artifact wants 20+ real-traffic pairs", style="yellow")
869
933
  if final_rail:
870
934
  rail(_out)
871
935
  rail_end(_out, f"done — {out} is the handoff artifact", style="green")
@@ -167,6 +167,14 @@ def _forward_to_openai_responder(body: dict, headers: dict) -> dict:
167
167
  and isinstance(rf, dict) and rf.get("type") == "json_schema"
168
168
  and ("schema" in detail.lower() or "invalid" in detail.lower())):
169
169
  body = {**body, "response_format": {"type": "json_object"}}
170
+ # Providers require the word "json" in the messages to use
171
+ # json_object mode. This nudge goes only to the stand-in —
172
+ # the proxy captured the original request before forwarding,
173
+ # so the recorded prompt is untouched.
174
+ messages = list(body.get("messages", []))
175
+ if "json" not in json.dumps(messages).lower():
176
+ messages.append({"role": "system", "content": "Respond in JSON."})
177
+ body = {**body, "messages": messages}
170
178
  fell_back = True
171
179
  print("nabla capture: stand-in oracle rejected this site's json_schema — "
172
180
  "retrying in json_object mode (the profile still carries the real schema)",
@@ -67,9 +67,13 @@ def rail_end(console: Console, text: str, style: str = "cyan") -> None:
67
67
  # ---------------------------------------------------------------------------
68
68
 
69
69
  def select(console: Console, label: str, options: list[tuple[str, str]],
70
- default: int = 0) -> int:
70
+ default: int = 0, on_first_draw=None) -> int:
71
71
  """Pick one of ``options`` ([(name, description), ...]); returns the index.
72
72
 
73
+ ``on_first_draw(lines_drawn)`` fires once after the menu is fully on
74
+ screen, before the first key is read — the hook that lets a banner
75
+ animation replay above an already-complete layout.
76
+
73
77
  Interactive Windows console: arrow keys + enter, pointer rendering,
74
78
  menu collapses to a single confirmation line after the choice.
75
79
  Anywhere else: numbered prompt.
@@ -123,6 +127,11 @@ def select(console: Console, label: str, options: list[tuple[str, str]],
123
127
  lines_drawn = len(options) + 1
124
128
 
125
129
  draw()
130
+ if on_first_draw is not None:
131
+ try:
132
+ on_first_draw(lines_drawn)
133
+ except Exception: # noqa: BLE001 - a cosmetic hook must never break input
134
+ pass
126
135
  while True:
127
136
  ch = msvcrt.getwch()
128
137
  if ch in ("\r", "\n"):
@@ -267,46 +276,46 @@ def make_progress(console: Console, unit: str = "samples") -> Progress:
267
276
  console=console,
268
277
  transient=True,
269
278
  )
270
-
271
-
272
- def prompt_digits(console: Console, label: str, default: int) -> int:
273
- """Numeric input that cannot go wrong: on an interactive Windows console
274
- it reads keys raw — digits echo, backspace works, everything else
275
- (arrows, brackets, console line-editing artifacts) is ignored. Elsewhere
276
- it strips non-digits from a plain input()."""
277
- prompt = Text(f"{RAIL} ", style=_RAIL_STYLE)
278
- prompt.append(label, style="bold")
279
- prompt.append(f" [{default}]", style="cyan")
280
- prompt.append(" ")
281
-
282
- interactive = (
283
- sys.platform == "win32"
284
- and console.is_terminal
285
- and sys.stdin.isatty()
286
- )
287
- if not interactive:
288
- console.print(prompt, end="")
289
- raw = "".join(ch for ch in input() if ch.isdigit())
290
- return int(raw) if raw else default
291
-
292
- import msvcrt
293
-
294
- console.print(prompt, end="")
295
- chars: list[str] = []
296
- while True:
297
- ch = msvcrt.getwch()
298
- if ch in ("\r", "\n"):
299
- print()
300
- break
301
- if ch == "\x03":
302
- raise KeyboardInterrupt
303
- if ch == "\b":
304
- if chars:
305
- chars.pop()
306
- print("\b \b", end="", flush=True)
307
- elif ch.isdigit():
308
- chars.append(ch)
309
- print(ch, end="", flush=True)
310
- elif ch in ("\xe0", "\x00"):
311
- msvcrt.getwch() # swallow the arrow/function-key payload
312
- return int("".join(chars)) if chars else default
279
+
280
+
281
+ def prompt_digits(console: Console, label: str, default: int) -> int:
282
+ """Numeric input that cannot go wrong: on an interactive Windows console
283
+ it reads keys raw — digits echo, backspace works, everything else
284
+ (arrows, brackets, console line-editing artifacts) is ignored. Elsewhere
285
+ it strips non-digits from a plain input()."""
286
+ prompt = Text(f"{RAIL} ", style=_RAIL_STYLE)
287
+ prompt.append(label, style="bold")
288
+ prompt.append(f" [{default}]", style="cyan")
289
+ prompt.append(" ")
290
+
291
+ interactive = (
292
+ sys.platform == "win32"
293
+ and console.is_terminal
294
+ and sys.stdin.isatty()
295
+ )
296
+ if not interactive:
297
+ console.print(prompt, end="")
298
+ raw = "".join(ch for ch in input() if ch.isdigit())
299
+ return int(raw) if raw else default
300
+
301
+ import msvcrt
302
+
303
+ console.print(prompt, end="")
304
+ chars: list[str] = []
305
+ while True:
306
+ ch = msvcrt.getwch()
307
+ if ch in ("\r", "\n"):
308
+ print()
309
+ break
310
+ if ch == "\x03":
311
+ raise KeyboardInterrupt
312
+ if ch == "\b":
313
+ if chars:
314
+ chars.pop()
315
+ print("\b \b", end="", flush=True)
316
+ elif ch.isdigit():
317
+ chars.append(ch)
318
+ print(ch, end="", flush=True)
319
+ elif ch in ("\xe0", "\x00"):
320
+ msvcrt.getwch() # swallow the arrow/function-key payload
321
+ return int("".join(chars)) if chars else default
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nabla-cli
3
- Version: 0.6.3
3
+ Version: 0.7.0
4
4
  Summary: Call-site harvester: scan OpenAI call sites, record real traffic, emit site profiles
5
5
  License: MIT
6
6
  Requires-Python: >=3.11
@@ -162,6 +162,23 @@ returns a goal description, implicit constraints, and difficulty
162
162
  dimensions with cited evidence. That's the goal line printed above, and
163
163
  it's what lands in the profile's `goal` block.
164
164
 
165
+ ### `nabla jeremy`
166
+
167
+ An alternative to `build` with the same inputs and site handling: instead
168
+ of the full site profile, it emits `<site>.jeremy.json` — a seed spec for
169
+ a problem generator:
170
+
171
+ ```json
172
+ {
173
+ "goal": "You are generating new problems to test and eventually fine-tune a small student model on the following task: ...",
174
+ "system_prompt": "<the site's own system prompt>",
175
+ "examples": [ {"problem": "...", "solution": "..."}, ... ]
176
+ }
177
+ ```
178
+
179
+ The goal text is composed deterministically from the same inferred goal
180
+ and constraints that `build` uses; the examples are the captured pairs.
181
+
165
182
  ## Providers
166
183
 
167
184
  `nabla init` supports three providers. Pick whichever you have a key
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "nabla-cli"
3
- version = "0.6.3"
3
+ version = "0.7.0"
4
4
  description = "Call-site harvester: scan OpenAI call sites, record real traffic, emit site profiles"
5
5
  readme = "README.md"
6
6
  license = { text = "MIT" }
@@ -517,3 +517,72 @@ def test_scan_skips_build_and_dist_dirs(tmp_path, monkeypatch, capsys):
517
517
  out = capsys.readouterr().out
518
518
  assert "1 call site found" in out
519
519
  assert "copy_of_site" not in out
520
+
521
+
522
+ # ---------------------------------------------------------------------------
523
+ # nabla jeremy — seed-spec sibling of build
524
+ # ---------------------------------------------------------------------------
525
+
526
+ def _fake_goal(*a, **k):
527
+ from nabla.contract import GoalBlock
528
+
529
+ return GoalBlock(
530
+ description="Solve a grade-school word problem step by step.",
531
+ constraints=["final answer is a bare number", "no invented constants"],
532
+ difficulty_dimensions=[{"name": "d", "levels": ["a", "b"], "evidence": "e"}],
533
+ )
534
+
535
+
536
+ def test_jeremy_single_site_writes_seed_spec(tmp_path, monkeypatch, capsys):
537
+ repo = _make_single_site_repo(tmp_path)
538
+ monkeypatch.chdir(tmp_path)
539
+ pair = {"input_slots": {"ocr_text": "r"}, "prompt": "A farmer has 6 crates...",
540
+ "completion": "The answer is 57.",
541
+ "tokens": {"in": 5, "out": 5}, "latency_ms": 10, "_source_kind": "samples"}
542
+ (tmp_path / "extract_receipt.captured.jsonl").write_text(
543
+ json.dumps(pair) + "\n", encoding="utf-8")
544
+ monkeypatch.setattr("nabla.induce.induce", _fake_goal)
545
+
546
+ rc = main(["jeremy", str(repo)])
547
+
548
+ assert rc == 0
549
+ seed = json.loads((tmp_path / "extract_receipt.jeremy.json").read_text(encoding="utf-8"))
550
+ assert set(seed) == {"goal", "system_prompt", "examples"}
551
+ assert seed["goal"].startswith("You are generating new problems")
552
+ assert "grade-school word problem" in seed["goal"]
553
+ assert "final answer is a bare number" in seed["goal"]
554
+ assert "Extract the receipt fields" in seed["system_prompt"]
555
+ assert seed["examples"] == [
556
+ {"problem": "A farmer has 6 crates...", "solution": "The answer is 57."}
557
+ ]
558
+ assert "extract_receipt.jeremy.json" in capsys.readouterr().out
559
+
560
+
561
+ def test_jeremy_multi_site(tmp_path, monkeypatch, capsys):
562
+ repo = _make_two_site_repo(tmp_path)
563
+ monkeypatch.chdir(tmp_path)
564
+ pair = {"input_slots": {}, "prompt": "p", "completion": "s",
565
+ "tokens": {"in": 1, "out": 1}, "latency_ms": 1, "_source_kind": "samples"}
566
+ for symbol in ("extract_receipt", "extract_menu"):
567
+ (tmp_path / f"{symbol}.captured.jsonl").write_text(
568
+ json.dumps(pair) + "\n", encoding="utf-8")
569
+ monkeypatch.setattr("nabla.induce.induce", _fake_goal)
570
+
571
+ rc = main(["jeremy", str(repo)])
572
+
573
+ assert rc == 0
574
+ assert (tmp_path / "extract_receipt.jeremy.json").exists()
575
+ assert (tmp_path / "extract_menu.jeremy.json").exists()
576
+ assert "built 2 seed spec(s)" in capsys.readouterr().out
577
+
578
+
579
+ def test_jeremy_without_captures_points_at_capture(tmp_path, monkeypatch, capsys):
580
+ repo = _make_single_site_repo(tmp_path)
581
+ monkeypatch.chdir(tmp_path)
582
+
583
+ rc = main(["jeremy", str(repo)])
584
+
585
+ assert rc == 2
586
+ err = capsys.readouterr().err
587
+ assert "nabla jeremy" in err
588
+ assert "nabla capture" in err
@@ -1,101 +0,0 @@
1
- """nabla terminal branding: the ∇ logo and startup banner.
2
-
3
- The banner prints only on a real terminal — piped output, tests, and
4
- --json consumers never see it. Block glyphs render via the Windows
5
- console's UTF-16 writer, so no legacy-codepage fallback is needed on an
6
- interactive console.
7
- """
8
-
9
- from __future__ import annotations
10
-
11
- from rich.console import Console
12
- from rich.text import Text
13
-
14
- from . import __version__
15
-
16
- # Uniform thick strokes like the wordmark: solid bar, solid diagonals,
17
- # single-block apex. Terminal cells are ~2:1 tall, so 6 rows x 11 cols
18
- # reads close to the mark's near-equilateral proportions.
19
- _LOGO = [
20
- "█▀▀▀▀▀▀▀▀▀█",
21
- " █ █",
22
- " █ █",
23
- " █ █",
24
- " █ █",
25
- " █",
26
- ]
27
-
28
- _BRAND_STYLE = "bold cyan"
29
-
30
-
31
- def banner_lines() -> list[tuple[str, str, str]]:
32
- """(logo_line, tagline, tagline_style) triples; outer rows have no tagline."""
33
- taglines = [
34
- ("", ""),
35
- ("nabla", "bold"),
36
- (f"v{__version__} · OpenAI call-site harvester", ""),
37
- ("scan · capture · build", "cyan"),
38
- ("", ""),
39
- ("", ""),
40
- ]
41
- return [(logo, text, style) for logo, (text, style) in zip(_LOGO, taglines)]
42
-
43
-
44
- def _compose(traced: set[tuple[int, int]] | None = None) -> Text:
45
- """The full banner. With ``traced`` given, every glyph is still present
46
- (no layout shift, ever) — untraced stroke cells just render quieter, and
47
- the traced set brightens as the animation sweeps through."""
48
- frame = Text()
49
- for r, (logo_line, tagline, tag_style) in enumerate(banner_lines()):
50
- frame.append(" ")
51
- for c, ch in enumerate(f"{logo_line:<14}"):
52
- if ch == " ":
53
- frame.append(" ")
54
- elif traced is None or (r, c) in traced:
55
- frame.append(ch, style=_BRAND_STYLE)
56
- else:
57
- frame.append(ch, style="dim cyan")
58
- if tagline:
59
- frame.append(tagline, style=tag_style)
60
- frame.append("\n")
61
- return frame
62
-
63
-
64
- def _stroke_frames() -> list[set[tuple[int, int]]]:
65
- """Reveal order that traces the mark like a pen: the top bar sweeps
66
- left to right, then the two diagonals descend together to the apex."""
67
- frames: list[set[tuple[int, int]]] = []
68
- mask: set[tuple[int, int]] = set()
69
- top_cells = [(0, c) for c, ch in enumerate(_LOGO[0]) if ch != " "]
70
- for i in range(0, len(top_cells), 2):
71
- mask.update(top_cells[i:i + 2])
72
- frames.append(set(mask))
73
- for r in range(1, len(_LOGO)):
74
- mask.update((r, c) for c, ch in enumerate(_LOGO[r]) if ch != " ")
75
- frames.append(set(mask))
76
- return frames
77
-
78
-
79
- def print_banner(console: Console | None = None, animate: bool = False) -> None:
80
- console = console or Console()
81
- if not console.is_terminal:
82
- return
83
- console.print()
84
- if animate:
85
- import time
86
-
87
- from rich.live import Live
88
-
89
- # Full-size first frame: the complete banner is on screen before a
90
- # single animation tick, so the layout never shifts — the sweep only
91
- # changes brightness along the stroke.
92
- with Live(_compose(traced=set()), console=console,
93
- refresh_per_second=30, transient=False) as live:
94
- time.sleep(0.12)
95
- for traced in _stroke_frames():
96
- live.update(_compose(traced=traced))
97
- time.sleep(0.09)
98
- live.update(_compose())
99
- else:
100
- console.print(_compose(), end="")
101
- console.print()
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes