nabla-cli 0.4.0__tar.gz → 0.5.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.4.0 → nabla_cli-0.5.0}/PKG-INFO +33 -33
- {nabla_cli-0.4.0 → nabla_cli-0.5.0}/README.md +32 -32
- {nabla_cli-0.4.0 → nabla_cli-0.5.0}/nabla/__init__.py +1 -1
- nabla_cli-0.5.0/nabla/branding.py +94 -0
- {nabla_cli-0.4.0 → nabla_cli-0.5.0}/nabla/cli.py +119 -58
- {nabla_cli-0.4.0 → nabla_cli-0.5.0}/nabla/recorder.py +8 -4
- {nabla_cli-0.4.0 → nabla_cli-0.5.0}/nabla/samplegen.py +5 -5
- {nabla_cli-0.4.0 → nabla_cli-0.5.0}/nabla/ui.py +10 -21
- {nabla_cli-0.4.0 → nabla_cli-0.5.0}/nabla_cli.egg-info/PKG-INFO +33 -33
- {nabla_cli-0.4.0 → nabla_cli-0.5.0}/pyproject.toml +1 -1
- {nabla_cli-0.4.0 → nabla_cli-0.5.0}/tests/test_cli_ux.py +9 -9
- nabla_cli-0.4.0/nabla/branding.py +0 -56
- {nabla_cli-0.4.0 → nabla_cli-0.5.0}/nabla/__main__.py +0 -0
- {nabla_cli-0.4.0 → nabla_cli-0.5.0}/nabla/config.py +0 -0
- {nabla_cli-0.4.0 → nabla_cli-0.5.0}/nabla/contract.py +0 -0
- {nabla_cli-0.4.0 → nabla_cli-0.5.0}/nabla/induce.py +0 -0
- {nabla_cli-0.4.0 → nabla_cli-0.5.0}/nabla/profile.py +0 -0
- {nabla_cli-0.4.0 → nabla_cli-0.5.0}/nabla/prompt_recon.py +0 -0
- {nabla_cli-0.4.0 → nabla_cli-0.5.0}/nabla/scanner.py +0 -0
- {nabla_cli-0.4.0 → nabla_cli-0.5.0}/nabla/schema_resolver.py +0 -0
- {nabla_cli-0.4.0 → nabla_cli-0.5.0}/nabla/schemas/site_profile.schema.json +0 -0
- {nabla_cli-0.4.0 → nabla_cli-0.5.0}/nabla/term.py +0 -0
- {nabla_cli-0.4.0 → nabla_cli-0.5.0}/nabla_cli.egg-info/SOURCES.txt +0 -0
- {nabla_cli-0.4.0 → nabla_cli-0.5.0}/nabla_cli.egg-info/dependency_links.txt +0 -0
- {nabla_cli-0.4.0 → nabla_cli-0.5.0}/nabla_cli.egg-info/entry_points.txt +0 -0
- {nabla_cli-0.4.0 → nabla_cli-0.5.0}/nabla_cli.egg-info/requires.txt +0 -0
- {nabla_cli-0.4.0 → nabla_cli-0.5.0}/nabla_cli.egg-info/top_level.txt +0 -0
- {nabla_cli-0.4.0 → nabla_cli-0.5.0}/setup.cfg +0 -0
- {nabla_cli-0.4.0 → nabla_cli-0.5.0}/tests/test_config.py +0 -0
- {nabla_cli-0.4.0 → nabla_cli-0.5.0}/tests/test_e2e_profile.py +0 -0
- {nabla_cli-0.4.0 → nabla_cli-0.5.0}/tests/test_induce.py +0 -0
- {nabla_cli-0.4.0 → nabla_cli-0.5.0}/tests/test_prompt_recon.py +0 -0
- {nabla_cli-0.4.0 → nabla_cli-0.5.0}/tests/test_recorder.py +0 -0
- {nabla_cli-0.4.0 → nabla_cli-0.5.0}/tests/test_samplegen.py +0 -0
- {nabla_cli-0.4.0 → nabla_cli-0.5.0}/tests/test_scanner.py +0 -0
- {nabla_cli-0.4.0 → nabla_cli-0.5.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.5.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
|
|
@@ -63,19 +63,19 @@ pip install nabla-cli
|
|
|
63
63
|
|
|
64
64
|
nabla init # one-time setup, once per machine
|
|
65
65
|
nabla scan
|
|
66
|
-
nabla
|
|
67
|
-
nabla
|
|
66
|
+
nabla capture
|
|
67
|
+
nabla build
|
|
68
68
|
```
|
|
69
69
|
|
|
70
70
|
No flags needed on the happy path: the repo defaults to the current
|
|
71
71
|
directory, `--site` is auto-picked when the repo has exactly one
|
|
72
72
|
supported call site (with more than one, pass `--site <symbol>` — the
|
|
73
|
-
names come from `scan`'s output), `
|
|
74
|
-
`<site>.
|
|
73
|
+
names come from `scan`'s output), `capture` writes
|
|
74
|
+
`<site>.captured.jsonl`, and `build` finds that file automatically by
|
|
75
75
|
the same convention.
|
|
76
76
|
|
|
77
|
-
If you don't pass `--
|
|
78
|
-
`<site>.
|
|
77
|
+
If you don't pass `--inputs`, `capture` looks for
|
|
78
|
+
`<site>.inputs.jsonl` and otherwise generates a handful of synthetic
|
|
79
79
|
inputs from the site's prompt template (saving them to that file so you
|
|
80
80
|
can edit them and rerun with realistic data). Profiles built on
|
|
81
81
|
generated inputs are marked `generated_samples` in their provenance —
|
|
@@ -110,23 +110,23 @@ extract_invoice app/invoice_extract.py create json_schema -
|
|
|
110
110
|
1 call site found.
|
|
111
111
|
```
|
|
112
112
|
|
|
113
|
-
### `nabla
|
|
113
|
+
### `nabla capture`
|
|
114
114
|
|
|
115
115
|
One progress line per sample, then a summary:
|
|
116
116
|
|
|
117
117
|
```
|
|
118
|
-
nabla
|
|
119
|
-
nabla
|
|
118
|
+
nabla capture: sample 1/6 -> 1 capture(s)
|
|
119
|
+
nabla capture: sample 2/6 -> 1 capture(s)
|
|
120
120
|
...
|
|
121
|
-
|
|
121
|
+
captured 6 (prompt, completion) pairs from site extract_invoice -> extract_invoice.captured.jsonl
|
|
122
122
|
```
|
|
123
123
|
|
|
124
|
-
`--out` defaults to `<site>.
|
|
125
|
-
`
|
|
124
|
+
`--out` defaults to `<site>.captured.jsonl` in the current directory (so
|
|
125
|
+
`build` can find it by the same convention afterward) — pass `--out`
|
|
126
126
|
yourself only to pick a different name or location.
|
|
127
127
|
|
|
128
|
-
To record your own inputs instead of generated ones, pass `--
|
|
129
|
-
(or create `<site>.
|
|
128
|
+
To record your own inputs instead of generated ones, pass `--inputs`
|
|
129
|
+
(or create `<site>.inputs.jsonl`, which is picked up automatically): a
|
|
130
130
|
JSONL file, one line per call to record. Each line is
|
|
131
131
|
`{"input_slots": {...}}`, where the keys match the site function's
|
|
132
132
|
keyword arguments (as reported by `scan`):
|
|
@@ -139,19 +139,19 @@ keyword arguments (as reported by `scan`):
|
|
|
139
139
|
nabla runs the site's function once per line, in a subprocess, with its
|
|
140
140
|
OpenAI client rerouted through a local recording proxy.
|
|
141
141
|
|
|
142
|
-
### `nabla
|
|
142
|
+
### `nabla build`
|
|
143
143
|
|
|
144
144
|
```
|
|
145
|
-
nabla
|
|
145
|
+
nabla build: goal — "Extract structured invoice fields (total, currency,
|
|
146
146
|
due date) from free-form invoice or receipt text, normalizing
|
|
147
147
|
locale-specific number and date formats."
|
|
148
|
-
nabla
|
|
148
|
+
nabla build: wrote extract_invoice.profile.json (site 4f2a9b1c3d8e…, supported=True, 6 examples)
|
|
149
149
|
```
|
|
150
150
|
|
|
151
151
|
`--out` defaults to `<site>.profile.json` in the current directory.
|
|
152
|
-
`--
|
|
153
|
-
the current directory — the file `
|
|
154
|
-
`--
|
|
152
|
+
`--captured` is optional: `build` looks for `<site>.captured.jsonl` in
|
|
153
|
+
the current directory — the file `capture` just wrote — and you only need
|
|
154
|
+
`--captured <path>` to point it at a different recording. The one LLM
|
|
155
155
|
call in this step is goal inference: it reads the reconstructed prompt
|
|
156
156
|
template, the resolved output schema, and the recorded examples, and
|
|
157
157
|
returns a goal description, implicit constraints, and difficulty
|
|
@@ -165,16 +165,16 @@ for — it doesn't need to be the provider your target repo already calls.
|
|
|
165
165
|
|
|
166
166
|
| Provider | Default model | Role |
|
|
167
167
|
|---|---|---|
|
|
168
|
-
| `openai` | your repo's own model (goal inference uses `gpt-4o-mini`) | Real upstream — `
|
|
169
|
-
| `groq` | `openai/gpt-oss-120b` | Stand-in oracle — `
|
|
170
|
-
| `gemini` | `gemini-3.5-flash` | Stand-in oracle — `
|
|
168
|
+
| `openai` | your repo's own model (goal inference uses `gpt-4o-mini`) | Real upstream — `capture` calls OpenAI directly with your key, auth passthrough. |
|
|
169
|
+
| `groq` | `openai/gpt-oss-120b` | Stand-in oracle — `capture` calls Groq instead of OpenAI. |
|
|
170
|
+
| `gemini` | `gemini-3.5-flash` | Stand-in oracle — `capture` calls Gemini instead of OpenAI. |
|
|
171
171
|
|
|
172
172
|
When you pick `groq` or `gemini`, they only stand in for *generating* the
|
|
173
|
-
completions during `
|
|
173
|
+
completions during `capture`. The profile's cost/latency baseline always
|
|
174
174
|
reports numbers for the target repo's own model — the one your code
|
|
175
175
|
actually calls in production — never the stand-in's.
|
|
176
176
|
|
|
177
|
-
Gemini's free tier has a very low daily quota: expect `
|
|
177
|
+
Gemini's free tier has a very low daily quota: expect `capture` to pace
|
|
178
178
|
requests with multi-second delays and to still hit rate limits past a
|
|
179
179
|
handful of samples. `groq` or `openai` handle larger sample sets more
|
|
180
180
|
comfortably.
|
|
@@ -198,11 +198,11 @@ work and take priority over it:
|
|
|
198
198
|
|
|
199
199
|
- `--site <symbol>` — skip site auto-pick; required once a repo has more
|
|
200
200
|
than one supported call site.
|
|
201
|
-
- `--out <path>` (`
|
|
202
|
-
`<site>.
|
|
203
|
-
- `--
|
|
204
|
-
other than `<site>.
|
|
205
|
-
- `--out <path>` (`
|
|
201
|
+
- `--out <path>` (`capture`) — write recorded examples somewhere other than
|
|
202
|
+
`<site>.captured.jsonl`.
|
|
203
|
+
- `--captured <path>` (`build`) — read recorded examples from somewhere
|
|
204
|
+
other than `<site>.captured.jsonl`.
|
|
205
|
+
- `--out <path>` (`build`) — write the profile somewhere other than
|
|
206
206
|
`<site>.profile.json`.
|
|
207
207
|
|
|
208
208
|
### Environment variables
|
|
@@ -216,7 +216,7 @@ highest first:
|
|
|
216
216
|
|
|
217
217
|
| Variable | Purpose |
|
|
218
218
|
|---|---|
|
|
219
|
-
| `OPENAI_API_KEY` | Default upstream for `
|
|
219
|
+
| `OPENAI_API_KEY` | Default upstream for `capture` (auth passthrough). |
|
|
220
220
|
| `GROQ_API_KEY` / `GEMINI_API_KEY` | That provider's conventional key env, read if `~/.nabla/config.json` has no stored key. |
|
|
221
221
|
| `NABLA_PROVIDER` | Overrides the provider `nabla init` selected. |
|
|
222
222
|
| `NABLA_UPSTREAM_BASE_URL` | Record through any OpenAI-compatible stand-in oracle instead. |
|
|
@@ -228,7 +228,7 @@ highest first:
|
|
|
228
228
|
|
|
229
229
|
## Notes
|
|
230
230
|
|
|
231
|
-
- `
|
|
231
|
+
- `capture` runs the site's enclosing function in a subprocess per sample,
|
|
232
232
|
with `OPENAI_BASE_URL` pointed at a local recording proxy. If the
|
|
233
233
|
target's tests mock the SDK, the proxy path reports zero traffic
|
|
234
234
|
explicitly instead of writing an empty profile.
|
|
@@ -49,19 +49,19 @@ pip install nabla-cli
|
|
|
49
49
|
|
|
50
50
|
nabla init # one-time setup, once per machine
|
|
51
51
|
nabla scan
|
|
52
|
-
nabla
|
|
53
|
-
nabla
|
|
52
|
+
nabla capture
|
|
53
|
+
nabla build
|
|
54
54
|
```
|
|
55
55
|
|
|
56
56
|
No flags needed on the happy path: the repo defaults to the current
|
|
57
57
|
directory, `--site` is auto-picked when the repo has exactly one
|
|
58
58
|
supported call site (with more than one, pass `--site <symbol>` — the
|
|
59
|
-
names come from `scan`'s output), `
|
|
60
|
-
`<site>.
|
|
59
|
+
names come from `scan`'s output), `capture` writes
|
|
60
|
+
`<site>.captured.jsonl`, and `build` finds that file automatically by
|
|
61
61
|
the same convention.
|
|
62
62
|
|
|
63
|
-
If you don't pass `--
|
|
64
|
-
`<site>.
|
|
63
|
+
If you don't pass `--inputs`, `capture` looks for
|
|
64
|
+
`<site>.inputs.jsonl` and otherwise generates a handful of synthetic
|
|
65
65
|
inputs from the site's prompt template (saving them to that file so you
|
|
66
66
|
can edit them and rerun with realistic data). Profiles built on
|
|
67
67
|
generated inputs are marked `generated_samples` in their provenance —
|
|
@@ -96,23 +96,23 @@ extract_invoice app/invoice_extract.py create json_schema -
|
|
|
96
96
|
1 call site found.
|
|
97
97
|
```
|
|
98
98
|
|
|
99
|
-
### `nabla
|
|
99
|
+
### `nabla capture`
|
|
100
100
|
|
|
101
101
|
One progress line per sample, then a summary:
|
|
102
102
|
|
|
103
103
|
```
|
|
104
|
-
nabla
|
|
105
|
-
nabla
|
|
104
|
+
nabla capture: sample 1/6 -> 1 capture(s)
|
|
105
|
+
nabla capture: sample 2/6 -> 1 capture(s)
|
|
106
106
|
...
|
|
107
|
-
|
|
107
|
+
captured 6 (prompt, completion) pairs from site extract_invoice -> extract_invoice.captured.jsonl
|
|
108
108
|
```
|
|
109
109
|
|
|
110
|
-
`--out` defaults to `<site>.
|
|
111
|
-
`
|
|
110
|
+
`--out` defaults to `<site>.captured.jsonl` in the current directory (so
|
|
111
|
+
`build` can find it by the same convention afterward) — pass `--out`
|
|
112
112
|
yourself only to pick a different name or location.
|
|
113
113
|
|
|
114
|
-
To record your own inputs instead of generated ones, pass `--
|
|
115
|
-
(or create `<site>.
|
|
114
|
+
To record your own inputs instead of generated ones, pass `--inputs`
|
|
115
|
+
(or create `<site>.inputs.jsonl`, which is picked up automatically): a
|
|
116
116
|
JSONL file, one line per call to record. Each line is
|
|
117
117
|
`{"input_slots": {...}}`, where the keys match the site function's
|
|
118
118
|
keyword arguments (as reported by `scan`):
|
|
@@ -125,19 +125,19 @@ keyword arguments (as reported by `scan`):
|
|
|
125
125
|
nabla runs the site's function once per line, in a subprocess, with its
|
|
126
126
|
OpenAI client rerouted through a local recording proxy.
|
|
127
127
|
|
|
128
|
-
### `nabla
|
|
128
|
+
### `nabla build`
|
|
129
129
|
|
|
130
130
|
```
|
|
131
|
-
nabla
|
|
131
|
+
nabla build: goal — "Extract structured invoice fields (total, currency,
|
|
132
132
|
due date) from free-form invoice or receipt text, normalizing
|
|
133
133
|
locale-specific number and date formats."
|
|
134
|
-
nabla
|
|
134
|
+
nabla build: wrote extract_invoice.profile.json (site 4f2a9b1c3d8e…, supported=True, 6 examples)
|
|
135
135
|
```
|
|
136
136
|
|
|
137
137
|
`--out` defaults to `<site>.profile.json` in the current directory.
|
|
138
|
-
`--
|
|
139
|
-
the current directory — the file `
|
|
140
|
-
`--
|
|
138
|
+
`--captured` is optional: `build` looks for `<site>.captured.jsonl` in
|
|
139
|
+
the current directory — the file `capture` just wrote — and you only need
|
|
140
|
+
`--captured <path>` to point it at a different recording. The one LLM
|
|
141
141
|
call in this step is goal inference: it reads the reconstructed prompt
|
|
142
142
|
template, the resolved output schema, and the recorded examples, and
|
|
143
143
|
returns a goal description, implicit constraints, and difficulty
|
|
@@ -151,16 +151,16 @@ for — it doesn't need to be the provider your target repo already calls.
|
|
|
151
151
|
|
|
152
152
|
| Provider | Default model | Role |
|
|
153
153
|
|---|---|---|
|
|
154
|
-
| `openai` | your repo's own model (goal inference uses `gpt-4o-mini`) | Real upstream — `
|
|
155
|
-
| `groq` | `openai/gpt-oss-120b` | Stand-in oracle — `
|
|
156
|
-
| `gemini` | `gemini-3.5-flash` | Stand-in oracle — `
|
|
154
|
+
| `openai` | your repo's own model (goal inference uses `gpt-4o-mini`) | Real upstream — `capture` calls OpenAI directly with your key, auth passthrough. |
|
|
155
|
+
| `groq` | `openai/gpt-oss-120b` | Stand-in oracle — `capture` calls Groq instead of OpenAI. |
|
|
156
|
+
| `gemini` | `gemini-3.5-flash` | Stand-in oracle — `capture` calls Gemini instead of OpenAI. |
|
|
157
157
|
|
|
158
158
|
When you pick `groq` or `gemini`, they only stand in for *generating* the
|
|
159
|
-
completions during `
|
|
159
|
+
completions during `capture`. The profile's cost/latency baseline always
|
|
160
160
|
reports numbers for the target repo's own model — the one your code
|
|
161
161
|
actually calls in production — never the stand-in's.
|
|
162
162
|
|
|
163
|
-
Gemini's free tier has a very low daily quota: expect `
|
|
163
|
+
Gemini's free tier has a very low daily quota: expect `capture` to pace
|
|
164
164
|
requests with multi-second delays and to still hit rate limits past a
|
|
165
165
|
handful of samples. `groq` or `openai` handle larger sample sets more
|
|
166
166
|
comfortably.
|
|
@@ -184,11 +184,11 @@ work and take priority over it:
|
|
|
184
184
|
|
|
185
185
|
- `--site <symbol>` — skip site auto-pick; required once a repo has more
|
|
186
186
|
than one supported call site.
|
|
187
|
-
- `--out <path>` (`
|
|
188
|
-
`<site>.
|
|
189
|
-
- `--
|
|
190
|
-
other than `<site>.
|
|
191
|
-
- `--out <path>` (`
|
|
187
|
+
- `--out <path>` (`capture`) — write recorded examples somewhere other than
|
|
188
|
+
`<site>.captured.jsonl`.
|
|
189
|
+
- `--captured <path>` (`build`) — read recorded examples from somewhere
|
|
190
|
+
other than `<site>.captured.jsonl`.
|
|
191
|
+
- `--out <path>` (`build`) — write the profile somewhere other than
|
|
192
192
|
`<site>.profile.json`.
|
|
193
193
|
|
|
194
194
|
### Environment variables
|
|
@@ -202,7 +202,7 @@ highest first:
|
|
|
202
202
|
|
|
203
203
|
| Variable | Purpose |
|
|
204
204
|
|---|---|
|
|
205
|
-
| `OPENAI_API_KEY` | Default upstream for `
|
|
205
|
+
| `OPENAI_API_KEY` | Default upstream for `capture` (auth passthrough). |
|
|
206
206
|
| `GROQ_API_KEY` / `GEMINI_API_KEY` | That provider's conventional key env, read if `~/.nabla/config.json` has no stored key. |
|
|
207
207
|
| `NABLA_PROVIDER` | Overrides the provider `nabla init` selected. |
|
|
208
208
|
| `NABLA_UPSTREAM_BASE_URL` | Record through any OpenAI-compatible stand-in oracle instead. |
|
|
@@ -214,7 +214,7 @@ highest first:
|
|
|
214
214
|
|
|
215
215
|
## Notes
|
|
216
216
|
|
|
217
|
-
- `
|
|
217
|
+
- `capture` runs the site's enclosing function in a subprocess per sample,
|
|
218
218
|
with `OPENAI_BASE_URL` pointed at a local recording proxy. If the
|
|
219
219
|
target's tests mock the SDK, the proxy path reports zero traffic
|
|
220
220
|
explicitly instead of writing an empty profile.
|
|
@@ -0,0 +1,94 @@
|
|
|
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(mask: set[tuple[int, int]] | None = None, with_taglines: bool = True) -> Text:
|
|
45
|
+
frame = Text()
|
|
46
|
+
for r, (logo_line, tagline, tag_style) in enumerate(banner_lines()):
|
|
47
|
+
if mask is None:
|
|
48
|
+
segment = logo_line
|
|
49
|
+
else:
|
|
50
|
+
segment = "".join(
|
|
51
|
+
ch if ch == " " or (r, c) in mask else " "
|
|
52
|
+
for c, ch in enumerate(logo_line)
|
|
53
|
+
)
|
|
54
|
+
frame.append(f" {segment:<14}", style=_BRAND_STYLE)
|
|
55
|
+
if with_taglines and tagline:
|
|
56
|
+
frame.append(tagline, style=tag_style)
|
|
57
|
+
frame.append("\n")
|
|
58
|
+
return frame
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _stroke_frames() -> list[set[tuple[int, int]]]:
|
|
62
|
+
"""Reveal order that traces the mark like a pen: the top bar sweeps
|
|
63
|
+
left to right, then the two diagonals descend together to the apex."""
|
|
64
|
+
frames: list[set[tuple[int, int]]] = []
|
|
65
|
+
mask: set[tuple[int, int]] = set()
|
|
66
|
+
top_cells = [(0, c) for c, ch in enumerate(_LOGO[0]) if ch != " "]
|
|
67
|
+
for i in range(0, len(top_cells), 2):
|
|
68
|
+
mask.update(top_cells[i:i + 2])
|
|
69
|
+
frames.append(set(mask))
|
|
70
|
+
for r in range(1, len(_LOGO)):
|
|
71
|
+
mask.update((r, c) for c, ch in enumerate(_LOGO[r]) if ch != " ")
|
|
72
|
+
frames.append(set(mask))
|
|
73
|
+
return frames
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def print_banner(console: Console | None = None, animate: bool = False) -> None:
|
|
77
|
+
console = console or Console()
|
|
78
|
+
if not console.is_terminal:
|
|
79
|
+
return
|
|
80
|
+
console.print()
|
|
81
|
+
if animate:
|
|
82
|
+
import time
|
|
83
|
+
|
|
84
|
+
from rich.live import Live
|
|
85
|
+
|
|
86
|
+
with Live(console=console, refresh_per_second=30, transient=False) as live:
|
|
87
|
+
for mask in _stroke_frames():
|
|
88
|
+
live.update(_compose(mask=mask, with_taglines=False))
|
|
89
|
+
time.sleep(0.09)
|
|
90
|
+
time.sleep(0.18)
|
|
91
|
+
live.update(_compose())
|
|
92
|
+
else:
|
|
93
|
+
console.print(_compose(), end="")
|
|
94
|
+
console.print()
|
|
@@ -48,8 +48,8 @@ def _build_parser() -> argparse.ArgumentParser:
|
|
|
48
48
|
"typical flow (run from inside the target repo):\n"
|
|
49
49
|
" nabla init one-time setup\n"
|
|
50
50
|
" nabla scan find call sites\n"
|
|
51
|
-
" nabla
|
|
52
|
-
" nabla
|
|
51
|
+
" nabla capture intercept real traffic through a site\n"
|
|
52
|
+
" nabla build assemble the site profile artifact\n"
|
|
53
53
|
),
|
|
54
54
|
)
|
|
55
55
|
parser.add_argument("--version", action="version", version=f"nabla {__version__}")
|
|
@@ -82,14 +82,15 @@ def _build_parser() -> argparse.ArgumentParser:
|
|
|
82
82
|
p_scan.add_argument("--json", action="store_true", help="machine-readable output")
|
|
83
83
|
|
|
84
84
|
p_record = sub.add_parser(
|
|
85
|
-
"
|
|
86
|
-
|
|
85
|
+
"capture",
|
|
86
|
+
aliases=["record"],
|
|
87
|
+
help="intercept (prompt, completion) pairs through a call site",
|
|
87
88
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
88
89
|
epilog=(
|
|
89
90
|
"examples:\n"
|
|
90
|
-
" nabla
|
|
91
|
-
" nabla
|
|
92
|
-
" nabla
|
|
91
|
+
" nabla capture auto: pick site, generate inputs\n"
|
|
92
|
+
" nabla capture --inputs inputs.jsonl\n"
|
|
93
|
+
" nabla capture path/to/repo --site extract_invoice --inputs inputs.jsonl\n"
|
|
93
94
|
),
|
|
94
95
|
)
|
|
95
96
|
p_record.add_argument("repo", nargs="?", default=".", help="path to target repo (default: current directory)")
|
|
@@ -98,24 +99,27 @@ def _build_parser() -> argparse.ArgumentParser:
|
|
|
98
99
|
help="site symbol (auto-picked when omitted and the repo has exactly one supported call site)",
|
|
99
100
|
)
|
|
100
101
|
p_record.add_argument(
|
|
101
|
-
"--samples",
|
|
102
|
-
help="JSONL
|
|
102
|
+
"--inputs", "--samples", dest="samples",
|
|
103
|
+
help="JSONL inputs to run through the site (default: <site>.inputs.jsonl if present, "
|
|
104
|
+
"else auto-generated)",
|
|
103
105
|
)
|
|
104
106
|
p_record.add_argument("--logs", help="JSONL of past request/response pairs")
|
|
105
107
|
p_record.add_argument("--proxy-cmd", help="command to run under the recording proxy")
|
|
106
|
-
p_record.add_argument("--num-samples", type=int, default=
|
|
107
|
-
help="how many inputs to auto-generate when
|
|
108
|
+
p_record.add_argument("--num-samples", type=int, default=None,
|
|
109
|
+
help="how many inputs to auto-generate when none are given "
|
|
110
|
+
"(default: asked interactively, or 5 when not a terminal)")
|
|
108
111
|
p_record.add_argument("--out", default=None,
|
|
109
|
-
help="output JSONL (default: <site>.
|
|
112
|
+
help="output JSONL (default: <site>.captured.jsonl)")
|
|
110
113
|
|
|
111
114
|
p_profile = sub.add_parser(
|
|
112
|
-
"
|
|
113
|
-
|
|
115
|
+
"build",
|
|
116
|
+
aliases=["profile"],
|
|
117
|
+
help="assemble the site profile artifact",
|
|
114
118
|
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
115
119
|
epilog=(
|
|
116
120
|
"examples:\n"
|
|
117
|
-
" nabla
|
|
118
|
-
" nabla
|
|
121
|
+
" nabla build auto: pick site, find <site>.captured.jsonl\n"
|
|
122
|
+
" nabla build path/to/repo --site extract_invoice --captured captured.jsonl\n"
|
|
119
123
|
),
|
|
120
124
|
)
|
|
121
125
|
p_profile.add_argument("repo", nargs="?", default=".", help="path to target repo (default: current directory)")
|
|
@@ -123,8 +127,8 @@ def _build_parser() -> argparse.ArgumentParser:
|
|
|
123
127
|
"--site",
|
|
124
128
|
help="site symbol (auto-picked when omitted and the repo has exactly one supported call site)",
|
|
125
129
|
)
|
|
126
|
-
p_profile.add_argument("--examples",
|
|
127
|
-
help="
|
|
130
|
+
p_profile.add_argument("--captured", "--examples", dest="examples",
|
|
131
|
+
help="captured JSONL from `nabla capture` (default: <site>.captured.jsonl)")
|
|
128
132
|
p_profile.add_argument("--out", default=None,
|
|
129
133
|
help="output path (default: <site>.profile.json)")
|
|
130
134
|
|
|
@@ -185,21 +189,49 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
185
189
|
commands = {
|
|
186
190
|
"init": _cmd_init,
|
|
187
191
|
"scan": _cmd_scan,
|
|
188
|
-
"
|
|
189
|
-
"
|
|
192
|
+
"capture": _cmd_record,
|
|
193
|
+
"record": _cmd_record, # legacy alias
|
|
194
|
+
"build": _cmd_profile,
|
|
195
|
+
"profile": _cmd_profile, # legacy alias
|
|
190
196
|
}
|
|
191
197
|
return commands[args.command](args)
|
|
192
198
|
|
|
193
199
|
|
|
200
|
+
def _clipboard_text() -> str:
|
|
201
|
+
"""Windows clipboard contents (CF_UNICODETEXT), or "" on any failure."""
|
|
202
|
+
try:
|
|
203
|
+
import ctypes
|
|
204
|
+
|
|
205
|
+
CF_UNICODETEXT = 13
|
|
206
|
+
user32, kernel32 = ctypes.windll.user32, ctypes.windll.kernel32
|
|
207
|
+
if not user32.OpenClipboard(0):
|
|
208
|
+
return ""
|
|
209
|
+
try:
|
|
210
|
+
handle = user32.GetClipboardData(CF_UNICODETEXT)
|
|
211
|
+
if not handle:
|
|
212
|
+
return ""
|
|
213
|
+
locked = kernel32.GlobalLock(handle)
|
|
214
|
+
if not locked:
|
|
215
|
+
return ""
|
|
216
|
+
try:
|
|
217
|
+
return ctypes.c_wchar_p(locked).value or ""
|
|
218
|
+
finally:
|
|
219
|
+
kernel32.GlobalUnlock(handle)
|
|
220
|
+
finally:
|
|
221
|
+
user32.CloseClipboard()
|
|
222
|
+
except Exception:
|
|
223
|
+
return ""
|
|
224
|
+
|
|
225
|
+
|
|
194
226
|
def _read_api_key(prompt: str) -> str:
|
|
195
227
|
"""Read a secret with visible masking: one `*` per character, backspace
|
|
196
|
-
supported,
|
|
197
|
-
|
|
198
|
-
fully hidden input where character
|
|
228
|
+
supported, and Ctrl+V pastes from the clipboard (classic consoles don't
|
|
229
|
+
translate Ctrl+V into keystrokes, which silently swallowed pastes).
|
|
230
|
+
Falls back to fully hidden input where character reading isn't available."""
|
|
199
231
|
if sys.platform == "win32" and sys.stdin.isatty():
|
|
200
232
|
import msvcrt
|
|
201
233
|
|
|
202
|
-
print(prompt, end="", flush=True)
|
|
234
|
+
print(f"{prompt}(paste with Ctrl+V or right-click) ", end="", flush=True)
|
|
203
235
|
chars: list[str] = []
|
|
204
236
|
while True:
|
|
205
237
|
ch = msvcrt.getwch()
|
|
@@ -208,7 +240,13 @@ def _read_api_key(prompt: str) -> str:
|
|
|
208
240
|
break
|
|
209
241
|
if ch == "\x03": # Ctrl+C
|
|
210
242
|
raise KeyboardInterrupt
|
|
211
|
-
if ch == "\
|
|
243
|
+
if ch == "\x16": # Ctrl+V — classic console gives us the raw control char
|
|
244
|
+
pasted = _clipboard_text().strip()
|
|
245
|
+
for c in pasted:
|
|
246
|
+
if c >= " ":
|
|
247
|
+
chars.append(c)
|
|
248
|
+
print("*" * len(pasted), end="", flush=True)
|
|
249
|
+
elif ch == "\b":
|
|
212
250
|
if chars:
|
|
213
251
|
chars.pop()
|
|
214
252
|
print("\b \b", end="", flush=True)
|
|
@@ -222,7 +260,7 @@ def _read_api_key(prompt: str) -> str:
|
|
|
222
260
|
def _cmd_init(args) -> int:
|
|
223
261
|
from .ui import RAIL, rail, rail_end, rail_ok, rail_start, select
|
|
224
262
|
|
|
225
|
-
print_banner(_out)
|
|
263
|
+
print_banner(_out, animate=True)
|
|
226
264
|
rail_start(_out, "nabla init")
|
|
227
265
|
rail(_out)
|
|
228
266
|
|
|
@@ -358,7 +396,7 @@ def _cmd_scan(args) -> int:
|
|
|
358
396
|
noun = "call site" if len(rows) == 1 else "call sites"
|
|
359
397
|
_out.print(f"{len(rows)} {noun} found — {supported_count} harvestable.", markup=False)
|
|
360
398
|
if supported_count:
|
|
361
|
-
_next_step("nabla
|
|
399
|
+
_next_step("nabla capture" + (f" {args.repo}" if args.repo != "." else ""))
|
|
362
400
|
if _unconfigured():
|
|
363
401
|
_warn("tip: run `nabla init` once to set up a provider — `nabla record` "
|
|
364
402
|
"and `nabla profile` need one")
|
|
@@ -370,10 +408,10 @@ def _rerun_command(args, symbol: str) -> str:
|
|
|
370
408
|
carrying over whichever other flags were already given."""
|
|
371
409
|
parts = ["nabla", args.command, args.repo, "--site", symbol]
|
|
372
410
|
for flag, attr in (
|
|
373
|
-
("--
|
|
411
|
+
("--inputs", "samples"),
|
|
374
412
|
("--logs", "logs"),
|
|
375
413
|
("--proxy-cmd", "proxy_cmd"),
|
|
376
|
-
("--
|
|
414
|
+
("--captured", "examples"),
|
|
377
415
|
):
|
|
378
416
|
value = getattr(args, attr, None)
|
|
379
417
|
if value:
|
|
@@ -395,7 +433,7 @@ def _resolve_site(args, sites):
|
|
|
395
433
|
if len(supported) == 1:
|
|
396
434
|
site = supported[0]
|
|
397
435
|
from .ui import rail
|
|
398
|
-
rail(_out, f"using site {site.symbol} (the only supported call site)"
|
|
436
|
+
rail(_out, f"using site {site.symbol} (the only supported call site)")
|
|
399
437
|
return site, None
|
|
400
438
|
|
|
401
439
|
candidates = supported or sites
|
|
@@ -417,7 +455,7 @@ def _resolve_site(args, sites):
|
|
|
417
455
|
def _cmd_record(args) -> int:
|
|
418
456
|
from .ui import make_progress, rail, rail_end, rail_ok, rail_start
|
|
419
457
|
|
|
420
|
-
rail_start(_out, "nabla
|
|
458
|
+
rail_start(_out, "nabla capture")
|
|
421
459
|
sites = scan(args.repo)
|
|
422
460
|
site, err = _resolve_site(args, sites)
|
|
423
461
|
if err is not None:
|
|
@@ -440,13 +478,17 @@ def _cmd_record(args) -> int:
|
|
|
440
478
|
elif args.proxy_cmd:
|
|
441
479
|
source = RecordSource(kind="proxy", proxy_command=args.proxy_cmd.split())
|
|
442
480
|
else:
|
|
443
|
-
conventional =
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
481
|
+
conventional = next(
|
|
482
|
+
(p for p in (Path(f"{site.symbol}.inputs.jsonl"),
|
|
483
|
+
Path(f"{site.symbol}.samples.jsonl")) # legacy name
|
|
484
|
+
if p.exists()),
|
|
485
|
+
None,
|
|
486
|
+
)
|
|
487
|
+
if conventional is not None:
|
|
488
|
+
rail(_out, f"using {conventional} (found by convention; pass --inputs to override)")
|
|
447
489
|
source = RecordSource(kind="samples", samples_file=str(conventional))
|
|
448
490
|
else:
|
|
449
|
-
source, err = _generate_samples_source(args, site,
|
|
491
|
+
source, err = _generate_samples_source(args, site, Path(f"{site.symbol}.inputs.jsonl"))
|
|
450
492
|
if err is not None:
|
|
451
493
|
return err
|
|
452
494
|
source_kind = "generated_samples"
|
|
@@ -469,23 +511,30 @@ def _cmd_record(args) -> int:
|
|
|
469
511
|
else:
|
|
470
512
|
examples = record(site, source, args.repo)
|
|
471
513
|
except ZeroTrafficError as exc:
|
|
472
|
-
_fail(f"nabla
|
|
514
|
+
_fail(f"nabla capture: {exc}")
|
|
515
|
+
return 1
|
|
516
|
+
except RuntimeError as exc:
|
|
517
|
+
message = str(exc)
|
|
518
|
+
_fail(f"nabla capture: {message[:400]}")
|
|
519
|
+
if "invalid_api_key" in message or "Invalid API Key" in message or "401" in message:
|
|
520
|
+
_warn("the configured provider rejected your API key — rerun `nabla init` "
|
|
521
|
+
"and paste it again (the input shows one * per character, including pastes)")
|
|
473
522
|
return 1
|
|
474
|
-
out = args.out or f"{site.symbol}.
|
|
523
|
+
out = args.out or f"{site.symbol}.captured.jsonl"
|
|
475
524
|
with open(out, "w", encoding="utf-8") as f:
|
|
476
525
|
for ex in examples:
|
|
477
526
|
f.write(json.dumps({**ex.to_dict(), "_source_kind": source_kind or source.kind}) + "\n")
|
|
478
527
|
rail(_out)
|
|
479
|
-
rail_ok(_out, f"
|
|
528
|
+
rail_ok(_out, f"captured {len(examples)} (prompt, completion) pairs from "
|
|
480
529
|
f"site {site.symbol} -> {out}")
|
|
481
530
|
if source_kind == "generated_samples":
|
|
482
|
-
rail(_out, f"inputs were synthetic — edit {site.symbol}.
|
|
531
|
+
rail(_out, f"inputs were synthetic — edit {site.symbol}.inputs.jsonl with real "
|
|
483
532
|
"data and rerun for a higher-fidelity harvest", style="yellow")
|
|
484
533
|
rail(_out)
|
|
485
534
|
if args.out is None and args.site is None:
|
|
486
|
-
rail_end(_out, f"next: nabla
|
|
535
|
+
rail_end(_out, f"next: nabla build{'' if args.repo == '.' else ' ' + args.repo}")
|
|
487
536
|
else:
|
|
488
|
-
rail_end(_out, f"next: nabla
|
|
537
|
+
rail_end(_out, f"next: nabla build {args.repo} --site {site.symbol} --captured {out}")
|
|
489
538
|
return 0
|
|
490
539
|
|
|
491
540
|
|
|
@@ -497,14 +546,27 @@ def _generate_samples_source(args, site, out_path: Path):
|
|
|
497
546
|
from .prompt_recon import reconstruct
|
|
498
547
|
from .samplegen import SampleGenError, generate_samples
|
|
499
548
|
|
|
549
|
+
n = args.num_samples
|
|
550
|
+
if n is None:
|
|
551
|
+
if _out.is_terminal and sys.stdin.isatty():
|
|
552
|
+
from .ui import select
|
|
553
|
+
options = [
|
|
554
|
+
("5", "quick look — fastest, fewest credits"),
|
|
555
|
+
("10", "balanced — decent goal inference"),
|
|
556
|
+
("20", "handoff floor — slowest, most credits"),
|
|
557
|
+
]
|
|
558
|
+
n = int(options[select(_out, "Synthetic inputs", options)][0])
|
|
559
|
+
else:
|
|
560
|
+
n = 5
|
|
561
|
+
|
|
500
562
|
template = reconstruct(site, args.repo)
|
|
501
563
|
schema, _ = resolve_schema(site, args.repo)
|
|
502
564
|
try:
|
|
503
|
-
items = generate_samples(template, schema,
|
|
565
|
+
items = generate_samples(template, schema, n)
|
|
504
566
|
except (SampleGenError, InduceError) as exc:
|
|
505
567
|
print(
|
|
506
|
-
f"nabla
|
|
507
|
-
'pass --
|
|
568
|
+
f"nabla capture: cannot auto-generate inputs: {exc}\n"
|
|
569
|
+
'pass --inputs FILE with your own (JSONL, one {"input_slots": {...}} '
|
|
508
570
|
"object per line), or run `nabla init` to configure a provider",
|
|
509
571
|
file=sys.stderr,
|
|
510
572
|
)
|
|
@@ -522,7 +584,7 @@ def _cmd_profile(args) -> int:
|
|
|
522
584
|
from .schema_resolver import resolve_schema as _resolve
|
|
523
585
|
from .ui import rail, rail_end, rail_ok, rail_start
|
|
524
586
|
|
|
525
|
-
rail_start(_out, "nabla
|
|
587
|
+
rail_start(_out, "nabla build")
|
|
526
588
|
sites = scan(args.repo)
|
|
527
589
|
site, err = _resolve_site(args, sites)
|
|
528
590
|
if err is not None:
|
|
@@ -532,20 +594,19 @@ def _cmd_profile(args) -> int:
|
|
|
532
594
|
|
|
533
595
|
examples_path = args.examples
|
|
534
596
|
if examples_path is None:
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
else:
|
|
542
|
-
record_cmd = f"nabla record {args.repo}"
|
|
597
|
+
candidates = [Path(f"{site.symbol}.captured.jsonl"),
|
|
598
|
+
Path(f"{site.symbol}.recorded.jsonl"), # legacy names
|
|
599
|
+
Path("recorded.jsonl")]
|
|
600
|
+
found = next((p for p in candidates if p.exists()), None)
|
|
601
|
+
if found is None:
|
|
602
|
+
capture_cmd = f"nabla capture {args.repo}"
|
|
543
603
|
if args.site:
|
|
544
|
-
|
|
545
|
-
print(f"nabla
|
|
546
|
-
f"Run first: {
|
|
604
|
+
capture_cmd += f" --site {site.symbol}"
|
|
605
|
+
print(f"nabla build: no captured pairs found ({candidates[0]}). "
|
|
606
|
+
f"Run first: {capture_cmd}", file=sys.stderr)
|
|
547
607
|
return 2
|
|
548
|
-
|
|
608
|
+
examples_path = str(found)
|
|
609
|
+
rail(_out, f"using {examples_path}")
|
|
549
610
|
|
|
550
611
|
examples, recorded_from = [], "samples"
|
|
551
612
|
for line in Path(examples_path).read_text(encoding="utf-8").splitlines():
|
|
@@ -415,9 +415,13 @@ def _run_site_in_subprocess(site: RawSite, repo_path: str, input_slots: dict, en
|
|
|
415
415
|
timeout = int(os.environ.get("NABLA_SAMPLE_TIMEOUT", "300"))
|
|
416
416
|
proc = subprocess.run(args, cwd=repo_path, env=env, capture_output=True, text=True, timeout=timeout)
|
|
417
417
|
if proc.returncode != 0:
|
|
418
|
+
# The last stderr lines carry the actual exception; the rest is
|
|
419
|
+
# traceback scaffolding that buries it.
|
|
420
|
+
lines = [l for l in (proc.stderr or proc.stdout or "").splitlines() if l.strip()]
|
|
421
|
+
detail = " | ".join(lines[-2:]) if lines else "no output"
|
|
418
422
|
raise RuntimeError(
|
|
419
423
|
f"Sample invocation of {site.symbol!r} in {site.file!r} failed "
|
|
420
|
-
f"(exit {proc.returncode}): {
|
|
424
|
+
f"(exit {proc.returncode}): {detail}"
|
|
421
425
|
)
|
|
422
426
|
|
|
423
427
|
|
|
@@ -460,7 +464,7 @@ def _record_samples(site: RawSite, source: RecordSource, repo_path: str,
|
|
|
460
464
|
_run_site_in_subprocess(site, repo_path, input_slots, env)
|
|
461
465
|
except (RuntimeError, subprocess.TimeoutExpired) as exc:
|
|
462
466
|
failures.append((i, str(exc)))
|
|
463
|
-
print(f"nabla
|
|
467
|
+
print(f"nabla capture: sample {i + 1}/{total} FAILED: {str(exc)[:300]}",
|
|
464
468
|
file=sys.stderr)
|
|
465
469
|
continue
|
|
466
470
|
with proxy._lock:
|
|
@@ -470,7 +474,7 @@ def _record_samples(site: RawSite, source: RecordSource, repo_path: str,
|
|
|
470
474
|
if on_progress is not None:
|
|
471
475
|
on_progress(i + 1, total)
|
|
472
476
|
else:
|
|
473
|
-
print(f"nabla
|
|
477
|
+
print(f"nabla capture: sample {i + 1}/{total} -> {len(new_captures)} capture(s)",
|
|
474
478
|
file=sys.stderr)
|
|
475
479
|
finally:
|
|
476
480
|
proxy.stop()
|
|
@@ -480,7 +484,7 @@ def _record_samples(site: RawSite, source: RecordSource, repo_path: str,
|
|
|
480
484
|
f"all {len(failures)} sample invocations failed; first error: {failures[0][1]}"
|
|
481
485
|
)
|
|
482
486
|
if failures:
|
|
483
|
-
print(f"nabla
|
|
487
|
+
print(f"nabla capture: {len(failures)}/{total} samples failed and were skipped",
|
|
484
488
|
file=sys.stderr)
|
|
485
489
|
return examples
|
|
486
490
|
|
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
"""Synthetic
|
|
2
|
-
|
|
1
|
+
"""Synthetic input generation for `nabla capture` when no --inputs file
|
|
2
|
+
is given.
|
|
3
3
|
|
|
4
4
|
The site's statically recovered prompt template, slots, system prompt, and
|
|
5
5
|
output schema are enough context for the configured provider to invent a
|
|
6
6
|
handful of plausible inputs. The CLI always writes generated inputs to
|
|
7
|
-
<site>.
|
|
7
|
+
<site>.inputs.jsonl so the user can inspect, edit, and rerun with real
|
|
8
8
|
data; profiles built from them carry provenance "generated_samples",
|
|
9
9
|
never "samples".
|
|
10
10
|
|
|
@@ -52,7 +52,7 @@ def generate_samples(template: PromptTemplate, schema: dict[str, Any] | None,
|
|
|
52
52
|
if not slot_names:
|
|
53
53
|
raise SampleGenError(
|
|
54
54
|
"site has no reconstructable input slots — cannot generate sample "
|
|
55
|
-
"inputs; pass --
|
|
55
|
+
"inputs; pass --inputs with your own JSONL instead"
|
|
56
56
|
)
|
|
57
57
|
|
|
58
58
|
model = model or os.environ.get("NABLA_SAMPLEGEN_MODEL") \
|
|
@@ -113,6 +113,6 @@ def _parse_samples(raw: str, slot_names: list[str], n: int) -> list[dict]:
|
|
|
113
113
|
if not items:
|
|
114
114
|
raise SampleGenError(
|
|
115
115
|
"sample generation produced no items covering every slot "
|
|
116
|
-
f"({slot_names}); pass --
|
|
116
|
+
f"({slot_names}); pass --inputs with your own JSONL instead"
|
|
117
117
|
)
|
|
118
118
|
return items
|
|
@@ -18,7 +18,7 @@ from __future__ import annotations
|
|
|
18
18
|
import sys
|
|
19
19
|
|
|
20
20
|
from rich.console import Console
|
|
21
|
-
from rich.progress import Progress,
|
|
21
|
+
from rich.progress import Progress, TextColumn
|
|
22
22
|
from rich.text import Text
|
|
23
23
|
|
|
24
24
|
RAIL = "│"
|
|
@@ -110,8 +110,9 @@ def select(console: Console, label: str, options: list[tuple[str, str]],
|
|
|
110
110
|
row.append(f" {_POINTER} {_ON} ", style="cyan")
|
|
111
111
|
row.append(f"{name:<{name_width}}", style="bold cyan")
|
|
112
112
|
else:
|
|
113
|
+
# Unselected is quieter, never grey — grey reads as disabled.
|
|
113
114
|
row.append(f" {_OFF} ", style="dim")
|
|
114
|
-
row.append(f"{name:<{name_width}}"
|
|
115
|
+
row.append(f"{name:<{name_width}}")
|
|
115
116
|
row.append(f" {desc}", style="dim")
|
|
116
117
|
console.print(row)
|
|
117
118
|
lines_drawn = len(options) + 1
|
|
@@ -141,30 +142,18 @@ def select(console: Console, label: str, options: list[tuple[str, str]],
|
|
|
141
142
|
|
|
142
143
|
|
|
143
144
|
# ---------------------------------------------------------------------------
|
|
144
|
-
# Progress
|
|
145
|
+
# Progress
|
|
145
146
|
# ---------------------------------------------------------------------------
|
|
146
147
|
|
|
147
|
-
class NablaBar(ProgressColumn):
|
|
148
|
-
"""A bar that fills with the nabla glyph — brand as instrument."""
|
|
149
|
-
|
|
150
|
-
def __init__(self, width: int = 28):
|
|
151
|
-
super().__init__()
|
|
152
|
-
self.width = width
|
|
153
|
-
|
|
154
|
-
def render(self, task) -> Text:
|
|
155
|
-
total = task.total or 1
|
|
156
|
-
filled = int(self.width * min(task.completed / total, 1.0))
|
|
157
|
-
bar = Text()
|
|
158
|
-
bar.append("∇" * filled, style="cyan")
|
|
159
|
-
bar.append("·" * (self.width - filled), style="dim")
|
|
160
|
-
return bar
|
|
161
|
-
|
|
162
|
-
|
|
163
148
|
def make_progress(console: Console, unit: str = "samples") -> Progress:
|
|
149
|
+
from rich.progress import BarColumn, SpinnerColumn, TimeElapsedColumn
|
|
150
|
+
|
|
164
151
|
return Progress(
|
|
165
152
|
TextColumn(RAIL, style="dim"),
|
|
166
|
-
|
|
167
|
-
|
|
153
|
+
SpinnerColumn(spinner_name="dots", style="cyan"),
|
|
154
|
+
BarColumn(bar_width=28, style="dim", complete_style="cyan", finished_style="green"),
|
|
155
|
+
TextColumn(f"{{task.completed}}/{{task.total}} {unit}"),
|
|
156
|
+
TimeElapsedColumn(),
|
|
168
157
|
console=console,
|
|
169
158
|
transient=True,
|
|
170
159
|
)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: nabla-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.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
|
|
@@ -63,19 +63,19 @@ pip install nabla-cli
|
|
|
63
63
|
|
|
64
64
|
nabla init # one-time setup, once per machine
|
|
65
65
|
nabla scan
|
|
66
|
-
nabla
|
|
67
|
-
nabla
|
|
66
|
+
nabla capture
|
|
67
|
+
nabla build
|
|
68
68
|
```
|
|
69
69
|
|
|
70
70
|
No flags needed on the happy path: the repo defaults to the current
|
|
71
71
|
directory, `--site` is auto-picked when the repo has exactly one
|
|
72
72
|
supported call site (with more than one, pass `--site <symbol>` — the
|
|
73
|
-
names come from `scan`'s output), `
|
|
74
|
-
`<site>.
|
|
73
|
+
names come from `scan`'s output), `capture` writes
|
|
74
|
+
`<site>.captured.jsonl`, and `build` finds that file automatically by
|
|
75
75
|
the same convention.
|
|
76
76
|
|
|
77
|
-
If you don't pass `--
|
|
78
|
-
`<site>.
|
|
77
|
+
If you don't pass `--inputs`, `capture` looks for
|
|
78
|
+
`<site>.inputs.jsonl` and otherwise generates a handful of synthetic
|
|
79
79
|
inputs from the site's prompt template (saving them to that file so you
|
|
80
80
|
can edit them and rerun with realistic data). Profiles built on
|
|
81
81
|
generated inputs are marked `generated_samples` in their provenance —
|
|
@@ -110,23 +110,23 @@ extract_invoice app/invoice_extract.py create json_schema -
|
|
|
110
110
|
1 call site found.
|
|
111
111
|
```
|
|
112
112
|
|
|
113
|
-
### `nabla
|
|
113
|
+
### `nabla capture`
|
|
114
114
|
|
|
115
115
|
One progress line per sample, then a summary:
|
|
116
116
|
|
|
117
117
|
```
|
|
118
|
-
nabla
|
|
119
|
-
nabla
|
|
118
|
+
nabla capture: sample 1/6 -> 1 capture(s)
|
|
119
|
+
nabla capture: sample 2/6 -> 1 capture(s)
|
|
120
120
|
...
|
|
121
|
-
|
|
121
|
+
captured 6 (prompt, completion) pairs from site extract_invoice -> extract_invoice.captured.jsonl
|
|
122
122
|
```
|
|
123
123
|
|
|
124
|
-
`--out` defaults to `<site>.
|
|
125
|
-
`
|
|
124
|
+
`--out` defaults to `<site>.captured.jsonl` in the current directory (so
|
|
125
|
+
`build` can find it by the same convention afterward) — pass `--out`
|
|
126
126
|
yourself only to pick a different name or location.
|
|
127
127
|
|
|
128
|
-
To record your own inputs instead of generated ones, pass `--
|
|
129
|
-
(or create `<site>.
|
|
128
|
+
To record your own inputs instead of generated ones, pass `--inputs`
|
|
129
|
+
(or create `<site>.inputs.jsonl`, which is picked up automatically): a
|
|
130
130
|
JSONL file, one line per call to record. Each line is
|
|
131
131
|
`{"input_slots": {...}}`, where the keys match the site function's
|
|
132
132
|
keyword arguments (as reported by `scan`):
|
|
@@ -139,19 +139,19 @@ keyword arguments (as reported by `scan`):
|
|
|
139
139
|
nabla runs the site's function once per line, in a subprocess, with its
|
|
140
140
|
OpenAI client rerouted through a local recording proxy.
|
|
141
141
|
|
|
142
|
-
### `nabla
|
|
142
|
+
### `nabla build`
|
|
143
143
|
|
|
144
144
|
```
|
|
145
|
-
nabla
|
|
145
|
+
nabla build: goal — "Extract structured invoice fields (total, currency,
|
|
146
146
|
due date) from free-form invoice or receipt text, normalizing
|
|
147
147
|
locale-specific number and date formats."
|
|
148
|
-
nabla
|
|
148
|
+
nabla build: wrote extract_invoice.profile.json (site 4f2a9b1c3d8e…, supported=True, 6 examples)
|
|
149
149
|
```
|
|
150
150
|
|
|
151
151
|
`--out` defaults to `<site>.profile.json` in the current directory.
|
|
152
|
-
`--
|
|
153
|
-
the current directory — the file `
|
|
154
|
-
`--
|
|
152
|
+
`--captured` is optional: `build` looks for `<site>.captured.jsonl` in
|
|
153
|
+
the current directory — the file `capture` just wrote — and you only need
|
|
154
|
+
`--captured <path>` to point it at a different recording. The one LLM
|
|
155
155
|
call in this step is goal inference: it reads the reconstructed prompt
|
|
156
156
|
template, the resolved output schema, and the recorded examples, and
|
|
157
157
|
returns a goal description, implicit constraints, and difficulty
|
|
@@ -165,16 +165,16 @@ for — it doesn't need to be the provider your target repo already calls.
|
|
|
165
165
|
|
|
166
166
|
| Provider | Default model | Role |
|
|
167
167
|
|---|---|---|
|
|
168
|
-
| `openai` | your repo's own model (goal inference uses `gpt-4o-mini`) | Real upstream — `
|
|
169
|
-
| `groq` | `openai/gpt-oss-120b` | Stand-in oracle — `
|
|
170
|
-
| `gemini` | `gemini-3.5-flash` | Stand-in oracle — `
|
|
168
|
+
| `openai` | your repo's own model (goal inference uses `gpt-4o-mini`) | Real upstream — `capture` calls OpenAI directly with your key, auth passthrough. |
|
|
169
|
+
| `groq` | `openai/gpt-oss-120b` | Stand-in oracle — `capture` calls Groq instead of OpenAI. |
|
|
170
|
+
| `gemini` | `gemini-3.5-flash` | Stand-in oracle — `capture` calls Gemini instead of OpenAI. |
|
|
171
171
|
|
|
172
172
|
When you pick `groq` or `gemini`, they only stand in for *generating* the
|
|
173
|
-
completions during `
|
|
173
|
+
completions during `capture`. The profile's cost/latency baseline always
|
|
174
174
|
reports numbers for the target repo's own model — the one your code
|
|
175
175
|
actually calls in production — never the stand-in's.
|
|
176
176
|
|
|
177
|
-
Gemini's free tier has a very low daily quota: expect `
|
|
177
|
+
Gemini's free tier has a very low daily quota: expect `capture` to pace
|
|
178
178
|
requests with multi-second delays and to still hit rate limits past a
|
|
179
179
|
handful of samples. `groq` or `openai` handle larger sample sets more
|
|
180
180
|
comfortably.
|
|
@@ -198,11 +198,11 @@ work and take priority over it:
|
|
|
198
198
|
|
|
199
199
|
- `--site <symbol>` — skip site auto-pick; required once a repo has more
|
|
200
200
|
than one supported call site.
|
|
201
|
-
- `--out <path>` (`
|
|
202
|
-
`<site>.
|
|
203
|
-
- `--
|
|
204
|
-
other than `<site>.
|
|
205
|
-
- `--out <path>` (`
|
|
201
|
+
- `--out <path>` (`capture`) — write recorded examples somewhere other than
|
|
202
|
+
`<site>.captured.jsonl`.
|
|
203
|
+
- `--captured <path>` (`build`) — read recorded examples from somewhere
|
|
204
|
+
other than `<site>.captured.jsonl`.
|
|
205
|
+
- `--out <path>` (`build`) — write the profile somewhere other than
|
|
206
206
|
`<site>.profile.json`.
|
|
207
207
|
|
|
208
208
|
### Environment variables
|
|
@@ -216,7 +216,7 @@ highest first:
|
|
|
216
216
|
|
|
217
217
|
| Variable | Purpose |
|
|
218
218
|
|---|---|
|
|
219
|
-
| `OPENAI_API_KEY` | Default upstream for `
|
|
219
|
+
| `OPENAI_API_KEY` | Default upstream for `capture` (auth passthrough). |
|
|
220
220
|
| `GROQ_API_KEY` / `GEMINI_API_KEY` | That provider's conventional key env, read if `~/.nabla/config.json` has no stored key. |
|
|
221
221
|
| `NABLA_PROVIDER` | Overrides the provider `nabla init` selected. |
|
|
222
222
|
| `NABLA_UPSTREAM_BASE_URL` | Record through any OpenAI-compatible stand-in oracle instead. |
|
|
@@ -228,7 +228,7 @@ highest first:
|
|
|
228
228
|
|
|
229
229
|
## Notes
|
|
230
230
|
|
|
231
|
-
- `
|
|
231
|
+
- `capture` runs the site's enclosing function in a subprocess per sample,
|
|
232
232
|
with `OPENAI_BASE_URL` pointed at a local recording proxy. If the
|
|
233
233
|
target's tests mock the SDK, the proxy path reports zero traffic
|
|
234
234
|
explicitly instead of writing an empty profile.
|
|
@@ -123,7 +123,7 @@ def test_record_no_source_generation_failure_mentions_samples(monkeypatch, capsy
|
|
|
123
123
|
|
|
124
124
|
assert rc == 1
|
|
125
125
|
err = capsys.readouterr().err
|
|
126
|
-
assert "--
|
|
126
|
+
assert "--inputs" in err
|
|
127
127
|
assert "provider unavailable" in err
|
|
128
128
|
|
|
129
129
|
|
|
@@ -179,7 +179,7 @@ def test_record_help_includes_examples(capsys):
|
|
|
179
179
|
assert exc.value.code == 0
|
|
180
180
|
out = capsys.readouterr().out
|
|
181
181
|
assert "examples:" in out
|
|
182
|
-
assert "nabla
|
|
182
|
+
assert "nabla capture" in out
|
|
183
183
|
|
|
184
184
|
|
|
185
185
|
# ---------------------------------------------------------------------------
|
|
@@ -203,10 +203,10 @@ def test_record_auto_names_output_by_symbol(tmp_path, monkeypatch, capsys):
|
|
|
203
203
|
rc = main(["record", str(repo), "--samples", str(samples)])
|
|
204
204
|
|
|
205
205
|
assert rc == 0
|
|
206
|
-
out_file = tmp_path / "extract_receipt.
|
|
206
|
+
out_file = tmp_path / "extract_receipt.captured.jsonl"
|
|
207
207
|
assert out_file.exists()
|
|
208
208
|
stdout = capsys.readouterr().out
|
|
209
|
-
assert "extract_receipt.
|
|
209
|
+
assert "extract_receipt.captured.jsonl" in stdout
|
|
210
210
|
|
|
211
211
|
|
|
212
212
|
def test_record_generates_samples_when_none_given(tmp_path, monkeypatch, capsys):
|
|
@@ -223,16 +223,16 @@ def test_record_generates_samples_when_none_given(tmp_path, monkeypatch, capsys)
|
|
|
223
223
|
rc = main(["record", str(repo), "--num-samples", "3"])
|
|
224
224
|
|
|
225
225
|
assert rc == 0
|
|
226
|
-
samples_file = tmp_path / "extract_receipt.
|
|
226
|
+
samples_file = tmp_path / "extract_receipt.inputs.jsonl"
|
|
227
227
|
assert samples_file.exists()
|
|
228
228
|
assert len(samples_file.read_text(encoding="utf-8").splitlines()) == 3
|
|
229
|
-
recorded = tmp_path / "extract_receipt.
|
|
229
|
+
recorded = tmp_path / "extract_receipt.captured.jsonl"
|
|
230
230
|
lines = [json.loads(x) for x in recorded.read_text(encoding="utf-8").splitlines()]
|
|
231
231
|
assert len(lines) == 3
|
|
232
232
|
assert all(line["_source_kind"] == "generated_samples" for line in lines)
|
|
233
233
|
stdout = capsys.readouterr().out
|
|
234
234
|
assert "generated 3 synthetic inputs" in stdout
|
|
235
|
-
assert "next: nabla
|
|
235
|
+
assert "next: nabla build" in stdout
|
|
236
236
|
|
|
237
237
|
|
|
238
238
|
def test_record_prefers_conventional_samples_file(tmp_path, monkeypatch, capsys):
|
|
@@ -253,7 +253,7 @@ def test_record_prefers_conventional_samples_file(tmp_path, monkeypatch, capsys)
|
|
|
253
253
|
stdout = capsys.readouterr().out
|
|
254
254
|
assert "found by convention" in stdout
|
|
255
255
|
lines = [json.loads(x) for x in
|
|
256
|
-
(tmp_path / "extract_receipt.
|
|
256
|
+
(tmp_path / "extract_receipt.captured.jsonl").read_text(encoding="utf-8").splitlines()]
|
|
257
257
|
assert lines and all(line["_source_kind"] == "samples" for line in lines)
|
|
258
258
|
|
|
259
259
|
|
|
@@ -293,7 +293,7 @@ def test_profile_without_recorded_file_exits_2_with_record_command(tmp_path, mon
|
|
|
293
293
|
|
|
294
294
|
assert rc == 2
|
|
295
295
|
err = capsys.readouterr().err
|
|
296
|
-
assert "nabla
|
|
296
|
+
assert "nabla capture" in err
|
|
297
297
|
|
|
298
298
|
|
|
299
299
|
def test_bare_scan_defaults_to_cwd(tmp_path, monkeypatch, capsys):
|
|
@@ -1,56 +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
|
-
# Proportioned after the wordmark: tall triangle, heavy top bar,
|
|
17
|
-
# strokes converging to a centered apex.
|
|
18
|
-
_LOGO = [
|
|
19
|
-
"▛▀▀▀▀▀▀▀▀▀▜",
|
|
20
|
-
"▚ ▞",
|
|
21
|
-
" ▚ ▞",
|
|
22
|
-
" ▚ ▞",
|
|
23
|
-
" ▚ ▞",
|
|
24
|
-
" ▚ ▞",
|
|
25
|
-
" ▀",
|
|
26
|
-
]
|
|
27
|
-
|
|
28
|
-
_BRAND_STYLE = "bold cyan"
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
def banner_lines() -> list[tuple[str, str]]:
|
|
32
|
-
"""(logo_line, tagline) pairs; taglines are empty on the outer rows."""
|
|
33
|
-
taglines = [
|
|
34
|
-
"",
|
|
35
|
-
"",
|
|
36
|
-
"nabla",
|
|
37
|
-
f"v{__version__} · OpenAI call-site harvester",
|
|
38
|
-
"scan · record · profile",
|
|
39
|
-
"",
|
|
40
|
-
"",
|
|
41
|
-
]
|
|
42
|
-
return list(zip(_LOGO, taglines))
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
def print_banner(console: Console | None = None) -> None:
|
|
46
|
-
console = console or Console()
|
|
47
|
-
if not console.is_terminal:
|
|
48
|
-
return
|
|
49
|
-
console.print()
|
|
50
|
-
for logo_line, tagline in banner_lines():
|
|
51
|
-
row = Text()
|
|
52
|
-
row.append(f" {logo_line:<14}", style=_BRAND_STYLE)
|
|
53
|
-
if tagline:
|
|
54
|
-
row.append(tagline, style="bold" if tagline == "nabla" else "dim")
|
|
55
|
-
console.print(row)
|
|
56
|
-
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
|