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.
@@ -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,3 @@
1
+ """nabla — call-site harvester CLI (pre-Phase-0)."""
2
+
3
+ __version__ = "0.1.0"
@@ -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)