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/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:"
@@ -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}