nabla-cli 0.1.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.1.0/PKG-INFO +82 -0
- nabla_cli-0.1.0/README.md +69 -0
- nabla_cli-0.1.0/nabla/__init__.py +3 -0
- nabla_cli-0.1.0/nabla/cli.py +121 -0
- nabla_cli-0.1.0/nabla/contract.py +132 -0
- nabla_cli-0.1.0/nabla/induce.py +176 -0
- nabla_cli-0.1.0/nabla/profile.py +138 -0
- nabla_cli-0.1.0/nabla/prompt_recon.py +542 -0
- nabla_cli-0.1.0/nabla/recorder.py +609 -0
- nabla_cli-0.1.0/nabla/scanner.py +453 -0
- nabla_cli-0.1.0/nabla/schema_resolver.py +309 -0
- nabla_cli-0.1.0/nabla/schemas/site_profile.schema.json +136 -0
- nabla_cli-0.1.0/nabla_cli.egg-info/PKG-INFO +82 -0
- nabla_cli-0.1.0/nabla_cli.egg-info/SOURCES.txt +24 -0
- nabla_cli-0.1.0/nabla_cli.egg-info/dependency_links.txt +1 -0
- nabla_cli-0.1.0/nabla_cli.egg-info/entry_points.txt +2 -0
- nabla_cli-0.1.0/nabla_cli.egg-info/requires.txt +6 -0
- nabla_cli-0.1.0/nabla_cli.egg-info/top_level.txt +1 -0
- nabla_cli-0.1.0/pyproject.toml +31 -0
- nabla_cli-0.1.0/setup.cfg +4 -0
- nabla_cli-0.1.0/tests/test_e2e_profile.py +99 -0
- nabla_cli-0.1.0/tests/test_induce.py +79 -0
- nabla_cli-0.1.0/tests/test_prompt_recon.py +178 -0
- nabla_cli-0.1.0/tests/test_recorder.py +279 -0
- nabla_cli-0.1.0/tests/test_scanner.py +96 -0
- nabla_cli-0.1.0/tests/test_schema_resolver.py +214 -0
nabla_cli-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: nabla-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Call-site harvester: scan OpenAI call sites, record real traffic, emit site profiles
|
|
5
|
+
License: MIT
|
|
6
|
+
Requires-Python: >=3.11
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
Requires-Dist: jsonschema
|
|
9
|
+
Requires-Dist: pydantic>=2
|
|
10
|
+
Requires-Dist: openai>=1.40
|
|
11
|
+
Provides-Extra: dev
|
|
12
|
+
Requires-Dist: pytest; extra == "dev"
|
|
13
|
+
|
|
14
|
+
# nabla — OpenAI call-site harvester
|
|
15
|
+
|
|
16
|
+
`nabla` finds the OpenAI call sites in a Python repo, records real
|
|
17
|
+
(prompt, completion) traffic for one of them, and emits a single
|
|
18
|
+
self-contained **site profile** (`site_profile.json`) describing that
|
|
19
|
+
call site: prompt template + slots, resolved output JSON Schema,
|
|
20
|
+
sampling parameters, recorded examples, an inferred goal with difficulty
|
|
21
|
+
dimensions, and a cost/latency baseline.
|
|
22
|
+
|
|
23
|
+
The profile is the handoff artifact for training a small, cheap
|
|
24
|
+
replacement model for that one call site — everything downstream codes
|
|
25
|
+
against the profile, never against your repo.
|
|
26
|
+
|
|
27
|
+
## Install
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
pip install nabla-cli
|
|
31
|
+
# or, isolated:
|
|
32
|
+
pipx install nabla-cli
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Requires Python ≥ 3.11.
|
|
36
|
+
|
|
37
|
+
## Quickstart
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
# 1. Find call sites (pure static analysis — no network, no API key)
|
|
41
|
+
nabla scan path/to/repo
|
|
42
|
+
|
|
43
|
+
# 2. Record real traffic for one site, driven by sample inputs
|
|
44
|
+
# (JSONL lines of {"input_slots": {...}} matching the site's function kwargs)
|
|
45
|
+
nabla record path/to/repo --site my_function --samples samples.jsonl --out recorded.jsonl
|
|
46
|
+
|
|
47
|
+
# 3. Assemble + validate the profile (one LLM call for goal inference)
|
|
48
|
+
nabla profile path/to/repo --site my_function --examples recorded.jsonl --out site_profile.json
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## What gets detected
|
|
52
|
+
|
|
53
|
+
`scan` classifies every `chat.completions.create` / `beta...parse` call
|
|
54
|
+
by verifiability: `json_schema` and `pydantic_parse` sites are supported
|
|
55
|
+
harvest targets; `tool_call`, `json_object_no_schema`, and `free_text`
|
|
56
|
+
sites are detected and reported but not harvested. Streaming,
|
|
57
|
+
multi-turn, vision, and `n>1` sites are flagged unsupported. Generic
|
|
58
|
+
wrapper functions are expanded to their callers; functions that own
|
|
59
|
+
their prompt template are kept as the site.
|
|
60
|
+
|
|
61
|
+
## Environment variables
|
|
62
|
+
|
|
63
|
+
| Variable | Purpose |
|
|
64
|
+
|---|---|
|
|
65
|
+
| `OPENAI_API_KEY` | Default upstream for `record` (auth passthrough). |
|
|
66
|
+
| `NABLA_UPSTREAM_BASE_URL` | Record through any OpenAI-compatible stand-in oracle instead. |
|
|
67
|
+
| `NABLA_UPSTREAM_API_KEY` | Key for the stand-in oracle. |
|
|
68
|
+
| `NABLA_UPSTREAM_MODEL` | Rewrite the model for the stand-in only (the target repo's own model still lands in the profile). |
|
|
69
|
+
| `NABLA_RECORD_DELAY_MS` | Pacing between samples for rate-limited upstreams (default 0). |
|
|
70
|
+
| `NABLA_SAMPLE_TIMEOUT` | Per-sample subprocess timeout in seconds (default 300). |
|
|
71
|
+
| `GEMINI_API_KEY` | Key for the default goal-inference provider (Gemini free tier). |
|
|
72
|
+
| `NABLA_INDUCE_BASE_URL` / `NABLA_INDUCE_API_KEY_ENV` / `NABLA_INDUCE_MODEL` | Swap the goal-inference provider/model. |
|
|
73
|
+
|
|
74
|
+
## Notes
|
|
75
|
+
|
|
76
|
+
- `record` runs the site's enclosing function in a subprocess per sample,
|
|
77
|
+
with `OPENAI_BASE_URL` pointed at a local recording proxy. If the
|
|
78
|
+
target's tests mock the SDK, the proxy path reports zero traffic
|
|
79
|
+
explicitly instead of writing an empty profile.
|
|
80
|
+
- Recorded prompts/completions are real data from your repo and leave
|
|
81
|
+
the machine only via the goal-inference call. Review before sharing
|
|
82
|
+
profiles.
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# nabla — OpenAI call-site harvester
|
|
2
|
+
|
|
3
|
+
`nabla` finds the OpenAI call sites in a Python repo, records real
|
|
4
|
+
(prompt, completion) traffic for one of them, and emits a single
|
|
5
|
+
self-contained **site profile** (`site_profile.json`) describing that
|
|
6
|
+
call site: prompt template + slots, resolved output JSON Schema,
|
|
7
|
+
sampling parameters, recorded examples, an inferred goal with difficulty
|
|
8
|
+
dimensions, and a cost/latency baseline.
|
|
9
|
+
|
|
10
|
+
The profile is the handoff artifact for training a small, cheap
|
|
11
|
+
replacement model for that one call site — everything downstream codes
|
|
12
|
+
against the profile, never against your repo.
|
|
13
|
+
|
|
14
|
+
## Install
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
pip install nabla-cli
|
|
18
|
+
# or, isolated:
|
|
19
|
+
pipx install nabla-cli
|
|
20
|
+
```
|
|
21
|
+
|
|
22
|
+
Requires Python ≥ 3.11.
|
|
23
|
+
|
|
24
|
+
## Quickstart
|
|
25
|
+
|
|
26
|
+
```bash
|
|
27
|
+
# 1. Find call sites (pure static analysis — no network, no API key)
|
|
28
|
+
nabla scan path/to/repo
|
|
29
|
+
|
|
30
|
+
# 2. Record real traffic for one site, driven by sample inputs
|
|
31
|
+
# (JSONL lines of {"input_slots": {...}} matching the site's function kwargs)
|
|
32
|
+
nabla record path/to/repo --site my_function --samples samples.jsonl --out recorded.jsonl
|
|
33
|
+
|
|
34
|
+
# 3. Assemble + validate the profile (one LLM call for goal inference)
|
|
35
|
+
nabla profile path/to/repo --site my_function --examples recorded.jsonl --out site_profile.json
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## What gets detected
|
|
39
|
+
|
|
40
|
+
`scan` classifies every `chat.completions.create` / `beta...parse` call
|
|
41
|
+
by verifiability: `json_schema` and `pydantic_parse` sites are supported
|
|
42
|
+
harvest targets; `tool_call`, `json_object_no_schema`, and `free_text`
|
|
43
|
+
sites are detected and reported but not harvested. Streaming,
|
|
44
|
+
multi-turn, vision, and `n>1` sites are flagged unsupported. Generic
|
|
45
|
+
wrapper functions are expanded to their callers; functions that own
|
|
46
|
+
their prompt template are kept as the site.
|
|
47
|
+
|
|
48
|
+
## Environment variables
|
|
49
|
+
|
|
50
|
+
| Variable | Purpose |
|
|
51
|
+
|---|---|
|
|
52
|
+
| `OPENAI_API_KEY` | Default upstream for `record` (auth passthrough). |
|
|
53
|
+
| `NABLA_UPSTREAM_BASE_URL` | Record through any OpenAI-compatible stand-in oracle instead. |
|
|
54
|
+
| `NABLA_UPSTREAM_API_KEY` | Key for the stand-in oracle. |
|
|
55
|
+
| `NABLA_UPSTREAM_MODEL` | Rewrite the model for the stand-in only (the target repo's own model still lands in the profile). |
|
|
56
|
+
| `NABLA_RECORD_DELAY_MS` | Pacing between samples for rate-limited upstreams (default 0). |
|
|
57
|
+
| `NABLA_SAMPLE_TIMEOUT` | Per-sample subprocess timeout in seconds (default 300). |
|
|
58
|
+
| `GEMINI_API_KEY` | Key for the default goal-inference provider (Gemini free tier). |
|
|
59
|
+
| `NABLA_INDUCE_BASE_URL` / `NABLA_INDUCE_API_KEY_ENV` / `NABLA_INDUCE_MODEL` | Swap the goal-inference provider/model. |
|
|
60
|
+
|
|
61
|
+
## Notes
|
|
62
|
+
|
|
63
|
+
- `record` runs the site's enclosing function in a subprocess per sample,
|
|
64
|
+
with `OPENAI_BASE_URL` pointed at a local recording proxy. If the
|
|
65
|
+
target's tests mock the SDK, the proxy path reports zero traffic
|
|
66
|
+
explicitly instead of writing an empty profile.
|
|
67
|
+
- Recorded prompts/completions are real data from your repo and leave
|
|
68
|
+
the machine only via the goal-inference call. Review before sharing
|
|
69
|
+
profiles.
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""nabla CLI entrypoint — T7. Subcommands: scan, record, profile."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import json
|
|
7
|
+
import sys
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from .contract import Example
|
|
11
|
+
from .profile import build_profile, find_site
|
|
12
|
+
from .recorder import RecordSource, ZeroTrafficError, record
|
|
13
|
+
from .scanner import scan
|
|
14
|
+
from .schema_resolver import resolve_schema
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def main(argv: list[str] | None = None) -> int:
|
|
18
|
+
parser = argparse.ArgumentParser(prog="nabla", description="Call-site harvester")
|
|
19
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
20
|
+
|
|
21
|
+
p_scan = sub.add_parser("scan", help="find + classify call sites")
|
|
22
|
+
p_scan.add_argument("repo", help="path to target repo")
|
|
23
|
+
p_scan.add_argument("--json", action="store_true", help="machine-readable output")
|
|
24
|
+
|
|
25
|
+
p_record = sub.add_parser("record", help="capture (prompt, completion) pairs")
|
|
26
|
+
p_record.add_argument("repo")
|
|
27
|
+
p_record.add_argument("--site", required=True, help="site symbol")
|
|
28
|
+
p_record.add_argument("--samples", help="JSONL sample inputs")
|
|
29
|
+
p_record.add_argument("--logs", help="JSONL of past request/response pairs")
|
|
30
|
+
p_record.add_argument("--proxy-cmd", help="command to run under the recording proxy")
|
|
31
|
+
p_record.add_argument("--out", default="recorded.jsonl")
|
|
32
|
+
|
|
33
|
+
p_profile = sub.add_parser("profile", help="assemble site_profile.json")
|
|
34
|
+
p_profile.add_argument("repo")
|
|
35
|
+
p_profile.add_argument("--site", required=True)
|
|
36
|
+
p_profile.add_argument("--examples", required=True, help="recorded JSONL from `nabla record`")
|
|
37
|
+
p_profile.add_argument("--out", default="site_profile.json")
|
|
38
|
+
|
|
39
|
+
args = parser.parse_args(argv)
|
|
40
|
+
return {"scan": _cmd_scan, "record": _cmd_record, "profile": _cmd_profile}[args.command](args)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _cmd_scan(args) -> int:
|
|
44
|
+
sites = scan(args.repo)
|
|
45
|
+
rows = []
|
|
46
|
+
for s in sites:
|
|
47
|
+
_, verifiability = resolve_schema(s, args.repo)
|
|
48
|
+
rows.append({**s.to_dict(), "verifiability": verifiability.value})
|
|
49
|
+
if args.json:
|
|
50
|
+
print(json.dumps(rows, indent=2))
|
|
51
|
+
return 0
|
|
52
|
+
print(f"{'SYMBOL':<22} {'FILE':<28} {'KIND':<6} {'VERIFIABILITY':<22} FLAGS")
|
|
53
|
+
for r in rows:
|
|
54
|
+
print(f"{r['symbol']:<22} {r['file']:<28} {r['kind']:<6} "
|
|
55
|
+
f"{r['verifiability']:<22} {','.join(r['flags']) or '-'}")
|
|
56
|
+
print(f"\n{len(rows)} call sites found.")
|
|
57
|
+
return 0
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _cmd_record(args) -> int:
|
|
61
|
+
sites = scan(args.repo)
|
|
62
|
+
site = find_site(sites, args.site)
|
|
63
|
+
if args.samples:
|
|
64
|
+
source = RecordSource(kind="samples", samples_file=args.samples)
|
|
65
|
+
elif args.logs:
|
|
66
|
+
source = RecordSource(kind="logs", logs_file=args.logs)
|
|
67
|
+
elif args.proxy_cmd:
|
|
68
|
+
source = RecordSource(kind="proxy", proxy_command=args.proxy_cmd.split())
|
|
69
|
+
else:
|
|
70
|
+
print("nabla record: one of --samples/--logs/--proxy-cmd is required", file=sys.stderr)
|
|
71
|
+
return 2
|
|
72
|
+
try:
|
|
73
|
+
examples = record(site, source, args.repo)
|
|
74
|
+
except ZeroTrafficError as exc:
|
|
75
|
+
print(f"nabla record: {exc}", file=sys.stderr)
|
|
76
|
+
return 1
|
|
77
|
+
with open(args.out, "w", encoding="utf-8") as f:
|
|
78
|
+
for ex in examples:
|
|
79
|
+
f.write(json.dumps({**ex.to_dict(), "_source_kind": source.kind}) + "\n")
|
|
80
|
+
print(f"nabla record: {len(examples)} examples -> {args.out}")
|
|
81
|
+
return 0
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _cmd_profile(args) -> int:
|
|
85
|
+
from .induce import induce
|
|
86
|
+
from .prompt_recon import reconstruct
|
|
87
|
+
from .schema_resolver import resolve_schema as _resolve
|
|
88
|
+
|
|
89
|
+
sites = scan(args.repo)
|
|
90
|
+
site = find_site(sites, args.site)
|
|
91
|
+
|
|
92
|
+
examples, recorded_from = [], "samples"
|
|
93
|
+
for line in Path(args.examples).read_text(encoding="utf-8").splitlines():
|
|
94
|
+
if not line.strip():
|
|
95
|
+
continue
|
|
96
|
+
d = json.loads(line)
|
|
97
|
+
recorded_from = d.get("_source_kind", recorded_from)
|
|
98
|
+
examples.append(Example(
|
|
99
|
+
input_slots=d.get("input_slots", {}), prompt=d["prompt"],
|
|
100
|
+
completion=d["completion"], tokens_in=d.get("tokens", {}).get("in", 0),
|
|
101
|
+
tokens_out=d.get("tokens", {}).get("out", 0),
|
|
102
|
+
latency_ms=d.get("latency_ms", 0)))
|
|
103
|
+
|
|
104
|
+
template = reconstruct(site, args.repo)
|
|
105
|
+
schema, _ = _resolve(site, args.repo)
|
|
106
|
+
goal = induce(template, schema, examples)
|
|
107
|
+
|
|
108
|
+
profile = build_profile(site, args.repo, examples, goal, recorded_from=recorded_from)
|
|
109
|
+
Path(args.out).write_text(json.dumps(profile, indent=2), encoding="utf-8")
|
|
110
|
+
supported = profile["classification"]["supported"]
|
|
111
|
+
print(f"nabla profile: wrote {args.out} "
|
|
112
|
+
f"(site {profile['site_id'][:12]}…, supported={supported}, "
|
|
113
|
+
f"{len(examples)} examples)")
|
|
114
|
+
if len(examples) < 20:
|
|
115
|
+
print(f"nabla profile: WARNING — {len(examples)} examples < 20; "
|
|
116
|
+
"Phase-0-ready profiles need >=20 (cli-plan §5.4)", file=sys.stderr)
|
|
117
|
+
return 0
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
if __name__ == "__main__":
|
|
121
|
+
sys.exit(main())
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""Shared contract for all nabla CLI workstreams (T0).
|
|
2
|
+
|
|
3
|
+
Every module codes against these types and nothing else from its siblings.
|
|
4
|
+
RawSite is deliberately serializable and AST-free: downstream consumers
|
|
5
|
+
(prompt reconstruction, schema resolution) re-parse the source file from
|
|
6
|
+
`file` + `span` so workstreams never import each other's internals.
|
|
7
|
+
|
|
8
|
+
Profile shape mirrors cli-plan.md §2 and nabla/schemas/site_profile.schema.json.
|
|
9
|
+
The JSON schema is the source of truth; these dataclasses are the typed
|
|
10
|
+
convenience layer. Bump PROFILE_VERSION on any breaking change to either.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import hashlib
|
|
16
|
+
import json
|
|
17
|
+
from dataclasses import dataclass, field
|
|
18
|
+
from enum import Enum
|
|
19
|
+
from typing import Any
|
|
20
|
+
|
|
21
|
+
PROFILE_VERSION = "0.1"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class Verifiability(str, Enum):
|
|
25
|
+
JSON_SCHEMA = "json_schema" # response_format json_schema — free verifier
|
|
26
|
+
PYDANTIC_PARSE = "pydantic_parse" # beta .parse(response_format=Model)
|
|
27
|
+
TOOL_CALL = "tool_call" # tools=[...] function schemas
|
|
28
|
+
JSON_OBJECT_NO_SCHEMA = "json_object_no_schema" # json_object mode, no schema
|
|
29
|
+
FREE_TEXT = "free_text" # nothing checkable
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# Site flags. A site carrying any of UNSUPPORTED_FLAGS is supported=False.
|
|
33
|
+
FLAG_STREAMING = "streaming"
|
|
34
|
+
FLAG_ASYNC = "async"
|
|
35
|
+
FLAG_MULTI_TURN = "multi_turn"
|
|
36
|
+
FLAG_VISION = "vision"
|
|
37
|
+
FLAG_N_GT_1 = "n_gt_1"
|
|
38
|
+
FLAG_WRAPPER_INDIRECT = "wrapper_indirect" # informational, does not unsupport
|
|
39
|
+
|
|
40
|
+
UNSUPPORTED_FLAGS = {FLAG_STREAMING, FLAG_MULTI_TURN, FLAG_VISION, FLAG_N_GT_1}
|
|
41
|
+
SUPPORTED_VERIFIABILITY = {Verifiability.JSON_SCHEMA, Verifiability.PYDANTIC_PARSE}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
@dataclass
|
|
45
|
+
class RawSite:
|
|
46
|
+
"""Output of the scanner (T1). Serializable; no AST nodes."""
|
|
47
|
+
file: str # repo-relative, forward slashes
|
|
48
|
+
span: tuple[int, int] # 1-indexed inclusive line range of the call expr
|
|
49
|
+
symbol: str # enclosing function/method name, bare, e.g. "enrich" or "run" (no parens — matches manifest)
|
|
50
|
+
kind: str # "create" | "parse"
|
|
51
|
+
flags: list[str] = field(default_factory=list)
|
|
52
|
+
# For a wrapper function's callers: the wrapper's own site info.
|
|
53
|
+
wrapper: RawSiteRef | None = None
|
|
54
|
+
|
|
55
|
+
def to_dict(self) -> dict[str, Any]:
|
|
56
|
+
d = {
|
|
57
|
+
"file": self.file,
|
|
58
|
+
"span": list(self.span),
|
|
59
|
+
"symbol": self.symbol,
|
|
60
|
+
"kind": self.kind,
|
|
61
|
+
"flags": sorted(self.flags),
|
|
62
|
+
}
|
|
63
|
+
if self.wrapper:
|
|
64
|
+
d["wrapper"] = {"file": self.wrapper.file, "span": list(self.wrapper.span),
|
|
65
|
+
"symbol": self.wrapper.symbol}
|
|
66
|
+
return d
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@dataclass
|
|
70
|
+
class RawSiteRef:
|
|
71
|
+
file: str
|
|
72
|
+
span: tuple[int, int]
|
|
73
|
+
symbol: str
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass
|
|
77
|
+
class Slot:
|
|
78
|
+
name: str
|
|
79
|
+
observed_values: list[str] = field(default_factory=list)
|
|
80
|
+
inferred_type: str = "unknown"
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass
|
|
84
|
+
class PromptTemplate:
|
|
85
|
+
"""Output of prompt reconstruction (T2)."""
|
|
86
|
+
template: str # user-message template with {slot} markers
|
|
87
|
+
slots: list[Slot] = field(default_factory=list)
|
|
88
|
+
system_prompt: str | None = None
|
|
89
|
+
embedded_few_shot: list[dict[str, str]] = field(default_factory=list)
|
|
90
|
+
partial: bool = False # True when fragments were unresolvable
|
|
91
|
+
# Unresolvable fragments appear in template as {UNRESOLVED_n} and are
|
|
92
|
+
# listed in slots with inferred_type="unresolved".
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@dataclass
|
|
96
|
+
class Example:
|
|
97
|
+
"""One recorded (prompt, completion) pair (T4)."""
|
|
98
|
+
input_slots: dict[str, str]
|
|
99
|
+
prompt: str
|
|
100
|
+
completion: str
|
|
101
|
+
tokens_in: int = 0
|
|
102
|
+
tokens_out: int = 0
|
|
103
|
+
latency_ms: int = 0
|
|
104
|
+
|
|
105
|
+
def to_dict(self) -> dict[str, Any]:
|
|
106
|
+
return {
|
|
107
|
+
"input_slots": self.input_slots,
|
|
108
|
+
"prompt": self.prompt,
|
|
109
|
+
"completion": self.completion,
|
|
110
|
+
"tokens": {"in": self.tokens_in, "out": self.tokens_out},
|
|
111
|
+
"latency_ms": self.latency_ms,
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@dataclass
|
|
116
|
+
class GoalBlock:
|
|
117
|
+
"""Output of goal inference / difficulty induction (T6)."""
|
|
118
|
+
description: str
|
|
119
|
+
constraints: list[str] = field(default_factory=list)
|
|
120
|
+
difficulty_dimensions: list[dict[str, Any]] = field(default_factory=list)
|
|
121
|
+
# each dimension: {"name": str, "levels": [str], "evidence": str}
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def compute_site_id(template: str, schema: dict[str, Any] | None) -> str:
|
|
125
|
+
"""Stable site identity: hash of normalized template + schema, not file:line."""
|
|
126
|
+
norm_template = " ".join(template.split())
|
|
127
|
+
norm_schema = json.dumps(schema, sort_keys=True, separators=(",", ":")) if schema else ""
|
|
128
|
+
return hashlib.sha256(f"{norm_template}\x00{norm_schema}".encode()).hexdigest()
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def is_supported(verifiability: Verifiability, flags: list[str]) -> bool:
|
|
132
|
+
return verifiability in SUPPORTED_VERIFIABILITY and not (set(flags) & UNSUPPORTED_FLAGS)
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""T6 — goal inference + difficulty-dimension induction (the one LLM step).
|
|
2
|
+
|
|
3
|
+
Runs on Gemini's free tier via its OpenAI-compatible endpoint. The client is
|
|
4
|
+
injectable so tests run fully offline; real responses are cached to disk so
|
|
5
|
+
re-runs are deterministic and cheap.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import hashlib
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import re
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from .contract import Example, GoalBlock, PromptTemplate
|
|
18
|
+
|
|
19
|
+
# Defaults target Gemini's free tier; every piece is env-overridable so the
|
|
20
|
+
# induction provider can be swapped without code changes (e.g. Groq when a
|
|
21
|
+
# daily quota is exhausted): NABLA_INDUCE_BASE_URL, NABLA_INDUCE_API_KEY_ENV
|
|
22
|
+
# (name of the env var holding the key, read from env or cli/.env — never
|
|
23
|
+
# hardcode keys here), NABLA_INDUCE_MODEL. Re-align the default with Phase 0's
|
|
24
|
+
# frontier choice when it lands.
|
|
25
|
+
INDUCE_BASE_URL = "https://generativelanguage.googleapis.com/v1beta/openai/"
|
|
26
|
+
INDUCE_API_KEY_ENV = "GEMINI_API_KEY"
|
|
27
|
+
DEFAULT_INDUCE_MODEL = "gemini-3.5-flash"
|
|
28
|
+
|
|
29
|
+
_SYSTEM = """You analyze a harvested LLM call site and produce a task profile for a downstream
|
|
30
|
+
training-data generator. You are given the call site's prompt template, its output
|
|
31
|
+
JSON schema, and real recorded (prompt, completion) examples.
|
|
32
|
+
|
|
33
|
+
Respond with ONLY a JSON object, no markdown fences, shaped exactly:
|
|
34
|
+
{
|
|
35
|
+
"description": "<what this call site's task is, one or two sentences>",
|
|
36
|
+
"constraints": ["<implicit rule mined from the system prompt or examples>", ...],
|
|
37
|
+
"difficulty_dimensions": [
|
|
38
|
+
{"name": "<snake_case dimension>", "levels": ["<easy>", "...", "<hard>"],
|
|
39
|
+
"evidence": "<which examples/template features show this dimension varies>"},
|
|
40
|
+
...
|
|
41
|
+
]
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
Rules:
|
|
45
|
+
- description states the task, not the mechanism ("extracts X from Y into schema Z").
|
|
46
|
+
- constraints are rules the completion must follow that the schema alone does not
|
|
47
|
+
enforce (null-discipline, normalization, language, tone).
|
|
48
|
+
- difficulty_dimensions are the axes along which REAL inputs to this site vary in
|
|
49
|
+
ways that plausibly make the task harder. 3-6 dimensions, each with 2+ ordered
|
|
50
|
+
levels (easiest first) and evidence citing the examples or template. Do not invent
|
|
51
|
+
dimensions the examples give no evidence for."""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class InduceError(RuntimeError):
|
|
55
|
+
"""The induction model returned something unusable."""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def induce(template: PromptTemplate, schema: dict[str, Any] | None,
|
|
59
|
+
examples: list[Example], *, model: str | None = None,
|
|
60
|
+
cache_dir: str | None = None, client: Any = None) -> GoalBlock:
|
|
61
|
+
"""Frontier-model pass producing goal description, constraints, and
|
|
62
|
+
difficulty dimensions with cited evidence.
|
|
63
|
+
|
|
64
|
+
`client` is any OpenAI-compatible client (injectable for tests); when
|
|
65
|
+
None, one is built for Gemini from GEMINI_API_KEY. Responses are cached
|
|
66
|
+
in `cache_dir` (default .nabla_cache next to cwd) keyed by inputs+model,
|
|
67
|
+
so repeat runs are offline and deterministic.
|
|
68
|
+
"""
|
|
69
|
+
model = model or os.environ.get("NABLA_INDUCE_MODEL", DEFAULT_INDUCE_MODEL)
|
|
70
|
+
user_payload = _build_user_payload(template, schema, examples)
|
|
71
|
+
|
|
72
|
+
cache_path = None
|
|
73
|
+
if cache_dir is not False: # allow cache_dir=False to disable entirely
|
|
74
|
+
cache_root = Path(cache_dir or ".nabla_cache")
|
|
75
|
+
key = hashlib.sha256(json.dumps([model, _SYSTEM, user_payload],
|
|
76
|
+
sort_keys=True).encode()).hexdigest()
|
|
77
|
+
cache_path = cache_root / f"induce-{key}.json"
|
|
78
|
+
if cache_path.exists():
|
|
79
|
+
return _parse_goal(cache_path.read_text(encoding="utf-8"))
|
|
80
|
+
|
|
81
|
+
if client is None:
|
|
82
|
+
client = _default_client()
|
|
83
|
+
|
|
84
|
+
response = client.chat.completions.create(
|
|
85
|
+
model=model,
|
|
86
|
+
messages=[
|
|
87
|
+
{"role": "system", "content": _SYSTEM},
|
|
88
|
+
{"role": "user", "content": user_payload},
|
|
89
|
+
],
|
|
90
|
+
temperature=0.2,
|
|
91
|
+
)
|
|
92
|
+
raw = response.choices[0].message.content or ""
|
|
93
|
+
goal = _parse_goal(raw) # validate before caching — never cache garbage
|
|
94
|
+
|
|
95
|
+
if cache_path is not None:
|
|
96
|
+
cache_path.parent.mkdir(parents=True, exist_ok=True)
|
|
97
|
+
cache_path.write_text(raw, encoding="utf-8")
|
|
98
|
+
return goal
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _default_client():
|
|
102
|
+
from openai import OpenAI
|
|
103
|
+
|
|
104
|
+
base_url = os.environ.get("NABLA_INDUCE_BASE_URL", INDUCE_BASE_URL)
|
|
105
|
+
key_env = os.environ.get("NABLA_INDUCE_API_KEY_ENV", INDUCE_API_KEY_ENV)
|
|
106
|
+
api_key = os.environ.get(key_env) or _read_dotenv_key(key_env)
|
|
107
|
+
if not api_key:
|
|
108
|
+
raise InduceError(
|
|
109
|
+
f"{key_env} not set (env or cli/.env). "
|
|
110
|
+
"T6 induction needs an API key for its OpenAI-compatible provider."
|
|
111
|
+
)
|
|
112
|
+
return OpenAI(base_url=base_url, api_key=api_key)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _read_dotenv_key(name: str) -> str | None:
|
|
116
|
+
# Minimal .env reader: look next to this package's parent (cli/.env), then cwd.
|
|
117
|
+
for candidate in (Path(__file__).resolve().parents[1] / ".env", Path(".env")):
|
|
118
|
+
try:
|
|
119
|
+
for line in candidate.read_text(encoding="utf-8").splitlines():
|
|
120
|
+
line = line.strip()
|
|
121
|
+
if line.startswith(f"{name}=") and not line.startswith("#"):
|
|
122
|
+
return line.split("=", 1)[1].strip().strip('"').strip("'")
|
|
123
|
+
except OSError:
|
|
124
|
+
continue
|
|
125
|
+
return None
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _build_user_payload(template: PromptTemplate, schema: dict[str, Any] | None,
|
|
129
|
+
examples: list[Example]) -> str:
|
|
130
|
+
parts = ["## Prompt template\n" + template.template]
|
|
131
|
+
if template.system_prompt:
|
|
132
|
+
parts.append("## System prompt\n" + template.system_prompt)
|
|
133
|
+
parts.append("## Slots\n" + json.dumps(
|
|
134
|
+
[{"name": s.name, "inferred_type": s.inferred_type} for s in template.slots]))
|
|
135
|
+
parts.append("## Output schema\n" +
|
|
136
|
+
(json.dumps(schema, indent=2) if schema else "(none)"))
|
|
137
|
+
shown = examples[:20]
|
|
138
|
+
ex_lines = [
|
|
139
|
+
f"### Example {i + 1}\nINPUT SLOTS: {json.dumps(e.input_slots)}\n"
|
|
140
|
+
f"PROMPT: {e.prompt}\nCOMPLETION: {e.completion}"
|
|
141
|
+
for i, e in enumerate(shown)
|
|
142
|
+
]
|
|
143
|
+
parts.append(f"## Recorded examples ({len(shown)} of {len(examples)})\n" +
|
|
144
|
+
"\n".join(ex_lines))
|
|
145
|
+
return "\n\n".join(parts)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _parse_goal(raw: str) -> GoalBlock:
|
|
149
|
+
text = raw.strip()
|
|
150
|
+
fence = re.match(r"^```(?:json)?\s*(.*?)\s*```$", text, re.DOTALL)
|
|
151
|
+
if fence:
|
|
152
|
+
text = fence.group(1)
|
|
153
|
+
try:
|
|
154
|
+
data = json.loads(text)
|
|
155
|
+
except json.JSONDecodeError as exc:
|
|
156
|
+
raise InduceError(f"induction model did not return JSON: {exc}\n{raw[:500]}") from exc
|
|
157
|
+
|
|
158
|
+
description = data.get("description")
|
|
159
|
+
constraints = data.get("constraints", [])
|
|
160
|
+
dims = data.get("difficulty_dimensions", [])
|
|
161
|
+
if not isinstance(description, str) or not description.strip():
|
|
162
|
+
raise InduceError("induction result missing 'description'")
|
|
163
|
+
if not isinstance(constraints, list) or not all(isinstance(c, str) for c in constraints):
|
|
164
|
+
raise InduceError("induction result 'constraints' must be a list of strings")
|
|
165
|
+
clean_dims: list[dict[str, Any]] = []
|
|
166
|
+
for d in dims if isinstance(dims, list) else []:
|
|
167
|
+
if (isinstance(d, dict) and isinstance(d.get("name"), str)
|
|
168
|
+
and isinstance(d.get("levels"), list) and len(d["levels"]) >= 2
|
|
169
|
+
and all(isinstance(level, str) for level in d["levels"])
|
|
170
|
+
and isinstance(d.get("evidence"), str)):
|
|
171
|
+
clean_dims.append({"name": d["name"], "levels": d["levels"],
|
|
172
|
+
"evidence": d["evidence"]})
|
|
173
|
+
if not clean_dims:
|
|
174
|
+
raise InduceError("induction result has no well-formed difficulty_dimensions")
|
|
175
|
+
return GoalBlock(description=description.strip(), constraints=constraints,
|
|
176
|
+
difficulty_dimensions=clean_dims)
|