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/config.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
"""Local storage: everything lives in ~/.contextsql (override with CONTEXTSQL_HOME)."""
|
|
2
|
+
import json, os
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
HOME = Path(os.getenv("CONTEXTSQL_HOME", Path.home() / ".contextsql"))
|
|
6
|
+
CONFIG, KNOW, GLOSS, LOG = (HOME / "config.json", HOME / "knowledge.json",
|
|
7
|
+
HOME / "glossary.json", HOME / "query_log.jsonl")
|
|
8
|
+
|
|
9
|
+
def load_json(path, default=None):
|
|
10
|
+
try:
|
|
11
|
+
return json.loads(Path(path).read_text(encoding="utf-8"))
|
|
12
|
+
except FileNotFoundError:
|
|
13
|
+
return default
|
|
14
|
+
|
|
15
|
+
def save_json(path, obj):
|
|
16
|
+
HOME.mkdir(parents=True, exist_ok=True)
|
|
17
|
+
Path(path).write_text(json.dumps(obj, indent=2, ensure_ascii=False), encoding="utf-8")
|
contextsql/db.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from sqlalchemy import create_engine
|
|
2
|
+
from sqlalchemy.engine import URL
|
|
3
|
+
|
|
4
|
+
DRIVERS = {"mysql": "mysql+pymysql", "postgresql": "postgresql+psycopg2"}
|
|
5
|
+
|
|
6
|
+
def make_url(dialect, host=None, port=None, user=None, password=None, database=None):
|
|
7
|
+
if dialect == "sqlite":
|
|
8
|
+
return f"sqlite:///{database}"
|
|
9
|
+
return URL.create(DRIVERS[dialect], username=user, password=password, host=host,
|
|
10
|
+
port=port, database=database).render_as_string(hide_password=False)
|
|
11
|
+
|
|
12
|
+
def get_engine(url):
|
|
13
|
+
return create_engine(url, pool_pre_ping=True)
|
contextsql/feedback.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Saved question->SQL pairs (confirmed good or user-corrected). Reused as examples for similar questions."""
|
|
2
|
+
import json
|
|
3
|
+
from .config import HOME
|
|
4
|
+
from .retrieve import toks
|
|
5
|
+
|
|
6
|
+
FILE = HOME / "examples.jsonl"
|
|
7
|
+
|
|
8
|
+
def save(question, sql, tables):
|
|
9
|
+
HOME.mkdir(parents=True, exist_ok=True)
|
|
10
|
+
with open(FILE, "a", encoding="utf-8") as f:
|
|
11
|
+
f.write(json.dumps({"question": question, "sql": sql, "tables": list(tables)}) + "\n")
|
|
12
|
+
|
|
13
|
+
def load():
|
|
14
|
+
try:
|
|
15
|
+
return [json.loads(l) for l in open(FILE, encoding="utf-8") if l.strip()]
|
|
16
|
+
except FileNotFoundError:
|
|
17
|
+
return []
|
|
18
|
+
|
|
19
|
+
def similar(question, tables, k=2, thresh=0.5):
|
|
20
|
+
q = set(toks(question))
|
|
21
|
+
scored = []
|
|
22
|
+
for ex in load():
|
|
23
|
+
e = set(toks(ex["question"]))
|
|
24
|
+
if not q or not e or not set(ex.get("tables", [])) <= set(tables):
|
|
25
|
+
continue
|
|
26
|
+
j = len(q & e) / len(q | e)
|
|
27
|
+
if j >= thresh:
|
|
28
|
+
scored.append((j, ex))
|
|
29
|
+
scored.sort(key=lambda x: -x[0])
|
|
30
|
+
out, seen = [], set()
|
|
31
|
+
for _, ex in scored:
|
|
32
|
+
if ex["sql"] not in seen:
|
|
33
|
+
seen.add(ex["sql"]); out.append((ex["question"], ex["sql"]))
|
|
34
|
+
if len(out) >= k: break
|
|
35
|
+
return out
|
contextsql/glossary.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""User-editable business glossary: term -> definition + SQL snippet + tables."""
|
|
2
|
+
from .config import GLOSS, load_json, save_json
|
|
3
|
+
|
|
4
|
+
def load():
|
|
5
|
+
return load_json(GLOSS, {})
|
|
6
|
+
|
|
7
|
+
def add(term, definition, sql, tables=()):
|
|
8
|
+
g = load()
|
|
9
|
+
g[term.lower()] = {"definition": definition, "sql": sql, "tables": list(tables)}
|
|
10
|
+
save_json(GLOSS, g)
|
|
11
|
+
|
|
12
|
+
def remove(term):
|
|
13
|
+
g = load()
|
|
14
|
+
found = g.pop(term.lower(), None) is not None
|
|
15
|
+
save_json(GLOSS, g)
|
|
16
|
+
return found
|
|
17
|
+
|
|
18
|
+
def merge_imported(rows):
|
|
19
|
+
"""rows: (term, definition, sql, tables) - never overwrites entries the user already has."""
|
|
20
|
+
g, added = load(), 0
|
|
21
|
+
for term, definition, sql, tables in rows:
|
|
22
|
+
if term.lower() not in g:
|
|
23
|
+
g[term.lower()] = {"definition": definition, "sql": sql,
|
|
24
|
+
"tables": [t.strip() for t in (tables or "").split(",") if t.strip()]}
|
|
25
|
+
added += 1
|
|
26
|
+
save_json(GLOSS, g)
|
|
27
|
+
return added
|
contextsql/hints.py
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
"""Retry hints - used identically by the agent and by the training-data builder."""
|
|
2
|
+
FAIL = "the previous attempt failed ({err}). Previous SQL: {sql} Write a corrected query."
|
|
3
|
+
EMPTY = "the previous attempt returned no rows. Previous SQL: {sql} Check the filter values against the data notes and fix the query."
|
|
4
|
+
MISS = "the answer must also include the columns: {cols}. Previous SQL: {sql} Add them to the SELECT list (and GROUP BY)."
|
|
5
|
+
UNANSWERABLE = "-- UNANSWERABLE:"
|
contextsql/introspect.py
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Read schema + profile the data (null rates, categorical values, near-duplicate spellings)."""
|
|
2
|
+
import re, difflib
|
|
3
|
+
from .names import singular, PREFIXES
|
|
4
|
+
from sqlalchemy import inspect
|
|
5
|
+
|
|
6
|
+
TEXT_HINTS = ("CHAR", "TEXT", "ENUM", "STRING", "CLOB")
|
|
7
|
+
SAMPLE_ABOVE, SAMPLE_SIZE = 200_000, 100_000
|
|
8
|
+
|
|
9
|
+
def clean_mysql_ddl(raw):
|
|
10
|
+
raw = raw.replace("`", "")
|
|
11
|
+
lines = raw.split("\n")
|
|
12
|
+
head, body = lines[0], [l.strip().rstrip(",") for l in lines[1:-1]]
|
|
13
|
+
body = [l for l in body if not l.startswith("KEY ") and not l.startswith("UNIQUE KEY")]
|
|
14
|
+
body = [re.sub(r"\s+(CHARACTER SET \w+|COLLATE \w+)", "", l) for l in body]
|
|
15
|
+
body = [re.sub(r"\s+DEFAULT NULL", "", l) for l in body]
|
|
16
|
+
return head + "\n " + ",\n ".join(body) + "\n);"
|
|
17
|
+
|
|
18
|
+
def col_type(engine, col):
|
|
19
|
+
try:
|
|
20
|
+
return str(col["type"].compile(dialect=engine.dialect))
|
|
21
|
+
except Exception:
|
|
22
|
+
return "UNKNOWN"
|
|
23
|
+
|
|
24
|
+
def ddl_for(engine, insp, table):
|
|
25
|
+
if engine.dialect.name == "mysql":
|
|
26
|
+
with engine.connect() as c:
|
|
27
|
+
raw = c.exec_driver_sql(f"SHOW CREATE TABLE `{table}`").fetchone()[1]
|
|
28
|
+
return clean_mysql_ddl(raw)
|
|
29
|
+
lines = []
|
|
30
|
+
for col in insp.get_columns(table):
|
|
31
|
+
lines.append(f"{col['name']} {col_type(engine, col)}" + ("" if col.get("nullable", True) else " NOT NULL"))
|
|
32
|
+
pk = insp.get_pk_constraint(table).get("constrained_columns") or []
|
|
33
|
+
if pk: lines.append(f"PRIMARY KEY ({', '.join(pk)})")
|
|
34
|
+
for fk in insp.get_foreign_keys(table):
|
|
35
|
+
lines.append(f"FOREIGN KEY ({', '.join(fk['constrained_columns'])}) REFERENCES "
|
|
36
|
+
f"{fk['referred_table']} ({', '.join(fk['referred_columns'])})")
|
|
37
|
+
return f"CREATE TABLE {table} (\n " + ",\n ".join(lines) + "\n);"
|
|
38
|
+
|
|
39
|
+
def similar_groups(values, threshold=0.9):
|
|
40
|
+
"""Find values that look like spelling/case variants of each other."""
|
|
41
|
+
flagged = set()
|
|
42
|
+
for i, a in enumerate(values):
|
|
43
|
+
for b in values[i + 1:]:
|
|
44
|
+
if a.lower() == b.lower() or difflib.SequenceMatcher(None, a.lower(), b.lower()).ratio() >= threshold:
|
|
45
|
+
flagged |= {a, b}
|
|
46
|
+
return flagged
|
|
47
|
+
|
|
48
|
+
def profile(engine, tables, max_distinct=30):
|
|
49
|
+
"""Returns (notes, values). notes: [{table, column, kind, text}], values: {'table.col': [..]}"""
|
|
50
|
+
q = engine.dialect.identifier_preparer.quote
|
|
51
|
+
notes, values = [], {}
|
|
52
|
+
with engine.connect() as c:
|
|
53
|
+
for t, meta in tables.items():
|
|
54
|
+
qt = q(t)
|
|
55
|
+
n = c.exec_driver_sql(f"SELECT COUNT(*) FROM {qt}").scalar() or 0
|
|
56
|
+
meta["rows"] = int(n)
|
|
57
|
+
if n == 0:
|
|
58
|
+
continue
|
|
59
|
+
src = qt if n <= SAMPLE_ABOVE else f"(SELECT * FROM {qt} LIMIT {SAMPLE_SIZE}) AS s"
|
|
60
|
+
cols = meta["columns"]
|
|
61
|
+
is_text = {col: any(h in meta["types"][col].upper() for h in TEXT_HINTS) for col in cols}
|
|
62
|
+
parts = [f"SUM(CASE WHEN {q(col)} IS NULL THEN 1 ELSE 0 END)" for col in cols]
|
|
63
|
+
tcols = [col for col in cols if is_text[col]]
|
|
64
|
+
parts += [f"COUNT(DISTINCT {q(col)})" for col in tcols]
|
|
65
|
+
row = c.exec_driver_sql(f"SELECT COUNT(*), {', '.join(parts)} FROM {src}").fetchone()
|
|
66
|
+
total, nulls, distincts = row[0] or 1, row[1:1 + len(cols)], row[1 + len(cols):]
|
|
67
|
+
for col, nl in zip(cols, nulls):
|
|
68
|
+
pct = 100 * (nl or 0) / total
|
|
69
|
+
if 1 <= pct < 100 and col not in meta["pk"]:
|
|
70
|
+
notes.append(dict(table=t, column=col, kind="null",
|
|
71
|
+
text=f"{t}.{col} can be NULL ({pct:.0f}% of rows); use COALESCE when aggregating."))
|
|
72
|
+
for col, d in zip(tcols, distincts):
|
|
73
|
+
if not d or d > max_distinct:
|
|
74
|
+
continue
|
|
75
|
+
rows = c.exec_driver_sql(
|
|
76
|
+
f"SELECT {q(col)}, COUNT(*) FROM {src} WHERE {q(col)} IS NOT NULL "
|
|
77
|
+
f"GROUP BY {q(col)} ORDER BY COUNT(*) DESC LIMIT {max_distinct}").fetchall()
|
|
78
|
+
vals = [str(r[0]) for r in rows]
|
|
79
|
+
values[f"{t}.{col}"] = vals
|
|
80
|
+
bad = similar_groups(vals)
|
|
81
|
+
if bad:
|
|
82
|
+
notes.append(dict(table=t, column=col, kind="variants", text=(
|
|
83
|
+
f"{t}.{col} has inconsistent spelling/case ({', '.join(vals)}); "
|
|
84
|
+
f"compare with LOWER() and treat similar spellings as the same.")))
|
|
85
|
+
elif d <= 12:
|
|
86
|
+
notes.append(dict(table=t, column=col, kind="values",
|
|
87
|
+
text=f"{t}.{col} has values: {', '.join(vals)}."))
|
|
88
|
+
return notes, values
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _norm(s):
|
|
92
|
+
return re.sub(r"[^a-z0-9]", "", s.lower())
|
|
93
|
+
|
|
94
|
+
def _base(table):
|
|
95
|
+
parts = [p for p in re.split(r"[_\W]+", re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", table).lower()) if p]
|
|
96
|
+
if len(parts) > 1 and parts[0] in PREFIXES: parts = parts[1:]
|
|
97
|
+
return "".join(parts)
|
|
98
|
+
|
|
99
|
+
def infer_joins(tables):
|
|
100
|
+
"""Guess foreign keys from column names when the database does not declare them."""
|
|
101
|
+
notes = []
|
|
102
|
+
for t, meta in tables.items():
|
|
103
|
+
declared = {c for fk in meta["fks"] for c in fk["cols"]}
|
|
104
|
+
meta["fks_inferred"] = []
|
|
105
|
+
for col in meta["columns"]:
|
|
106
|
+
if col in declared or col in meta["pk"]:
|
|
107
|
+
continue
|
|
108
|
+
nc = _norm(col)
|
|
109
|
+
for p, pm in tables.items():
|
|
110
|
+
if p == t or len(pm["pk"]) != 1:
|
|
111
|
+
continue
|
|
112
|
+
pk = pm["pk"][0]
|
|
113
|
+
cands = {_norm(singular(_base(p))) + "id", _norm(_base(p)) + "id"}
|
|
114
|
+
if _norm(pk) != "id":
|
|
115
|
+
cands.add(_norm(pk))
|
|
116
|
+
if nc in cands:
|
|
117
|
+
meta["fks_inferred"].append({"cols": [col], "ref_table": p, "ref_cols": [pk]})
|
|
118
|
+
notes.append(dict(table=t, column=col, kind="join", ref_table=p,
|
|
119
|
+
text=f"{t}.{col} references {p}.{pk} (inferred from column names)."))
|
|
120
|
+
break
|
|
121
|
+
return notes
|
contextsql/learn.py
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""One-time scan: schema + data profile + glossary import -> ~/.contextsql/knowledge.json"""
|
|
2
|
+
from datetime import datetime, timezone
|
|
3
|
+
from sqlalchemy import inspect
|
|
4
|
+
from . import glossary, autoglossary
|
|
5
|
+
from .config import KNOW, save_json
|
|
6
|
+
from .introspect import ddl_for, col_type, profile, infer_joins
|
|
7
|
+
|
|
8
|
+
EXCLUDE_DEFAULT = {"query_log", "business_glossary"}
|
|
9
|
+
|
|
10
|
+
def scan(engine, exclude=EXCLUDE_DEFAULT, say=print):
|
|
11
|
+
"""Read schema + profile data. Returns (knowledge, all_table_names). Saves nothing."""
|
|
12
|
+
insp = inspect(engine)
|
|
13
|
+
all_tables = insp.get_table_names()
|
|
14
|
+
tables = {}
|
|
15
|
+
for t in all_tables:
|
|
16
|
+
if t in exclude:
|
|
17
|
+
continue
|
|
18
|
+
cols = insp.get_columns(t)
|
|
19
|
+
tables[t] = {
|
|
20
|
+
"ddl": ddl_for(engine, insp, t),
|
|
21
|
+
"columns": [c["name"] for c in cols],
|
|
22
|
+
"types": {c["name"]: col_type(engine, c) for c in cols},
|
|
23
|
+
"pk": insp.get_pk_constraint(t).get("constrained_columns") or [],
|
|
24
|
+
"fks": [{"cols": f["constrained_columns"], "ref_table": f["referred_table"],
|
|
25
|
+
"ref_cols": f["referred_columns"]} for f in insp.get_foreign_keys(t)],
|
|
26
|
+
}
|
|
27
|
+
say(f" schema {t} ({len(cols)} columns)")
|
|
28
|
+
say(" profiling data ...")
|
|
29
|
+
notes, values = profile(engine, tables)
|
|
30
|
+
notes += infer_joins(tables)
|
|
31
|
+
know = {"dialect": engine.dialect.name, "tables": tables, "notes": notes, "values": values,
|
|
32
|
+
"learned_at": datetime.now(timezone.utc).isoformat(timespec="seconds")}
|
|
33
|
+
try:
|
|
34
|
+
know["autogloss"] = autoglossary.build_verified(know, engine)
|
|
35
|
+
except Exception:
|
|
36
|
+
know["autogloss"] = {}
|
|
37
|
+
return know, all_tables
|
|
38
|
+
|
|
39
|
+
def learn(engine, exclude=EXCLUDE_DEFAULT, say=print):
|
|
40
|
+
know, all_tables = scan(engine, exclude, say)
|
|
41
|
+
imported = 0
|
|
42
|
+
if "business_glossary" in all_tables:
|
|
43
|
+
with engine.connect() as c:
|
|
44
|
+
rows = c.exec_driver_sql(
|
|
45
|
+
"SELECT term, definition, sql_snippet, tables_used FROM business_glossary").fetchall()
|
|
46
|
+
imported = glossary.merge_imported([tuple(r) for r in rows])
|
|
47
|
+
save_json(KNOW, know)
|
|
48
|
+
return know, imported
|
contextsql/names.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Name helpers: abbreviation expansion + simple singularization."""
|
|
2
|
+
FULL2ABBR = {"customer": "cust", "quantity": "qty", "amount": "amt", "employee": "emp", "department": "dept",
|
|
3
|
+
"number": "no", "description": "descr", "address": "addr", "date": "dt", "price": "prc",
|
|
4
|
+
"order": "ord", "product": "prod", "invoice": "inv", "account": "acct", "transaction": "txn",
|
|
5
|
+
"patient": "pat", "doctor": "doc", "appointment": "appt", "student": "stud", "course": "crs",
|
|
6
|
+
"shipment": "shp", "warehouse": "wh", "subscription": "sub", "ticket": "tkt", "property": "prop",
|
|
7
|
+
"listing": "lst", "member": "mem", "book": "bk", "room": "rm", "booking": "bkg", "salary": "sal",
|
|
8
|
+
"category": "cat", "status": "sts", "created": "crt", "supplier": "supp", "manager": "mgr",
|
|
9
|
+
"policy": "pol", "claim": "clm", "passenger": "pax", "flight": "flt", "aircraft": "acft",
|
|
10
|
+
"subscriber": "subs", "meter": "mtr", "reading": "rdg", "farm": "frm", "crop": "crp",
|
|
11
|
+
"harvest": "hrv", "project": "proj", "worker": "wrk", "task": "tsk", "material": "mtl",
|
|
12
|
+
"event": "evt", "attendee": "atnd", "donor": "dnr", "donation": "dntn", "campaign": "cmpgn",
|
|
13
|
+
"volunteer": "vlnt", "vehicle": "veh", "owner": "ownr", "service": "svc", "drug": "drg",
|
|
14
|
+
"purchase": "purch", "stock": "stk", "candidate": "cand", "application": "appl",
|
|
15
|
+
"interview": "intv", "artist": "artst", "track": "trk", "stream": "strm", "listener": "lstnr",
|
|
16
|
+
"reservation": "resv", "discount": "disc", "percent": "pct"}
|
|
17
|
+
ABBR2FULL = {v: k for k, v in FULL2ABBR.items()}
|
|
18
|
+
ABBR2FULL.update({"cus": "customer", "acc": "account", "desc": "description", "trx": "transaction",
|
|
19
|
+
"tx": "transaction", "nbr": "number", "amnt": "amount", "num": "number", "qnty": "quantity",
|
|
20
|
+
"emp": "employee", "dpt": "department", "cust": "customer", "prd": "product", "ord": "order",
|
|
21
|
+
"qty": "quantity", "amt": "amount", "prc": "price", "dt": "date", "sts": "status"})
|
|
22
|
+
PREFIXES = {"tbl", "t", "tb", "mst", "dim"}
|
|
23
|
+
|
|
24
|
+
def singular(w):
|
|
25
|
+
if w.endswith("statuses"): return w[:-2]
|
|
26
|
+
if w.endswith("ies") and len(w) > 4: return w[:-3] + "y"
|
|
27
|
+
if w.endswith("sses"): return w[:-2]
|
|
28
|
+
if w.endswith("s") and not w.endswith("ss") and len(w) > 3: return w[:-1]
|
|
29
|
+
return w
|
contextsql/phrasing.py
ADDED
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Request-phrase helpers: requested-column extraction (inference + training) and paraphrasing (training)."""
|
|
2
|
+
import re
|
|
3
|
+
from .retrieve import toks, name_alts, hit, all_fks, GENERIC_COLS
|
|
4
|
+
|
|
5
|
+
CONN = r"\b(?:with their|with its|along with|alongside|including|plus|together with|as well as|with)\b"
|
|
6
|
+
BLOCK = {"at", "least", "more", "than", "over", "under", "less", "only", "between", "greater", "fewer"}
|
|
7
|
+
CONN_CHOICES = ["with their", "along with", "alongside", "including", "plus", "together with", "as well as", "with"]
|
|
8
|
+
|
|
9
|
+
def requested_columns(question, know, tables):
|
|
10
|
+
"""Columns the user asked to see ('... with country and email'). A known data value means it is a filter instead."""
|
|
11
|
+
parts = re.split(CONN, question, flags=re.I)
|
|
12
|
+
if len(parts) < 2:
|
|
13
|
+
return []
|
|
14
|
+
seg = parts[-1]
|
|
15
|
+
if re.search(r"\d", seg) or BLOCK & set(seg.lower().split()):
|
|
16
|
+
return []
|
|
17
|
+
q = set(toks(seg))
|
|
18
|
+
if not q:
|
|
19
|
+
return []
|
|
20
|
+
for vals in know.get("values", {}).values():
|
|
21
|
+
for v in vals:
|
|
22
|
+
vt = set(toks(v))
|
|
23
|
+
if vt and vt <= q:
|
|
24
|
+
return []
|
|
25
|
+
out = []
|
|
26
|
+
for t in tables:
|
|
27
|
+
meta = know["tables"][t]
|
|
28
|
+
skip = {c for fk in all_fks(meta) for c in fk["cols"]} | set(meta["pk"])
|
|
29
|
+
alts_t = name_alts(t)
|
|
30
|
+
tname = set().union(*alts_t) if alts_t else set()
|
|
31
|
+
for col in meta["columns"]:
|
|
32
|
+
alts = [a for a in name_alts(col) if not (a & GENERIC_COLS)]
|
|
33
|
+
if not alts or col in skip or set().union(*alts) <= tname:
|
|
34
|
+
continue
|
|
35
|
+
if hit(alts, q) and col not in out:
|
|
36
|
+
out.append(col)
|
|
37
|
+
return out
|
|
38
|
+
|
|
39
|
+
WORD_SWAPS = {"total": ["total", "overall"], "average": ["average", "mean"], "highest": ["highest", "largest", "biggest"],
|
|
40
|
+
"lowest": ["lowest", "smallest"], "per": ["per", "for each"], "Top": ["Top", "Best"],
|
|
41
|
+
"give": ["give", "show", "list"], "Number": ["Number", "Count"]}
|
|
42
|
+
|
|
43
|
+
def paraphrase(q, rng, extra=False):
|
|
44
|
+
for k, opts in WORD_SWAPS.items():
|
|
45
|
+
if re.search(rf"\b{k}\b", q) and rng.random() < 0.4:
|
|
46
|
+
q = re.sub(rf"\b{k}\b", rng.choice(opts), q, count=1)
|
|
47
|
+
if extra and rng.random() < 0.8:
|
|
48
|
+
q = re.sub(CONN, rng.choice(CONN_CHOICES), q, count=1, flags=re.I)
|
|
49
|
+
return q
|
|
50
|
+
|
|
51
|
+
def typo(q, rng, p=0.06):
|
|
52
|
+
"""Light real-world noise: drop or swap one letter in a long word."""
|
|
53
|
+
if rng.random() > p:
|
|
54
|
+
return q
|
|
55
|
+
ws = q.split(" ")
|
|
56
|
+
idx = [i for i, w in enumerate(ws) if len(w) >= 6 and w.isalpha()]
|
|
57
|
+
if not idx:
|
|
58
|
+
return q
|
|
59
|
+
i = rng.choice(idx); w = ws[i]; j = rng.randrange(1, len(w) - 2)
|
|
60
|
+
ws[i] = w[:j] + w[j + 1:] if rng.random() < 0.5 else w[:j] + w[j + 1] + w[j] + w[j + 2:]
|
|
61
|
+
return " ".join(ws)
|
contextsql/prompt.py
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
DATE_RULE = "Date rule: 'last month' = order_date >= DATE_FORMAT(CURDATE() - INTERVAL 1 MONTH,'%Y-%m-01') AND order_date < DATE_FORMAT(CURDATE(),'%Y-%m-01')"
|
|
2
|
+
|
|
3
|
+
def build_prompt(schema, ctx_lines, question):
|
|
4
|
+
ctx = "\n".join(list(ctx_lines) + [DATE_RULE]) if ctx_lines else DATE_RULE
|
|
5
|
+
return f"### Schema\n{schema}\n\n### Business context\n{ctx}\n\n### Question\n{question}\n\n### SQL\n"
|
contextsql/retrieve.py
ADDED
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
"""Pick relevant tables, columns, glossary entries and data notes for a question (no extra model needed)."""
|
|
2
|
+
import re
|
|
3
|
+
from .names import ABBR2FULL, PREFIXES
|
|
4
|
+
|
|
5
|
+
STOP = set("""the a an of in on by for per to from and or with without is are was were be our we us my me i you your
|
|
6
|
+
all any show list give get find what which who whom how many much top best most least highest lowest total number count
|
|
7
|
+
last past each every please can could tell about than then that this these those it its there their them do does did has have
|
|
8
|
+
had day days week weeks month months year years""".split())
|
|
9
|
+
GENERIC_COLS = {"id", "name", "date", "type", "value", "created", "updated", "at"}
|
|
10
|
+
|
|
11
|
+
def stem(w):
|
|
12
|
+
if w.endswith("ies") and len(w) > 4: return w[:-3] + "y"
|
|
13
|
+
if w.endswith("s") and len(w) > 3 and not w.endswith("ss"): return w[:-1]
|
|
14
|
+
return w
|
|
15
|
+
|
|
16
|
+
def toks(text):
|
|
17
|
+
return [stem(w) for w in re.findall(r"[a-z0-9]+", text.lower()) if w not in STOP and not w.isdigit()]
|
|
18
|
+
|
|
19
|
+
def name_alts(name):
|
|
20
|
+
"""[{alternatives for token 1}, {alternatives for token 2}, ...] e.g. tbl_cust -> [{cust, customer}]"""
|
|
21
|
+
name = re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", name)
|
|
22
|
+
parts = [p for p in re.split(r"[_\W]+", name.lower()) if p]
|
|
23
|
+
if len(parts) > 1 and parts[0] in PREFIXES: parts = parts[1:]
|
|
24
|
+
out = []
|
|
25
|
+
for p in parts:
|
|
26
|
+
alt = {stem(p)}
|
|
27
|
+
if p in ABBR2FULL: alt.add(stem(ABBR2FULL[p]))
|
|
28
|
+
out.append(alt)
|
|
29
|
+
return out
|
|
30
|
+
|
|
31
|
+
def skel(w):
|
|
32
|
+
return w[0] + re.sub(r"[aeiou]", "", w[1:])
|
|
33
|
+
|
|
34
|
+
def tok_match(t, w):
|
|
35
|
+
if t == w:
|
|
36
|
+
return True
|
|
37
|
+
if len(t) >= 4 and len(w) >= 4 and (w.startswith(t) or t.startswith(w)):
|
|
38
|
+
return True
|
|
39
|
+
return len(t) >= 4 and len(w) >= 5 and skel(w) == t # cstmr == skeleton of "customer"
|
|
40
|
+
|
|
41
|
+
def hit(alts, q):
|
|
42
|
+
return bool(alts) and all(any(tok_match(t, w) for t in alt for w in q) for alt in alts)
|
|
43
|
+
|
|
44
|
+
def all_fks(meta):
|
|
45
|
+
return list(meta["fks"]) + list(meta.get("fks_inferred", []))
|
|
46
|
+
|
|
47
|
+
def select(question, know, gloss, max_tables=5):
|
|
48
|
+
q = set(toks(question))
|
|
49
|
+
tables = know["tables"]
|
|
50
|
+
score = {t: 0.0 for t in tables}
|
|
51
|
+
hit_cols = set()
|
|
52
|
+
|
|
53
|
+
for t, meta in tables.items():
|
|
54
|
+
if hit(name_alts(t), q): score[t] += 4
|
|
55
|
+
fk_cols = {c for fk in all_fks(meta) for c in fk["cols"]}
|
|
56
|
+
for col in meta["columns"]:
|
|
57
|
+
if col in fk_cols: continue
|
|
58
|
+
alts = [a for a in name_alts(col) if not (a & GENERIC_COLS)]
|
|
59
|
+
if alts and hit(alts, q):
|
|
60
|
+
score[t] += 1; hit_cols.add(f"{t}.{col}")
|
|
61
|
+
for key, vals in know.get("values", {}).items():
|
|
62
|
+
t = key.split(".")[0]
|
|
63
|
+
for v in vals:
|
|
64
|
+
vt = set(toks(v))
|
|
65
|
+
if vt and vt <= q:
|
|
66
|
+
score[t] += 2; hit_cols.add(key)
|
|
67
|
+
|
|
68
|
+
matched = [k for k, g in gloss.items() if set(toks(k)) and set(toks(k)) <= q]
|
|
69
|
+
for k in list(matched):
|
|
70
|
+
d = gloss[k]["definition"].lower()
|
|
71
|
+
matched += [o for o in gloss if o not in matched and o != k and o in d]
|
|
72
|
+
for k in matched:
|
|
73
|
+
for t in gloss[k].get("tables", []):
|
|
74
|
+
if t in score: score[t] += 5
|
|
75
|
+
|
|
76
|
+
chosen = [t for t, s in sorted(score.items(), key=lambda x: -x[1]) if s > 0][:max_tables]
|
|
77
|
+
if not chosen:
|
|
78
|
+
chosen = sorted(tables, key=lambda t: -tables[t].get("rows", 0))[:min(4, len(tables))]
|
|
79
|
+
|
|
80
|
+
if len(chosen) <= 1: # one table matched: include its parent tables so joins stay possible
|
|
81
|
+
for t in list(chosen):
|
|
82
|
+
for fk in all_fks(tables[t]):
|
|
83
|
+
if fk["ref_table"] in tables and fk["ref_table"] not in chosen and len(chosen) < 3:
|
|
84
|
+
chosen.append(fk["ref_table"])
|
|
85
|
+
|
|
86
|
+
for t, meta in tables.items(): # bridge tables connecting two chosen tables
|
|
87
|
+
if t in chosen: continue
|
|
88
|
+
links = {fk["ref_table"] for fk in all_fks(meta)} | {o for o, m in tables.items()
|
|
89
|
+
if any(fk["ref_table"] == t for fk in all_fks(m))}
|
|
90
|
+
if len(links & set(chosen)) >= 2 and len(chosen) < max_tables + 1:
|
|
91
|
+
chosen.append(t)
|
|
92
|
+
|
|
93
|
+
notes = []
|
|
94
|
+
for n in know.get("notes", []):
|
|
95
|
+
if n["table"] not in chosen: continue
|
|
96
|
+
if n["kind"] == "join":
|
|
97
|
+
if n["ref_table"] in chosen: notes.append(n)
|
|
98
|
+
elif n["kind"] in ("variants", "null") or f"{n['table']}.{n['column']}" in hit_cols:
|
|
99
|
+
notes.append(n)
|
|
100
|
+
|
|
101
|
+
# columns worth keeping when a table is too wide to show in full
|
|
102
|
+
gtext = " ".join(gloss[k]["sql"] + " " + gloss[k]["definition"] for k in matched).lower()
|
|
103
|
+
keep = {}
|
|
104
|
+
for t in chosen:
|
|
105
|
+
meta = tables[t]
|
|
106
|
+
ks = set(meta["pk"]) | {c for fk in all_fks(meta) for c in fk["cols"]} | set(meta["columns"][:6])
|
|
107
|
+
ks |= {c for c in meta["columns"] if f"{t}.{c}" in hit_cols or c.lower() in gtext}
|
|
108
|
+
ks |= {n["column"] for n in notes if n["table"] == t}
|
|
109
|
+
keep[t] = ks
|
|
110
|
+
return {"tables": chosen, "glossary": matched, "notes": notes, "scores": score, "keep": keep}
|