semlayer 0.3.0b1__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.
- semlayer/__init__.py +5 -0
- semlayer/benchmark.py +115 -0
- semlayer/cli.py +246 -0
- semlayer/compile.py +352 -0
- semlayer/connectors/__init__.py +1 -0
- semlayer/connectors/bigquery.py +66 -0
- semlayer/connectors/snowflake.py +82 -0
- semlayer/context.py +400 -0
- semlayer/cq/__init__.py +1 -0
- semlayer/cq/answer.py +226 -0
- semlayer/cq/generate.py +83 -0
- semlayer/describe/__init__.py +2 -0
- semlayer/describe/run.py +157 -0
- semlayer/drift.py +309 -0
- semlayer/enrich/__init__.py +2 -0
- semlayer/enrich/run.py +671 -0
- semlayer/errors.py +33 -0
- semlayer/export/__init__.py +1 -0
- semlayer/export/dbt.py +130 -0
- semlayer/init_cmd.py +60 -0
- semlayer/link/__init__.py +2 -0
- semlayer/link/candidates.py +214 -0
- semlayer/link/run.py +255 -0
- semlayer/link/table_type.py +71 -0
- semlayer/link/validate_llm.py +62 -0
- semlayer/llm/__init__.py +1 -0
- semlayer/llm/provider.py +161 -0
- semlayer/mcp_server.py +198 -0
- semlayer/ontology/__init__.py +2 -0
- semlayer/ontology/base.py +105 -0
- semlayer/pipeline.py +85 -0
- semlayer/profile/__init__.py +2 -0
- semlayer/profile/llm_typing.py +110 -0
- semlayer/profile/run.py +192 -0
- semlayer/profile/stats.py +155 -0
- semlayer/profile/typing_rules.py +239 -0
- semlayer/review.py +133 -0
- semlayer/scoring.py +108 -0
- semlayer/source.py +110 -0
- semlayer/spec/cq-suite.schema.json +58 -0
- semlayer/spec/semantic-layer.schema.json +550 -0
- semlayer/telemetry.py +49 -0
- semlayer/validate.py +275 -0
- semlayer-0.3.0b1.dist-info/METADATA +80 -0
- semlayer-0.3.0b1.dist-info/RECORD +49 -0
- semlayer-0.3.0b1.dist-info/WHEEL +5 -0
- semlayer-0.3.0b1.dist-info/entry_points.txt +2 -0
- semlayer-0.3.0b1.dist-info/licenses/LICENSE +201 -0
- semlayer-0.3.0b1.dist-info/top_level.txt +1 -0
semlayer/__init__.py
ADDED
semlayer/benchmark.py
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
"""Reproducible benchmark runner (M6): the ablation, hardened for publication.
|
|
2
|
+
|
|
3
|
+
Reproducibility contract (adversarial B5): results are exact under pinned
|
|
4
|
+
engine + prompt versions + models via committed cassettes; cross-model
|
|
5
|
+
variance is REPORTED, never hidden. `run_benchmark` returns structured
|
|
6
|
+
results; report generation renders docs/benchmark.md.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from collections import defaultdict
|
|
12
|
+
|
|
13
|
+
CONDITIONS = ["schema_only", "semantic", "semantic+ontology"]
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def run_fixture(fixture: str, answer_model: str | None = None,
|
|
17
|
+
conditions: list[str] | None = None) -> dict:
|
|
18
|
+
"""Run one fixture's CQ suite under each ablation condition and score results."""
|
|
19
|
+
import importlib
|
|
20
|
+
import sys
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
import duckdb
|
|
24
|
+
import yaml
|
|
25
|
+
|
|
26
|
+
oss = Path(__file__).resolve().parent.parent.parent
|
|
27
|
+
sys.path.insert(0, str(oss / "fixtures"))
|
|
28
|
+
|
|
29
|
+
from semlayer.cq.answer import (
|
|
30
|
+
answer_with_repair,
|
|
31
|
+
ontology_context,
|
|
32
|
+
schema_only_context,
|
|
33
|
+
score_answer,
|
|
34
|
+
semantic_context,
|
|
35
|
+
)
|
|
36
|
+
from semlayer.llm.provider import AnthropicProvider
|
|
37
|
+
from semlayer.ontology import build_base_graph
|
|
38
|
+
from semlayer.pipeline import infer
|
|
39
|
+
from semlayer.source import DuckDBSource
|
|
40
|
+
|
|
41
|
+
mod = importlib.import_module(f"generators.{fixture}")
|
|
42
|
+
con = duckdb.connect(":memory:")
|
|
43
|
+
mod.build(con)
|
|
44
|
+
doc = infer(DuckDBSource(con), llm=AnthropicProvider())
|
|
45
|
+
graph = build_base_graph(doc)
|
|
46
|
+
cq_path = oss / "fixtures" / "cqs" / f"{fixture}.yaml"
|
|
47
|
+
suite = yaml.safe_load(cq_path.read_text())["cq_suite"]["questions"]
|
|
48
|
+
|
|
49
|
+
out: dict = {"fixture": fixture, "n_cqs": len(suite), "conditions": {}}
|
|
50
|
+
for cond in conditions or CONDITIONS:
|
|
51
|
+
llm = AnthropicProvider(model=answer_model) if answer_model else AnthropicProvider()
|
|
52
|
+
passed = 0
|
|
53
|
+
fails: list[dict] = []
|
|
54
|
+
by_cat: dict[str, list[int]] = defaultdict(lambda: [0, 0])
|
|
55
|
+
for cq in suite:
|
|
56
|
+
q = cq["question"]
|
|
57
|
+
ctx = (schema_only_context(doc) if cond == "schema_only"
|
|
58
|
+
else semantic_context(doc, q) if cond == "semantic"
|
|
59
|
+
else ontology_context(doc, graph, q))
|
|
60
|
+
ok, detail = score_answer(con, cq, answer_with_repair(llm, con, ctx, q))
|
|
61
|
+
is_meta_kind = cq["expected_kind"] in ("refusal", "clarification")
|
|
62
|
+
cat = cq.get("trap") or (cq["expected_kind"] if is_meta_kind else cq["complexity"])
|
|
63
|
+
by_cat[cat][0] += ok
|
|
64
|
+
by_cat[cat][1] += 1
|
|
65
|
+
passed += ok
|
|
66
|
+
if not ok:
|
|
67
|
+
fails.append({"id": cq["id"], "category": cat, "detail": detail[:60]})
|
|
68
|
+
out["conditions"][cond] = {
|
|
69
|
+
"pass_rate": round(passed / len(suite), 3),
|
|
70
|
+
"passed": passed,
|
|
71
|
+
"by_category": {k: {"passed": v[0], "total": v[1]} for k, v in sorted(by_cat.items())},
|
|
72
|
+
"fails": fails,
|
|
73
|
+
"spend": llm.spend_summary,
|
|
74
|
+
"model": llm.model,
|
|
75
|
+
}
|
|
76
|
+
con.close()
|
|
77
|
+
return out
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def render_report(results: list[dict], engine_version: str, notes: list[str]) -> str:
|
|
81
|
+
"""Render the ablation results table + failure breakdown as a markdown report."""
|
|
82
|
+
lines = [
|
|
83
|
+
"# Benchmark: text-to-SQL accuracy — raw schema vs. inferred semantic layer",
|
|
84
|
+
"",
|
|
85
|
+
f"Engine {engine_version}; answers execution-scored against gold CQ suites",
|
|
86
|
+
"(fixed questions, human-verified expected SQL, executed live). Reproducible",
|
|
87
|
+
"under pinned engine+model via committed cassettes; re-run with:",
|
|
88
|
+
"`python -m semlayer.benchmark`.",
|
|
89
|
+
"",
|
|
90
|
+
"| Fixture | CQs | Answerer | schema_only | semantic | semantic+ontology |",
|
|
91
|
+
"|---|---|---|---|---|---|",
|
|
92
|
+
]
|
|
93
|
+
for r in results:
|
|
94
|
+
conds = r["conditions"]
|
|
95
|
+
model = next(iter(conds.values()))["model"].split("-2")[0]
|
|
96
|
+
row = [r["fixture"], str(r["n_cqs"]), model]
|
|
97
|
+
for c in CONDITIONS:
|
|
98
|
+
row.append(f"{conds[c]['pass_rate']:.2f}" if c in conds else "—")
|
|
99
|
+
lines.append("| " + " | ".join(row) + " |")
|
|
100
|
+
lines.append("")
|
|
101
|
+
lines.append("## Failure breakdown by category (semantic condition)")
|
|
102
|
+
lines.append("")
|
|
103
|
+
lines.append("| Fixture | Category | Passed/Total |")
|
|
104
|
+
lines.append("|---|---|---|")
|
|
105
|
+
for r in results:
|
|
106
|
+
sem = r["conditions"].get("semantic")
|
|
107
|
+
if not sem:
|
|
108
|
+
continue
|
|
109
|
+
for cat, v in sem["by_category"].items():
|
|
110
|
+
lines.append(f"| {r['fixture']} | {cat} | {v['passed']}/{v['total']} |")
|
|
111
|
+
lines.append("")
|
|
112
|
+
lines.append("## Notes")
|
|
113
|
+
for n in notes:
|
|
114
|
+
lines.append(f"- {n}")
|
|
115
|
+
return "\n".join(lines) + "\n"
|
semlayer/cli.py
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
"""semlayer command-line interface.
|
|
2
|
+
|
|
3
|
+
Structure: one handler per subcommand, dispatched from `main`. The CLI is the
|
|
4
|
+
error boundary (BETA Q1): `SemlayerError` renders as message + actionable
|
|
5
|
+
hint; raw third-party tracebacks never reach users. Exit codes: 0 success,
|
|
6
|
+
1 domain outcome (invalid doc / drift found), 2 usage or environment error.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import argparse
|
|
12
|
+
import logging
|
|
13
|
+
import sys
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
from semlayer.errors import SemlayerError
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
20
|
+
parser = argparse.ArgumentParser(prog="semlayer", description="Semantic layer toolkit")
|
|
21
|
+
parser.add_argument("-v", "--verbose", action="store_true",
|
|
22
|
+
help="Stage-by-stage progress logging")
|
|
23
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
24
|
+
|
|
25
|
+
p = sub.add_parser("validate", help="Validate semantic layer document(s) against the spec")
|
|
26
|
+
p.add_argument("paths", nargs="+", help="YAML/JSON document paths")
|
|
27
|
+
p.add_argument("-q", "--quiet", action="store_true", help="Only print failures")
|
|
28
|
+
|
|
29
|
+
p = sub.add_parser("init", help="Generate minimal-grant setup script for a warehouse")
|
|
30
|
+
p.add_argument("warehouse", choices=["snowflake", "bigquery"])
|
|
31
|
+
p.add_argument("--database", default="<your_database>")
|
|
32
|
+
p.add_argument("--project", default="<your_project>")
|
|
33
|
+
p.add_argument("--warehouse-name", default="<your_warehouse>")
|
|
34
|
+
|
|
35
|
+
p = sub.add_parser("infer", help="Infer a semantic layer from a warehouse")
|
|
36
|
+
p.add_argument("source", help="duckdb:<path> | snowflake | bigquery")
|
|
37
|
+
p.add_argument("-o", "--out", default="semantic_layer.yaml")
|
|
38
|
+
p.add_argument("--no-llm", action="store_true", help="Deterministic dry-run (no LLM calls)")
|
|
39
|
+
p.add_argument("--no-sample-egress", action="store_true",
|
|
40
|
+
help="Never include cell values in LLM prompts")
|
|
41
|
+
p.add_argument("--context", action="append", metavar="PATH",
|
|
42
|
+
help="Knowledge docs as inference priors: files, dirs, or globs of "
|
|
43
|
+
".md/.txt/.rst/.html prose or .csv/.tsv data dictionaries "
|
|
44
|
+
"(repeatable). Doc text IS sent to the LLM — don't pass secrets. "
|
|
45
|
+
"Docs never override data: contradictions land in review.")
|
|
46
|
+
|
|
47
|
+
p = sub.add_parser("review", help="Review inferred claims (terminal queue)")
|
|
48
|
+
p.add_argument("doc", help="semantic layer YAML path")
|
|
49
|
+
p.add_argument("--list", action="store_true", help="List items without prompting")
|
|
50
|
+
|
|
51
|
+
p = sub.add_parser("mcp", help="Serve a semantic layer over MCP (stdio)")
|
|
52
|
+
p.add_argument("doc", help="semantic layer YAML path")
|
|
53
|
+
|
|
54
|
+
p = sub.add_parser("drift", help="Detect warehouse drift vs a semantic layer")
|
|
55
|
+
p.add_argument("doc", help="semantic layer YAML path")
|
|
56
|
+
p.add_argument("source", help="duckdb:<path> | snowflake | bigquery")
|
|
57
|
+
p.add_argument("--apply", action="store_true",
|
|
58
|
+
help="Apply orphaning/conflicts to the doc (writes in place)")
|
|
59
|
+
p.add_argument("--cqs", help="CQ suite YAML to regression-check")
|
|
60
|
+
return parser
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _load_doc(path: str) -> dict:
|
|
64
|
+
import yaml
|
|
65
|
+
|
|
66
|
+
try:
|
|
67
|
+
doc = yaml.safe_load(Path(path).read_text())
|
|
68
|
+
except FileNotFoundError as e:
|
|
69
|
+
raise SemlayerError(f"document not found: {path}",
|
|
70
|
+
hint="run `semlayer infer` first, or check the path") from e
|
|
71
|
+
except yaml.YAMLError as e:
|
|
72
|
+
raise SemlayerError(f"document is not valid YAML: {path} ({e})") from e
|
|
73
|
+
if not isinstance(doc, dict) or "semantic_layer" not in doc:
|
|
74
|
+
raise SemlayerError(f"not a semantic layer document: {path}",
|
|
75
|
+
hint="expected a top-level `semantic_layer:` mapping")
|
|
76
|
+
return doc
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _save_doc(path: str, doc: dict) -> None:
|
|
80
|
+
import yaml
|
|
81
|
+
|
|
82
|
+
Path(path).write_text(yaml.safe_dump(doc, sort_keys=False, width=100))
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _cmd_validate(args: argparse.Namespace) -> int:
|
|
86
|
+
from semlayer.validate import validate_file
|
|
87
|
+
|
|
88
|
+
failed = 0
|
|
89
|
+
for p in args.paths:
|
|
90
|
+
result = validate_file(Path(p))
|
|
91
|
+
if result.ok:
|
|
92
|
+
if not args.quiet:
|
|
93
|
+
warn = f" ({len(result.warnings)} warnings)" if result.warnings else ""
|
|
94
|
+
print(f"OK {p}{warn}")
|
|
95
|
+
for w in result.warnings:
|
|
96
|
+
print(f" warn: {w}")
|
|
97
|
+
else:
|
|
98
|
+
failed += 1
|
|
99
|
+
print(f"FAIL {p}")
|
|
100
|
+
for e in result.errors:
|
|
101
|
+
print(f" {e}")
|
|
102
|
+
return 1 if failed else 0
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _cmd_init(args: argparse.Namespace) -> int:
|
|
106
|
+
from semlayer.init_cmd import render
|
|
107
|
+
|
|
108
|
+
print(render(args.warehouse, database=args.database,
|
|
109
|
+
project=args.project, warehouse_name=args.warehouse_name))
|
|
110
|
+
return 0
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _cmd_infer(args: argparse.Namespace) -> int:
|
|
114
|
+
import json
|
|
115
|
+
|
|
116
|
+
from semlayer.pipeline import infer_with_report, open_source_from_uri
|
|
117
|
+
from semlayer.review import collect
|
|
118
|
+
from semlayer.validate import validate_document
|
|
119
|
+
|
|
120
|
+
llm = None
|
|
121
|
+
if not args.no_llm:
|
|
122
|
+
from semlayer.llm.provider import AnthropicProvider
|
|
123
|
+
|
|
124
|
+
llm = AnthropicProvider()
|
|
125
|
+
context = None
|
|
126
|
+
if args.context:
|
|
127
|
+
from semlayer.context import load_context
|
|
128
|
+
|
|
129
|
+
context = load_context(args.context)
|
|
130
|
+
print(f"context: {len(context.chunks)} doc sections, "
|
|
131
|
+
f"{len(context.dictionary)} dictionary rows")
|
|
132
|
+
src = open_source_from_uri(args.source)
|
|
133
|
+
doc, report = infer_with_report(src, llm=llm, no_sample_egress=args.no_sample_egress,
|
|
134
|
+
context=context)
|
|
135
|
+
if context:
|
|
136
|
+
report["context"] = {"chunks": len(context.chunks),
|
|
137
|
+
"dictionary_rows": len(context.dictionary)}
|
|
138
|
+
Path(args.out + ".report.json").write_text(json.dumps(report, indent=1))
|
|
139
|
+
result = validate_document(doc)
|
|
140
|
+
_save_doc(args.out, doc)
|
|
141
|
+
n = len(doc["semantic_layer"]["tables"])
|
|
142
|
+
mode = f"({llm.spend_summary})" if llm else "(deterministic mode)"
|
|
143
|
+
print(f"wrote {args.out}: {n} tables, valid={result.ok} {mode}")
|
|
144
|
+
items = collect(doc)
|
|
145
|
+
if items:
|
|
146
|
+
print(f"{len(items)} items await review: semlayer review {args.out}")
|
|
147
|
+
return 0 if result.ok else 1
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _cmd_review(args: argparse.Namespace) -> int:
|
|
151
|
+
from semlayer.review import apply, collect
|
|
152
|
+
|
|
153
|
+
doc = _load_doc(args.doc)
|
|
154
|
+
items = collect(doc)
|
|
155
|
+
if not items:
|
|
156
|
+
print("review queue empty")
|
|
157
|
+
return 0
|
|
158
|
+
for i, it in enumerate(items, 1):
|
|
159
|
+
loc = f"{it.table}.{it.column}" if it.column else it.table
|
|
160
|
+
print(f"[{i}/{len(items)}] {it.kind}: {loc}\n claim: {it.claim}"
|
|
161
|
+
+ (f"\n evidence: {it.evidence}" if it.evidence else ""))
|
|
162
|
+
if args.list:
|
|
163
|
+
continue
|
|
164
|
+
ans = input(" accept/reject/skip [a/r/s]? ").strip().lower()
|
|
165
|
+
if ans in ("a", "accept"):
|
|
166
|
+
apply(doc, it, "accept")
|
|
167
|
+
elif ans in ("r", "reject"):
|
|
168
|
+
apply(doc, it, "reject")
|
|
169
|
+
if not args.list:
|
|
170
|
+
_save_doc(args.doc, doc)
|
|
171
|
+
print(f"saved {args.doc}")
|
|
172
|
+
return 0
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _cmd_drift(args: argparse.Namespace) -> int:
|
|
176
|
+
from semlayer import drift as drift_mod
|
|
177
|
+
from semlayer.pipeline import open_source_from_uri
|
|
178
|
+
|
|
179
|
+
doc = _load_doc(args.doc)
|
|
180
|
+
src = open_source_from_uri(args.source)
|
|
181
|
+
# the modeled tables ARE the structural baseline: drift is defined
|
|
182
|
+
# relative to what the document claims, not a stored snapshot
|
|
183
|
+
old = {t["name"]: {c["name"]: c["sql_type"] for c in t["columns"]}
|
|
184
|
+
for t in doc["semantic_layer"]["tables"]
|
|
185
|
+
if t.get("lifecycle") not in ("orphaned",)}
|
|
186
|
+
events = drift_mod.diff_snapshots(old, drift_mod.snapshot(src))
|
|
187
|
+
events += drift_mod.semantic_drift(src, doc)
|
|
188
|
+
if not events:
|
|
189
|
+
print("no drift detected")
|
|
190
|
+
return 0
|
|
191
|
+
cs = drift_mod.apply_drift(doc, events)
|
|
192
|
+
if args.cqs and args.source.startswith("duckdb:"):
|
|
193
|
+
import duckdb
|
|
194
|
+
import yaml
|
|
195
|
+
|
|
196
|
+
suite = yaml.safe_load(Path(args.cqs).read_text())["cq_suite"]["questions"]
|
|
197
|
+
con = duckdb.connect(args.source.split(":", 1)[1])
|
|
198
|
+
try:
|
|
199
|
+
cs.broken_cqs = drift_mod.cq_regression(con, suite, events, doc)
|
|
200
|
+
finally:
|
|
201
|
+
con.close()
|
|
202
|
+
print(drift_mod.render_changeset(cs))
|
|
203
|
+
if args.apply:
|
|
204
|
+
_save_doc(args.doc, doc)
|
|
205
|
+
print(f"applied to {args.doc}")
|
|
206
|
+
return 1 # nonzero: drift found (CI-hook friendly)
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _cmd_mcp(args: argparse.Namespace) -> int:
|
|
210
|
+
from semlayer.mcp_server import build_server
|
|
211
|
+
|
|
212
|
+
build_server(_load_doc(args.doc)).run()
|
|
213
|
+
return 0
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
_HANDLERS = {
|
|
217
|
+
"validate": _cmd_validate,
|
|
218
|
+
"init": _cmd_init,
|
|
219
|
+
"infer": _cmd_infer,
|
|
220
|
+
"review": _cmd_review,
|
|
221
|
+
"drift": _cmd_drift,
|
|
222
|
+
"mcp": _cmd_mcp,
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def main(argv: list[str] | None = None) -> int:
|
|
227
|
+
"""Parse arguments, dispatch, and render taxonomy errors with hints."""
|
|
228
|
+
args = _build_parser().parse_args(argv)
|
|
229
|
+
logging.basicConfig(
|
|
230
|
+
level=logging.DEBUG if args.verbose else logging.WARNING,
|
|
231
|
+
format="%(levelname)s %(name)s: %(message)s",
|
|
232
|
+
)
|
|
233
|
+
from semlayer import telemetry
|
|
234
|
+
|
|
235
|
+
telemetry.record("cli", command=args.command)
|
|
236
|
+
try:
|
|
237
|
+
return _HANDLERS[args.command](args)
|
|
238
|
+
except SemlayerError as e:
|
|
239
|
+
print(f"error: {e}", file=sys.stderr)
|
|
240
|
+
if e.hint:
|
|
241
|
+
print(f"hint: {e.hint}", file=sys.stderr)
|
|
242
|
+
return 2
|
|
243
|
+
|
|
244
|
+
|
|
245
|
+
if __name__ == "__main__":
|
|
246
|
+
sys.exit(main())
|