sqljev 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.
- sqljev/__init__.py +6 -0
- sqljev/__main__.py +3 -0
- sqljev/aws_lambda.py +16 -0
- sqljev/cli.py +516 -0
- sqljev/core.py +674 -0
- sqljev/demo.py +307 -0
- sqljev/duckdb.py +56 -0
- sqljev/finetune.py +367 -0
- sqljev/gateway.py +215 -0
- sqljev/spark.py +64 -0
- sqljev-0.1.0.dist-info/METADATA +450 -0
- sqljev-0.1.0.dist-info/RECORD +16 -0
- sqljev-0.1.0.dist-info/WHEEL +4 -0
- sqljev-0.1.0.dist-info/entry_points.txt +2 -0
- sqljev-0.1.0.dist-info/licenses/LICENSE +176 -0
- sqljev-0.1.0.dist-info/licenses/NOTICE +36 -0
sqljev/__init__.py
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
"""sqljev: ask your SQL rows questions in plain language, answered by Laya (open weights) or Jev."""
|
|
2
|
+
from .core import (BACKENDS, FUNCTIONS, Jev, JevError, __version__, default_engine, laya_question,
|
|
3
|
+
make_config, to_row_json)
|
|
4
|
+
|
|
5
|
+
__all__ = ["BACKENDS", "FUNCTIONS", "Jev", "JevError", "__version__", "default_engine", "laya_question",
|
|
6
|
+
"make_config", "to_row_json"]
|
sqljev/__main__.py
ADDED
sqljev/aws_lambda.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""AWS Lambda handler for Redshift Lambda UDFs (CREATE EXTERNAL FUNCTION ... LAMBDA 'sqljev').
|
|
2
|
+
|
|
3
|
+
Handler: sqljev.aws_lambda.handler. Configure with SQLJEV_* environment variables on the function, e.g.
|
|
4
|
+
SQLJEV_BACKEND=gateway + SQLJEV_API_URL=https://your-gateway/v1/eval, or SQLJEV_BACKEND=jev + TYPESAFE_API_KEY.
|
|
5
|
+
The engine (and its answer cache) lives as long as the Lambda container does.
|
|
6
|
+
"""
|
|
7
|
+
import json
|
|
8
|
+
|
|
9
|
+
from .core import default_engine
|
|
10
|
+
from .gateway import handle_redshift
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def handler(event, context=None):
|
|
14
|
+
if isinstance(event, (str, bytes)):
|
|
15
|
+
event = json.loads(event)
|
|
16
|
+
return json.dumps(handle_redshift(default_engine(), event))
|
sqljev/cli.py
ADDED
|
@@ -0,0 +1,516 @@
|
|
|
1
|
+
"""sqljev command line: plain-language questions over any SQLAlchemy database, the gateway, and the
|
|
2
|
+
fine-tuning loop (dataset -> fine-tune Laya -> eval).
|
|
3
|
+
|
|
4
|
+
sqljev query URL "SELECT * FROM tickets" --where "the customer is angry" --limit 20
|
|
5
|
+
sqljev query URL "SELECT * FROM tickets" --rank "the customer is angry" --limit 10
|
|
6
|
+
sqljev query URL "SELECT * FROM tickets" --choice "which team?" --options billing,technical,sales
|
|
7
|
+
sqljev materialize URL "SELECT id, body FROM tickets" --key id --prob "the customer is angry" --into t_angry
|
|
8
|
+
sqljev dataset URL "SELECT * FROM tickets" --label team --choice "which team?" -o tickets.jsonl
|
|
9
|
+
sqljev eval tickets.test.jsonl
|
|
10
|
+
sqljev gateway --port 8765
|
|
11
|
+
"""
|
|
12
|
+
import argparse
|
|
13
|
+
import csv
|
|
14
|
+
import hashlib
|
|
15
|
+
import json
|
|
16
|
+
import sys
|
|
17
|
+
import time
|
|
18
|
+
|
|
19
|
+
from . import core
|
|
20
|
+
from .core import Jev, JevError, check_question, laya_question, to_row_json
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
# ---------------------------------------------------------------- database access (SQLAlchemy)
|
|
24
|
+
|
|
25
|
+
def _engine(url):
|
|
26
|
+
try:
|
|
27
|
+
import sqlalchemy
|
|
28
|
+
except ImportError:
|
|
29
|
+
raise JevError("sqljev: database access needs SQLAlchemy: pip install 'sqljev[db]'")
|
|
30
|
+
return sqlalchemy.create_engine(url)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def stream_rows(url, sql, chunk=1000):
|
|
34
|
+
"""Rows as dicts, streamed with a server-side cursor where the driver supports it."""
|
|
35
|
+
import sqlalchemy
|
|
36
|
+
eng = _engine(url)
|
|
37
|
+
with eng.connect() as conn:
|
|
38
|
+
res = conn.execution_options(stream_results=True, yield_per=chunk).execute(sqlalchemy.text(sql))
|
|
39
|
+
for m in res.mappings():
|
|
40
|
+
yield dict(m)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _model_view(columns, exclude=()):
|
|
44
|
+
"""The part of a row the model sees: --columns selects, the label column is always excluded."""
|
|
45
|
+
cols = [c.strip() for c in columns.split(",")] if columns else None
|
|
46
|
+
|
|
47
|
+
def view(row):
|
|
48
|
+
return {k: v for k, v in row.items() if (cols is None or k in cols) and k not in exclude}
|
|
49
|
+
return view
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
# ---------------------------------------------------------------- question selection shared by commands
|
|
53
|
+
|
|
54
|
+
def _question(a, need=True):
|
|
55
|
+
picked = [(k, getattr(a, k)) for k in ("where", "rank", "prob", "choice", "score", "noul")
|
|
56
|
+
if getattr(a, k, None)]
|
|
57
|
+
if len(picked) != 1:
|
|
58
|
+
if not need:
|
|
59
|
+
return None
|
|
60
|
+
raise JevError("sqljev: give exactly one of --where / --rank / --prob / --choice / --score")
|
|
61
|
+
mode, text = picked[0]
|
|
62
|
+
kind = {"choice": "choice", "score": "score"}.get(mode, "noul")
|
|
63
|
+
opts = a.options if kind == "choice" else getattr(a, "levels", None) if kind == "score" else None
|
|
64
|
+
if kind == "choice" and opts is None and mode == "choice" and getattr(a, "label", None):
|
|
65
|
+
return mode, text, kind, None # dataset: options come from the label column
|
|
66
|
+
kind, opts = check_question(kind, opts)
|
|
67
|
+
return mode, text, kind, opts
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _settings(a):
|
|
71
|
+
return {k: getattr(a, k) for k in ("backend", "api_url", "model", "device", "batch_size", "concurrency",
|
|
72
|
+
"max_rows", "max_len") if getattr(a, k, None) is not None} | \
|
|
73
|
+
({"drop_nulls": False} if getattr(a, "keep_nulls", False) else {})
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _value(kind, a, opts):
|
|
77
|
+
if a is None:
|
|
78
|
+
return None
|
|
79
|
+
if kind == "noul":
|
|
80
|
+
return a["noul"]
|
|
81
|
+
if kind == "choice":
|
|
82
|
+
return a["choice"]
|
|
83
|
+
return a["score"]
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
# ---------------------------------------------------------------- output
|
|
87
|
+
|
|
88
|
+
def _fmt(v, width=40):
|
|
89
|
+
if isinstance(v, float):
|
|
90
|
+
return "%.3f" % v
|
|
91
|
+
s = "" if v is None else str(v)
|
|
92
|
+
s = s.replace("\n", " ")
|
|
93
|
+
return s if len(s) <= width else s[:width - 1] + "…"
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def write_rows(rows, fmt, out=None):
|
|
97
|
+
out = out or sys.stdout
|
|
98
|
+
rows = list(rows)
|
|
99
|
+
if not rows:
|
|
100
|
+
print("(0 rows)", file=sys.stderr)
|
|
101
|
+
return
|
|
102
|
+
cols = list(rows[0].keys())
|
|
103
|
+
if fmt == "jsonl":
|
|
104
|
+
for r in rows:
|
|
105
|
+
out.write(json.dumps(r, default=str, ensure_ascii=False) + "\n")
|
|
106
|
+
elif fmt == "csv":
|
|
107
|
+
w = csv.DictWriter(out, fieldnames=cols)
|
|
108
|
+
w.writeheader()
|
|
109
|
+
w.writerows(rows)
|
|
110
|
+
else:
|
|
111
|
+
cells = [[_fmt(r.get(c)) for c in cols] for r in rows]
|
|
112
|
+
widths = [max(len(c), *(len(row[i]) for row in cells)) for i, c in enumerate(cols)]
|
|
113
|
+
out.write(" | ".join(c.ljust(w) for c, w in zip(cols, widths)) + "\n")
|
|
114
|
+
out.write("-+-".join("-" * w for w in widths) + "\n")
|
|
115
|
+
for row in cells:
|
|
116
|
+
out.write(" | ".join(v.ljust(w) for v, w in zip(row, widths)) + "\n")
|
|
117
|
+
print("(%d row%s)" % (len(rows), "" if len(rows) == 1 else "s"), file=sys.stderr)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
# ---------------------------------------------------------------- commands
|
|
121
|
+
|
|
122
|
+
def cmd_query(a):
|
|
123
|
+
mode, text, kind, opts = _question(a)
|
|
124
|
+
jev = Jev(**_settings(a))
|
|
125
|
+
view = _model_view(a.columns)
|
|
126
|
+
rows = stream_rows(a.url, a.sql)
|
|
127
|
+
col = {"where": "jev_prob", "rank": "jev_prob", "prob": "jev_prob", "choice": "jev_choice",
|
|
128
|
+
"score": "jev_score"}[mode]
|
|
129
|
+
t0 = time.time()
|
|
130
|
+
if mode == "where":
|
|
131
|
+
# Streaming: rows are judged in order with a bounded read-ahead, so --limit stops the scan early.
|
|
132
|
+
threshold = a.threshold if a.threshold is not None else jev.cfg["threshold"]
|
|
133
|
+
|
|
134
|
+
def passing():
|
|
135
|
+
n = 0
|
|
136
|
+
for row, ans in jev.evaluate_iter(rows, text, kind, opts, key=view):
|
|
137
|
+
p = ans["noul"]
|
|
138
|
+
if p >= threshold:
|
|
139
|
+
yield {**row, col: p}
|
|
140
|
+
n += 1
|
|
141
|
+
if a.limit and n >= a.limit:
|
|
142
|
+
return
|
|
143
|
+
write_rows(passing(), a.format)
|
|
144
|
+
else:
|
|
145
|
+
all_rows = list(rows)
|
|
146
|
+
answers = jev.evaluate([view(r) for r in all_rows], text, kind, opts)
|
|
147
|
+
out = [{**r, col: _value(kind, ans, opts)} for r, ans in zip(all_rows, answers)]
|
|
148
|
+
if mode == "rank" or (mode == "score" and a.sort):
|
|
149
|
+
out.sort(key=lambda r: -(r[col] or 0))
|
|
150
|
+
write_rows(out[:a.limit] if a.limit else out, a.format)
|
|
151
|
+
_report(jev, t0, a)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def cmd_materialize(a):
|
|
155
|
+
"""Judge every row and write (key, value, answer) into a table, joinable in any database."""
|
|
156
|
+
import sqlalchemy as sa
|
|
157
|
+
mode, text, kind, opts = _question(a)
|
|
158
|
+
jev = Jev(**_settings(a))
|
|
159
|
+
view = _model_view(a.columns)
|
|
160
|
+
rows = list(stream_rows(a.url, a.sql))
|
|
161
|
+
if rows and a.key not in rows[0]:
|
|
162
|
+
raise JevError("sqljev: key column %r is not in the query result" % a.key)
|
|
163
|
+
t0 = time.time()
|
|
164
|
+
answers = jev.evaluate([view(r) for r in rows], text, kind, opts)
|
|
165
|
+
col = "jev_" + ("choice" if kind == "choice" else "score" if kind == "score" else "prob")
|
|
166
|
+
key_type = sa.BigInteger() if rows and isinstance(rows[0][a.key], int) else sa.String(255)
|
|
167
|
+
eng = _engine(a.url)
|
|
168
|
+
meta = sa.MetaData()
|
|
169
|
+
schema, _, name = a.into.rpartition(".")
|
|
170
|
+
table = sa.Table(name, meta, sa.Column(a.key, key_type, primary_key=True),
|
|
171
|
+
sa.Column(col, sa.String(255) if kind == "choice" else sa.Float()),
|
|
172
|
+
sa.Column("jev_answer", sa.Text()), schema=schema or None)
|
|
173
|
+
with eng.begin() as conn:
|
|
174
|
+
if a.replace:
|
|
175
|
+
table.drop(conn, checkfirst=True)
|
|
176
|
+
table.create(conn, checkfirst=True)
|
|
177
|
+
payload = [{a.key: r[a.key], col: _value(kind, ans, opts), "jev_answer": json.dumps(ans)}
|
|
178
|
+
for r, ans in zip(rows, answers)]
|
|
179
|
+
for i in range(0, len(payload), 1000):
|
|
180
|
+
conn.execute(table.insert(), payload[i:i + 1000])
|
|
181
|
+
print("sqljev: wrote %d rows to %s (%s)" % (len(rows), a.into, col), file=sys.stderr)
|
|
182
|
+
_report(jev, t0, a)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _truthy(v):
|
|
186
|
+
if isinstance(v, bool):
|
|
187
|
+
return v
|
|
188
|
+
s = str(v).strip().lower()
|
|
189
|
+
if s in ("1", "true", "t", "yes", "y"):
|
|
190
|
+
return True
|
|
191
|
+
if s in ("0", "false", "f", "no", "n"):
|
|
192
|
+
return False
|
|
193
|
+
raise JevError("sqljev: label %r is not a boolean" % (v,))
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def cmd_dataset(a):
|
|
197
|
+
"""Labelled SQL rows -> Laya training/eval data, using exactly the state and question sqljev asks at
|
|
198
|
+
query time, so a checkpoint fine-tuned on it sees what it will be asked."""
|
|
199
|
+
mode, text, kind, opts = _question(a)
|
|
200
|
+
view = _model_view(a.columns, exclude={a.label})
|
|
201
|
+
rows = list(stream_rows(a.url, a.sql))
|
|
202
|
+
if not rows:
|
|
203
|
+
raise JevError("sqljev: the query returned no rows")
|
|
204
|
+
if a.label not in rows[0]:
|
|
205
|
+
raise JevError("sqljev: label column %r is not in the query result" % a.label)
|
|
206
|
+
if kind == "choice" and opts is None:
|
|
207
|
+
opts = sorted({str(r[a.label]) for r in rows if r[a.label] is not None})
|
|
208
|
+
kind, opts = check_question(kind, opts)
|
|
209
|
+
q = {"q": laya_question(kind, text, opts)}
|
|
210
|
+
base = a.output[:-6] if a.output.endswith(".jsonl") else a.output
|
|
211
|
+
outs = {"train": open(base + ".train.jsonl", "w"), "test": open(base + ".test.jsonl", "w")} \
|
|
212
|
+
if a.test_fraction else {"all": open(a.output, "w")}
|
|
213
|
+
counts = {k: 0 for k in outs}
|
|
214
|
+
skipped = 0
|
|
215
|
+
for r in rows:
|
|
216
|
+
lab = r[a.label]
|
|
217
|
+
if lab is None:
|
|
218
|
+
skipped += 1
|
|
219
|
+
continue
|
|
220
|
+
if kind == "noul":
|
|
221
|
+
expected = _truthy(lab)
|
|
222
|
+
gold = {"label": "true" if expected else "false",
|
|
223
|
+
"probabilities": {"true": float(expected), "false": float(not expected)}}
|
|
224
|
+
elif kind == "choice":
|
|
225
|
+
expected = str(lab)
|
|
226
|
+
if expected not in opts:
|
|
227
|
+
skipped += 1
|
|
228
|
+
continue
|
|
229
|
+
gold = {"label": expected, "probabilities": {o: float(o == expected) for o in opts}}
|
|
230
|
+
else:
|
|
231
|
+
idx = opts.index(str(lab)) if str(lab) in opts else int(lab)
|
|
232
|
+
expected = idx
|
|
233
|
+
gold = {"label": idx, "probabilities": {str(i): float(i == idx) for i in range(len(opts))}}
|
|
234
|
+
state = json.loads(to_row_json(view(r), not a.keep_nulls))
|
|
235
|
+
rid = hashlib.sha1(json.dumps(state, sort_keys=True, default=str).encode()).hexdigest()[:16]
|
|
236
|
+
split = "all"
|
|
237
|
+
if a.test_fraction:
|
|
238
|
+
split = "test" if int(rid[:8], 16) / 0xFFFFFFFF < a.test_fraction else "train"
|
|
239
|
+
if a.format == "typed-decisions":
|
|
240
|
+
rec = {"id": rid, "workflow": a.workflow, "state": json.dumps(state, ensure_ascii=False),
|
|
241
|
+
"questions": json.dumps(q), "gold": json.dumps({"q": gold})}
|
|
242
|
+
else:
|
|
243
|
+
rec = {"state": state, "questions": q, "expected": {"q": expected}, "tags": [a.workflow]}
|
|
244
|
+
outs[split].write(json.dumps(rec, ensure_ascii=False, default=str) + "\n")
|
|
245
|
+
counts[split] += 1
|
|
246
|
+
for f in outs.values():
|
|
247
|
+
f.close()
|
|
248
|
+
print("sqljev: %s%s" % (", ".join("%s %d" % kv for kv in counts.items()),
|
|
249
|
+
", skipped %d (no/unknown label)" % skipped if skipped else ""), file=sys.stderr)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def cmd_eval(a):
|
|
253
|
+
"""Accuracy of the configured backend / checkpoint on a dataset file (from `sqljev dataset`)."""
|
|
254
|
+
from .finetune import accuracy
|
|
255
|
+
s = _settings(a)
|
|
256
|
+
res = accuracy(a.file, **s)
|
|
257
|
+
print(json.dumps({"backend": s.get("backend", "local"), "model": s.get("model"), **res}))
|
|
258
|
+
if a.min_accuracy is not None and res["accuracy"] < a.min_accuracy:
|
|
259
|
+
sys.exit(1)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def cmd_finetune(a):
|
|
263
|
+
from .finetune import finetune
|
|
264
|
+
finetune(a.train, a.out, base=a.base, epochs=a.epochs, micro_batch=a.micro_batch, grad_accum=a.grad_accum,
|
|
265
|
+
train_layers=a.train_layers, device=a.device, seed=a.seed, log=lambda m: print(m, file=sys.stderr, flush=True))
|
|
266
|
+
print("sqljev: use it with --model %s (or SQLJEV_MODEL=%s)" % (a.out, a.out), file=sys.stderr)
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def cmd_publish(a):
|
|
270
|
+
import os
|
|
271
|
+
from .finetune import publish
|
|
272
|
+
metrics = json.loads(a.metrics) if a.metrics else None
|
|
273
|
+
url = publish(a.dir, a.repo, token=os.environ.get("HF_TOKEN"), private=not a.public, metrics=metrics)
|
|
274
|
+
print("sqljev: published %s\nsqljev: every database can now use SQLJEV_MODEL=%s" % (url, a.repo), file=sys.stderr)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def cmd_gateway(a):
|
|
278
|
+
from .gateway import serve
|
|
279
|
+
serve(a.host, a.port, Jev(**_settings(a)), certfile=a.certfile, keyfile=a.keyfile)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _quote(dialect, ident):
|
|
283
|
+
q = {"mssql": ("[", "]"), "mysql": ("`", "`"), "mariadb": ("`", "`")}.get(dialect, ('"', '"'))
|
|
284
|
+
return q[0] + ident.replace(q[1], q[1] * 2) + q[1]
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _lit(s):
|
|
288
|
+
return "'" + s.replace("'", "''") + "'"
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
class _Dialect:
|
|
292
|
+
"""What `sqljev judge` needs from each database: resolve the source, a row -> JSON expression computed by
|
|
293
|
+
the database itself, and the jev.answers / jev_answers statements of sql/<db>/install.sql."""
|
|
294
|
+
|
|
295
|
+
def __init__(self, conn, source, columns):
|
|
296
|
+
import sqlalchemy as sa
|
|
297
|
+
self.sa, self.conn, self.name = sa, conn, conn.dialect.name
|
|
298
|
+
cols = [c.strip() for c in columns.split(",")] if columns else None
|
|
299
|
+
if self.name == "mssql":
|
|
300
|
+
self.table = conn.execute(sa.text(
|
|
301
|
+
"SELECT QUOTENAME(OBJECT_SCHEMA_NAME(OBJECT_ID(:s))) + N'.' + QUOTENAME(OBJECT_NAME(OBJECT_ID(:s)))"),
|
|
302
|
+
{"s": source}).scalar()
|
|
303
|
+
inner = ", ".join("t." + _quote(self.name, c) for c in cols) if cols else "t.*"
|
|
304
|
+
self.row = "(SELECT %s FOR JSON PATH, WITHOUT_ARRAY_WRAPPER)" % inner
|
|
305
|
+
self.hash, self.answers, self.qkey = "jev.row_hash(%s)", "jev.answers", "SELECT jev.question_key(:q, :k, :o)"
|
|
306
|
+
elif self.name == "postgresql":
|
|
307
|
+
self.table = conn.execute(sa.text("SELECT to_regclass(:s)::text"), {"s": source}).scalar()
|
|
308
|
+
self.row = ("jsonb_build_object(%s)" % ", ".join("%s, t.%s" % (_lit(c), _quote(self.name, c)) for c in cols)
|
|
309
|
+
if cols else "to_jsonb(t)")
|
|
310
|
+
self.hash, self.answers = "jev.row_hash(%s)", "jev.answers"
|
|
311
|
+
self.qkey = "SELECT jev.question_key(:q, :k, CAST(:o AS text[]))"
|
|
312
|
+
elif self.name in ("mysql", "mariadb"):
|
|
313
|
+
schema, _, tbl = source.rpartition(".")
|
|
314
|
+
found = conn.execute(sa.text(
|
|
315
|
+
"SELECT COLUMN_NAME FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = COALESCE(:sc, DATABASE()) "
|
|
316
|
+
"AND TABLE_NAME = :t ORDER BY ORDINAL_POSITION"), {"sc": schema or None, "t": tbl}).scalars().all()
|
|
317
|
+
self.table = ".".join(_quote(self.name, x) for x in ([schema] if schema else []) + [tbl]) if found else None
|
|
318
|
+
use = [c for c in found if c in cols] if cols else found
|
|
319
|
+
self.row = "JSON_OBJECT(%s)" % ", ".join("%s, t.%s" % (_lit(c), _quote(self.name, c)) for c in use)
|
|
320
|
+
self.hash, self.answers, self.qkey = "jev_row_hash(%s)", "jev_answers", "SELECT jev_question_key(:q, :k, :o)"
|
|
321
|
+
else:
|
|
322
|
+
raise JevError("sqljev: judge supports SQL Server, PostgreSQL, MySQL and MariaDB (got %s); for other "
|
|
323
|
+
"databases use `sqljev materialize`" % self.name)
|
|
324
|
+
if not self.table:
|
|
325
|
+
raise JevError("sqljev: %r is not a table or view" % source)
|
|
326
|
+
|
|
327
|
+
def question_key(self, text, kind, opts):
|
|
328
|
+
o = opts if self.name == "postgresql" else (json.dumps(opts) if opts else None)
|
|
329
|
+
return self.conn.execute(self.sa.text(self.qkey), {"q": text, "k": kind, "o": o}).scalar()
|
|
330
|
+
|
|
331
|
+
def pending(self, qkey, where):
|
|
332
|
+
"""(row_hash, row_json) for rows with no answer yet, de-duplicated by content."""
|
|
333
|
+
sql = ("SELECT %s AS h, x.j AS j FROM (SELECT %s AS j FROM %s AS t%s) AS x WHERE NOT EXISTS "
|
|
334
|
+
"(SELECT 1 FROM %s AS a WHERE a.question_key = :qk AND a.row_hash = %s)"
|
|
335
|
+
% (self.hash % "x.j", self.row, self.table, " WHERE " + where if where else "", self.answers,
|
|
336
|
+
self.hash % "x.j"))
|
|
337
|
+
todo = {}
|
|
338
|
+
for h, j in self.conn.execute(self.sa.text(sql), {"qk": qkey}):
|
|
339
|
+
todo.setdefault(bytes(h), j if isinstance(j, str) else json.dumps(j))
|
|
340
|
+
return todo
|
|
341
|
+
|
|
342
|
+
def store(self, qkey, pairs):
|
|
343
|
+
sql = {"mssql": "INSERT jev.answers (question_key, row_hash, answer) VALUES (:qk, :h, :a)",
|
|
344
|
+
"postgresql": "INSERT INTO jev.answers (question_key, row_hash, answer) VALUES (:qk, :h, CAST(:a AS jsonb))"
|
|
345
|
+
" ON CONFLICT DO NOTHING"}.get(
|
|
346
|
+
self.name, "INSERT IGNORE INTO jev_answers (question_key, row_hash, answer) VALUES (:qk, :h, :a)")
|
|
347
|
+
rows = [{"qk": qkey, "h": h, "a": json.dumps(a)} for h, a in pairs]
|
|
348
|
+
for i in range(0, len(rows), 500):
|
|
349
|
+
self.conn.execute(self.sa.text(sql), rows[i:i + 500])
|
|
350
|
+
|
|
351
|
+
def usage(self, mode, text, opts):
|
|
352
|
+
fn = ("jev." if self.name in ("mssql", "postgresql") else "jev_") + \
|
|
353
|
+
{"choice": "choice", "score": "score"}.get(mode, "prob")
|
|
354
|
+
n = "N" if self.name == "mssql" else ""
|
|
355
|
+
args = [self.row, n + _lit(text)]
|
|
356
|
+
if opts:
|
|
357
|
+
args.append("ARRAY[%s]" % ", ".join(_lit(o) for o in opts) if self.name == "postgresql"
|
|
358
|
+
else n + _lit(json.dumps(opts)))
|
|
359
|
+
return "%s(%s)" % (fn, ", ".join(args))
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def cmd_judge(a):
|
|
363
|
+
"""Judge every row of a table that has no answer yet and store the answers in the database, where the
|
|
364
|
+
lookup functions of sql/<db>/install.sql read them. The database computes each row's JSON and hash itself,
|
|
365
|
+
so its functions find exactly these answers, and a row edited later is judged again on the next run."""
|
|
366
|
+
mode, text, kind, opts = _question(a)
|
|
367
|
+
jev = Jev(**_settings(a))
|
|
368
|
+
t0 = time.time()
|
|
369
|
+
with _engine(a.url).begin() as conn:
|
|
370
|
+
d = _Dialect(conn, a.source, a.columns)
|
|
371
|
+
qkey = d.question_key(text, kind, opts)
|
|
372
|
+
todo = d.pending(qkey, a.filter)
|
|
373
|
+
answers = jev.evaluate(list(todo.values()), text, kind, opts)
|
|
374
|
+
d.store(qkey, zip(todo, answers))
|
|
375
|
+
print("sqljev: judged %d new rows of %s" % (len(todo), d.table), file=sys.stderr)
|
|
376
|
+
print("sqljev: read them with %s" % d.usage(mode, text, opts), file=sys.stderr)
|
|
377
|
+
_report(jev, t0, a)
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def _report(jev, t0, a):
|
|
381
|
+
if a.stats:
|
|
382
|
+
s = jev.stats()
|
|
383
|
+
s["seconds"] = round(time.time() - t0, 2)
|
|
384
|
+
print("sqljev: " + json.dumps(s), file=sys.stderr)
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
# ---------------------------------------------------------------- argument parsing
|
|
388
|
+
|
|
389
|
+
def _common(p):
|
|
390
|
+
g = p.add_argument_group("model")
|
|
391
|
+
g.add_argument("--backend", choices=core.BACKENDS, help="default: local (Laya in-process); env SQLJEV_BACKEND")
|
|
392
|
+
g.add_argument("--api-url", help="gateway / laya-serve / Jev endpoint")
|
|
393
|
+
g.add_argument("--model", help="Laya checkpoint (english, multilingual, typed-decisions), a fine-tuned "
|
|
394
|
+
"checkpoint directory or Hub id, or a Jev model id")
|
|
395
|
+
g.add_argument("--device", help="cpu, cuda, mps (local backend)")
|
|
396
|
+
g.add_argument("--batch-size", type=int, help="rows per forward pass / request")
|
|
397
|
+
g.add_argument("--concurrency", type=int)
|
|
398
|
+
g.add_argument("--max-len", type=int, help="Laya token budget per row (multilingual reads up to 8192)")
|
|
399
|
+
g.add_argument("--max-rows", type=int, help="spend guard: refuse to send more rows than this")
|
|
400
|
+
g.add_argument("--keep-nulls", action="store_true", help="send NULL columns to the model too")
|
|
401
|
+
g.add_argument("--stats", action="store_true", help="print engine stats to stderr")
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def _questions(p, dataset=False):
|
|
405
|
+
g = p.add_argument_group("question (exactly one)")
|
|
406
|
+
if dataset:
|
|
407
|
+
g.add_argument("--noul", metavar="CONDITION", help="yes/no condition; label column holds true/false")
|
|
408
|
+
else:
|
|
409
|
+
g.add_argument("--where", metavar="CONDITION", help="keep rows that satisfy the condition (streams)")
|
|
410
|
+
g.add_argument("--rank", metavar="CONDITION", help="order rows by probability, most likely first")
|
|
411
|
+
g.add_argument("--prob", metavar="CONDITION", help="add jev_prob for every row")
|
|
412
|
+
g.add_argument("--choice", metavar="QUESTION", help="classify each row into one of --options")
|
|
413
|
+
g.add_argument("--score", metavar="QUESTION", help="rate each row along ordered --levels")
|
|
414
|
+
g.add_argument("--options", type=core.parse_options, help="comma list or JSON array")
|
|
415
|
+
g.add_argument("--levels", type=core.parse_options, help="comma list or JSON array, lowest first")
|
|
416
|
+
p.add_argument("--columns", help="comma list of columns the model sees (default: all)")
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
def main(argv=None):
|
|
420
|
+
p = argparse.ArgumentParser(prog="sqljev", description="Ask your SQL rows questions in plain language.")
|
|
421
|
+
p.add_argument("--version", action="version", version="sqljev " + core.__version__)
|
|
422
|
+
sub = p.add_subparsers(dest="cmd", required=True)
|
|
423
|
+
|
|
424
|
+
q = sub.add_parser("query", help="run a SELECT and filter / rank / classify its rows")
|
|
425
|
+
q.add_argument("url", help="SQLAlchemy URL, e.g. postgresql://..., mssql+pyodbc://..., snowflake://...")
|
|
426
|
+
q.add_argument("sql")
|
|
427
|
+
_questions(q)
|
|
428
|
+
q.add_argument("--threshold", type=float)
|
|
429
|
+
q.add_argument("--limit", type=int)
|
|
430
|
+
q.add_argument("--sort", action="store_true", help="with --score: highest first")
|
|
431
|
+
q.add_argument("--format", choices=("table", "csv", "jsonl"), default="table")
|
|
432
|
+
_common(q)
|
|
433
|
+
q.set_defaults(fn=cmd_query)
|
|
434
|
+
|
|
435
|
+
m = sub.add_parser("materialize", help="write judgments into a table, keyed by a column")
|
|
436
|
+
m.add_argument("url")
|
|
437
|
+
m.add_argument("sql")
|
|
438
|
+
m.add_argument("--key", required=True)
|
|
439
|
+
m.add_argument("--into", required=True, help="table name ([schema.]table)")
|
|
440
|
+
m.add_argument("--replace", action="store_true")
|
|
441
|
+
_questions(m)
|
|
442
|
+
_common(m)
|
|
443
|
+
m.set_defaults(fn=cmd_materialize)
|
|
444
|
+
|
|
445
|
+
d = sub.add_parser("dataset", help="labelled rows -> Laya fine-tuning / eval JSONL")
|
|
446
|
+
d.add_argument("url")
|
|
447
|
+
d.add_argument("sql")
|
|
448
|
+
d.add_argument("--label", required=True, help="column holding the ground truth (never shown to the model)")
|
|
449
|
+
d.add_argument("-o", "--output", required=True)
|
|
450
|
+
d.add_argument("--format", choices=("laya-evals", "typed-decisions"), default="laya-evals")
|
|
451
|
+
d.add_argument("--test-fraction", type=float, default=0.0, help="write OUTPUT.train/.test.jsonl")
|
|
452
|
+
d.add_argument("--workflow", default="sql")
|
|
453
|
+
d.add_argument("--keep-nulls", action="store_true")
|
|
454
|
+
_questions(d, dataset=True)
|
|
455
|
+
d.set_defaults(fn=cmd_dataset)
|
|
456
|
+
|
|
457
|
+
e = sub.add_parser("eval", help="accuracy of a backend / checkpoint on a dataset file")
|
|
458
|
+
e.add_argument("file")
|
|
459
|
+
e.add_argument("--min-accuracy", type=float)
|
|
460
|
+
_common(e)
|
|
461
|
+
e.set_defaults(fn=cmd_eval)
|
|
462
|
+
|
|
463
|
+
f = sub.add_parser("finetune", help="fine-tune Laya on a `sqljev dataset` file (one GPU)")
|
|
464
|
+
f.add_argument("train", help="training JSONL from `sqljev dataset`")
|
|
465
|
+
f.add_argument("--out", required=True, help="output checkpoint directory")
|
|
466
|
+
f.add_argument("--base", default="convaiinnovations/laya",
|
|
467
|
+
help="english (default), multilingual, typed-decisions, a Hub id or a local checkpoint")
|
|
468
|
+
f.add_argument("--epochs", type=int, default=3)
|
|
469
|
+
f.add_argument("--micro-batch", type=int, default=8)
|
|
470
|
+
f.add_argument("--grad-accum", type=int, default=8)
|
|
471
|
+
f.add_argument("--train-layers", type=int, help="low-memory mode: train only the top N encoder layers + head")
|
|
472
|
+
f.add_argument("--device")
|
|
473
|
+
f.add_argument("--seed", type=int, default=0)
|
|
474
|
+
f.set_defaults(fn=cmd_finetune)
|
|
475
|
+
|
|
476
|
+
pb = sub.add_parser("publish", help="upload a fine-tuned checkpoint to the Hugging Face Hub (HF_TOKEN)")
|
|
477
|
+
pb.add_argument("dir")
|
|
478
|
+
pb.add_argument("--repo", required=True, help="e.g. your-org/laya-tickets")
|
|
479
|
+
pb.add_argument("--public", action="store_true", help="default: private")
|
|
480
|
+
pb.add_argument("--metrics", help='JSON, e.g. \'{"base": 0.41, "finetuned": 0.93}\'')
|
|
481
|
+
pb.set_defaults(fn=cmd_publish)
|
|
482
|
+
|
|
483
|
+
g = sub.add_parser("gateway", help="serve the HTTP gateway for SQL Server, Snowflake, BigQuery, Redshift")
|
|
484
|
+
g.add_argument("--host", default="127.0.0.1")
|
|
485
|
+
g.add_argument("--port", type=int, default=8765)
|
|
486
|
+
g.add_argument("--certfile", help="serve HTTPS with this certificate (PEM)")
|
|
487
|
+
g.add_argument("--keyfile", help="private key for --certfile")
|
|
488
|
+
_common(g)
|
|
489
|
+
g.set_defaults(fn=cmd_gateway)
|
|
490
|
+
|
|
491
|
+
s = sub.add_parser("judge", help="judge a table into jev answers (SQL Server, PostgreSQL, MySQL, MariaDB)")
|
|
492
|
+
s.add_argument("url", help="mssql+pymssql://..., postgresql://..., mysql+pymysql://..., mariadb+pymysql://...")
|
|
493
|
+
s.add_argument("--source", required=True, help="table or view, e.g. dbo.tickets / public.tickets / tickets")
|
|
494
|
+
s.add_argument("--where", dest="filter", help="filter in the database's SQL, applied before judging")
|
|
495
|
+
s.add_argument("--columns", help="comma list of columns the model sees (default: all)")
|
|
496
|
+
g2 = s.add_argument_group("question (exactly one)")
|
|
497
|
+
g2.add_argument("--prob", metavar="CONDITION")
|
|
498
|
+
g2.add_argument("--choice", metavar="QUESTION")
|
|
499
|
+
g2.add_argument("--score", metavar="QUESTION")
|
|
500
|
+
g2.add_argument("--options", type=core.parse_options)
|
|
501
|
+
g2.add_argument("--levels", type=core.parse_options)
|
|
502
|
+
_common(s)
|
|
503
|
+
s.set_defaults(fn=cmd_judge)
|
|
504
|
+
|
|
505
|
+
a = p.parse_args(argv)
|
|
506
|
+
try:
|
|
507
|
+
a.fn(a)
|
|
508
|
+
except JevError as e:
|
|
509
|
+
print(str(e), file=sys.stderr)
|
|
510
|
+
sys.exit(2)
|
|
511
|
+
except BrokenPipeError:
|
|
512
|
+
pass
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
if __name__ == "__main__":
|
|
516
|
+
main()
|