nabla-cli 0.2.1__tar.gz → 0.4.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.2.1 → nabla_cli-0.4.0}/PKG-INFO +2 -1
- {nabla_cli-0.2.1 → nabla_cli-0.4.0}/nabla/__init__.py +1 -1
- nabla_cli-0.4.0/nabla/branding.py +56 -0
- {nabla_cli-0.2.1 → nabla_cli-0.4.0}/nabla/cli.py +128 -43
- {nabla_cli-0.2.1 → nabla_cli-0.4.0}/nabla/recorder.py +13 -5
- nabla_cli-0.4.0/nabla/ui.py +170 -0
- {nabla_cli-0.2.1 → nabla_cli-0.4.0}/nabla_cli.egg-info/PKG-INFO +2 -1
- {nabla_cli-0.2.1 → nabla_cli-0.4.0}/nabla_cli.egg-info/SOURCES.txt +2 -0
- {nabla_cli-0.2.1 → nabla_cli-0.4.0}/nabla_cli.egg-info/requires.txt +1 -0
- {nabla_cli-0.2.1 → nabla_cli-0.4.0}/pyproject.toml +2 -1
- {nabla_cli-0.2.1 → nabla_cli-0.4.0}/tests/test_cli_ux.py +2 -2
- {nabla_cli-0.2.1 → nabla_cli-0.4.0}/README.md +0 -0
- {nabla_cli-0.2.1 → nabla_cli-0.4.0}/nabla/__main__.py +0 -0
- {nabla_cli-0.2.1 → nabla_cli-0.4.0}/nabla/config.py +0 -0
- {nabla_cli-0.2.1 → nabla_cli-0.4.0}/nabla/contract.py +0 -0
- {nabla_cli-0.2.1 → nabla_cli-0.4.0}/nabla/induce.py +0 -0
- {nabla_cli-0.2.1 → nabla_cli-0.4.0}/nabla/profile.py +0 -0
- {nabla_cli-0.2.1 → nabla_cli-0.4.0}/nabla/prompt_recon.py +0 -0
- {nabla_cli-0.2.1 → nabla_cli-0.4.0}/nabla/samplegen.py +0 -0
- {nabla_cli-0.2.1 → nabla_cli-0.4.0}/nabla/scanner.py +0 -0
- {nabla_cli-0.2.1 → nabla_cli-0.4.0}/nabla/schema_resolver.py +0 -0
- {nabla_cli-0.2.1 → nabla_cli-0.4.0}/nabla/schemas/site_profile.schema.json +0 -0
- {nabla_cli-0.2.1 → nabla_cli-0.4.0}/nabla/term.py +0 -0
- {nabla_cli-0.2.1 → nabla_cli-0.4.0}/nabla_cli.egg-info/dependency_links.txt +0 -0
- {nabla_cli-0.2.1 → nabla_cli-0.4.0}/nabla_cli.egg-info/entry_points.txt +0 -0
- {nabla_cli-0.2.1 → nabla_cli-0.4.0}/nabla_cli.egg-info/top_level.txt +0 -0
- {nabla_cli-0.2.1 → nabla_cli-0.4.0}/setup.cfg +0 -0
- {nabla_cli-0.2.1 → nabla_cli-0.4.0}/tests/test_config.py +0 -0
- {nabla_cli-0.2.1 → nabla_cli-0.4.0}/tests/test_e2e_profile.py +0 -0
- {nabla_cli-0.2.1 → nabla_cli-0.4.0}/tests/test_induce.py +0 -0
- {nabla_cli-0.2.1 → nabla_cli-0.4.0}/tests/test_prompt_recon.py +0 -0
- {nabla_cli-0.2.1 → nabla_cli-0.4.0}/tests/test_recorder.py +0 -0
- {nabla_cli-0.2.1 → nabla_cli-0.4.0}/tests/test_samplegen.py +0 -0
- {nabla_cli-0.2.1 → nabla_cli-0.4.0}/tests/test_scanner.py +0 -0
- {nabla_cli-0.2.1 → nabla_cli-0.4.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.4.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
|
|
@@ -8,6 +8,7 @@ Description-Content-Type: text/markdown
|
|
|
8
8
|
Requires-Dist: jsonschema
|
|
9
9
|
Requires-Dist: pydantic>=2
|
|
10
10
|
Requires-Dist: openai>=1.40
|
|
11
|
+
Requires-Dist: rich>=13.0
|
|
11
12
|
Provides-Extra: dev
|
|
12
13
|
Requires-Dist: pytest; extra == "dev"
|
|
13
14
|
|
|
@@ -0,0 +1,56 @@
|
|
|
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()
|
|
@@ -9,13 +9,28 @@ import sys
|
|
|
9
9
|
from getpass import getpass
|
|
10
10
|
from pathlib import Path
|
|
11
11
|
|
|
12
|
+
from rich.console import Console
|
|
13
|
+
|
|
14
|
+
from . import __version__
|
|
15
|
+
from .branding import print_banner
|
|
12
16
|
from .config import PROVIDERS, apply_to_env, save_config
|
|
13
|
-
from .contract import Example, is_supported
|
|
17
|
+
from .contract import Example, Verifiability, is_supported
|
|
14
18
|
from .profile import build_profile, find_site
|
|
15
19
|
from .recorder import RecordSource, ZeroTrafficError, record
|
|
16
20
|
from .scanner import scan
|
|
17
21
|
from .schema_resolver import resolve_schema
|
|
18
|
-
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _make_console(stderr: bool = False) -> Console:
|
|
25
|
+
console = Console(stderr=stderr, highlight=False)
|
|
26
|
+
if not console.is_terminal:
|
|
27
|
+
# Wide fixed width so captured/piped output never wraps or truncates.
|
|
28
|
+
console = Console(stderr=stderr, highlight=False, width=200)
|
|
29
|
+
return console
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
_out = _make_console()
|
|
33
|
+
_errcon = _make_console(stderr=True)
|
|
19
34
|
|
|
20
35
|
_PROVIDER_DESCRIPTIONS = {
|
|
21
36
|
"openai": "real OpenAI API, uses your OPENAI_API_KEY",
|
|
@@ -37,6 +52,7 @@ def _build_parser() -> argparse.ArgumentParser:
|
|
|
37
52
|
" nabla profile write the site profile\n"
|
|
38
53
|
),
|
|
39
54
|
)
|
|
55
|
+
parser.add_argument("--version", action="version", version=f"nabla {__version__}")
|
|
40
56
|
sub = parser.add_subparsers(dest="command", required=True)
|
|
41
57
|
|
|
42
58
|
p_init = sub.add_parser(
|
|
@@ -116,15 +132,19 @@ def _build_parser() -> argparse.ArgumentParser:
|
|
|
116
132
|
|
|
117
133
|
|
|
118
134
|
def _ok(msg: str) -> None:
|
|
119
|
-
print(
|
|
135
|
+
_out.print(f"✓ {msg}" if _out.is_terminal else msg, style="green", markup=False)
|
|
120
136
|
|
|
121
137
|
|
|
122
138
|
def _warn(msg: str) -> None:
|
|
123
|
-
print(
|
|
139
|
+
_errcon.print(msg, style="yellow", markup=False)
|
|
124
140
|
|
|
125
141
|
|
|
126
142
|
def _fail(msg: str) -> None:
|
|
127
|
-
print(
|
|
143
|
+
_errcon.print(msg, style="red", markup=False)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _next_step(cmd: str) -> None:
|
|
147
|
+
_out.print(f"→ next: {cmd}" if _out.is_terminal else f"next: {cmd}", style="cyan", markup=False)
|
|
128
148
|
|
|
129
149
|
|
|
130
150
|
def _unconfigured() -> bool:
|
|
@@ -140,7 +160,6 @@ def _unconfigured() -> bool:
|
|
|
140
160
|
|
|
141
161
|
|
|
142
162
|
def main(argv: list[str] | None = None) -> int:
|
|
143
|
-
init_terminal()
|
|
144
163
|
# LLM-produced text (goal descriptions, error bodies) can contain
|
|
145
164
|
# characters legacy Windows consoles (cp1252) can't encode; degrade to
|
|
146
165
|
# "?" instead of crashing mid-command.
|
|
@@ -158,6 +177,7 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
158
177
|
parser = _build_parser()
|
|
159
178
|
argv = sys.argv[1:] if argv is None else argv
|
|
160
179
|
if not argv:
|
|
180
|
+
print_banner(_out)
|
|
161
181
|
parser.print_help()
|
|
162
182
|
return 0
|
|
163
183
|
|
|
@@ -200,23 +220,24 @@ def _read_api_key(prompt: str) -> str:
|
|
|
200
220
|
|
|
201
221
|
|
|
202
222
|
def _cmd_init(args) -> int:
|
|
223
|
+
from .ui import RAIL, rail, rail_end, rail_ok, rail_start, select
|
|
224
|
+
|
|
225
|
+
print_banner(_out)
|
|
226
|
+
rail_start(_out, "nabla init")
|
|
227
|
+
rail(_out)
|
|
228
|
+
|
|
203
229
|
provider = args.provider
|
|
204
230
|
if provider is None:
|
|
205
231
|
names = list(PROVIDERS)
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
choice = input("> ").strip()
|
|
211
|
-
if choice.isdigit() and 1 <= int(choice) <= len(names):
|
|
212
|
-
provider = names[int(choice) - 1]
|
|
213
|
-
else:
|
|
214
|
-
provider = choice
|
|
232
|
+
options = [(name, _PROVIDER_DESCRIPTIONS.get(name, "")) for name in names]
|
|
233
|
+
provider = names[select(_out, "Provider", options)]
|
|
234
|
+
else:
|
|
235
|
+
rail(_out, f"Provider {provider}")
|
|
215
236
|
|
|
216
237
|
api_key = args.api_key
|
|
217
238
|
if api_key is None:
|
|
218
239
|
key_env = PROVIDERS.get(provider, {}).get("api_key_env", "API key")
|
|
219
|
-
api_key = _read_api_key(f"
|
|
240
|
+
api_key = _read_api_key(f"{RAIL} {key_env} ")
|
|
220
241
|
|
|
221
242
|
if not api_key:
|
|
222
243
|
_fail("nabla init: no API key given")
|
|
@@ -229,12 +250,14 @@ def _cmd_init(args) -> int:
|
|
|
229
250
|
return 2
|
|
230
251
|
|
|
231
252
|
masked = f"...{api_key[-4:]}" if len(api_key) >= 4 else "..."
|
|
232
|
-
|
|
253
|
+
rail(_out)
|
|
254
|
+
rail_ok(_out, f"saved to {path} (key: {masked})")
|
|
233
255
|
try:
|
|
234
256
|
_ensure_scripts_on_path()
|
|
235
257
|
except Exception:
|
|
236
258
|
pass # PATH help is best-effort; setup itself already succeeded
|
|
237
|
-
|
|
259
|
+
rail(_out)
|
|
260
|
+
rail_end(_out, "next: nabla scan — run it from inside the repo you want to harvest")
|
|
238
261
|
return 0
|
|
239
262
|
|
|
240
263
|
|
|
@@ -300,23 +323,42 @@ def _cmd_scan(args) -> int:
|
|
|
300
323
|
if args.json:
|
|
301
324
|
print(json.dumps(rows, indent=2))
|
|
302
325
|
return 0
|
|
303
|
-
|
|
326
|
+
|
|
327
|
+
from rich import box as rich_box
|
|
328
|
+
from rich.table import Table
|
|
329
|
+
from rich.text import Text
|
|
330
|
+
|
|
331
|
+
table = Table(box=rich_box.SIMPLE_HEAD, header_style="bold", pad_edge=False)
|
|
332
|
+
for col in ("SYMBOL", "FILE", "KIND", "VERIFIABILITY", "HARVESTABLE", "FLAGS"):
|
|
333
|
+
table.add_column(col, overflow="fold")
|
|
304
334
|
supported_count = 0
|
|
305
|
-
|
|
335
|
+
# Harvestable sites first — the reader's question is "what can I use?"
|
|
336
|
+
keyed = sorted(rows, key=lambda r: not is_supported(Verifiability(r["verifiability"]), r["flags"]))
|
|
337
|
+
for r in keyed:
|
|
306
338
|
verif = r["verifiability"]
|
|
339
|
+
harvestable = is_supported(Verifiability(verif), r["flags"])
|
|
340
|
+
if harvestable:
|
|
341
|
+
supported_count += 1
|
|
307
342
|
if verif in ("json_schema", "pydantic_parse"):
|
|
308
343
|
verif_color = "green"
|
|
309
|
-
if not r["flags"]:
|
|
310
|
-
supported_count += 1
|
|
311
344
|
elif verif in ("tool_call", "json_object_no_schema"):
|
|
312
345
|
verif_color = "yellow"
|
|
313
346
|
else:
|
|
314
347
|
verif_color = "dim"
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
348
|
+
table.add_row(
|
|
349
|
+
Text(r["symbol"], style="cyan" if harvestable else "dim"),
|
|
350
|
+
r["file"],
|
|
351
|
+
r["kind"],
|
|
352
|
+
Text(verif, style=verif_color),
|
|
353
|
+
Text("yes", style="green") if harvestable else Text("no", style="dim"),
|
|
354
|
+
",".join(r["flags"]) or "-",
|
|
355
|
+
style=None if harvestable else "dim",
|
|
356
|
+
)
|
|
357
|
+
_out.print(table)
|
|
358
|
+
noun = "call site" if len(rows) == 1 else "call sites"
|
|
359
|
+
_out.print(f"{len(rows)} {noun} found — {supported_count} harvestable.", markup=False)
|
|
360
|
+
if supported_count:
|
|
361
|
+
_next_step("nabla record" + (f" {args.repo}" if args.repo != "." else ""))
|
|
320
362
|
if _unconfigured():
|
|
321
363
|
_warn("tip: run `nabla init` once to set up a provider — `nabla record` "
|
|
322
364
|
"and `nabla profile` need one")
|
|
@@ -352,7 +394,8 @@ def _resolve_site(args, sites):
|
|
|
352
394
|
|
|
353
395
|
if len(supported) == 1:
|
|
354
396
|
site = supported[0]
|
|
355
|
-
|
|
397
|
+
from .ui import rail
|
|
398
|
+
rail(_out, f"using site {site.symbol} (the only supported call site)", style="dim")
|
|
356
399
|
return site, None
|
|
357
400
|
|
|
358
401
|
candidates = supported or sites
|
|
@@ -372,10 +415,15 @@ def _resolve_site(args, sites):
|
|
|
372
415
|
|
|
373
416
|
|
|
374
417
|
def _cmd_record(args) -> int:
|
|
418
|
+
from .ui import make_progress, rail, rail_end, rail_ok, rail_start
|
|
419
|
+
|
|
420
|
+
rail_start(_out, "nabla record")
|
|
375
421
|
sites = scan(args.repo)
|
|
376
422
|
site, err = _resolve_site(args, sites)
|
|
377
423
|
if err is not None:
|
|
378
424
|
return err
|
|
425
|
+
if args.site:
|
|
426
|
+
rail(_out, f"site {site.symbol} · {site.file}", style="dim")
|
|
379
427
|
|
|
380
428
|
if _unconfigured():
|
|
381
429
|
_warn(
|
|
@@ -394,7 +442,8 @@ def _cmd_record(args) -> int:
|
|
|
394
442
|
else:
|
|
395
443
|
conventional = Path(f"{site.symbol}.samples.jsonl")
|
|
396
444
|
if conventional.exists():
|
|
397
|
-
|
|
445
|
+
rail(_out, f"using {conventional} (found by convention; pass --samples to override)",
|
|
446
|
+
style="dim")
|
|
398
447
|
source = RecordSource(kind="samples", samples_file=str(conventional))
|
|
399
448
|
else:
|
|
400
449
|
source, err = _generate_samples_source(args, site, conventional)
|
|
@@ -402,8 +451,23 @@ def _cmd_record(args) -> int:
|
|
|
402
451
|
return err
|
|
403
452
|
source_kind = "generated_samples"
|
|
404
453
|
|
|
454
|
+
on_progress = None
|
|
455
|
+
progress = None
|
|
456
|
+
if _out.is_terminal and source.kind == "samples":
|
|
457
|
+
progress = make_progress(_out)
|
|
458
|
+
task_ref: list = []
|
|
459
|
+
|
|
460
|
+
def on_progress(done: int, total: int) -> None:
|
|
461
|
+
if not task_ref:
|
|
462
|
+
task_ref.append(progress.add_task("recording", total=total))
|
|
463
|
+
progress.update(task_ref[0], completed=done)
|
|
464
|
+
|
|
405
465
|
try:
|
|
406
|
-
|
|
466
|
+
if progress is not None:
|
|
467
|
+
with progress:
|
|
468
|
+
examples = record(site, source, args.repo, on_progress=on_progress)
|
|
469
|
+
else:
|
|
470
|
+
examples = record(site, source, args.repo)
|
|
407
471
|
except ZeroTrafficError as exc:
|
|
408
472
|
_fail(f"nabla record: {exc}")
|
|
409
473
|
return 1
|
|
@@ -411,11 +475,17 @@ def _cmd_record(args) -> int:
|
|
|
411
475
|
with open(out, "w", encoding="utf-8") as f:
|
|
412
476
|
for ex in examples:
|
|
413
477
|
f.write(json.dumps({**ex.to_dict(), "_source_kind": source_kind or source.kind}) + "\n")
|
|
414
|
-
|
|
478
|
+
rail(_out)
|
|
479
|
+
rail_ok(_out, f"recorded {len(examples)} (prompt, completion) pairs from "
|
|
480
|
+
f"site {site.symbol} -> {out}")
|
|
481
|
+
if source_kind == "generated_samples":
|
|
482
|
+
rail(_out, f"inputs were synthetic — edit {site.symbol}.samples.jsonl with real "
|
|
483
|
+
"data and rerun for a higher-fidelity harvest", style="yellow")
|
|
484
|
+
rail(_out)
|
|
415
485
|
if args.out is None and args.site is None:
|
|
416
|
-
|
|
486
|
+
rail_end(_out, f"next: nabla profile{'' if args.repo == '.' else ' ' + args.repo}")
|
|
417
487
|
else:
|
|
418
|
-
|
|
488
|
+
rail_end(_out, f"next: nabla profile {args.repo} --site {site.symbol} --examples {out}")
|
|
419
489
|
return 0
|
|
420
490
|
|
|
421
491
|
|
|
@@ -440,8 +510,9 @@ def _generate_samples_source(args, site, out_path: Path):
|
|
|
440
510
|
)
|
|
441
511
|
return None, 1
|
|
442
512
|
out_path.write_text("\n".join(json.dumps(item) for item in items) + "\n", encoding="utf-8")
|
|
443
|
-
|
|
444
|
-
|
|
513
|
+
from .ui import rail
|
|
514
|
+
rail(_out, f"no samples given — generated {len(items)} synthetic inputs -> {out_path} "
|
|
515
|
+
"(edit this file and rerun to use realistic inputs)", style="dim")
|
|
445
516
|
return RecordSource(kind="samples", samples_file=str(out_path)), None
|
|
446
517
|
|
|
447
518
|
|
|
@@ -449,11 +520,15 @@ def _cmd_profile(args) -> int:
|
|
|
449
520
|
from .induce import induce
|
|
450
521
|
from .prompt_recon import reconstruct
|
|
451
522
|
from .schema_resolver import resolve_schema as _resolve
|
|
523
|
+
from .ui import rail, rail_end, rail_ok, rail_start
|
|
452
524
|
|
|
525
|
+
rail_start(_out, "nabla profile")
|
|
453
526
|
sites = scan(args.repo)
|
|
454
527
|
site, err = _resolve_site(args, sites)
|
|
455
528
|
if err is not None:
|
|
456
529
|
return err
|
|
530
|
+
if args.site:
|
|
531
|
+
rail(_out, f"site {site.symbol} · {site.file}", style="dim")
|
|
457
532
|
|
|
458
533
|
examples_path = args.examples
|
|
459
534
|
if examples_path is None:
|
|
@@ -470,7 +545,7 @@ def _cmd_profile(args) -> int:
|
|
|
470
545
|
print(f"nabla profile: no recorded examples found ({conventional} or recorded.jsonl). "
|
|
471
546
|
f"Run first: {record_cmd}", file=sys.stderr)
|
|
472
547
|
return 2
|
|
473
|
-
|
|
548
|
+
rail(_out, f"using {examples_path}", style="dim")
|
|
474
549
|
|
|
475
550
|
examples, recorded_from = [], "samples"
|
|
476
551
|
for line in Path(examples_path).read_text(encoding="utf-8").splitlines():
|
|
@@ -486,19 +561,29 @@ def _cmd_profile(args) -> int:
|
|
|
486
561
|
|
|
487
562
|
template = reconstruct(site, args.repo)
|
|
488
563
|
schema, _ = _resolve(site, args.repo)
|
|
489
|
-
|
|
564
|
+
if _out.is_terminal:
|
|
565
|
+
with _out.status(f"inferring goal from {len(examples)} examples", spinner="dots"):
|
|
566
|
+
goal = induce(template, schema, examples)
|
|
567
|
+
else:
|
|
568
|
+
goal = induce(template, schema, examples)
|
|
490
569
|
|
|
491
570
|
profile = build_profile(site, args.repo, examples, goal, recorded_from=recorded_from)
|
|
492
571
|
out = args.out or f"{site.symbol}.profile.json"
|
|
493
572
|
Path(out).write_text(json.dumps(profile, indent=2), encoding="utf-8")
|
|
494
573
|
supported = profile["classification"]["supported"]
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
574
|
+
rail(_out)
|
|
575
|
+
rail_ok(_out, f"wrote {out}")
|
|
576
|
+
rail(_out, f"site {site.symbol} · {profile['site_id'][:16]}…")
|
|
577
|
+
rail(_out, f"supported {supported}")
|
|
578
|
+
rail(_out, f"examples {len(examples)} ({recorded_from})")
|
|
579
|
+
rail(_out, f"goal {goal.description[:90]}")
|
|
580
|
+
dims = ", ".join(d["name"] for d in goal.difficulty_dimensions[:4])
|
|
581
|
+
rail(_out, f"difficulty {dims}")
|
|
498
582
|
if len(examples) < 20:
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
583
|
+
rail(_out, f"only {len(examples)} examples (< 20) — enough for a look, but a "
|
|
584
|
+
"handoff-quality profile wants 20+ real-traffic pairs", style="yellow")
|
|
585
|
+
rail(_out)
|
|
586
|
+
rail_end(_out, f"done — {out} is the handoff artifact", style="green")
|
|
502
587
|
return 0
|
|
503
588
|
|
|
504
589
|
|
|
@@ -421,7 +421,8 @@ def _run_site_in_subprocess(site: RawSite, repo_path: str, input_slots: dict, en
|
|
|
421
421
|
)
|
|
422
422
|
|
|
423
423
|
|
|
424
|
-
def _record_samples(site: RawSite, source: RecordSource, repo_path: str
|
|
424
|
+
def _record_samples(site: RawSite, source: RecordSource, repo_path: str,
|
|
425
|
+
on_progress=None) -> list[Example]:
|
|
425
426
|
if not source.samples_file:
|
|
426
427
|
raise ValueError("RecordSource.kind='samples' requires samples_file")
|
|
427
428
|
sample_lines = _read_jsonl(source.samples_file)
|
|
@@ -466,8 +467,11 @@ def _record_samples(site: RawSite, source: RecordSource, repo_path: str) -> list
|
|
|
466
467
|
new_captures = list(proxy.captured[before:])
|
|
467
468
|
for capture in new_captures:
|
|
468
469
|
examples.append(_capture_to_example(capture, input_slots))
|
|
469
|
-
|
|
470
|
-
|
|
470
|
+
if on_progress is not None:
|
|
471
|
+
on_progress(i + 1, total)
|
|
472
|
+
else:
|
|
473
|
+
print(f"nabla record: sample {i + 1}/{total} -> {len(new_captures)} capture(s)",
|
|
474
|
+
file=sys.stderr)
|
|
471
475
|
finally:
|
|
472
476
|
proxy.stop()
|
|
473
477
|
|
|
@@ -544,15 +548,19 @@ def _record_logs(site: RawSite, source: RecordSource, repo_path: str) -> list[Ex
|
|
|
544
548
|
# Public interface
|
|
545
549
|
# ---------------------------------------------------------------------------
|
|
546
550
|
|
|
547
|
-
def record(site: RawSite, source: RecordSource, repo_path: str
|
|
551
|
+
def record(site: RawSite, source: RecordSource, repo_path: str,
|
|
552
|
+
on_progress=None) -> list[Example]:
|
|
548
553
|
"""Capture real (prompt, completion, tokens, latency) pairs for site.
|
|
549
554
|
|
|
550
555
|
Must: attribute captured requests back to the site (template match),
|
|
551
556
|
populate token counts and latency, raise ZeroTrafficError on an empty
|
|
552
557
|
proxy run instead of returning [].
|
|
558
|
+
|
|
559
|
+
``on_progress(done, total)`` (samples path only) replaces the default
|
|
560
|
+
per-sample stderr lines — used by the CLI to drive a progress bar.
|
|
553
561
|
"""
|
|
554
562
|
if source.kind == "samples":
|
|
555
|
-
return _record_samples(site, source, repo_path)
|
|
563
|
+
return _record_samples(site, source, repo_path, on_progress=on_progress)
|
|
556
564
|
if source.kind == "proxy":
|
|
557
565
|
return _record_proxy(site, source, repo_path)
|
|
558
566
|
if source.kind == "logs":
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""nabla's terminal design system.
|
|
2
|
+
|
|
3
|
+
One visual language across commands:
|
|
4
|
+
|
|
5
|
+
- a rail (``┌ │ └``) threads each multi-step flow so the user always sees
|
|
6
|
+
where they are in the sequence — structure as information, not decoration;
|
|
7
|
+
- selection is pointer-driven (arrow keys) on an interactive Windows
|
|
8
|
+
console, with a numbered fallback everywhere else;
|
|
9
|
+
- progress fills with the nabla glyph itself — the one flourish;
|
|
10
|
+
- color carries meaning only: cyan = brand/interactive, green = done,
|
|
11
|
+
yellow = caution, red = failure, dim = structure.
|
|
12
|
+
|
|
13
|
+
Glyphs are box-drawing / geometric only — never emoji.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import sys
|
|
19
|
+
|
|
20
|
+
from rich.console import Console
|
|
21
|
+
from rich.progress import Progress, ProgressColumn, TextColumn
|
|
22
|
+
from rich.text import Text
|
|
23
|
+
|
|
24
|
+
RAIL = "│"
|
|
25
|
+
_POINTER = "❯"
|
|
26
|
+
_ON = "●"
|
|
27
|
+
_OFF = "○"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# ---------------------------------------------------------------------------
|
|
31
|
+
# Rail primitives
|
|
32
|
+
# ---------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
def rail_start(console: Console, title: str) -> None:
|
|
35
|
+
line = Text("┌ ", style="dim")
|
|
36
|
+
line.append(title, style="bold")
|
|
37
|
+
console.print(line)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def rail(console: Console, text: str = "", style: str | None = None) -> None:
|
|
41
|
+
line = Text(RAIL, style="dim")
|
|
42
|
+
if text:
|
|
43
|
+
line.append(" " + text, style=style or "")
|
|
44
|
+
console.print(line)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def rail_ok(console: Console, text: str) -> None:
|
|
48
|
+
line = Text(f"{RAIL} ", style="dim")
|
|
49
|
+
line.append("✓ ", style="green")
|
|
50
|
+
line.append(text)
|
|
51
|
+
console.print(line)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def rail_end(console: Console, text: str, style: str = "cyan") -> None:
|
|
55
|
+
line = Text("└ ", style="dim")
|
|
56
|
+
line.append(text, style=style)
|
|
57
|
+
console.print(line)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# ---------------------------------------------------------------------------
|
|
61
|
+
# Selection
|
|
62
|
+
# ---------------------------------------------------------------------------
|
|
63
|
+
|
|
64
|
+
def select(console: Console, label: str, options: list[tuple[str, str]],
|
|
65
|
+
default: int = 0) -> int:
|
|
66
|
+
"""Pick one of ``options`` ([(name, description), ...]); returns the index.
|
|
67
|
+
|
|
68
|
+
Interactive Windows console: arrow keys + enter, pointer rendering,
|
|
69
|
+
menu collapses to a single confirmation line after the choice.
|
|
70
|
+
Anywhere else: numbered prompt.
|
|
71
|
+
"""
|
|
72
|
+
interactive = (
|
|
73
|
+
sys.platform == "win32"
|
|
74
|
+
and console.is_terminal
|
|
75
|
+
and sys.stdin.isatty()
|
|
76
|
+
)
|
|
77
|
+
if not interactive:
|
|
78
|
+
rail(console, label, style="bold")
|
|
79
|
+
for i, (name, desc) in enumerate(options, start=1):
|
|
80
|
+
rail(console, f" {i}) {name} — {desc}")
|
|
81
|
+
choice = input(f"{RAIL} > ").strip()
|
|
82
|
+
if choice.isdigit() and 1 <= int(choice) <= len(options):
|
|
83
|
+
return int(choice) - 1
|
|
84
|
+
for i, (name, _) in enumerate(options):
|
|
85
|
+
if name == choice:
|
|
86
|
+
return i
|
|
87
|
+
return default
|
|
88
|
+
|
|
89
|
+
import msvcrt
|
|
90
|
+
|
|
91
|
+
from .term import init_terminal
|
|
92
|
+
|
|
93
|
+
init_terminal()
|
|
94
|
+
idx = default
|
|
95
|
+
name_width = max(len(name) for name, _ in options)
|
|
96
|
+
hint = "↑ ↓ move · enter chooses"
|
|
97
|
+
lines_drawn = 0
|
|
98
|
+
|
|
99
|
+
def draw() -> None:
|
|
100
|
+
nonlocal lines_drawn
|
|
101
|
+
if lines_drawn:
|
|
102
|
+
sys.stdout.write(f"\x1b[{lines_drawn}A\x1b[0J")
|
|
103
|
+
header = Text(f"{RAIL} ", style="dim")
|
|
104
|
+
header.append(label, style="bold")
|
|
105
|
+
header.append(f" {hint}", style="dim")
|
|
106
|
+
console.print(header)
|
|
107
|
+
for i, (name, desc) in enumerate(options):
|
|
108
|
+
row = Text(f"{RAIL} ", style="dim")
|
|
109
|
+
if i == idx:
|
|
110
|
+
row.append(f" {_POINTER} {_ON} ", style="cyan")
|
|
111
|
+
row.append(f"{name:<{name_width}}", style="bold cyan")
|
|
112
|
+
else:
|
|
113
|
+
row.append(f" {_OFF} ", style="dim")
|
|
114
|
+
row.append(f"{name:<{name_width}}", style="dim")
|
|
115
|
+
row.append(f" {desc}", style="dim")
|
|
116
|
+
console.print(row)
|
|
117
|
+
lines_drawn = len(options) + 1
|
|
118
|
+
|
|
119
|
+
draw()
|
|
120
|
+
while True:
|
|
121
|
+
ch = msvcrt.getwch()
|
|
122
|
+
if ch in ("\r", "\n"):
|
|
123
|
+
break
|
|
124
|
+
if ch == "\x03":
|
|
125
|
+
raise KeyboardInterrupt
|
|
126
|
+
if ch in ("\xe0", "\x00"):
|
|
127
|
+
arrow = msvcrt.getwch()
|
|
128
|
+
if arrow == "H":
|
|
129
|
+
idx = (idx - 1) % len(options)
|
|
130
|
+
elif arrow == "P":
|
|
131
|
+
idx = (idx + 1) % len(options)
|
|
132
|
+
draw()
|
|
133
|
+
|
|
134
|
+
# Collapse the menu into one confirmation line.
|
|
135
|
+
sys.stdout.write(f"\x1b[{lines_drawn}A\x1b[0J")
|
|
136
|
+
chosen = Text(f"{RAIL} ", style="dim")
|
|
137
|
+
chosen.append(f"{label} ", style="bold")
|
|
138
|
+
chosen.append(options[idx][0], style="cyan")
|
|
139
|
+
console.print(chosen)
|
|
140
|
+
return idx
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
# ---------------------------------------------------------------------------
|
|
144
|
+
# Progress: the ∇ bar
|
|
145
|
+
# ---------------------------------------------------------------------------
|
|
146
|
+
|
|
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
|
+
def make_progress(console: Console, unit: str = "samples") -> Progress:
|
|
164
|
+
return Progress(
|
|
165
|
+
TextColumn(RAIL, style="dim"),
|
|
166
|
+
NablaBar(),
|
|
167
|
+
TextColumn(f"{{task.completed}}/{{task.total}} {unit}", style="dim"),
|
|
168
|
+
console=console,
|
|
169
|
+
transient=True,
|
|
170
|
+
)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: nabla-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.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
|
|
@@ -8,6 +8,7 @@ Description-Content-Type: text/markdown
|
|
|
8
8
|
Requires-Dist: jsonschema
|
|
9
9
|
Requires-Dist: pydantic>=2
|
|
10
10
|
Requires-Dist: openai>=1.40
|
|
11
|
+
Requires-Dist: rich>=13.0
|
|
11
12
|
Provides-Extra: dev
|
|
12
13
|
Requires-Dist: pytest; extra == "dev"
|
|
13
14
|
|
|
@@ -2,6 +2,7 @@ README.md
|
|
|
2
2
|
pyproject.toml
|
|
3
3
|
nabla/__init__.py
|
|
4
4
|
nabla/__main__.py
|
|
5
|
+
nabla/branding.py
|
|
5
6
|
nabla/cli.py
|
|
6
7
|
nabla/config.py
|
|
7
8
|
nabla/contract.py
|
|
@@ -13,6 +14,7 @@ nabla/samplegen.py
|
|
|
13
14
|
nabla/scanner.py
|
|
14
15
|
nabla/schema_resolver.py
|
|
15
16
|
nabla/term.py
|
|
17
|
+
nabla/ui.py
|
|
16
18
|
nabla/schemas/site_profile.schema.json
|
|
17
19
|
nabla_cli.egg-info/PKG-INFO
|
|
18
20
|
nabla_cli.egg-info/SOURCES.txt
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "nabla-cli"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.4.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" }
|
|
@@ -9,6 +9,7 @@ dependencies = [
|
|
|
9
9
|
"jsonschema",
|
|
10
10
|
"pydantic>=2",
|
|
11
11
|
"openai>=1.40",
|
|
12
|
+
"rich>=13.0",
|
|
12
13
|
]
|
|
13
14
|
|
|
14
15
|
[project.optional-dependencies]
|
|
@@ -80,7 +80,7 @@ def test_init_writes_config(tmp_path, monkeypatch, capsys):
|
|
|
80
80
|
out = capsys.readouterr().out
|
|
81
81
|
assert "gsk_test" not in out # the full key must never be echoed
|
|
82
82
|
assert "test" in out # masked last-4 tail is shown
|
|
83
|
-
assert "
|
|
83
|
+
assert "next: nabla scan" in out
|
|
84
84
|
|
|
85
85
|
|
|
86
86
|
def test_init_bad_provider_exits_nonzero_with_helpful_message(capsys):
|
|
@@ -282,7 +282,7 @@ def test_profile_finds_recorded_file_by_convention(tmp_path, monkeypatch, capsys
|
|
|
282
282
|
assert (tmp_path / "extract_receipt.profile.json").exists()
|
|
283
283
|
stdout = capsys.readouterr().out
|
|
284
284
|
assert "using extract_receipt.recorded.jsonl" in stdout
|
|
285
|
-
assert "
|
|
285
|
+
assert "Extracts receipt fields." in stdout
|
|
286
286
|
|
|
287
287
|
|
|
288
288
|
def test_profile_without_recorded_file_exits_2_with_record_command(tmp_path, monkeypatch, capsys):
|
|
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
|