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.
- {nabla_cli-0.6.3 → nabla_cli-0.7.0}/PKG-INFO +18 -1
- {nabla_cli-0.6.3 → nabla_cli-0.7.0}/README.md +17 -0
- {nabla_cli-0.6.3 → nabla_cli-0.7.0}/nabla/__init__.py +1 -1
- nabla_cli-0.7.0/nabla/branding.py +139 -0
- {nabla_cli-0.6.3 → nabla_cli-0.7.0}/nabla/cli.py +89 -25
- {nabla_cli-0.6.3 → nabla_cli-0.7.0}/nabla/recorder.py +8 -0
- {nabla_cli-0.6.3 → nabla_cli-0.7.0}/nabla/ui.py +53 -44
- {nabla_cli-0.6.3 → nabla_cli-0.7.0}/nabla_cli.egg-info/PKG-INFO +18 -1
- {nabla_cli-0.6.3 → nabla_cli-0.7.0}/pyproject.toml +1 -1
- {nabla_cli-0.6.3 → nabla_cli-0.7.0}/tests/test_cli_ux.py +69 -0
- nabla_cli-0.6.3/nabla/branding.py +0 -101
- {nabla_cli-0.6.3 → nabla_cli-0.7.0}/nabla/__main__.py +0 -0
- {nabla_cli-0.6.3 → nabla_cli-0.7.0}/nabla/config.py +0 -0
- {nabla_cli-0.6.3 → nabla_cli-0.7.0}/nabla/contract.py +0 -0
- {nabla_cli-0.6.3 → nabla_cli-0.7.0}/nabla/induce.py +0 -0
- {nabla_cli-0.6.3 → nabla_cli-0.7.0}/nabla/profile.py +0 -0
- {nabla_cli-0.6.3 → nabla_cli-0.7.0}/nabla/prompt_recon.py +0 -0
- {nabla_cli-0.6.3 → nabla_cli-0.7.0}/nabla/samplegen.py +0 -0
- {nabla_cli-0.6.3 → nabla_cli-0.7.0}/nabla/scanner.py +0 -0
- {nabla_cli-0.6.3 → nabla_cli-0.7.0}/nabla/schema_resolver.py +0 -0
- {nabla_cli-0.6.3 → nabla_cli-0.7.0}/nabla/schemas/site_profile.schema.json +0 -0
- {nabla_cli-0.6.3 → nabla_cli-0.7.0}/nabla/term.py +0 -0
- {nabla_cli-0.6.3 → nabla_cli-0.7.0}/nabla_cli.egg-info/SOURCES.txt +0 -0
- {nabla_cli-0.6.3 → nabla_cli-0.7.0}/nabla_cli.egg-info/dependency_links.txt +0 -0
- {nabla_cli-0.6.3 → nabla_cli-0.7.0}/nabla_cli.egg-info/entry_points.txt +0 -0
- {nabla_cli-0.6.3 → nabla_cli-0.7.0}/nabla_cli.egg-info/requires.txt +0 -0
- {nabla_cli-0.6.3 → nabla_cli-0.7.0}/nabla_cli.egg-info/top_level.txt +0 -0
- {nabla_cli-0.6.3 → nabla_cli-0.7.0}/setup.cfg +0 -0
- {nabla_cli-0.6.3 → nabla_cli-0.7.0}/tests/test_config.py +0 -0
- {nabla_cli-0.6.3 → nabla_cli-0.7.0}/tests/test_e2e_profile.py +0 -0
- {nabla_cli-0.6.3 → nabla_cli-0.7.0}/tests/test_induce.py +0 -0
- {nabla_cli-0.6.3 → nabla_cli-0.7.0}/tests/test_prompt_recon.py +0 -0
- {nabla_cli-0.6.3 → nabla_cli-0.7.0}/tests/test_recorder.py +0 -0
- {nabla_cli-0.6.3 → nabla_cli-0.7.0}/tests/test_samplegen.py +0 -0
- {nabla_cli-0.6.3 → nabla_cli-0.7.0}/tests/test_scanner.py +0 -0
- {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.
|
|
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
|
|
@@ -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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
782
|
-
#
|
|
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
|
|
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)}
|
|
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
|
|
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
|
|
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
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
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
|
|
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.
|
|
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
|
|
@@ -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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|