contextsql-garvit 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.
- contextsql/__init__.py +1 -0
- contextsql/agent.py +192 -0
- contextsql/autoglossary.py +279 -0
- contextsql/cli.py +165 -0
- contextsql/config.py +17 -0
- contextsql/db.py +13 -0
- contextsql/feedback.py +35 -0
- contextsql/glossary.py +27 -0
- contextsql/hints.py +5 -0
- contextsql/introspect.py +121 -0
- contextsql/learn.py +48 -0
- contextsql/names.py +29 -0
- contextsql/phrasing.py +61 -0
- contextsql/prompt.py +5 -0
- contextsql/retrieve.py +110 -0
- contextsql_garvit-0.1.0.dist-info/METADATA +416 -0
- contextsql_garvit-0.1.0.dist-info/RECORD +20 -0
- contextsql_garvit-0.1.0.dist-info/WHEEL +5 -0
- contextsql_garvit-0.1.0.dist-info/entry_points.txt +2 -0
- contextsql_garvit-0.1.0.dist-info/top_level.txt +1 -0
contextsql/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
contextsql/agent.py
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""Question -> SQL (local LLM) -> validate -> run read-only -> self-correct on errors."""
|
|
2
|
+
import os, re, time, json
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from datetime import datetime, timezone
|
|
5
|
+
from . import feedback, glossary as gl
|
|
6
|
+
from .config import LOG, HOME
|
|
7
|
+
from .hints import FAIL, EMPTY, MISS
|
|
8
|
+
from .phrasing import requested_columns
|
|
9
|
+
from .prompt import build_prompt
|
|
10
|
+
from .retrieve import select
|
|
11
|
+
|
|
12
|
+
MAX_PROMPT_TOKENS, MAX_ROWS, TIMEOUT_S = 1000, 200, 15
|
|
13
|
+
FORBID = re.compile(r"\b(insert|update|delete|drop|alter|truncate|create|grant|revoke|replace|merge|call|exec|"
|
|
14
|
+
r"outfile|dumpfile|load_file|sleep|benchmark|pg_sleep)\b", re.I)
|
|
15
|
+
|
|
16
|
+
class LlamaGenerator:
|
|
17
|
+
def __init__(self, path, n_ctx=2048, threads=None):
|
|
18
|
+
try:
|
|
19
|
+
from llama_cpp import Llama
|
|
20
|
+
except ImportError:
|
|
21
|
+
raise SystemExit("llama-cpp-python is not installed. Run:\n pip install llama-cpp-python "
|
|
22
|
+
"--extra-index-url https://abetlen.github.io/llama-cpp-python/whl/cpu")
|
|
23
|
+
threads = threads or max(1, os.cpu_count() or 2)
|
|
24
|
+
self.llm = Llama(model_path=path, n_ctx=n_ctx, n_threads=threads, n_batch=256, verbose=False)
|
|
25
|
+
try: # reuse the processed prompt prefix between questions
|
|
26
|
+
from llama_cpp.llama_cache import LlamaRAMCache
|
|
27
|
+
self.llm.set_cache(LlamaRAMCache(capacity_bytes=256 << 20))
|
|
28
|
+
except Exception:
|
|
29
|
+
pass
|
|
30
|
+
|
|
31
|
+
def count_tokens(self, text):
|
|
32
|
+
return len(self.llm.tokenize(text.encode("utf-8"), add_bos=False))
|
|
33
|
+
|
|
34
|
+
def generate(self, prompt, temperature=0.0):
|
|
35
|
+
out = self.llm(prompt, max_tokens=300, temperature=temperature, stop=[";"], echo=False)
|
|
36
|
+
return out["choices"][0]["text"]
|
|
37
|
+
|
|
38
|
+
def prune_ddl(meta, keep, max_cols=25):
|
|
39
|
+
"""Show only relevant columns of very wide tables (keeps keys, matched columns and the first few)."""
|
|
40
|
+
if not keep or len(meta["columns"]) <= max_cols:
|
|
41
|
+
return meta["ddl"]
|
|
42
|
+
lines = meta["ddl"].split("\n")
|
|
43
|
+
head, body, tail = lines[0], lines[1:-1], lines[-1]
|
|
44
|
+
out = [l for l in body if l.strip().split(" ")[0].rstrip(",") in keep or
|
|
45
|
+
l.strip().startswith(("PRIMARY", "CONSTRAINT", "FOREIGN"))]
|
|
46
|
+
out = [l.rstrip(",") for l in out]
|
|
47
|
+
return head + "\n" + ",\n".join(out) + "\n" + tail
|
|
48
|
+
|
|
49
|
+
def clean_sql(text):
|
|
50
|
+
text = re.sub(r"```(?:sql)?", "", text).strip()
|
|
51
|
+
return " ".join(text.split()).rstrip(";").strip() + ";"
|
|
52
|
+
|
|
53
|
+
def validate(sql):
|
|
54
|
+
s = re.sub(r"--[^\n]*|/\*.*?\*/", "", sql, flags=re.S).strip().rstrip(";")
|
|
55
|
+
s = re.sub(r"'(?:[^']|'')*'", "''", s) # ignore words inside string literals
|
|
56
|
+
if ";" in s: return False, "only one statement is allowed"
|
|
57
|
+
if not re.match(r"^\s*(select|with)\b", s, re.I): return False, "only SELECT queries are allowed"
|
|
58
|
+
m = FORBID.search(s)
|
|
59
|
+
if m: return False, f"forbidden keyword: {m.group(0)}"
|
|
60
|
+
return True, ""
|
|
61
|
+
|
|
62
|
+
@dataclass
|
|
63
|
+
class Result:
|
|
64
|
+
question: str
|
|
65
|
+
sql: str = ""
|
|
66
|
+
columns: list = field(default_factory=list)
|
|
67
|
+
rows: list = field(default_factory=list)
|
|
68
|
+
ok: bool = False
|
|
69
|
+
error: str = ""
|
|
70
|
+
attempts: int = 0
|
|
71
|
+
latency_ms: int = 0
|
|
72
|
+
tables: list = field(default_factory=list)
|
|
73
|
+
truncated: bool = False
|
|
74
|
+
confidence: str = "high" # "low": weak table match, retries needed, or still missing requested columns
|
|
75
|
+
|
|
76
|
+
class Agent:
|
|
77
|
+
def __init__(self, engine, know, generator, max_attempts=3):
|
|
78
|
+
self.engine, self.know, self.gen, self.max_attempts = engine, know, generator, max_attempts
|
|
79
|
+
self._cache = {}
|
|
80
|
+
|
|
81
|
+
def _count(self, text):
|
|
82
|
+
return self.gen.count_tokens(text) if hasattr(self.gen, "count_tokens") else len(text) // 3
|
|
83
|
+
|
|
84
|
+
def glossary(self):
|
|
85
|
+
return {**self.know.get("autogloss", {}), **gl.load()} # your own entries override automatic ones
|
|
86
|
+
|
|
87
|
+
def make_prompt(self, question, hint=None):
|
|
88
|
+
gloss = self.glossary()
|
|
89
|
+
sel = select(question, self.know, gloss)
|
|
90
|
+
tables, notes, terms = list(sel["tables"]), list(sel["notes"]), list(sel["glossary"])
|
|
91
|
+
req = requested_columns(question, self.know, tables)
|
|
92
|
+
low = max(sel["scores"].values(), default=0) <= 0
|
|
93
|
+
shots = feedback.similar(question, tables)
|
|
94
|
+
while True:
|
|
95
|
+
ctx = [f"- {k}: {gloss[k]['definition']} SQL: {gloss[k]['sql']}" for k in terms]
|
|
96
|
+
ctx += [f"- data note: {n['text']}" for n in notes if n["table"] in tables
|
|
97
|
+
and (n["kind"] != "join" or n["ref_table"] in tables)]
|
|
98
|
+
if req: ctx.append(f"- requested columns: {', '.join(req)}")
|
|
99
|
+
ctx += [f"- example: Q: {q} SQL: {sq}" for q, sq in shots]
|
|
100
|
+
if hint: ctx.append(f"- note: {hint}")
|
|
101
|
+
schema = "\n\n".join(prune_ddl(self.know["tables"][t], sel["keep"].get(t)) for t in tables)
|
|
102
|
+
prompt = build_prompt(schema, ctx, question)
|
|
103
|
+
if self._count(prompt) <= MAX_PROMPT_TOKENS: break
|
|
104
|
+
if shots:
|
|
105
|
+
shots = [] # drop examples first
|
|
106
|
+
elif any(n["kind"] == "values" for n in notes):
|
|
107
|
+
notes = [n for n in notes if n["kind"] != "values"] # drop value lists first
|
|
108
|
+
elif len(tables) > 1:
|
|
109
|
+
tables.remove(min(tables, key=lambda t: sel["scores"].get(t, 0)))
|
|
110
|
+
else:
|
|
111
|
+
break
|
|
112
|
+
return prompt, tables, low, req
|
|
113
|
+
|
|
114
|
+
def run_sql(self, sql):
|
|
115
|
+
d = self.engine.dialect.name
|
|
116
|
+
body = sql.rstrip(";")
|
|
117
|
+
if d in ("mysql", "postgresql"): body = body.replace("%", "%%")
|
|
118
|
+
with self.engine.connect() as c:
|
|
119
|
+
try:
|
|
120
|
+
if d == "mysql": c.exec_driver_sql("SET SESSION TRANSACTION READ ONLY")
|
|
121
|
+
elif d == "postgresql": c.exec_driver_sql("SET TRANSACTION READ ONLY")
|
|
122
|
+
except Exception:
|
|
123
|
+
pass
|
|
124
|
+
for stmt in (f"SET SESSION MAX_EXECUTION_TIME={TIMEOUT_S * 1000}", f"SET SESSION max_statement_time={TIMEOUT_S}",
|
|
125
|
+
f"SET statement_timeout = {TIMEOUT_S * 1000}"):
|
|
126
|
+
try:
|
|
127
|
+
c.exec_driver_sql(stmt); break
|
|
128
|
+
except Exception:
|
|
129
|
+
continue
|
|
130
|
+
c.exec_driver_sql(("EXPLAIN QUERY PLAN " if d == "sqlite" else "EXPLAIN ") + body)
|
|
131
|
+
res = c.exec_driver_sql(body)
|
|
132
|
+
cols = list(res.keys()); rows = res.fetchmany(MAX_ROWS + 1)
|
|
133
|
+
return cols, [tuple(r) for r in rows[:MAX_ROWS]], len(rows) > MAX_ROWS
|
|
134
|
+
|
|
135
|
+
@staticmethod
|
|
136
|
+
def missing(req, sql):
|
|
137
|
+
m = re.search(r"select\s+(.*?)\s+from\b", sql, re.I | re.S)
|
|
138
|
+
clause = (m.group(1) if m else sql).lower()
|
|
139
|
+
return [c for c in req if c.lower() not in clause]
|
|
140
|
+
|
|
141
|
+
def ask(self, question):
|
|
142
|
+
key = " ".join(question.lower().split())
|
|
143
|
+
if key in self._cache:
|
|
144
|
+
return self._cache[key]
|
|
145
|
+
t0, r, hint, best, low0 = time.time(), Result(question=question), None, None, False
|
|
146
|
+
for i in range(1, self.max_attempts + 1):
|
|
147
|
+
prompt, tables, low, req = self.make_prompt(question, hint)
|
|
148
|
+
low0 = low0 or (i == 1 and low)
|
|
149
|
+
raw = self.gen.generate(prompt, temperature=0.0 if i == 1 else 0.2 * (i - 1))
|
|
150
|
+
r.tables, r.attempts = tables, i
|
|
151
|
+
if raw.strip().lower().startswith("-- unanswerable"): # the model says the data cannot answer this
|
|
152
|
+
r.ok, r.sql, r.confidence = False, "", "low"
|
|
153
|
+
r.error = raw.strip().split(":", 1)[-1].strip() or "this database has no matching information"
|
|
154
|
+
best = None
|
|
155
|
+
break
|
|
156
|
+
sql = clean_sql(raw)
|
|
157
|
+
r.sql = sql
|
|
158
|
+
ok, err = validate(sql)
|
|
159
|
+
if ok:
|
|
160
|
+
try:
|
|
161
|
+
cols, rows, trunc = self.run_sql(sql)
|
|
162
|
+
r.columns, r.rows, r.truncated, r.ok, r.error = cols, rows, trunc, True, ""
|
|
163
|
+
miss = self.missing(req, sql)
|
|
164
|
+
if best is None or rows or not best[2]:
|
|
165
|
+
best = (sql, cols, rows, trunc, tables, i)
|
|
166
|
+
if i < self.max_attempts and (not rows or miss):
|
|
167
|
+
hint = EMPTY.format(sql=sql) if not rows else MISS.format(cols=", ".join(miss), sql=sql)
|
|
168
|
+
r.ok = False
|
|
169
|
+
continue
|
|
170
|
+
r.confidence = "low" if (low0 or i > 1 or miss or not rows) else "high"
|
|
171
|
+
break
|
|
172
|
+
except Exception as e:
|
|
173
|
+
err = str(getattr(e, "orig", e))[:300]
|
|
174
|
+
r.error = err
|
|
175
|
+
hint = FAIL.format(err=err, sql=sql)
|
|
176
|
+
if not r.ok and best: # retries failed: fall back to the earlier valid answer
|
|
177
|
+
r.sql, r.columns, r.rows, r.truncated, r.tables, r.attempts = best
|
|
178
|
+
r.ok, r.error, r.confidence = True, "", "low"
|
|
179
|
+
if not r.ok:
|
|
180
|
+
r.confidence = "low"
|
|
181
|
+
r.latency_ms = int((time.time() - t0) * 1000)
|
|
182
|
+
try:
|
|
183
|
+
HOME.mkdir(parents=True, exist_ok=True)
|
|
184
|
+
with open(LOG, "a", encoding="utf-8") as f:
|
|
185
|
+
f.write(json.dumps({"ts": datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
|
186
|
+
"question": question, "sql": r.sql, "ok": r.ok, "error": r.error,
|
|
187
|
+
"attempts": r.attempts, "latency_ms": r.latency_ms, "confidence": r.confidence, "tables": r.tables}) + "\n")
|
|
188
|
+
except Exception:
|
|
189
|
+
pass
|
|
190
|
+
if r.ok:
|
|
191
|
+
self._cache[key] = r
|
|
192
|
+
return r
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
"""Auto-built business glossary: detects common patterns in ANY schema (valid-status filters, revenue,
|
|
2
|
+
'without child', 'active', outcome rates) and can generate verified training examples from them."""
|
|
3
|
+
import re
|
|
4
|
+
from .names import singular, ABBR2FULL, PREFIXES
|
|
5
|
+
from .retrieve import name_alts, all_fks
|
|
6
|
+
|
|
7
|
+
BAD = ("cancel", "void", "reject", "fail", "refund", "return", "declin", "abandon", "delet", "invalid", "expire",
|
|
8
|
+
"lost", "no_show", "drop", "default", "withdraw", "lapse", "churn")
|
|
9
|
+
GOOD = ("complet", "deliver", "paid", "resolv", "clos", "won", "approv", "done", "success", "checked", "arriv", "receiv", "hired")
|
|
10
|
+
STATUS = {"status", "statu", "state", "stage", "outcome", "sts"}
|
|
11
|
+
QTY = {"quantity", "qty"}
|
|
12
|
+
PRICE = {"price", "prc"}
|
|
13
|
+
PRICE2 = {"cost", "fare"}
|
|
14
|
+
DISC = {"discount", "disc"}
|
|
15
|
+
TEXT = ("CHAR", "TEXT", "ENUM", "STRING")
|
|
16
|
+
DATE = ("DATE", "TIME")
|
|
17
|
+
NUM = ("INT", "DECIMAL", "NUMERIC", "FLOAT", "DOUBLE", "REAL")
|
|
18
|
+
DISP = {"name", "title", "label", "company"}
|
|
19
|
+
|
|
20
|
+
def _flat(col):
|
|
21
|
+
s = set()
|
|
22
|
+
for a in name_alts(col): s |= a
|
|
23
|
+
return s
|
|
24
|
+
|
|
25
|
+
def _is(meta, col, kinds):
|
|
26
|
+
return any(k in meta["types"][col].upper() for k in kinds)
|
|
27
|
+
|
|
28
|
+
def _flag(meta, col):
|
|
29
|
+
t = meta["types"][col].upper()
|
|
30
|
+
return "TINYINT(1)" in t or "BOOL" in t or t.startswith("BIT")
|
|
31
|
+
|
|
32
|
+
def _keys(meta):
|
|
33
|
+
return {c for fk in all_fks(meta) for c in fk["cols"]} | set(meta["pk"])
|
|
34
|
+
|
|
35
|
+
def cols_of(meta, tokens, kinds=None, exclude=()):
|
|
36
|
+
return [c for c in meta["columns"] if c not in exclude and _flat(c) & tokens
|
|
37
|
+
and (kinds is None or _is(meta, c, kinds)) and not _flag(meta, c)]
|
|
38
|
+
|
|
39
|
+
def label(name, sing=False):
|
|
40
|
+
n = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", name)
|
|
41
|
+
parts = [p for p in re.split(r"[_\W]+", n.lower()) if p]
|
|
42
|
+
if len(parts) > 1 and parts[0] in PREFIXES: parts = parts[1:]
|
|
43
|
+
words = [ABBR2FULL.get(p, p) for p in parts]
|
|
44
|
+
if sing and words: words[-1] = singular(words[-1])
|
|
45
|
+
return " ".join(words)
|
|
46
|
+
|
|
47
|
+
class Labels:
|
|
48
|
+
"""Plain-English names. With synthetic metadata they are exact; otherwise derived from the physical names."""
|
|
49
|
+
def __init__(self, meta=None):
|
|
50
|
+
self.m = {}
|
|
51
|
+
if meta:
|
|
52
|
+
for T in meta["tables"].values():
|
|
53
|
+
self.m[T["phys"]] = (T["label_sg"], T["label_pl"])
|
|
54
|
+
for c in T["cols"]: self.m[(T["phys"], c["phys"])] = c["label"]
|
|
55
|
+
def tbl(self, t):
|
|
56
|
+
if t in self.m: return self.m[t]
|
|
57
|
+
sg = label(t, True); return sg, sg + "s"
|
|
58
|
+
def col(self, t, c): return self.m.get((t, c)) or label(c)
|
|
59
|
+
|
|
60
|
+
def disp_col(meta):
|
|
61
|
+
for c in meta["columns"]:
|
|
62
|
+
if _is(meta, c, TEXT) and _flat(c) & DISP: return c
|
|
63
|
+
for c in meta["columns"]:
|
|
64
|
+
if _is(meta, c, TEXT): return c
|
|
65
|
+
return meta["pk"][0] if meta["pk"] else meta["columns"][0]
|
|
66
|
+
|
|
67
|
+
def cat_cols(know, t, exclude=()):
|
|
68
|
+
return [c for c in know["tables"][t]["columns"] if f"{t}.{c}" in know.get("values", {}) and c not in exclude]
|
|
69
|
+
|
|
70
|
+
def build(know, labels=None):
|
|
71
|
+
"""-> {term: entry}. entry: kind, term, definition, tpl (with {a} alias slot), sql, tables, params."""
|
|
72
|
+
L = labels or Labels()
|
|
73
|
+
T, E, valid = know["tables"], {}, {}
|
|
74
|
+
for t, m in T.items(): # 1) valid-status filter
|
|
75
|
+
for col in cols_of(m, STATUS, TEXT):
|
|
76
|
+
vals = know.get("values", {}).get(f"{t}.{col}", [])
|
|
77
|
+
bad = sorted({v.lower() for v in vals if any(b in v.lower() for b in BAD)})
|
|
78
|
+
if bad:
|
|
79
|
+
sg, _ = L.tbl(t); term = f"valid {sg}"
|
|
80
|
+
tpl = "LOWER({a}.%s) NOT IN (%s)" % (col, ", ".join(f"'{b}'" for b in bad))
|
|
81
|
+
valid[t] = dict(col=col, tpl=tpl, term=term)
|
|
82
|
+
E[term] = dict(kind="valid", term=term, table=t, col=col, tpl=tpl, sql=tpl.format(a="t"), tables=[t],
|
|
83
|
+
definition=f"A {sg} whose {L.col(t, col)} is not {' or '.join(bad)} (compare in lowercase).")
|
|
84
|
+
break
|
|
85
|
+
for t, m in T.items(): # 2) revenue
|
|
86
|
+
if "revenue" in E: break
|
|
87
|
+
ex = _keys(m)
|
|
88
|
+
qty, pr = cols_of(m, QTY, NUM, ex), cols_of(m, PRICE, NUM, ex) or cols_of(m, PRICE2, NUM, ex)
|
|
89
|
+
if not (qty and pr): continue
|
|
90
|
+
disc = cols_of(m, DISC, NUM)
|
|
91
|
+
expr = ("SUM({i}.%s * {i}.%s * (1 - COALESCE({i}.%s,0)/100))" % (qty[0], pr[0], disc[0])) if disc \
|
|
92
|
+
else "SUM({i}.%s * {i}.%s)" % (qty[0], pr[0])
|
|
93
|
+
par = next((fk for fk in all_fks(m) if fk["ref_table"] in valid and len(fk["cols"]) == 1), None)
|
|
94
|
+
sg, pl = L.tbl(t)
|
|
95
|
+
defi = f"Sum of {L.col(t, qty[0])} times {L.col(t, pr[0])}{' after discount' if disc else ''}, "
|
|
96
|
+
if par:
|
|
97
|
+
psg, _ = L.tbl(par["ref_table"]); defi += f"for valid {psg}s only."
|
|
98
|
+
else:
|
|
99
|
+
defi += f"over all {pl}."
|
|
100
|
+
E["revenue"] = dict(kind="revenue_A", term="revenue", table=t, tpl=expr, sql=expr.format(i="i"), definition=defi,
|
|
101
|
+
tables=[t] + ([par["ref_table"]] if par else []), par=par, qty=qty[0], price=pr[0])
|
|
102
|
+
if "revenue" not in E:
|
|
103
|
+
for t, m in T.items():
|
|
104
|
+
qty = cols_of(m, QTY, NUM, _keys(m))
|
|
105
|
+
if not qty: continue
|
|
106
|
+
for fk in all_fks(m):
|
|
107
|
+
M = fk["ref_table"]
|
|
108
|
+
if M not in T or len(fk["cols"]) != 1: continue
|
|
109
|
+
mp = cols_of(T[M], PRICE, NUM, _keys(T[M])) or cols_of(T[M], PRICE2, NUM, _keys(T[M]))
|
|
110
|
+
if mp:
|
|
111
|
+
expr = "SUM({i}.%s * {m}.%s)" % (qty[0], mp[0])
|
|
112
|
+
E["revenue"] = dict(kind="revenue_B", term="revenue", table=t, tpl=expr, sql=expr.format(i="i", m="m"),
|
|
113
|
+
definition=f"Sum of {L.col(t, qty[0])} times {L.col(M, mp[0])} of the linked {L.tbl(M)[0]}.",
|
|
114
|
+
tables=[t, M], par=fk, qty=qty[0], price=mp[0], pt=M)
|
|
115
|
+
break
|
|
116
|
+
if "revenue" in E: break
|
|
117
|
+
n_wo = n_act = 0
|
|
118
|
+
for ct, m in T.items(): # 3) without-child, 4) active parent
|
|
119
|
+
for fk in all_fks(m):
|
|
120
|
+
P = fk["ref_table"]
|
|
121
|
+
if P not in T or len(fk["cols"]) != 1 or P == ct: continue
|
|
122
|
+
psg, ppl = L.tbl(P); csg, cpl = L.tbl(ct)
|
|
123
|
+
term = f"{psg} without {csg}"
|
|
124
|
+
if term not in E and n_wo < 6:
|
|
125
|
+
n_wo += 1
|
|
126
|
+
tpl = "NOT EXISTS (SELECT 1 FROM %s c WHERE c.%s = {a}.%s)" % (ct, fk["cols"][0], fk["ref_cols"][0])
|
|
127
|
+
E[term] = dict(kind="without", term=term, table=P, child=ct, fk=fk, tpl=tpl, sql=tpl.format(a="p"),
|
|
128
|
+
tables=[P, ct], definition=f"A {psg} that has no {csg} records.")
|
|
129
|
+
dcols = [c for c in m["columns"] if _is(m, c, DATE) and c not in _keys(m)]
|
|
130
|
+
term = f"active {psg}"
|
|
131
|
+
if dcols and term not in E and n_act < 6:
|
|
132
|
+
n_act += 1
|
|
133
|
+
tpl = "{a}.%s >= DATE_SUB(CURDATE(), INTERVAL 90 DAY)" % dcols[0]
|
|
134
|
+
E[term] = dict(kind="active", term=term, table=P, child=ct, fk=fk, date=dcols[0], tpl=tpl, sql=tpl.format(a="c"),
|
|
135
|
+
tables=[P, ct], definition=f"A {psg} with at least one {csg} in the last 90 days (by {L.col(ct, dcols[0])}).")
|
|
136
|
+
for t, m in T.items(): # 5) outcome share
|
|
137
|
+
for col in cols_of(m, STATUS, TEXT):
|
|
138
|
+
vals = know.get("values", {}).get(f"{t}.{col}", [])
|
|
139
|
+
good = [v for v in vals if any(g in v.lower() for g in GOOD)]
|
|
140
|
+
if good:
|
|
141
|
+
v, sg = good[0], L.tbl(t)[0]; term = f"{v.lower().replace('_', ' ')} {sg}"
|
|
142
|
+
tpl = "ROUND(100 * SUM({a}.%s = '%s') / COUNT(*), 2)" % (col, v)
|
|
143
|
+
E.setdefault(term, dict(kind="outcome", term=term, table=t, col=col, value=v, tpl=tpl, sql=tpl.format(a="t"),
|
|
144
|
+
tables=[t], definition=f"Percentage of {L.tbl(t)[1]} whose {L.col(t, col)} is {v}."))
|
|
145
|
+
break
|
|
146
|
+
for t, m in T.items(): # 6) soft delete -> "current" records
|
|
147
|
+
for col in m["columns"]:
|
|
148
|
+
if "deleted" in _flat(col) and col not in _keys(m):
|
|
149
|
+
sg, _ = L.tbl(t); term = f"current {sg}"
|
|
150
|
+
fl = _flag(m, col)
|
|
151
|
+
tpl = ("{a}.%s = 0" if fl else "{a}.%s IS NULL") % col
|
|
152
|
+
E[term] = dict(kind="current", term=term, table=t, col=col, tpl=tpl, sql=tpl.format(a="t"), tables=[t],
|
|
153
|
+
definition=f"A {sg} that has not been deleted ({L.col(t, col)} {'is 0' if fl else 'is empty'}).")
|
|
154
|
+
break
|
|
155
|
+
return E
|
|
156
|
+
|
|
157
|
+
def probe_sql(e):
|
|
158
|
+
k = e["kind"]
|
|
159
|
+
if k == "valid": return f"SELECT COUNT(*) FROM {e['table']} t WHERE {e['tpl'].format(a='t')}"
|
|
160
|
+
if k == "revenue_A": return f"SELECT {e['tpl'].format(i='i')} FROM {e['table']} i"
|
|
161
|
+
if k == "revenue_B":
|
|
162
|
+
fk = e["par"]; return f"SELECT {e['tpl'].format(i='i', m='m')} FROM {e['table']} i JOIN {e['pt']} m ON i.{fk['cols'][0]} = m.{fk['ref_cols'][0]}"
|
|
163
|
+
if k == "without": return f"SELECT COUNT(*) FROM {e['table']} p WHERE {e['tpl'].format(a='p')}"
|
|
164
|
+
if k == "active":
|
|
165
|
+
fk = e["fk"]; return (f"SELECT COUNT(*) FROM {e['table']} p JOIN {e['child']} c ON c.{fk['cols'][0]} = p.{fk['ref_cols'][0]} "
|
|
166
|
+
f"WHERE {e['tpl'].format(a='c')}")
|
|
167
|
+
return f"SELECT {e['tpl'].format(a='t')} FROM {e['table']} t"
|
|
168
|
+
|
|
169
|
+
def build_verified(know, engine):
|
|
170
|
+
"""Entries whose probe query actually runs on this database, in the public glossary format."""
|
|
171
|
+
out = {}
|
|
172
|
+
with engine.connect() as c:
|
|
173
|
+
for term, e in build(know).items():
|
|
174
|
+
try:
|
|
175
|
+
c.exec_driver_sql(probe_sql(e).replace("%", "%%") if engine.dialect.name in ("mysql", "postgresql") else probe_sql(e)).fetchall()
|
|
176
|
+
except Exception:
|
|
177
|
+
continue
|
|
178
|
+
out[term] = dict(definition=e["definition"], sql=e["sql"], tables=e["tables"], auto=True)
|
|
179
|
+
return out
|
|
180
|
+
|
|
181
|
+
def examples(know, labels, entries):
|
|
182
|
+
"""Training examples that use the glossary entries. -> [{family, variants, sql, tables, used, terms, req, extra}]"""
|
|
183
|
+
L, T, out = labels, know["tables"], []
|
|
184
|
+
def add(fam, variants, sql, tables, terms, used=()):
|
|
185
|
+
out.append(dict(family=fam, variants=variants, sql=" ".join(sql.split()), tables=list(dict.fromkeys(tables)),
|
|
186
|
+
used=list(used), terms=list(terms), req=[], extra=False))
|
|
187
|
+
for term, e in entries.items():
|
|
188
|
+
k, t = e["kind"], e["table"]
|
|
189
|
+
sg, pl = L.tbl(t)
|
|
190
|
+
if k == "valid":
|
|
191
|
+
f = e["tpl"].format(a="t"); m = T[t]
|
|
192
|
+
add("gl_valid_count", [f"How many valid {pl} are there?", f"Number of valid {pl}", f"Count the valid {pl}"],
|
|
193
|
+
f"SELECT COUNT(*) FROM {t} t WHERE {f}", [t], [term], [f"{t}.{e['col']}"])
|
|
194
|
+
for c in cat_cols(know, t, {e["col"]})[:2]:
|
|
195
|
+
add("gl_valid_group", [f"Number of valid {pl} per {L.col(t, c)}", f"How many valid {pl} for each {L.col(t, c)}?"],
|
|
196
|
+
f"SELECT t.{c}, COUNT(*) AS n FROM {t} t WHERE {f} GROUP BY t.{c} ORDER BY n DESC", [t], [term], [f"{t}.{c}"])
|
|
197
|
+
for n in [c for c in m["columns"] if _is(m, c, NUM) and not _flag(m, c) and c not in _keys(m)][:2]:
|
|
198
|
+
add("gl_valid_sum", [f"Total {L.col(t, n)} of valid {pl}", f"What is the total {L.col(t, n)} across valid {pl}?"],
|
|
199
|
+
f"SELECT ROUND(SUM(t.{n}),2) AS total FROM {t} t WHERE {f}", [t], [term])
|
|
200
|
+
for d in [c for c in m["columns"] if _is(m, c, DATE) and c not in _keys(m)][:1]:
|
|
201
|
+
for days in (90, 365):
|
|
202
|
+
add("gl_valid_recent", [f"How many valid {pl} in the last {days} days?", f"Number of valid {pl} within the past {days} days"],
|
|
203
|
+
f"SELECT COUNT(*) FROM {t} t WHERE {f} AND t.{d} >= DATE_SUB(CURDATE(), INTERVAL {days} DAY)", [t], [term])
|
|
204
|
+
elif k in ("revenue_A", "revenue_B"):
|
|
205
|
+
fk, par = e.get("par"), e.get("par")
|
|
206
|
+
ex = e["tpl"].format(i="i", m="m")
|
|
207
|
+
if k == "revenue_A":
|
|
208
|
+
join, where, ptab, terms = f"{t} i", "", None, [term]
|
|
209
|
+
if fk:
|
|
210
|
+
ptab = fk["ref_table"]; vt = next(x for x in entries.values() if x["kind"] == "valid" and x["table"] == ptab)
|
|
211
|
+
join = f"{t} i JOIN {ptab} p ON i.{fk['cols'][0]} = p.{fk['ref_cols'][0]}"
|
|
212
|
+
where = " WHERE " + vt["tpl"].format(a="p"); terms = [term, vt["term"]]
|
|
213
|
+
else:
|
|
214
|
+
ptab = e["pt"]; join = f"{t} i JOIN {ptab} m ON i.{fk['cols'][0]} = m.{fk['ref_cols'][0]}"; where, terms = "", [term]
|
|
215
|
+
al = "p" if k == "revenue_A" else "m"
|
|
216
|
+
add("gl_revenue", ["What is our total revenue?", "Total revenue", "How much revenue have we made?"],
|
|
217
|
+
f"SELECT ROUND({ex},2) AS revenue FROM {join}{where}", [t] + ([ptab] if ptab else []), terms)
|
|
218
|
+
if ptab:
|
|
219
|
+
pm = T[ptab]
|
|
220
|
+
for c in cat_cols(know, ptab)[:2]:
|
|
221
|
+
if k == "revenue_A" and c == next(x for x in entries.values() if x["kind"] == "valid" and x["table"] == ptab)["col"]: continue
|
|
222
|
+
add("gl_revenue_group", [f"Revenue by {L.col(ptab, c)}", f"Show revenue per {L.col(ptab, c)}"],
|
|
223
|
+
f"SELECT {al}.{c}, ROUND({ex},2) AS revenue FROM {join}{where} GROUP BY {al}.{c} ORDER BY revenue DESC", [t, ptab], terms, [f"{ptab}.{c}"])
|
|
224
|
+
if k == "revenue_A":
|
|
225
|
+
d, pk = disp_col(pm), (pm["pk"] or [pm["columns"][0]])[0]
|
|
226
|
+
psg, ppl = L.tbl(ptab)
|
|
227
|
+
for kk in (3, 5, 10):
|
|
228
|
+
add("gl_revenue_top", [f"Top {kk} {ppl} by revenue", f"Which {kk} {ppl} bring in the most revenue?"],
|
|
229
|
+
f"SELECT p.{d}, ROUND({ex},2) AS revenue FROM {join}{where} GROUP BY p.{pk}, p.{d} ORDER BY revenue DESC LIMIT {kk}", [t, ptab], terms)
|
|
230
|
+
for dc in [c for c in pm["columns"] if _is(pm, c, DATE) and c not in _keys(pm)][:1]:
|
|
231
|
+
for days in (90, 365):
|
|
232
|
+
w2 = (where + " AND " if where else " WHERE ") + f"p.{dc} >= DATE_SUB(CURDATE(), INTERVAL {days} DAY)"
|
|
233
|
+
add("gl_revenue_recent", [f"Revenue in the last {days} days", f"How much revenue did we make in the past {days} days?"],
|
|
234
|
+
f"SELECT ROUND({ex},2) AS revenue FROM {join}{w2}", [t, ptab], terms)
|
|
235
|
+
elif k == "without":
|
|
236
|
+
P, C, fk = t, e["child"], e["fk"]; psg, ppl = L.tbl(P); csg, cpl = L.tbl(C); d = disp_col(T[P])
|
|
237
|
+
f = e["tpl"].format(a="p")
|
|
238
|
+
add("gl_without_count", [f"How many {ppl} have no {cpl}?", f"Number of {ppl} without any {cpl}", f"Count {ppl} that have never had a {csg}"],
|
|
239
|
+
f"SELECT COUNT(*) FROM {P} p WHERE {f}", [P, C], [term])
|
|
240
|
+
for kk in (5, 10):
|
|
241
|
+
add("gl_without_list", [f"List {kk} {ppl} without {cpl}", f"Show {kk} {ppl} that have no {cpl}"],
|
|
242
|
+
f"SELECT p.{d} FROM {P} p WHERE {f} LIMIT {kk}", [P, C], [term])
|
|
243
|
+
elif k == "active":
|
|
244
|
+
P, C, fk = t, e["child"], e["fk"]; psg, ppl = L.tbl(P); csg, cpl = L.tbl(C)
|
|
245
|
+
f = e["tpl"].format(a="c"); pk = fk["ref_cols"][0]
|
|
246
|
+
base = f"FROM {P} p JOIN {C} c ON c.{fk['cols'][0]} = p.{pk} WHERE {f}"
|
|
247
|
+
add("gl_active_count", [f"How many active {ppl} do we have?", f"Number of active {ppl}", f"Count active {ppl}"],
|
|
248
|
+
f"SELECT COUNT(DISTINCT p.{pk}) AS active {base}", [P, C], [term])
|
|
249
|
+
for c2 in cat_cols(know, P)[:2]:
|
|
250
|
+
add("gl_active_group", [f"Active {ppl} by {L.col(P, c2)}", f"How many active {ppl} per {L.col(P, c2)}?"],
|
|
251
|
+
f"SELECT p.{c2}, COUNT(DISTINCT p.{pk}) AS active {base} GROUP BY p.{c2} ORDER BY active DESC", [P, C], [term], [f"{P}.{c2}"])
|
|
252
|
+
elif k == "current":
|
|
253
|
+
f = e["tpl"].format(a="t"); m = T[t]
|
|
254
|
+
add("gl_current_count", [f"How many current {pl} do we have?", f"Number of {pl} that are not deleted", f"Count the active {pl} records"],
|
|
255
|
+
f"SELECT COUNT(*) FROM {t} t WHERE {f}", [t], [term])
|
|
256
|
+
add("gl_deleted_count", [f"How many {pl} have been deleted?", f"Number of deleted {pl}"],
|
|
257
|
+
f"SELECT COUNT(*) FROM {t} t WHERE NOT ({f})", [t], [term])
|
|
258
|
+
for c in cat_cols(know, t)[:2]:
|
|
259
|
+
add("gl_current_group", [f"Number of current {pl} per {L.col(t, c)}", f"{L.col(t, c)} wise count of current {pl}"],
|
|
260
|
+
f"SELECT t.{c}, COUNT(*) AS n FROM {t} t WHERE {f} GROUP BY t.{c} ORDER BY n DESC", [t], [term], [f"{t}.{c}"])
|
|
261
|
+
for n in [c for c in m["columns"] if _is(m, c, NUM) and not _flag(m, c) and c not in _keys(m)][:1]:
|
|
262
|
+
add("gl_current_sum", [f"Total {L.col(t, n)} of current {pl}", f"What is the total {L.col(t, n)} across current {pl}?"],
|
|
263
|
+
f"SELECT ROUND(SUM(t.{n}),2) AS total FROM {t} t WHERE {f}", [t], [term])
|
|
264
|
+
elif k == "outcome":
|
|
265
|
+
v, col = e["value"], e["col"]; vtxt = v.replace("_", " ")
|
|
266
|
+
add("gl_outcome_pct", [f"What percentage of {pl} are {vtxt}?", f"Share of {pl} that are {vtxt}", f"What is the {vtxt} rate of {pl}?"],
|
|
267
|
+
f"SELECT ROUND(100 * SUM(t.{col} = '{v}') / COUNT(*), 2) AS pct FROM {t} t", [t], [term], [f"{t}.{col}"])
|
|
268
|
+
for c in cat_cols(know, t, {col})[:1]:
|
|
269
|
+
add("gl_outcome_group", [f"Percentage of {pl} that are {vtxt} by {L.col(t, c)}", f"{vtxt.capitalize()} rate of {pl} per {L.col(t, c)}"],
|
|
270
|
+
f"SELECT t.{c}, ROUND(100 * SUM(t.{col} = '{v}') / COUNT(*), 2) AS pct FROM {t} t GROUP BY t.{c} ORDER BY pct DESC", [t], [term], [f"{t}.{c}"])
|
|
271
|
+
for t, m in T.items(): # flag columns (no glossary term needed)
|
|
272
|
+
sg, pl = L.tbl(t)
|
|
273
|
+
for c in [c for c in m["columns"] if _flag(m, c)][:2]:
|
|
274
|
+
fl = re.sub(r"^is ", "", L.col(t, c))
|
|
275
|
+
add("flag_count", [f"How many {pl} are {fl}?", f"Number of {pl} that are {fl}", f"Count the {fl} {pl}"],
|
|
276
|
+
f"SELECT COUNT(*) FROM {t} WHERE {c} = 1", [t], [])
|
|
277
|
+
add("flag_pct", [f"What percentage of {pl} are {fl}?", f"Share of {pl} that are {fl}"],
|
|
278
|
+
f"SELECT ROUND(100 * AVG({c}), 2) AS pct FROM {t}", [t], [])
|
|
279
|
+
return out
|
contextsql/cli.py
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
"""contextsql connect | learn | ask | glossary | status"""
|
|
2
|
+
import argparse, getpass, json, os, sys
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from . import glossary as gl
|
|
5
|
+
from .config import CONFIG, KNOW, LOG, load_json, save_json
|
|
6
|
+
from .db import make_url, get_engine
|
|
7
|
+
|
|
8
|
+
HF_REPO = "Garvitarunav/contextsql-v4"
|
|
9
|
+
HF_FILE = "contextsql-v4-q4_k_m.gguf"
|
|
10
|
+
|
|
11
|
+
def default_model():
|
|
12
|
+
p = Path("models/contextsql-q4_k_m.gguf")
|
|
13
|
+
if p.exists():
|
|
14
|
+
return str(p.resolve())
|
|
15
|
+
from huggingface_hub import hf_hub_download
|
|
16
|
+
print("Downloading model (1.8 GB, first time only) ...")
|
|
17
|
+
return hf_hub_download(HF_REPO, HF_FILE)
|
|
18
|
+
|
|
19
|
+
def need(path, hint):
|
|
20
|
+
obj = load_json(path)
|
|
21
|
+
if obj is None:
|
|
22
|
+
sys.exit(f"Not ready yet - run: {hint}")
|
|
23
|
+
return obj
|
|
24
|
+
|
|
25
|
+
def cmd_connect(a):
|
|
26
|
+
d = a.dialect
|
|
27
|
+
if d == "sqlite":
|
|
28
|
+
db = a.db or input("SQLite file path: ")
|
|
29
|
+
url = make_url("sqlite", database=db)
|
|
30
|
+
else:
|
|
31
|
+
host = a.host or input("Host [127.0.0.1]: ") or "127.0.0.1"
|
|
32
|
+
port = a.port or int(input(f"Port [{3306 if d == 'mysql' else 5432}]: ") or (3306 if d == "mysql" else 5432))
|
|
33
|
+
user = a.user or input("User (a READ-ONLY user is strongly recommended): ")
|
|
34
|
+
pw = a.password if a.password is not None else getpass.getpass("Password: ")
|
|
35
|
+
db = a.db or input("Database name: ")
|
|
36
|
+
url = make_url(d, host, port, user, pw, db)
|
|
37
|
+
try:
|
|
38
|
+
with get_engine(url).connect() as c:
|
|
39
|
+
c.exec_driver_sql("SELECT 1")
|
|
40
|
+
except Exception as e:
|
|
41
|
+
sys.exit(f"Could not connect: {getattr(e, 'orig', e)}")
|
|
42
|
+
model = a.model or default_model()
|
|
43
|
+
save_json(CONFIG, {"url": url, "model": model})
|
|
44
|
+
print("Connected and saved.")
|
|
45
|
+
print("Next: contextsql learn")
|
|
46
|
+
|
|
47
|
+
def cmd_learn(a):
|
|
48
|
+
from .learn import learn
|
|
49
|
+
cfg = need(CONFIG, "contextsql connect")
|
|
50
|
+
print("Learning your database (one-time) ...")
|
|
51
|
+
know, imported = learn(get_engine(cfg["url"]))
|
|
52
|
+
rows = sum(t.get("rows", 0) for t in know["tables"].values())
|
|
53
|
+
print(f"\nDone: {len(know['tables'])} tables, {rows:,} rows scanned, {len(know['notes'])} data notes, "
|
|
54
|
+
f"{len(know.get('autogloss', {}))} auto glossary terms, {imported} glossary terms imported.")
|
|
55
|
+
for n in know["notes"][:6]:
|
|
56
|
+
print(" -", n["text"])
|
|
57
|
+
if not gl.load():
|
|
58
|
+
print("\nTip: teach it your key metrics (2 minutes):\n"
|
|
59
|
+
' contextsql glossary add "revenue" "Sum of quantity*price for completed orders" '
|
|
60
|
+
'"SUM(quantity*unit_price)" --tables orders,order_items')
|
|
61
|
+
print("\nNext: contextsql ask \"your question\"")
|
|
62
|
+
|
|
63
|
+
def show(r):
|
|
64
|
+
print(f"\nSQL ({r.attempts} attempt{'s' if r.attempts != 1 else ''}, {r.latency_ms} ms; tables: {', '.join(r.tables)}):\n {r.sql}")
|
|
65
|
+
if r.confidence == "low":
|
|
66
|
+
print(" (low confidence - please check the SQL above, or rephrase / add a glossary term)")
|
|
67
|
+
if not r.ok:
|
|
68
|
+
print(f"\nCould not answer: {r.error}"); return
|
|
69
|
+
if not r.rows:
|
|
70
|
+
print("\n(no rows)"); return
|
|
71
|
+
cells = [[("NULL" if v is None else str(v)) for v in row] for row in r.rows[:20]]
|
|
72
|
+
w = [max(len(str(c)), *(len(row[i]) for row in cells)) for i, c in enumerate(r.columns)]
|
|
73
|
+
print("\n" + " ".join(str(c).ljust(w[i]) for i, c in enumerate(r.columns)))
|
|
74
|
+
print(" ".join("-" * x for x in w))
|
|
75
|
+
for row in cells: print(" ".join(v.ljust(w[i]) for i, v in enumerate(row)))
|
|
76
|
+
more = len(r.rows) - 20
|
|
77
|
+
print(f"\n{len(r.rows)}{'+' if r.truncated else ''} row(s)" + (f" (showing 20, {more} more)" if more > 0 else ""))
|
|
78
|
+
|
|
79
|
+
def cmd_ask(a):
|
|
80
|
+
from .agent import Agent, LlamaGenerator
|
|
81
|
+
cfg = need(CONFIG, "contextsql connect")
|
|
82
|
+
know = need(KNOW, "contextsql learn")
|
|
83
|
+
if not cfg.get("model") or not Path(cfg["model"]).exists():
|
|
84
|
+
cfg["model"] = default_model()
|
|
85
|
+
save_json(CONFIG, cfg)
|
|
86
|
+
print("Loading model ...")
|
|
87
|
+
agent = Agent(get_engine(cfg["url"]), know, LlamaGenerator(cfg["model"], threads=a.threads))
|
|
88
|
+
q = " ".join(a.question).strip()
|
|
89
|
+
if q:
|
|
90
|
+
show(agent.ask(q)); return
|
|
91
|
+
print("Ask anything about your data (empty line to quit).")
|
|
92
|
+
while True:
|
|
93
|
+
q = input("\n> ").strip()
|
|
94
|
+
if not q: break
|
|
95
|
+
show(agent.ask(q))
|
|
96
|
+
|
|
97
|
+
def cmd_glossary(a):
|
|
98
|
+
if a.action == "add":
|
|
99
|
+
if not (a.term and a.definition and a.sql): sys.exit('usage: contextsql glossary add "term" "definition" "sql" [--tables a,b]')
|
|
100
|
+
gl.add(a.term, a.definition, a.sql, [t for t in (a.tables or "").split(",") if t])
|
|
101
|
+
print(f"Added '{a.term}'. Re-run learn is not needed.")
|
|
102
|
+
elif a.action == "remove":
|
|
103
|
+
print("Removed." if gl.remove(a.term) else "Not found.")
|
|
104
|
+
else:
|
|
105
|
+
auto = (load_json(KNOW) or {}).get("autogloss", {})
|
|
106
|
+
for k, g in {**auto, **gl.load()}.items():
|
|
107
|
+
tag = " (auto)" if k in auto and k not in gl.load() else ""
|
|
108
|
+
print(f"- {k}{tag}: {g['definition']}\n SQL: {g['sql']} tables: {', '.join(g['tables'])}")
|
|
109
|
+
|
|
110
|
+
def _last():
|
|
111
|
+
try:
|
|
112
|
+
return json.loads(open(LOG, encoding="utf-8").read().strip().splitlines()[-1])
|
|
113
|
+
except Exception:
|
|
114
|
+
sys.exit("No previous question found - ask something first.")
|
|
115
|
+
|
|
116
|
+
def cmd_good(a):
|
|
117
|
+
from . import feedback
|
|
118
|
+
e = _last()
|
|
119
|
+
if not e.get("ok"):
|
|
120
|
+
sys.exit('The last answer did not succeed. Use: contextsql fix "SELECT ..."')
|
|
121
|
+
feedback.save(e["question"], e["sql"], e.get("tables", []))
|
|
122
|
+
print("Saved. It will be used as an example for similar questions.")
|
|
123
|
+
|
|
124
|
+
def cmd_fix(a):
|
|
125
|
+
from . import feedback
|
|
126
|
+
from .agent import Agent, validate, clean_sql
|
|
127
|
+
e, sql = _last(), clean_sql(" ".join(a.sql))
|
|
128
|
+
ok, err = validate(sql)
|
|
129
|
+
if not ok: sys.exit(f"Not saved: {err}")
|
|
130
|
+
cfg = need(CONFIG, "contextsql connect")
|
|
131
|
+
try:
|
|
132
|
+
Agent(get_engine(cfg["url"]), None, None).run_sql(sql)
|
|
133
|
+
except Exception as ex:
|
|
134
|
+
sys.exit(f"Not saved - that SQL does not run: {getattr(ex, 'orig', ex)}")
|
|
135
|
+
feedback.save(e["question"], sql, e.get("tables", []))
|
|
136
|
+
print("Saved your corrected SQL for that question.")
|
|
137
|
+
|
|
138
|
+
def cmd_status(a):
|
|
139
|
+
cfg, know = load_json(CONFIG), load_json(KNOW)
|
|
140
|
+
print("connected:", bool(cfg), "| model:", (cfg or {}).get("model") or "-")
|
|
141
|
+
if know:
|
|
142
|
+
print(f"learned: {len(know['tables'])} tables, {len(know['notes'])} notes, at {know['learned_at']}")
|
|
143
|
+
print("glossary terms:", len(gl.load()))
|
|
144
|
+
|
|
145
|
+
def main(argv=None):
|
|
146
|
+
p = argparse.ArgumentParser(prog="contextsql", description="Ask your database questions in plain language - local and free.")
|
|
147
|
+
s = p.add_subparsers(dest="cmd", required=True)
|
|
148
|
+
c = s.add_parser("connect"); c.set_defaults(fn=cmd_connect)
|
|
149
|
+
c.add_argument("--dialect", default="mysql", choices=["mysql", "postgresql", "sqlite"])
|
|
150
|
+
c.add_argument("--host"); c.add_argument("--port", type=int); c.add_argument("--user")
|
|
151
|
+
c.add_argument("--password"); c.add_argument("--db"); c.add_argument("--model")
|
|
152
|
+
s.add_parser("learn").set_defaults(fn=cmd_learn)
|
|
153
|
+
k = s.add_parser("ask"); k.add_argument("question", nargs="*"); k.add_argument("--threads", type=int); k.set_defaults(fn=cmd_ask)
|
|
154
|
+
g = s.add_parser("glossary"); g.set_defaults(fn=cmd_glossary)
|
|
155
|
+
g.add_argument("action", choices=["add", "list", "remove"], nargs="?", default="list")
|
|
156
|
+
g.add_argument("term", nargs="?"); g.add_argument("definition", nargs="?"); g.add_argument("sql", nargs="?")
|
|
157
|
+
g.add_argument("--tables")
|
|
158
|
+
s.add_parser("good").set_defaults(fn=cmd_good)
|
|
159
|
+
f = s.add_parser("fix"); f.add_argument("sql", nargs="+"); f.set_defaults(fn=cmd_fix)
|
|
160
|
+
s.add_parser("status").set_defaults(fn=cmd_status)
|
|
161
|
+
a = p.parse_args(argv)
|
|
162
|
+
a.fn(a)
|
|
163
|
+
|
|
164
|
+
if __name__ == "__main__":
|
|
165
|
+
main()
|