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 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()