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/core.py
ADDED
|
@@ -0,0 +1,674 @@
|
|
|
1
|
+
"""sqljev.core — plain-language predicates for SQL rows, judged by a System One decision model.
|
|
2
|
+
|
|
3
|
+
j = Jev() # backend "local": Laya in-process
|
|
4
|
+
j.prob(rows, "the customer is angry") # [0.93, 0.08, ...]
|
|
5
|
+
j.call("jev_choice", [(row, "which team?", ["billing", "technical"]), ...])
|
|
6
|
+
|
|
7
|
+
A SQL question is the same question over many rows, so everything here is organised around one
|
|
8
|
+
(question, rows) pair: rows are serialised to compact JSON objects, de-duplicated by content, looked up
|
|
9
|
+
in an answer cache and only the misses are sent to the model, in as few forward passes / requests as the
|
|
10
|
+
backend allows.
|
|
11
|
+
|
|
12
|
+
Backends (setting `backend`, env SQLJEV_BACKEND):
|
|
13
|
+
|
|
14
|
+
local Laya (Convai Innovations, Apache 2.0) in this process via `laya` — `predict_batch` packs
|
|
15
|
+
many rows into shared forward passes. Default. Fastest, free, data never leaves the host.
|
|
16
|
+
gateway a `sqljev gateway` over HTTP (which runs Laya locally) — many rows per request.
|
|
17
|
+
For databases that call out over HTTP: SQL Server, Snowflake, BigQuery, Redshift.
|
|
18
|
+
laya-serve a stock `laya-serve` over the Jev wire protocol (POST /v1/systemone) — one row per request.
|
|
19
|
+
jev TypeSafe's hosted Jev (POST /v1/systemone) — 20 rows per request in one shared state.
|
|
20
|
+
|
|
21
|
+
This file is deliberately one stdlib-only module, so it can be pasted into a database UDF body.
|
|
22
|
+
`laya` (and torch) are imported lazily and only by the `local` backend.
|
|
23
|
+
|
|
24
|
+
The connection pool, retries and the Jev batch prompt are ported from pg-jev
|
|
25
|
+
(https://github.com/realZachi/pg-jev, PostgreSQL License, Copyright (c) 2026 Zachi).
|
|
26
|
+
"""
|
|
27
|
+
import hashlib
|
|
28
|
+
import http.client
|
|
29
|
+
import json
|
|
30
|
+
import os
|
|
31
|
+
import random
|
|
32
|
+
import select
|
|
33
|
+
import socket
|
|
34
|
+
import ssl
|
|
35
|
+
import threading
|
|
36
|
+
import time
|
|
37
|
+
from collections import OrderedDict, deque
|
|
38
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
39
|
+
from urllib.parse import urlsplit
|
|
40
|
+
|
|
41
|
+
__version__ = "0.1.0"
|
|
42
|
+
|
|
43
|
+
BACKENDS = ("local", "gateway", "laya-serve", "jev")
|
|
44
|
+
KINDS = ("noul", "score", "choice")
|
|
45
|
+
LAYA_CHECKPOINTS = ("english", "multilingual", "typed-decisions")
|
|
46
|
+
JEV_USD_PER_INPUT_TOKEN = 0.042 / 1_000_000 # jev-1.13 list price; output tokens are free
|
|
47
|
+
|
|
48
|
+
# Per-backend defaults. batch_size means: rows per forward pass (local), rows per HTTP request (the rest).
|
|
49
|
+
BACKEND_DEFAULTS = {
|
|
50
|
+
"local": {"api_url": None, "batch_size": 64, "concurrency": 1},
|
|
51
|
+
"gateway": {"api_url": "http://127.0.0.1:8765/v1/eval", "batch_size": 256, "concurrency": 4},
|
|
52
|
+
# Laya reads one state of 512-1,024 tokens: packing several rows into it would cut rows off.
|
|
53
|
+
"laya-serve": {"api_url": "http://127.0.0.1:8000/v1/systemone", "batch_size": 1, "concurrency": 4},
|
|
54
|
+
# pg-jev measured: batches of 1-20 rows 100 % correct, 40 rows 92-98 %, 80 rows 77-94 %.
|
|
55
|
+
"jev": {"api_url": "https://api.typesafe.ai/v1/systemone", "batch_size": 20, "concurrency": 16,
|
|
56
|
+
"model": "jev-latest"},
|
|
57
|
+
}
|
|
58
|
+
COMMON_DEFAULTS = {"backend": "local", "api_key": None, "model": None, "device": None, "max_len": None,
|
|
59
|
+
"threshold": 0.5, "timeout": 60.0, "keepalive": 600.0, "max_rows": 0, "max_chars": 0,
|
|
60
|
+
"cache_size": 200_000, "drop_nulls": True}
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _bool(v):
|
|
64
|
+
return v if isinstance(v, bool) else str(v).strip().lower() in ("1", "true", "on", "yes")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
SETTINGS = {"backend": str, "api_url": str, "api_key": str, "model": str, "device": str, "max_len": int,
|
|
68
|
+
"batch_size": int, "concurrency": int, "threshold": float, "timeout": float, "keepalive": float,
|
|
69
|
+
"max_rows": int, "max_chars": int, "cache_size": int, "drop_nulls": _bool}
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class JevError(RuntimeError):
|
|
73
|
+
"""Raised for configuration, validation, spend-guard and model/API errors."""
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def make_config(env=None, **overrides):
|
|
77
|
+
"""Settings from SQLJEV_<NAME> environment variables, then keyword overrides (None = unset),
|
|
78
|
+
then the backend's defaults. Returns a plain dict (picklable, so it travels to Spark executors)."""
|
|
79
|
+
env = os.environ if env is None else env
|
|
80
|
+
cfg = {}
|
|
81
|
+
for name, cast in SETTINGS.items():
|
|
82
|
+
v = env.get("SQLJEV_" + name.upper())
|
|
83
|
+
if v not in (None, ""):
|
|
84
|
+
cfg[name] = cast(v)
|
|
85
|
+
for name, v in overrides.items():
|
|
86
|
+
if name not in SETTINGS:
|
|
87
|
+
raise JevError("sqljev: unknown setting %r (known: %s)" % (name, ", ".join(sorted(SETTINGS))))
|
|
88
|
+
if v is not None:
|
|
89
|
+
cfg[name] = SETTINGS[name](v)
|
|
90
|
+
backend = cfg.get("backend", COMMON_DEFAULTS["backend"])
|
|
91
|
+
if backend not in BACKENDS:
|
|
92
|
+
raise JevError("sqljev: unknown backend %r (one of %s)" % (backend, ", ".join(BACKENDS)))
|
|
93
|
+
for k, v in {**COMMON_DEFAULTS, **BACKEND_DEFAULTS[backend]}.items():
|
|
94
|
+
cfg.setdefault(k, v)
|
|
95
|
+
if not cfg["api_key"]:
|
|
96
|
+
cfg["api_key"] = env.get({"jev": "TYPESAFE_API_KEY", "laya-serve": "LAYA_API_KEY",
|
|
97
|
+
"gateway": "SQLJEV_GATEWAY_TOKEN"}.get(backend, ""), None)
|
|
98
|
+
if backend == "laya-serve":
|
|
99
|
+
cfg["batch_size"] = 1
|
|
100
|
+
cfg["batch_size"] = max(1, cfg["batch_size"])
|
|
101
|
+
cfg["concurrency"] = max(1, cfg["concurrency"])
|
|
102
|
+
return cfg
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
# ---------------------------------------------------------------- rows
|
|
106
|
+
|
|
107
|
+
def to_row_json(row, drop_nulls=True):
|
|
108
|
+
"""Compact JSON text for a row. dicts (column -> value) become objects; SQL NULL columns are dropped
|
|
109
|
+
by default, which saves tokens in Laya's 512-1,024-token window. A string that already holds JSON
|
|
110
|
+
(to_json(t), OBJECT_CONSTRUCT(*), FOR JSON ...) is re-encoded the same way; other strings become a
|
|
111
|
+
JSON string."""
|
|
112
|
+
if isinstance(row, (bytes, bytearray)):
|
|
113
|
+
row = row.decode()
|
|
114
|
+
if isinstance(row, str):
|
|
115
|
+
try:
|
|
116
|
+
row = json.loads(row)
|
|
117
|
+
except ValueError:
|
|
118
|
+
return json.dumps(row, ensure_ascii=False)
|
|
119
|
+
if drop_nulls and isinstance(row, dict):
|
|
120
|
+
row = {k: v for k, v in row.items() if v is not None}
|
|
121
|
+
return json.dumps(row, ensure_ascii=False, separators=(",", ":"), default=str)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def row_hash(text):
|
|
125
|
+
return hashlib.sha1(text.encode()).hexdigest()
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def parse_options(v):
|
|
129
|
+
"""text[] / ARRAY / list / numpy array / '["a","b"]' / 'a,b' -> list of str, or None."""
|
|
130
|
+
if v is None:
|
|
131
|
+
return None
|
|
132
|
+
if hasattr(v, "tolist"):
|
|
133
|
+
v = v.tolist()
|
|
134
|
+
if isinstance(v, str):
|
|
135
|
+
s = v.strip()
|
|
136
|
+
if s.startswith("["):
|
|
137
|
+
v = json.loads(s)
|
|
138
|
+
else:
|
|
139
|
+
v = [p.strip() for p in s.split(",") if p.strip()]
|
|
140
|
+
return [str(o) for o in v]
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def check_question(kind, options):
|
|
144
|
+
if kind not in KINDS:
|
|
145
|
+
raise JevError("sqljev: unknown kind %r (one of noul, score, choice)" % (kind,))
|
|
146
|
+
opts = parse_options(options)
|
|
147
|
+
if kind == "noul":
|
|
148
|
+
return kind, None
|
|
149
|
+
if not opts or len(opts) < 2:
|
|
150
|
+
raise JevError("sqljev: %s needs at least two %s" % (kind, "levels" if kind == "score" else "options"))
|
|
151
|
+
return kind, opts
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
# ---------------------------------------------------------------- questions: one place, used at inference and for training data
|
|
155
|
+
|
|
156
|
+
def _clause(text):
|
|
157
|
+
return text.strip().rstrip("?.!").strip()
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def laya_question(kind, query, opts):
|
|
161
|
+
"""The question Laya is asked about one SQL row (the row is the state). `sqljev dataset` builds
|
|
162
|
+
training data with this same function, so a fine-tuned checkpoint sees exactly what it is asked."""
|
|
163
|
+
if kind == "noul":
|
|
164
|
+
# A statement ("the customer is angry") becomes "Is it true that the customer is angry?"; a question
|
|
165
|
+
# is asked as written. On labelled rows this beat "Does this record satisfy ..." phrasings.
|
|
166
|
+
q = query.strip()
|
|
167
|
+
return {"type": "noul", "instructions": q if q.endswith("?") else "Is it true that %s?" % _clause(q)}
|
|
168
|
+
if kind == "score":
|
|
169
|
+
return {"type": "score", "instructions": query, "criteria": list(opts)}
|
|
170
|
+
return {"type": "choice", "instructions": query, "criteria": {o: o for o in opts}}
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def jev_question(i, kind, query, opts):
|
|
174
|
+
"""pg-jev's batch prompt: one shared state {"condition", "rows"}, one question per row index."""
|
|
175
|
+
ref = "rows[%d]" % i
|
|
176
|
+
if kind == "noul":
|
|
177
|
+
return {"type": "noul",
|
|
178
|
+
"instructions": "Does the record `%s` satisfy the condition stated in `condition`?" % ref}
|
|
179
|
+
if kind == "score":
|
|
180
|
+
return {"type": "score", "instructions": "Rate the record `%s`: %s" % (ref, query), "criteria": opts}
|
|
181
|
+
return {"type": "choice", "instructions": "For the record `%s`: %s" % (ref, query),
|
|
182
|
+
"criteria": {o: None for o in opts}}
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
# ---------------------------------------------------------------- UDF-style dispatch shared by every database adapter
|
|
186
|
+
|
|
187
|
+
# name -> (kind or None when it is an argument, min args, max args); args start with (row, question)
|
|
188
|
+
FUNCTIONS = {
|
|
189
|
+
"jev": ("noul", 2, 3), # row, condition [, threshold] -> boolean
|
|
190
|
+
"jev_prob": ("noul", 2, 2), # row, condition -> float
|
|
191
|
+
"jev_score": ("score", 3, 3), # row, question, levels -> float 0..n-1
|
|
192
|
+
"jev_score_norm": ("score", 3, 3), # row, question, levels -> float 0..1
|
|
193
|
+
"jev_choice": ("choice", 3, 3), # row, question, options -> text
|
|
194
|
+
"jev_confidence": (None, 4, 4), # row, question, kind, options -> float
|
|
195
|
+
"jev_eval": (None, 2, 4), # row, question [, kind [, options]] -> answer object
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def function_name(fn):
|
|
200
|
+
name = str(fn).rsplit(".", 1)[-1].strip('`"[] ').lower()
|
|
201
|
+
if name not in FUNCTIONS:
|
|
202
|
+
raise JevError("sqljev: unknown function %r (one of %s)" % (fn, ", ".join(FUNCTIONS)))
|
|
203
|
+
return name
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
# ---------------------------------------------------------------- the engine
|
|
207
|
+
|
|
208
|
+
class Jev:
|
|
209
|
+
"""Judges SQL rows against plain-language questions. Thread-safe; keep one per process."""
|
|
210
|
+
|
|
211
|
+
def __init__(self, cfg=None, **overrides):
|
|
212
|
+
self.cfg = make_config(**{**(cfg or {}), **overrides})
|
|
213
|
+
self._lock = threading.Lock()
|
|
214
|
+
self._model_lock = threading.Lock()
|
|
215
|
+
self._cache = OrderedDict()
|
|
216
|
+
self._conns = []
|
|
217
|
+
self._pool = None
|
|
218
|
+
self._ssl = None
|
|
219
|
+
self._laya = None
|
|
220
|
+
self._sent = [0, 0] # rows, chars sent since the last reset_budget()
|
|
221
|
+
self._stats = {"requests": 0, "forward_batches": 0, "rows_evaluated": 0, "cache_hits": 0,
|
|
222
|
+
"duplicates": 0, "input_tokens": 0, "output_tokens": 0, "model_ms": 0.0,
|
|
223
|
+
"errors": 0, "retries": 0}
|
|
224
|
+
|
|
225
|
+
# ------------------------------------------------ public API
|
|
226
|
+
|
|
227
|
+
def evaluate(self, rows, query, kind="noul", options=None):
|
|
228
|
+
"""Answer dicts aligned with `rows` (None for a None row). Duplicates and cached rows cost nothing."""
|
|
229
|
+
kind, opts = check_question(kind, options)
|
|
230
|
+
key = self._qkey(kind, query, opts)
|
|
231
|
+
texts = [None if r is None else to_row_json(r, self.cfg["drop_nulls"]) for r in rows]
|
|
232
|
+
hashes = [None if t is None else row_hash(t) for t in texts]
|
|
233
|
+
found, todo = {}, {}
|
|
234
|
+
for h, t in zip(hashes, texts):
|
|
235
|
+
if h is None:
|
|
236
|
+
continue
|
|
237
|
+
if h in found or h in todo:
|
|
238
|
+
self._bump("duplicates")
|
|
239
|
+
continue
|
|
240
|
+
a = self._cache_get(key, h)
|
|
241
|
+
if a is None:
|
|
242
|
+
todo[h] = t
|
|
243
|
+
else:
|
|
244
|
+
found[h] = a
|
|
245
|
+
self._bump("cache_hits")
|
|
246
|
+
if todo:
|
|
247
|
+
pairs = list(todo.items())
|
|
248
|
+
bs = self.cfg["batch_size"]
|
|
249
|
+
batches = [pairs[i:i + bs] for i in range(0, len(pairs), bs)]
|
|
250
|
+
self._guard(pairs)
|
|
251
|
+
if len(batches) == 1 or self.cfg["concurrency"] == 1:
|
|
252
|
+
for b in batches:
|
|
253
|
+
found.update(self._run_batch(key, kind, query, opts, b))
|
|
254
|
+
else:
|
|
255
|
+
futs = [self._executor().submit(self._run_batch, key, kind, query, opts, b) for b in batches]
|
|
256
|
+
for f in futs:
|
|
257
|
+
found.update(f.result())
|
|
258
|
+
return [None if h is None else found[h] for h in hashes]
|
|
259
|
+
|
|
260
|
+
def evaluate_iter(self, rows, query, kind="noul", options=None, key=None):
|
|
261
|
+
"""Stream (row, answer) pairs in input order with a bounded read-ahead, so a consumer that stops
|
|
262
|
+
early (LIMIT) only pays for the rows in flight. Memory stays constant for any number of rows.
|
|
263
|
+
`key(item)` picks what the model sees of each item (default: the item itself)."""
|
|
264
|
+
kind, opts = check_question(kind, options)
|
|
265
|
+
view = key or (lambda item: item)
|
|
266
|
+
key = self._qkey(kind, query, opts)
|
|
267
|
+
bs, cap = self.cfg["batch_size"], 2 * self.cfg["concurrency"]
|
|
268
|
+
limit = max(bs * cap, 64)
|
|
269
|
+
it, window, inflight, active = iter(rows), deque(), {}, []
|
|
270
|
+
pending, pending_set, exhausted = [], set(), False
|
|
271
|
+
|
|
272
|
+
def submit(pairs):
|
|
273
|
+
self._guard(pairs)
|
|
274
|
+
fut = self._executor().submit(self._run_batch, key, kind, query, opts, pairs)
|
|
275
|
+
for h, _ in pairs:
|
|
276
|
+
inflight[h] = fut
|
|
277
|
+
active.append(fut)
|
|
278
|
+
return fut
|
|
279
|
+
|
|
280
|
+
def flush():
|
|
281
|
+
nonlocal pending
|
|
282
|
+
if pending:
|
|
283
|
+
submit(pending)
|
|
284
|
+
pending_set.clear()
|
|
285
|
+
pending = []
|
|
286
|
+
|
|
287
|
+
while True:
|
|
288
|
+
active[:] = [f for f in active if not f.done()]
|
|
289
|
+
while not exhausted and len(window) < limit and len(active) < cap:
|
|
290
|
+
try:
|
|
291
|
+
row = next(it)
|
|
292
|
+
except StopIteration:
|
|
293
|
+
exhausted = True
|
|
294
|
+
break
|
|
295
|
+
seen = None if row is None else view(row)
|
|
296
|
+
if seen is None:
|
|
297
|
+
window.append([row, None, None, None])
|
|
298
|
+
continue
|
|
299
|
+
t = to_row_json(seen, self.cfg["drop_nulls"])
|
|
300
|
+
h = row_hash(t)
|
|
301
|
+
a = self._cache_get(key, h)
|
|
302
|
+
window.append([row, h, t, a])
|
|
303
|
+
if a is not None:
|
|
304
|
+
self._bump("cache_hits")
|
|
305
|
+
elif h in inflight or h in pending_set:
|
|
306
|
+
self._bump("duplicates")
|
|
307
|
+
else:
|
|
308
|
+
pending.append((h, t))
|
|
309
|
+
pending_set.add(h)
|
|
310
|
+
if len(pending) >= bs:
|
|
311
|
+
flush()
|
|
312
|
+
if exhausted:
|
|
313
|
+
flush()
|
|
314
|
+
if not window:
|
|
315
|
+
return
|
|
316
|
+
row, h, t, a = window[0]
|
|
317
|
+
if a is None and h is not None:
|
|
318
|
+
a = self._cache_get(key, h)
|
|
319
|
+
if a is None:
|
|
320
|
+
if h in pending_set:
|
|
321
|
+
flush()
|
|
322
|
+
fut = inflight.get(h) or submit([(h, t)])
|
|
323
|
+
a = fut.result()[h]
|
|
324
|
+
inflight.pop(h, None)
|
|
325
|
+
window.popleft()
|
|
326
|
+
yield row, a
|
|
327
|
+
|
|
328
|
+
def call(self, fn, calls):
|
|
329
|
+
"""Evaluate a UDF call batch: `calls` is a list of argument tuples (row, question, ...) as a
|
|
330
|
+
database hands them over. Calls are grouped by question, so one batch can mix questions."""
|
|
331
|
+
name = function_name(fn)
|
|
332
|
+
fixed_kind, lo, hi = FUNCTIONS[name]
|
|
333
|
+
calls = [tuple(_null(v) for v in c) for c in calls]
|
|
334
|
+
out = [None] * len(calls)
|
|
335
|
+
groups = {}
|
|
336
|
+
for i, args in enumerate(calls):
|
|
337
|
+
if not lo <= len(args) <= hi:
|
|
338
|
+
raise JevError("sqljev: %s takes %d-%d arguments, got %d" % (name, lo, hi, len(args)))
|
|
339
|
+
row, query = args[0], args[1]
|
|
340
|
+
if row is None or query is None:
|
|
341
|
+
continue
|
|
342
|
+
if fixed_kind:
|
|
343
|
+
kind = fixed_kind
|
|
344
|
+
opts = args[2] if kind != "noul" else None
|
|
345
|
+
else:
|
|
346
|
+
kind = args[2] if len(args) > 2 and args[2] is not None else "noul"
|
|
347
|
+
opts = args[3] if len(args) > 3 else None
|
|
348
|
+
kind, opts = check_question(kind, opts)
|
|
349
|
+
gk = (kind, query, None if opts is None else tuple(opts))
|
|
350
|
+
groups.setdefault(gk, []).append(i)
|
|
351
|
+
for (kind, query, opts), idx in groups.items():
|
|
352
|
+
answers = self.evaluate([calls[i][0] for i in idx], query, kind, opts and list(opts))
|
|
353
|
+
for i, a in zip(idx, answers):
|
|
354
|
+
out[i] = self._extract(name, a, calls[i], opts)
|
|
355
|
+
return out
|
|
356
|
+
|
|
357
|
+
def prob(self, rows, condition):
|
|
358
|
+
return self.call("jev_prob", [(r, condition) for r in rows])
|
|
359
|
+
|
|
360
|
+
def where(self, rows, condition, threshold=None):
|
|
361
|
+
return self.call("jev", [(r, condition, threshold) for r in rows])
|
|
362
|
+
|
|
363
|
+
def score(self, rows, question, levels, normalize=False):
|
|
364
|
+
return self.call("jev_score_norm" if normalize else "jev_score", [(r, question, levels) for r in rows])
|
|
365
|
+
|
|
366
|
+
def choice(self, rows, question, options):
|
|
367
|
+
return self.call("jev_choice", [(r, question, options) for r in rows])
|
|
368
|
+
|
|
369
|
+
def stats(self):
|
|
370
|
+
with self._lock:
|
|
371
|
+
s = dict(self._stats)
|
|
372
|
+
s["cached_answers"] = len(self._cache)
|
|
373
|
+
s["pooled_connections"] = len(self._conns)
|
|
374
|
+
s["backend"] = self.cfg["backend"]
|
|
375
|
+
s["estimated_cost_usd"] = round(s["input_tokens"] * JEV_USD_PER_INPUT_TOKEN, 6) \
|
|
376
|
+
if self.cfg["backend"] == "jev" else 0.0
|
|
377
|
+
return s
|
|
378
|
+
|
|
379
|
+
def clear_cache(self):
|
|
380
|
+
with self._lock:
|
|
381
|
+
self._cache.clear()
|
|
382
|
+
|
|
383
|
+
def reset_budget(self):
|
|
384
|
+
with self._lock:
|
|
385
|
+
self._sent = [0, 0]
|
|
386
|
+
|
|
387
|
+
def close(self):
|
|
388
|
+
if self._pool is not None:
|
|
389
|
+
self._pool.shutdown(wait=False)
|
|
390
|
+
self._pool = None
|
|
391
|
+
with self._lock:
|
|
392
|
+
conns, self._conns = self._conns, []
|
|
393
|
+
for c, _ in conns:
|
|
394
|
+
c.close()
|
|
395
|
+
|
|
396
|
+
# ------------------------------------------------ internals
|
|
397
|
+
|
|
398
|
+
def _extract(self, name, a, args, opts):
|
|
399
|
+
if a is None:
|
|
400
|
+
return None
|
|
401
|
+
if name == "jev":
|
|
402
|
+
t = args[2] if len(args) > 2 and args[2] is not None else self.cfg["threshold"]
|
|
403
|
+
return a["noul"] >= float(t)
|
|
404
|
+
if name == "jev_prob":
|
|
405
|
+
return a["noul"]
|
|
406
|
+
if name == "jev_score":
|
|
407
|
+
return a["score"]
|
|
408
|
+
if name == "jev_score_norm":
|
|
409
|
+
return a["score"] / max(len(opts) - 1, 1)
|
|
410
|
+
if name == "jev_choice":
|
|
411
|
+
return a["choice"]
|
|
412
|
+
if name == "jev_confidence":
|
|
413
|
+
return a.get("confidence")
|
|
414
|
+
return a
|
|
415
|
+
|
|
416
|
+
def _qkey(self, kind, query, opts):
|
|
417
|
+
return json.dumps([kind, query, opts])
|
|
418
|
+
|
|
419
|
+
def _bump(self, name, n=1):
|
|
420
|
+
with self._lock:
|
|
421
|
+
self._stats[name] += n
|
|
422
|
+
|
|
423
|
+
def _cache_get(self, key, h):
|
|
424
|
+
if not self.cfg["cache_size"]:
|
|
425
|
+
return None
|
|
426
|
+
with self._lock:
|
|
427
|
+
a = self._cache.get((key, h))
|
|
428
|
+
if a is not None:
|
|
429
|
+
self._cache.move_to_end((key, h))
|
|
430
|
+
return a
|
|
431
|
+
|
|
432
|
+
def _cache_put(self, key, answers):
|
|
433
|
+
size = self.cfg["cache_size"]
|
|
434
|
+
if not size:
|
|
435
|
+
return
|
|
436
|
+
with self._lock:
|
|
437
|
+
for h, a in answers.items():
|
|
438
|
+
self._cache[(key, h)] = a
|
|
439
|
+
while len(self._cache) > size:
|
|
440
|
+
self._cache.popitem(last=False)
|
|
441
|
+
|
|
442
|
+
def _guard(self, pairs):
|
|
443
|
+
"""Spend guard: refuse to send more than max_rows rows / max_chars characters of row data
|
|
444
|
+
from this engine until reset_budget()."""
|
|
445
|
+
mr, mc = self.cfg["max_rows"], self.cfg["max_chars"]
|
|
446
|
+
if not (mr or mc):
|
|
447
|
+
return
|
|
448
|
+
with self._lock:
|
|
449
|
+
rows, chars = self._sent[0] + len(pairs), self._sent[1] + sum(len(t) for _, t in pairs)
|
|
450
|
+
if mr and rows > mr:
|
|
451
|
+
raise JevError("sqljev: would send %d rows to the model, above max_rows = %d" % (rows, mr))
|
|
452
|
+
if mc and chars > mc:
|
|
453
|
+
raise JevError("sqljev: would send %d characters of row data, above max_chars = %d" % (chars, mc))
|
|
454
|
+
self._sent = [rows, chars]
|
|
455
|
+
|
|
456
|
+
def _executor(self):
|
|
457
|
+
with self._lock:
|
|
458
|
+
if self._pool is None:
|
|
459
|
+
self._pool = ThreadPoolExecutor(max_workers=self.cfg["concurrency"], thread_name_prefix="sqljev")
|
|
460
|
+
return self._pool
|
|
461
|
+
|
|
462
|
+
def _run_batch(self, key, kind, query, opts, pairs):
|
|
463
|
+
"""Judge one batch of (row_hash, row_json) pairs; returns {row_hash: answer}."""
|
|
464
|
+
try:
|
|
465
|
+
b = self.cfg["backend"]
|
|
466
|
+
if b == "local":
|
|
467
|
+
got = self._run_local(kind, query, opts, pairs)
|
|
468
|
+
elif b == "gateway":
|
|
469
|
+
got = self._run_gateway(kind, query, opts, pairs)
|
|
470
|
+
else:
|
|
471
|
+
got = self._run_systemone(kind, query, opts, pairs)
|
|
472
|
+
except Exception:
|
|
473
|
+
self._bump("errors")
|
|
474
|
+
raise
|
|
475
|
+
self._cache_put(key, got)
|
|
476
|
+
self._bump("rows_evaluated", len(pairs))
|
|
477
|
+
return got
|
|
478
|
+
|
|
479
|
+
# -- local: Laya in-process, many rows per forward pass
|
|
480
|
+
|
|
481
|
+
def _laya_model(self):
|
|
482
|
+
if self._laya is None:
|
|
483
|
+
try:
|
|
484
|
+
import laya
|
|
485
|
+
except ImportError:
|
|
486
|
+
raise JevError("sqljev: backend 'local' needs Laya: pip install 'sqljev[laya]'")
|
|
487
|
+
m = self.cfg["model"]
|
|
488
|
+
if m and m not in LAYA_CHECKPOINTS: # a fine-tuned checkpoint: local directory or Hub id
|
|
489
|
+
self._laya = ("agent", laya.load(m, device=self.cfg["device"]))
|
|
490
|
+
else:
|
|
491
|
+
self._laya = ("router", laya.Router(device=self.cfg["device"]))
|
|
492
|
+
return self._laya
|
|
493
|
+
|
|
494
|
+
def _run_local(self, kind, query, opts, pairs):
|
|
495
|
+
q = {"q": laya_question(kind, query, opts)}
|
|
496
|
+
states = [json.loads(t) for _, t in pairs]
|
|
497
|
+
t0 = time.time()
|
|
498
|
+
with self._model_lock: # one forward pass at a time; the model is the bottleneck
|
|
499
|
+
how, m = self._laya_model()
|
|
500
|
+
if how == "agent":
|
|
501
|
+
res = m.predict_batch(states, q, batch_size=self.cfg["batch_size"], max_len=self.cfg["max_len"],
|
|
502
|
+
sort_by_length=True)
|
|
503
|
+
else:
|
|
504
|
+
req = [{"state": s, "questions": q} for s in states]
|
|
505
|
+
if self.cfg["model"]:
|
|
506
|
+
for r in req:
|
|
507
|
+
r["model"] = self.cfg["model"]
|
|
508
|
+
res = m.predict_batch(req, batch_size=self.cfg["batch_size"])
|
|
509
|
+
with self._lock:
|
|
510
|
+
self._stats["forward_batches"] += -(-len(pairs) // self.cfg["batch_size"])
|
|
511
|
+
self._stats["model_ms"] += (time.time() - t0) * 1000
|
|
512
|
+
return {h: r["answers"]["q"] for (h, _), r in zip(pairs, res)}
|
|
513
|
+
|
|
514
|
+
# -- gateway: sqljev's own batch protocol
|
|
515
|
+
|
|
516
|
+
def _run_gateway(self, kind, query, opts, pairs):
|
|
517
|
+
body = json.dumps({"kind": kind, "question": query, "options": opts,
|
|
518
|
+
"rows": [json.loads(t) for _, t in pairs]}).encode()
|
|
519
|
+
data = self._post(body)
|
|
520
|
+
answers = data.get("answers") or []
|
|
521
|
+
if len(answers) != len(pairs):
|
|
522
|
+
raise JevError("sqljev: gateway returned %d answers for %d rows" % (len(answers), len(pairs)))
|
|
523
|
+
return {h: a for (h, _), a in zip(pairs, answers)}
|
|
524
|
+
|
|
525
|
+
# -- /v1/systemone: TypeSafe Jev (20 rows in one state) or laya-serve (one row per request)
|
|
526
|
+
|
|
527
|
+
def _run_systemone(self, kind, query, opts, pairs):
|
|
528
|
+
rows = [json.loads(t) for _, t in pairs]
|
|
529
|
+
if self.cfg["backend"] == "jev":
|
|
530
|
+
state = {"condition": query, "rows": rows} if kind == "noul" else {"rows": rows}
|
|
531
|
+
qs = {("r%d" % i): jev_question(i, kind, query, opts) for i in range(len(rows))}
|
|
532
|
+
ids = ["r%d" % i for i in range(len(rows))]
|
|
533
|
+
else:
|
|
534
|
+
state, qs, ids = rows[0], {"q": laya_question(kind, query, opts)}, ["q"]
|
|
535
|
+
req = {"state": state, "questions": qs}
|
|
536
|
+
if self.cfg["model"]:
|
|
537
|
+
req["model"] = self.cfg["model"]
|
|
538
|
+
data = self._post(json.dumps(req).encode())
|
|
539
|
+
answers = data.get("answers") or {}
|
|
540
|
+
if any(i not in answers for i in ids):
|
|
541
|
+
raise JevError("sqljev: the model returned %d of %d answers" % (len(answers), len(ids)))
|
|
542
|
+
usage = data.get("usage") or {}
|
|
543
|
+
with self._lock:
|
|
544
|
+
self._stats["input_tokens"] += usage.get("input_tokens", 0)
|
|
545
|
+
self._stats["output_tokens"] += usage.get("output_tokens", 0)
|
|
546
|
+
return {h: answers[i] for (h, _), i in zip(pairs, ids)}
|
|
547
|
+
|
|
548
|
+
# -- HTTP: persistent keep-alive connections, retries honouring Retry-After (from pg-jev)
|
|
549
|
+
|
|
550
|
+
def _borrow(self, url):
|
|
551
|
+
while True:
|
|
552
|
+
with self._lock:
|
|
553
|
+
entry = self._conns.pop() if self._conns else None
|
|
554
|
+
if entry is None:
|
|
555
|
+
break
|
|
556
|
+
c, last = entry
|
|
557
|
+
if time.time() - last < self.cfg["keepalive"] and _alive(c):
|
|
558
|
+
return c, True
|
|
559
|
+
c.close()
|
|
560
|
+
if url.scheme == "https":
|
|
561
|
+
if self._ssl is None:
|
|
562
|
+
self._ssl = ssl.create_default_context()
|
|
563
|
+
c = http.client.HTTPSConnection(url.hostname, url.port, timeout=self.cfg["timeout"], context=self._ssl)
|
|
564
|
+
else:
|
|
565
|
+
c = http.client.HTTPConnection(url.hostname, url.port, timeout=self.cfg["timeout"])
|
|
566
|
+
return c, False
|
|
567
|
+
|
|
568
|
+
def _release(self, c, reusable):
|
|
569
|
+
if reusable:
|
|
570
|
+
with self._lock:
|
|
571
|
+
if len(self._conns) < self.cfg["concurrency"]:
|
|
572
|
+
self._conns.append((c, time.time()))
|
|
573
|
+
return
|
|
574
|
+
c.close()
|
|
575
|
+
|
|
576
|
+
def _post(self, body):
|
|
577
|
+
url = urlsplit(self.cfg["api_url"] or "")
|
|
578
|
+
if url.scheme not in ("http", "https"):
|
|
579
|
+
raise JevError("sqljev: api_url %r is not an http(s) URL" % self.cfg["api_url"])
|
|
580
|
+
if self.cfg["backend"] == "jev" and not self.cfg["api_key"]:
|
|
581
|
+
raise JevError("sqljev: no API key. Set SQLJEV_API_KEY or TYPESAFE_API_KEY.")
|
|
582
|
+
path = (url.path or "/") + ("?" + url.query if url.query else "")
|
|
583
|
+
headers = {"Content-Type": "application/json", "User-Agent": "sqljev/" + __version__}
|
|
584
|
+
if self.cfg["api_key"]:
|
|
585
|
+
headers["Authorization"] = "Bearer " + self.cfg["api_key"]
|
|
586
|
+
delay, last = 0.5, None
|
|
587
|
+
for attempt in range(7):
|
|
588
|
+
c, reused = self._borrow(url)
|
|
589
|
+
t0 = time.time()
|
|
590
|
+
try:
|
|
591
|
+
if not reused:
|
|
592
|
+
c.connect()
|
|
593
|
+
_tcp_keepalive(c)
|
|
594
|
+
c.request("POST", path, body=body, headers=headers)
|
|
595
|
+
resp = c.getresponse()
|
|
596
|
+
raw = resp.read()
|
|
597
|
+
except (http.client.HTTPException, OSError) as e:
|
|
598
|
+
c.close()
|
|
599
|
+
last = "%s: %s" % (type(e).__name__, e)
|
|
600
|
+
self._bump("retries")
|
|
601
|
+
if reused and attempt == 0:
|
|
602
|
+
continue # a keep-alive connection went stale: retry at once on a fresh one
|
|
603
|
+
time.sleep(delay + random.random() * 0.25)
|
|
604
|
+
delay = min(delay * 2, 8)
|
|
605
|
+
continue
|
|
606
|
+
self._release(c, not resp.will_close)
|
|
607
|
+
if resp.status == 200:
|
|
608
|
+
with self._lock:
|
|
609
|
+
self._stats["requests"] += 1
|
|
610
|
+
self._stats["model_ms"] += (time.time() - t0) * 1000
|
|
611
|
+
return json.loads(raw.decode())
|
|
612
|
+
last = "%s %s" % (resp.status, raw.decode(errors="replace")[:300])
|
|
613
|
+
if resp.status in (408, 429, 503, 529) or resp.status >= 500:
|
|
614
|
+
self._bump("retries")
|
|
615
|
+
wait = _retry_after(resp)
|
|
616
|
+
time.sleep(min(wait if wait is not None else delay, 30) + random.random() * 0.25)
|
|
617
|
+
delay = min(delay * 2, 8)
|
|
618
|
+
continue
|
|
619
|
+
raise JevError("sqljev: %s error %s" % (self.cfg["backend"], last))
|
|
620
|
+
raise JevError("sqljev: %s unreachable after retries: %s" % (self.cfg["backend"], last))
|
|
621
|
+
|
|
622
|
+
|
|
623
|
+
def _null(v):
|
|
624
|
+
"""SQL NULL arrives as None, or as NaN from pandas-based UDF runtimes (Snowflake, Spark)."""
|
|
625
|
+
return None if isinstance(v, float) and v != v else v
|
|
626
|
+
|
|
627
|
+
|
|
628
|
+
def _alive(c):
|
|
629
|
+
"""An idle keep-alive connection never has unread data: readability means EOF or a TLS close alert."""
|
|
630
|
+
sock = c.sock
|
|
631
|
+
if sock is None:
|
|
632
|
+
return False
|
|
633
|
+
try:
|
|
634
|
+
readable, _, _ = select.select([sock], [], [], 0)
|
|
635
|
+
return not readable
|
|
636
|
+
except (OSError, ValueError):
|
|
637
|
+
return False
|
|
638
|
+
|
|
639
|
+
|
|
640
|
+
def _tcp_keepalive(c):
|
|
641
|
+
try:
|
|
642
|
+
sock = c.sock
|
|
643
|
+
sock.setsockopt(socket.SOL_SOCKET, socket.SO_KEEPALIVE, 1)
|
|
644
|
+
for name, value in (("TCP_KEEPIDLE", 30), ("TCP_KEEPINTVL", 10), ("TCP_KEEPCNT", 3)):
|
|
645
|
+
if hasattr(socket, name):
|
|
646
|
+
sock.setsockopt(socket.IPPROTO_TCP, getattr(socket, name), value)
|
|
647
|
+
except OSError:
|
|
648
|
+
pass
|
|
649
|
+
|
|
650
|
+
|
|
651
|
+
def _retry_after(resp):
|
|
652
|
+
v = resp.getheader("retry-after-ms")
|
|
653
|
+
if v and v.strip().isdigit():
|
|
654
|
+
return int(v) / 1000.0
|
|
655
|
+
v = resp.getheader("retry-after")
|
|
656
|
+
if v:
|
|
657
|
+
try:
|
|
658
|
+
return float(v)
|
|
659
|
+
except ValueError:
|
|
660
|
+
pass
|
|
661
|
+
return None
|
|
662
|
+
|
|
663
|
+
|
|
664
|
+
_DEFAULT = None
|
|
665
|
+
_DEFAULT_LOCK = threading.Lock()
|
|
666
|
+
|
|
667
|
+
|
|
668
|
+
def default_engine(**overrides):
|
|
669
|
+
"""A process-wide engine, so a UDF's answer cache and connections survive across calls."""
|
|
670
|
+
global _DEFAULT
|
|
671
|
+
with _DEFAULT_LOCK:
|
|
672
|
+
if _DEFAULT is None:
|
|
673
|
+
_DEFAULT = Jev(**overrides)
|
|
674
|
+
return _DEFAULT
|