nabla-cli 0.1.0__py3-none-any.whl
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/__init__.py +3 -0
- nabla/cli.py +121 -0
- nabla/contract.py +132 -0
- nabla/induce.py +176 -0
- nabla/profile.py +138 -0
- nabla/prompt_recon.py +542 -0
- nabla/recorder.py +609 -0
- nabla/scanner.py +453 -0
- nabla/schema_resolver.py +309 -0
- nabla/schemas/site_profile.schema.json +136 -0
- nabla_cli-0.1.0.dist-info/METADATA +82 -0
- nabla_cli-0.1.0.dist-info/RECORD +15 -0
- nabla_cli-0.1.0.dist-info/WHEEL +5 -0
- nabla_cli-0.1.0.dist-info/entry_points.txt +2 -0
- nabla_cli-0.1.0.dist-info/top_level.txt +1 -0
nabla/__init__.py
ADDED
nabla/cli.py
ADDED
|
@@ -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())
|
nabla/contract.py
ADDED
|
@@ -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)
|
nabla/induce.py
ADDED
|
@@ -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)
|
nabla/profile.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""T7 — site profile assembly: compose T1–T6 outputs into a validated
|
|
2
|
+
site_profile.json (the Phase 0 handoff artifact).
|
|
3
|
+
|
|
4
|
+
Also owns sampling-parameter extraction (model/temperature/max_tokens/...),
|
|
5
|
+
which is plain AST kwarg reading and belongs to assembly rather than any
|
|
6
|
+
single workstream.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import ast
|
|
12
|
+
import json
|
|
13
|
+
from importlib import resources
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Any
|
|
16
|
+
|
|
17
|
+
from .contract import (
|
|
18
|
+
PROFILE_VERSION,
|
|
19
|
+
Example,
|
|
20
|
+
GoalBlock,
|
|
21
|
+
PromptTemplate,
|
|
22
|
+
RawSite,
|
|
23
|
+
Verifiability,
|
|
24
|
+
compute_site_id,
|
|
25
|
+
is_supported,
|
|
26
|
+
)
|
|
27
|
+
from .prompt_recon import reconstruct
|
|
28
|
+
from .recorder import baseline_stats
|
|
29
|
+
from .schema_resolver import resolve_schema
|
|
30
|
+
|
|
31
|
+
# Packaged with the module (nabla/schemas/) so installed wheels find it too.
|
|
32
|
+
_SCHEMA_RESOURCE = resources.files("nabla").joinpath("schemas/site_profile.schema.json")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def extract_sampling(site: RawSite, repo_path: str) -> dict[str, Any]:
|
|
36
|
+
"""Read literal sampling kwargs off the SDK call (wrapper's call for
|
|
37
|
+
wrapper-indirect sites). Non-literal values are reported as null."""
|
|
38
|
+
ref = site.wrapper or site
|
|
39
|
+
path = Path(repo_path) / ref.file
|
|
40
|
+
sampling: dict[str, Any] = {"model": "unknown", "temperature": None,
|
|
41
|
+
"max_tokens": None, "top_p": None, "stop": None}
|
|
42
|
+
try:
|
|
43
|
+
tree = ast.parse(path.read_text(encoding="utf-8"))
|
|
44
|
+
except (OSError, SyntaxError):
|
|
45
|
+
return sampling
|
|
46
|
+
for node in ast.walk(tree):
|
|
47
|
+
if (isinstance(node, ast.Call)
|
|
48
|
+
and node.lineno == ref.span[0]
|
|
49
|
+
and getattr(node, "end_lineno", node.lineno) == ref.span[1]):
|
|
50
|
+
for kw in node.keywords:
|
|
51
|
+
if kw.arg in sampling and isinstance(kw.value, ast.Constant):
|
|
52
|
+
sampling[kw.arg] = kw.value.value
|
|
53
|
+
break
|
|
54
|
+
if sampling["model"] is None or sampling["model"] == "unknown":
|
|
55
|
+
sampling["model"] = "unknown"
|
|
56
|
+
return sampling
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def build_profile(site: RawSite, repo_path: str, examples: list[Example],
|
|
60
|
+
goal: GoalBlock, recorded_from: str = "samples") -> dict[str, Any]:
|
|
61
|
+
"""Assemble the complete profile dict. Raises jsonschema.ValidationError
|
|
62
|
+
if the result does not conform to the frozen schema."""
|
|
63
|
+
template: PromptTemplate = reconstruct(site, repo_path)
|
|
64
|
+
schema, verifiability = resolve_schema(site, repo_path)
|
|
65
|
+
sampling = extract_sampling(site, repo_path)
|
|
66
|
+
|
|
67
|
+
# Fold observed slot values from recorded examples into the template slots.
|
|
68
|
+
observed: dict[str, list[str]] = {}
|
|
69
|
+
for ex in examples:
|
|
70
|
+
for name, value in ex.input_slots.items():
|
|
71
|
+
vals = observed.setdefault(name, [])
|
|
72
|
+
if str(value) not in vals and len(vals) < 5:
|
|
73
|
+
vals.append(str(value))
|
|
74
|
+
|
|
75
|
+
profile = {
|
|
76
|
+
"profile_version": PROFILE_VERSION,
|
|
77
|
+
"site_id": compute_site_id(template.template, schema),
|
|
78
|
+
"source": {"file": site.file, "span": list(site.span), "symbol": site.symbol},
|
|
79
|
+
"classification": {
|
|
80
|
+
"supported": is_supported(verifiability, site.flags),
|
|
81
|
+
"verifiability": verifiability.value,
|
|
82
|
+
"flags": sorted(site.flags),
|
|
83
|
+
},
|
|
84
|
+
"prompt": {
|
|
85
|
+
"template": template.template,
|
|
86
|
+
"partial": template.partial,
|
|
87
|
+
"slots": [
|
|
88
|
+
{"name": s.name,
|
|
89
|
+
"observed_values": observed.get(s.name, s.observed_values),
|
|
90
|
+
"inferred_type": s.inferred_type}
|
|
91
|
+
for s in template.slots
|
|
92
|
+
],
|
|
93
|
+
"system_prompt": template.system_prompt,
|
|
94
|
+
"embedded_few_shot": template.embedded_few_shot,
|
|
95
|
+
},
|
|
96
|
+
"output_schema": schema,
|
|
97
|
+
"sampling": sampling,
|
|
98
|
+
"examples": [e.to_dict() for e in examples],
|
|
99
|
+
"goal": {
|
|
100
|
+
"description": goal.description,
|
|
101
|
+
"constraints": goal.constraints,
|
|
102
|
+
"difficulty_dimensions": goal.difficulty_dimensions,
|
|
103
|
+
},
|
|
104
|
+
"baseline": _baseline_block(examples, sampling["model"]),
|
|
105
|
+
"provenance": {"recorded_from": recorded_from if examples else "none"},
|
|
106
|
+
}
|
|
107
|
+
validate_profile(profile)
|
|
108
|
+
return profile
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _baseline_block(examples: list[Example], model: str) -> dict[str, Any]:
|
|
112
|
+
if not examples:
|
|
113
|
+
return {"cost_per_call_usd": 0.0, "p50_latency_ms": 0, "calls_observed": 0}
|
|
114
|
+
stats = baseline_stats(examples, model)
|
|
115
|
+
return {
|
|
116
|
+
"cost_per_call_usd": float(stats.get("cost_per_call_usd", 0.0)),
|
|
117
|
+
"p50_latency_ms": int(stats.get("p50_latency_ms", 0)),
|
|
118
|
+
"calls_observed": int(stats.get("calls_observed", len(examples))),
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def validate_profile(profile: dict[str, Any]) -> None:
|
|
123
|
+
import jsonschema
|
|
124
|
+
|
|
125
|
+
schema = json.loads(_SCHEMA_RESOURCE.read_text(encoding="utf-8"))
|
|
126
|
+
jsonschema.validate(profile, schema)
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def find_site(sites: list[RawSite], selector: str) -> RawSite:
|
|
130
|
+
"""Match a site by symbol or site-id-ish prefix; error lists candidates."""
|
|
131
|
+
matches = [s for s in sites if s.symbol == selector]
|
|
132
|
+
if len(matches) == 1:
|
|
133
|
+
return matches[0]
|
|
134
|
+
if not matches:
|
|
135
|
+
available = ", ".join(sorted({s.symbol for s in sites}))
|
|
136
|
+
raise SystemExit(f"nabla: no site named {selector!r}. Sites found: {available}")
|
|
137
|
+
files = ", ".join(s.file for s in matches)
|
|
138
|
+
raise SystemExit(f"nabla: {selector!r} is ambiguous across: {files} — split not yet supported")
|