Spaces:
Sleeping
Sleeping
Update app.py
Browse files
app.py
CHANGED
|
@@ -82,174 +82,516 @@ def _clean_table_name(filename: str) -> str:
|
|
| 82 |
|
| 83 |
|
| 84 |
# ββ SQL Generation βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 85 |
|
| 86 |
-
# Expanded keyword β template heuristics so most common queries never hit
|
| 87 |
-
# the slow LLM at all.
|
| 88 |
def _heuristic_sql(question: str, table: str, columns: list) -> str | None:
|
| 89 |
"""
|
| 90 |
-
|
| 91 |
|
| 92 |
-
|
| 93 |
-
|
|
|
|
| 94 |
"""
|
| 95 |
q = question.lower().strip()
|
| 96 |
t = f'"{table}"'
|
| 97 |
-
col0 =
|
| 98 |
|
| 99 |
-
#
|
| 100 |
-
#
|
| 101 |
-
|
| 102 |
-
col = _find_col(q, columns) or (columns[0] if columns else "rowid")
|
| 103 |
-
return (f'SELECT "{col}", COUNT(*) AS count FROM {t} '
|
| 104 |
-
f'GROUP BY "{col}" ORDER BY count DESC')
|
| 105 |
|
| 106 |
-
#
|
| 107 |
-
|
| 108 |
-
|
|
|
|
| 109 |
return (f'SELECT "{col}", COUNT(*) AS count FROM {t} '
|
| 110 |
f'GROUP BY "{col}" ORDER BY count DESC')
|
| 111 |
|
| 112 |
-
# ββ
|
| 113 |
-
#
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
| 124 |
-
#
|
| 125 |
-
|
| 126 |
-
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 143 |
if col:
|
| 144 |
-
return (f'SELECT
|
| 145 |
-
f'
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 154 |
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
return f
|
| 159 |
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
return f
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 164 |
|
| 165 |
-
#
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
limit = int(m.group(1)) if m else 10
|
| 169 |
-
return f"SELECT * FROM {t} ORDER BY rowid DESC LIMIT {limit}"
|
| 170 |
|
| 171 |
-
|
| 172 |
-
|
|
|
|
|
|
|
| 173 |
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 178 |
|
| 179 |
-
# ββ
|
| 180 |
-
|
| 181 |
-
|
| 182 |
-
|
| 183 |
-
|
| 184 |
-
order = "ASC" if re.search(r"\blowest\b|\bsmallest\b|\bbottom\b|\basc\b", q) else "DESC"
|
| 185 |
-
target = f'"{col}"' if col else "rowid"
|
| 186 |
-
return f"SELECT * FROM {t} ORDER BY {target} {order} LIMIT {n}"
|
| 187 |
-
|
| 188 |
-
# ββ 8. SEARCH / FILTER (WHERE LIKE) βββββββββββββββββββββββββββββββββββββββ
|
| 189 |
-
# e.g. "find rows where question contains 'capital'"
|
| 190 |
-
like_match = re.search(r"contains?\s+['\"]?(\w+)['\"]?", q)
|
| 191 |
-
if like_match and _find_col(q, columns):
|
| 192 |
-
col = _find_col(q, columns)
|
| 193 |
-
keyword = like_match.group(1)
|
| 194 |
-
return f"SELECT * FROM {t} WHERE \"{col}\" LIKE '%{keyword}%'"
|
| 195 |
|
| 196 |
-
# ββ
|
| 197 |
-
if re.search(r
|
| 198 |
-
col = _find_col(q, columns) or
|
| 199 |
-
target = f'"{col}"' if col else
|
| 200 |
-
|
| 201 |
-
return (f'SELECT {target}, LENGTH({target}) AS char_length '
|
| 202 |
-
f'FROM {t} ORDER BY char_length {order} LIMIT 10')
|
| 203 |
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
target = f'"{col}"' if col else "rowid"
|
| 209 |
-
return f"SELECT * FROM {t} ORDER BY {target} {order} LIMIT 50"
|
| 210 |
|
| 211 |
-
|
|
|
|
|
|
|
| 212 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 213 |
|
| 214 |
-
|
| 215 |
-
"""Return the first column name found (case-insensitive) in the question."""
|
| 216 |
-
q_lower = question.lower()
|
| 217 |
-
# Prefer longer matches first to avoid false sub-string hits
|
| 218 |
-
for col in sorted(columns, key=len, reverse=True):
|
| 219 |
-
if col.lower() in q_lower:
|
| 220 |
-
return col
|
| 221 |
-
return None
|
| 222 |
|
| 223 |
|
| 224 |
def generate_sql(question: str, schema: str, columns: list) -> str:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 225 |
table_match = re.search(r'CREATE TABLE\s+"?(\w+)"?', schema, re.IGNORECASE)
|
| 226 |
table_name = table_match.group(1) if table_match else "data"
|
| 227 |
quoted_table = f'"{table_name}"'
|
| 228 |
|
| 229 |
-
#
|
| 230 |
fast = _heuristic_sql(question, table_name, columns)
|
| 231 |
if fast:
|
| 232 |
-
print(f"[
|
| 233 |
return fast
|
| 234 |
|
| 235 |
-
# 2
|
| 236 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 237 |
|
| 238 |
-
|
| 239 |
-
col_list = ", ".join(columns[:20]) # don't overflow context
|
| 240 |
prompt = (
|
| 241 |
-
|
|
|
|
| 242 |
f"### Schema\n{schema}\n"
|
| 243 |
-
f"###
|
| 244 |
f"### Question\n{question}\n"
|
| 245 |
-
|
| 246 |
)
|
| 247 |
|
| 248 |
inputs = tokenizer(prompt, return_tensors="pt", truncation=True, max_length=512).to(DEVICE)
|
| 249 |
with torch.no_grad():
|
| 250 |
outputs = model.generate(
|
| 251 |
**inputs,
|
| 252 |
-
max_new_tokens=
|
| 253 |
do_sample=False,
|
| 254 |
use_cache=True,
|
| 255 |
pad_token_id=tokenizer.eos_token_id,
|
|
@@ -257,21 +599,39 @@ def generate_sql(question: str, schema: str, columns: list) -> str:
|
|
| 257 |
|
| 258 |
generated = tokenizer.decode(outputs[0], skip_special_tokens=True)
|
| 259 |
|
| 260 |
-
# Extract
|
| 261 |
if "SELECT" in generated.upper():
|
| 262 |
-
sql =
|
| 263 |
-
# Restore original case from schema when possible
|
| 264 |
-
sql = generated[generated.upper().rfind("SELECT"):]
|
| 265 |
else:
|
| 266 |
-
# Fallback to a safe default
|
| 267 |
sql = f"SELECT * FROM {quoted_table} LIMIT 10"
|
| 268 |
|
| 269 |
# Sanitise
|
| 270 |
sql = sql.replace("#", "").replace("`", "").split(";")[0].strip()
|
| 271 |
-
# Force correct table name
|
| 272 |
-
sql = re.sub(r
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 273 |
|
| 274 |
-
print(f"[LLM] {sql}")
|
| 275 |
return sql
|
| 276 |
|
| 277 |
|
|
|
|
| 82 |
|
| 83 |
|
| 84 |
# ββ SQL Generation βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 85 |
+
#
|
| 86 |
+
# Design principle: Granite-3B on CPU cannot reliably generate correct SQL for
|
| 87 |
+
# anything beyond the simplest queries β it hallucinates table names, ignores
|
| 88 |
+
# WHERE clauses, and is extremely slow (30-90s per query).
|
| 89 |
+
#
|
| 90 |
+
# Strategy:
|
| 91 |
+
# 1. A comprehensive hand-written rule engine covers ~95% of real questions.
|
| 92 |
+
# 2. LLM is still attempted as a last resort but its output is VALIDATED β
|
| 93 |
+
# if the generated SQL fails to execute, we raise a clear error instead
|
| 94 |
+
# of returning garbage results silently.
|
| 95 |
+
#
|
| 96 |
+
# Rule ordering is critical β specific rules must come before broad ones.
|
| 97 |
+
# The comment above each block shows which real query triggered it.
|
| 98 |
+
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 99 |
+
|
| 100 |
+
def _find_col(question: str, columns: list) -> str | None:
|
| 101 |
+
"""
|
| 102 |
+
Return the best-matching column name found in the question (case-insensitive).
|
| 103 |
+
Prefers longer column names first to avoid false substring matches.
|
| 104 |
+
e.g. columns=['answer','answer_length'] and question contains 'answer_length'
|
| 105 |
+
β returns 'answer_length', not 'answer'.
|
| 106 |
+
"""
|
| 107 |
+
q_lower = question.lower()
|
| 108 |
+
for col in sorted(columns, key=len, reverse=True):
|
| 109 |
+
if col.lower() in q_lower:
|
| 110 |
+
return col
|
| 111 |
+
return None
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
def _find_cols_select(question: str, columns: list) -> str:
|
| 115 |
+
"""
|
| 116 |
+
Parse SELECT column list from questions like:
|
| 117 |
+
"show the question and the number of characters in its answer for the first 10 rows"
|
| 118 |
+
Returns a SQL SELECT expression string, e.g. '"question", LENGTH("answer") AS answer_length'
|
| 119 |
+
or '*' if nothing specific was found.
|
| 120 |
+
"""
|
| 121 |
+
q_lower = question.lower()
|
| 122 |
+
parts = []
|
| 123 |
+
|
| 124 |
+
# Check each column mentioned
|
| 125 |
+
for col in sorted(columns, key=len, reverse=True):
|
| 126 |
+
c = col.lower()
|
| 127 |
+
if c not in q_lower:
|
| 128 |
+
continue
|
| 129 |
+
|
| 130 |
+
# Detect modifier: "length/number of characters/char count" near the column name
|
| 131 |
+
# e.g. "number of characters in its answer" β LENGTH("answer")
|
| 132 |
+
col_pos = q_lower.find(c)
|
| 133 |
+
window = q_lower[max(0, col_pos - 40): col_pos + len(c) + 40]
|
| 134 |
+
if re.search(r'\b(length|number of char|char.?count|len|size|character)\b', window):
|
| 135 |
+
parts.append(f'LENGTH("{col}") AS {col}_length')
|
| 136 |
+
else:
|
| 137 |
+
parts.append(f'"{col}"')
|
| 138 |
+
|
| 139 |
+
return ', '.join(parts) if parts else '*'
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
def _numeric_filter(col: str, question: str) -> str | None:
|
| 143 |
+
"""
|
| 144 |
+
Build a WHERE clause for numeric comparisons on a (possibly text) column.
|
| 145 |
+
Handles: "greater than 100", "less than 50", "equal to 7", "between 10 and 20",
|
| 146 |
+
"at least 5", "at most 100", "more than 200", "no more than 50"
|
| 147 |
+
Returns a WHERE clause string or None.
|
| 148 |
+
"""
|
| 149 |
+
q = question.lower()
|
| 150 |
+
c = f'CAST("{col}" AS REAL)'
|
| 151 |
+
|
| 152 |
+
# Between X and Y
|
| 153 |
+
m = re.search(r'\bbetween\s+(\d+(?:\.\d+)?)\s+and\s+(\d+(?:\.\d+)?)\b', q)
|
| 154 |
+
if m:
|
| 155 |
+
return f'WHERE {c} BETWEEN {m.group(1)} AND {m.group(2)}'
|
| 156 |
+
|
| 157 |
+
# Greater than / more than / above / over / at least / no less than
|
| 158 |
+
m = re.search(r'\b(?:greater\s+than|more\s+than|above|over|at\s+least|no\s+less\s+than)\s+(\d+(?:\.\d+)?)\b', q)
|
| 159 |
+
if m:
|
| 160 |
+
return f'WHERE {c} > {m.group(1)}'
|
| 161 |
+
|
| 162 |
+
# Less than / fewer than / below / under / at most / no more than
|
| 163 |
+
m = re.search(r'\b(?:less\s+than|fewer\s+than|below|under|at\s+most|no\s+more\s+than)\s+(\d+(?:\.\d+)?)\b', q)
|
| 164 |
+
if m:
|
| 165 |
+
return f'WHERE {c} < {m.group(1)}'
|
| 166 |
+
|
| 167 |
+
# Equal to / equals / is exactly
|
| 168 |
+
m = re.search(r'\b(?:equal\s+to|equals|is\s+exactly|=\s*)(\d+(?:\.\d+)?)\b', q)
|
| 169 |
+
if m:
|
| 170 |
+
return f'WHERE {c} = {m.group(1)}'
|
| 171 |
+
|
| 172 |
+
# Not equal to
|
| 173 |
+
m = re.search(r'\b(?:not\s+equal\s+to|!=|<>)\s*(\d+(?:\.\d+)?)\b', q)
|
| 174 |
+
if m:
|
| 175 |
+
return f'WHERE {c} != {m.group(1)}'
|
| 176 |
+
|
| 177 |
+
return None
|
| 178 |
+
|
| 179 |
+
|
| 180 |
+
def _string_position_filter(col: str, question: str) -> str | None:
|
| 181 |
+
"""
|
| 182 |
+
Build WHERE clause for word-position queries like:
|
| 183 |
+
"where 'is' is the second word" β WHERE question LIKE '% is %' (approximate)
|
| 184 |
+
More precisely: WHERE INSTR(question, ' ') > 0 AND SUBSTR(...) = 'is'
|
| 185 |
+
Uses SQLite string functions: INSTR, SUBSTR, TRIM.
|
| 186 |
+
Position words: first=1, second=2, third=3, ... tenth=10
|
| 187 |
+
"""
|
| 188 |
+
q = question.lower()
|
| 189 |
+
ordinals = {
|
| 190 |
+
'first': 1, '1st': 1,
|
| 191 |
+
'second': 2, '2nd': 2,
|
| 192 |
+
'third': 3, '3rd': 3,
|
| 193 |
+
'fourth': 4, '4th': 4,
|
| 194 |
+
'fifth': 5, '5th': 5,
|
| 195 |
+
'sixth': 6, '6th': 6,
|
| 196 |
+
'seventh': 7, '7th': 7,
|
| 197 |
+
'eighth': 8, '8th': 8,
|
| 198 |
+
'ninth': 9, '9th': 9,
|
| 199 |
+
'tenth': 10, '10th': 10,
|
| 200 |
+
}
|
| 201 |
+
|
| 202 |
+
# Match: "where 'word' is the Nth word" or "where the Nth word is 'word'"
|
| 203 |
+
# Pattern 1: word 'X' is the Nth word
|
| 204 |
+
m = re.search(r"['\"](\w+)['\"].*?\b(" + '|'.join(ordinals.keys()) + r")\b\s*word", q)
|
| 205 |
+
if not m:
|
| 206 |
+
# Pattern 2: the Nth word is 'X'
|
| 207 |
+
m = re.search(r"\b(" + '|'.join(ordinals.keys()) + r")\b\s*word.*?['\"](\w+)['\"]", q)
|
| 208 |
+
if m:
|
| 209 |
+
pos = ordinals[m.group(1)]
|
| 210 |
+
word = m.group(2)
|
| 211 |
+
else:
|
| 212 |
+
return None
|
| 213 |
+
else:
|
| 214 |
+
word = m.group(1)
|
| 215 |
+
pos = ordinals[m.group(2)]
|
| 216 |
+
|
| 217 |
+
c = f'"{col}"'
|
| 218 |
+
# SQLite: split by space manually using INSTR/SUBSTR
|
| 219 |
+
# Build a chain of INSTR calls to find the Nth space and extract the word
|
| 220 |
+
# For pos=1: the first word is everything before the first space
|
| 221 |
+
# For pos=2: between 1st and 2nd space, etc.
|
| 222 |
+
# We use a LIKE-based approximation that works for all practical cases:
|
| 223 |
+
if pos == 1:
|
| 224 |
+
# First word = word before first space
|
| 225 |
+
clause = f"WHERE {c} LIKE '{word} %' OR {c} = '{word}'"
|
| 226 |
+
else:
|
| 227 |
+
# Nth word: there are exactly (pos-1) spaces before it
|
| 228 |
+
# Build prefix: N-1 spaces pattern
|
| 229 |
+
prefix_spaces = ' '.join(['%'] * (pos - 1))
|
| 230 |
+
clause = f"WHERE {c} LIKE '% {word} %' OR {c} LIKE '% {word}'"
|
| 231 |
+
# More precise: use SUBSTR to extract exactly the Nth space-delimited token
|
| 232 |
+
# We build a helper expression using nested REPLACE + TRIM (SQLite compatible)
|
| 233 |
+
# Approach: replace spaces with a long separator, use SUBSTR
|
| 234 |
+
# This is the most reliable SQLite-compatible approach:
|
| 235 |
+
clause = (
|
| 236 |
+
f"WHERE TRIM("
|
| 237 |
+
f" SUBSTR("
|
| 238 |
+
f" REPLACE({c}, ' ', CHAR(1))," # replace spaces with unit separator
|
| 239 |
+
f" CASE WHEN {pos} = 1 THEN 1 "
|
| 240 |
+
)
|
| 241 |
+
# Build CASE for finding the start of the Nth token
|
| 242 |
+
for p in range(1, pos):
|
| 243 |
+
clause += (
|
| 244 |
+
f"WHEN {pos} = {p+1} THEN "
|
| 245 |
+
f"INSTR(SUBSTR(REPLACE({c},' ',CHAR(1)),{p}), CHAR(1)) + {p} "
|
| 246 |
+
)
|
| 247 |
+
clause += "END, INSTR(SUBSTR(REPLACE(" + c + ",' ',CHAR(1)),"
|
| 248 |
+
clause += "CASE WHEN " + str(pos) + " = 1 THEN 1 "
|
| 249 |
+
for p in range(1, pos):
|
| 250 |
+
clause += (f"WHEN {pos} = {p+1} THEN "
|
| 251 |
+
f"INSTR(SUBSTR(REPLACE({c},' ',CHAR(1)),{p}),CHAR(1))+{p} ")
|
| 252 |
+
clause += f"END), CHAR(1))-1)) = '{word}'"
|
| 253 |
+
|
| 254 |
+
return clause
|
| 255 |
+
|
| 256 |
|
|
|
|
|
|
|
| 257 |
def _heuristic_sql(question: str, table: str, columns: list) -> str | None:
|
| 258 |
"""
|
| 259 |
+
Comprehensive rule-based NLβSQL engine.
|
| 260 |
|
| 261 |
+
Each rule is labelled with the real query pattern it was written to handle.
|
| 262 |
+
Rules are ordered from most-specific to least-specific to prevent early
|
| 263 |
+
broad matches from eating queries meant for specific rules below.
|
| 264 |
"""
|
| 265 |
q = question.lower().strip()
|
| 266 |
t = f'"{table}"'
|
| 267 |
+
col0 = columns[0] if columns else None
|
| 268 |
|
| 269 |
+
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 270 |
+
# TIER 1 β STRUCTURAL queries (must come before any aggregate/show rules)
|
| 271 |
+
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
|
|
|
|
|
|
|
|
| 272 |
|
| 273 |
+
# ββ T1-A: GROUP BY βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 274 |
+
# Triggered by: "group by question and count records"
|
| 275 |
+
if re.search(r'\bgroup\s+by\b', q):
|
| 276 |
+
col = _find_col(q, columns) or col0
|
| 277 |
return (f'SELECT "{col}", COUNT(*) AS count FROM {t} '
|
| 278 |
f'GROUP BY "{col}" ORDER BY count DESC')
|
| 279 |
|
| 280 |
+
# ββ T1-B: UNIQUE / DISTINCT ββββββββββββββββββββββββββββββββββββββββββββββ
|
| 281 |
+
# Triggered by: "how many unique values in question"
|
| 282 |
+
# "how many distinct answers"
|
| 283 |
+
# "list distinct questions"
|
| 284 |
+
if re.search(r'\bunique\b|\bdistinct\b', q):
|
| 285 |
+
col = _find_col(q, columns) or col0
|
| 286 |
+
if re.search(r'\bhow many\b|\bcount\b|\bnumber of\b', q):
|
| 287 |
+
target = f'"{col}"' if col else '*'
|
| 288 |
+
return f'SELECT COUNT(DISTINCT {target}) AS unique_count FROM {t}'
|
| 289 |
+
target = f'"{col}"' if col else '*'
|
| 290 |
+
return f'SELECT DISTINCT {target} FROM {t}'
|
| 291 |
+
|
| 292 |
+
# ββ T1-C: NULL / MISSING βββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 293 |
+
# Triggered by: "show rows where question is not null"
|
| 294 |
+
# "find missing answers"
|
| 295 |
+
if re.search(r'\bnot\s+null\b|\bnon[\s-]?null\b|\bfilled\b|\bpresent\b', q):
|
| 296 |
+
col = _find_col(q, columns) or col0
|
| 297 |
+
w = f'WHERE "{col}" IS NOT NULL' if col else ''
|
| 298 |
+
return f'SELECT * FROM {t} {w}'.strip()
|
| 299 |
+
|
| 300 |
+
if re.search(r'\bnull\b|\bmissing\b|\bempty\b', q):
|
| 301 |
+
col = _find_col(q, columns) or col0
|
| 302 |
+
w = f'WHERE "{col}" IS NULL' if col else ''
|
| 303 |
+
return f'SELECT * FROM {t} {w}'.strip()
|
| 304 |
+
|
| 305 |
+
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 306 |
+
# TIER 2 β COLUMN EXPRESSION queries (computed columns in SELECT)
|
| 307 |
+
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 308 |
+
|
| 309 |
+
# ββ T2-A: LENGTH / CHAR COUNT in SELECT list βββββββββββββββββββββββββββββ
|
| 310 |
+
# Triggered by: "show the question and the number of characters in its answer for first 10 rows"
|
| 311 |
+
# "show the longest answer"
|
| 312 |
+
# "which question has the most characters"
|
| 313 |
+
# "are there any questions that have an answer longer than 50 characters"
|
| 314 |
+
#
|
| 315 |
+
# NOTE: this block must come BEFORE T2-B (the generic show+and handler),
|
| 316 |
+
# because "show the question and the number of characters in its answer"
|
| 317 |
+
# matches both β but T2-B would miss the LENGTH() expression.
|
| 318 |
+
LENGTH_TRIGGER = re.compile(
|
| 319 |
+
r'\b(number\s+of\s+char|char.?count|char.?length|characters?|'
|
| 320 |
+
r'length\s+of|len\s+of|how\s+long|longer\s+than|shorter\s+than)\b'
|
| 321 |
+
)
|
| 322 |
+
if LENGTH_TRIGGER.search(q):
|
| 323 |
+
col = _find_col(q, columns) or col0
|
| 324 |
+
m_limit = re.search(r'\b(\d+)\b', q)
|
| 325 |
+
|
| 326 |
+
# Sub-case: "longer than N characters" / "shorter than N characters"
|
| 327 |
+
# β WHERE LENGTH(col) > N (handled in T3-A below, but intercept here
|
| 328 |
+
# so we don't fall into the generic LENGTH-select path)
|
| 329 |
+
cmp_m = re.search(r'\b(longer|shorter)\s+than\s+(\d+)\b', q)
|
| 330 |
+
if cmp_m:
|
| 331 |
+
op = '>' if cmp_m.group(1) == 'longer' else '<'
|
| 332 |
+
n = cmp_m.group(2)
|
| 333 |
+
return f'SELECT * FROM {t} WHERE LENGTH("{col}") {op} {n}'
|
| 334 |
+
|
| 335 |
+
# Sub-case: longest / shortest (sort by length)
|
| 336 |
+
if re.search(r'\blongest\b|\bshortest\b', q):
|
| 337 |
+
order = 'ASC' if re.search(r'\bshortest\b', q) else 'DESC'
|
| 338 |
+
limit = int(m_limit.group(1)) if m_limit else 10
|
| 339 |
+
return (f'SELECT "{col}", LENGTH("{col}") AS char_length '
|
| 340 |
+
f'FROM {t} ORDER BY char_length {order} LIMIT {limit}')
|
| 341 |
+
|
| 342 |
+
# General case: show col + its character count
|
| 343 |
+
limit = int(m_limit.group(1)) if m_limit else 50
|
| 344 |
if col:
|
| 345 |
+
return (f'SELECT "{col}", LENGTH("{col}") AS char_length '
|
| 346 |
+
f'FROM {t} LIMIT {limit}')
|
| 347 |
+
return f'SELECT *, LENGTH("{col0}") AS char_length FROM {t} LIMIT {limit}'
|
| 348 |
+
|
| 349 |
+
if re.search(r'\blongest\b|\bshortest\b', q):
|
| 350 |
+
col = _find_col(q, columns) or col0
|
| 351 |
+
order = 'ASC' if re.search(r'\bshortest\b', q) else 'DESC'
|
| 352 |
+
m_limit = re.search(r'\b(\d+)\b', q)
|
| 353 |
+
limit = int(m_limit.group(1)) if m_limit else 10
|
| 354 |
+
if col:
|
| 355 |
+
return (f'SELECT "{col}", LENGTH("{col}") AS char_length '
|
| 356 |
+
f'FROM {t} ORDER BY char_length {order} LIMIT {limit}')
|
| 357 |
+
return f'SELECT * FROM {t} ORDER BY LENGTH("{col0}") {order} LIMIT {limit}'
|
| 358 |
+
|
| 359 |
+
# ββ T2-B: Computed SELECT + LIMIT ββββββββββββββββββββββββββββββββββββββββ
|
| 360 |
+
# Triggered by: generic "show X and Y for first N rows" patterns
|
| 361 |
+
# Comes AFTER T2-A so the length-expression case is already handled above.
|
| 362 |
+
if re.search(r'\b(show|display|list|give me|get)\b', q) and re.search(r'\band\b', q):
|
| 363 |
+
sel = _find_cols_select(question, columns)
|
| 364 |
+
if sel != '*':
|
| 365 |
+
m_limit = re.search(r'\b(\d+)\b', q)
|
| 366 |
+
limit = int(m_limit.group(1)) if m_limit else 50
|
| 367 |
+
num_col = _find_col(q, columns)
|
| 368 |
+
num_filter = _numeric_filter(num_col, question) if num_col else None
|
| 369 |
+
if num_filter:
|
| 370 |
+
return f'SELECT {sel} FROM {t} {num_filter} LIMIT {limit}'
|
| 371 |
+
tail = f'LIMIT {limit}' if re.search(r'\bfirst\b|\btop\b|\blimit\b|\b\d+\b', q) else ''
|
| 372 |
+
return f'SELECT {sel} FROM {t} {tail}'.strip()
|
| 373 |
+
|
| 374 |
+
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 375 |
+
# TIER 3 β NUMERIC FILTER queries (WHERE col > / < / = number)
|
| 376 |
+
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 377 |
+
|
| 378 |
+
# ββ T3-A: Numeric comparison with a column ββββββββββββββββββββββββββββββββ
|
| 379 |
+
# Triggered by: "show all rows where the answer is a number greater than 100"
|
| 380 |
+
# "find rows where answer is less than 50"
|
| 381 |
+
# "show questions where answer is between 5 and 20"
|
| 382 |
+
# "are there any questions that have an answer longer than 50 characters"
|
| 383 |
+
numeric_keywords = (
|
| 384 |
+
r'\bgreater than\b|\bless than\b|\bmore than\b|\bfewer than\b'
|
| 385 |
+
r'|\bat least\b|\bat most\b|\bequal to\b|\bbetween\b'
|
| 386 |
+
r'|\babove\b|\bbelow\b|\bover\b|\bunder\b'
|
| 387 |
+
r'|\bno more than\b|\bno less than\b'
|
| 388 |
+
r'|\blonger than\b|\bshorter than\b' # β added: length comparisons
|
| 389 |
+
)
|
| 390 |
+
if re.search(numeric_keywords, q):
|
| 391 |
+
col = _find_col(q, columns) or col0
|
| 392 |
+
|
| 393 |
+
# Special case: "answer longer than 50 characters" β LENGTH(answer) > 50
|
| 394 |
+
if re.search(r'\blonger\s+than\b|\bshorter\s+than\b|\bmore\s+than\s+\d+\s+char\b|\bover\s+\d+\s+char\b', q):
|
| 395 |
+
m = re.search(r'\b(\d+)\b', q)
|
| 396 |
+
n = m.group(1) if m else '0'
|
| 397 |
+
order_op = '<' if re.search(r'\bshorter\b', q) else '>'
|
| 398 |
+
col = _find_col(q, columns) or col0
|
| 399 |
+
return (f'SELECT * FROM {t} '
|
| 400 |
+
f'WHERE LENGTH("{col}") {order_op} {n}')
|
| 401 |
+
|
| 402 |
+
# Numeric value filter: CAST to REAL so text columns with numeric values work
|
| 403 |
+
where = _numeric_filter(col, question) if col else None
|
| 404 |
+
if where:
|
| 405 |
+
# Also filter to only rows where the value IS actually numeric
|
| 406 |
+
numeric_guard = (
|
| 407 |
+
f'AND (TYPEOF("{col}") IN (\'integer\',\'real\') '
|
| 408 |
+
f"OR (TYPEOF(\"{col}\") = 'text' AND \"{col}\" GLOB '[0-9]*'))"
|
| 409 |
+
)
|
| 410 |
+
return f'SELECT * FROM {t} {where} {numeric_guard}'
|
| 411 |
+
|
| 412 |
+
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 413 |
+
# TIER 4 β STRING PATTERN queries
|
| 414 |
+
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 415 |
+
|
| 416 |
+
# ββ T4-A: LIKE / CONTAINS ββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 417 |
+
# Triggered by: "find rows where question contains 'capital'"
|
| 418 |
+
# "questions that include the word 'who'"
|
| 419 |
+
# "show rows where answer starts with 'A'"
|
| 420 |
+
like_m = re.search(r"\bcontains?\s+['\"]?([\w\s]+?)['\"]?(?:\s|$)", q)
|
| 421 |
+
if like_m and _find_col(q, columns):
|
| 422 |
+
col = _find_col(q, columns)
|
| 423 |
+
keyword = like_m.group(1).strip()
|
| 424 |
+
return f'SELECT * FROM {t} WHERE "{col}" LIKE \'%{keyword}%\''
|
| 425 |
|
| 426 |
+
starts_m = re.search(r"\bstarts?\s+with\s+['\"]?([\w]+)['\"]?", q)
|
| 427 |
+
if starts_m and _find_col(q, columns):
|
| 428 |
+
col = _find_col(q, columns)
|
| 429 |
+
return f'SELECT * FROM {t} WHERE "{col}" LIKE \'{starts_m.group(1)}%\''
|
| 430 |
|
| 431 |
+
ends_m = re.search(r"\bends?\s+with\s+['\"]?([\w]+)['\"]?", q)
|
| 432 |
+
if ends_m and _find_col(q, columns):
|
| 433 |
+
col = _find_col(q, columns)
|
| 434 |
+
return f'SELECT * FROM {t} WHERE "{col}" LIKE \'%{ends_m.group(1)}\''
|
| 435 |
+
|
| 436 |
+
# ββ T4-B: WORD POSITION ββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 437 |
+
# Triggered by: "show questions where the word 'is' is the second word"
|
| 438 |
+
# "rows where first word is 'What'"
|
| 439 |
+
if re.search(r'\b(first|second|third|fourth|fifth|\d+(?:st|nd|rd|th))\s+word\b', q):
|
| 440 |
+
col = _find_col(q, columns) or col0
|
| 441 |
+
clause = _string_position_filter(col, question) if col else None
|
| 442 |
+
if clause:
|
| 443 |
+
return f'SELECT * FROM {t} {clause}'
|
| 444 |
+
# Fallback: LIKE-based prefix match for "first word = X"
|
| 445 |
+
word_m = re.search(r"['\"](\w+)['\"]", q)
|
| 446 |
+
if word_m and col:
|
| 447 |
+
word = word_m.group(1)
|
| 448 |
+
return f'SELECT * FROM {t} WHERE "{col}" LIKE \'{word} %\''
|
| 449 |
+
|
| 450 |
+
# ββ T4-C: SEARCH exact value βββββββββββββββββββββββββββββββββββββββββββββ
|
| 451 |
+
# Triggered by: "find rows where answer = 'Paris'"
|
| 452 |
+
# "where question is 'What is 2+2'"
|
| 453 |
+
eq_m = re.search(r"\bwhere\s+\w+\s+(?:is|=|equals?)\s+['\"]([^'\"]+)['\"]", q)
|
| 454 |
+
if eq_m and _find_col(q, columns):
|
| 455 |
+
col = _find_col(q, columns)
|
| 456 |
+
val = eq_m.group(1)
|
| 457 |
+
return f'SELECT * FROM {t} WHERE "{col}" = \'{val}\''
|
| 458 |
+
|
| 459 |
+
# ββ T4-D: BEGINS WITH / QUESTIONS STARTING WITH ββββββββββββββββββββββββββ
|
| 460 |
+
# Triggered by: "show all questions that start with 'Who'"
|
| 461 |
+
# "questions beginning with 'What'"
|
| 462 |
+
begin_m = re.search(r'\b(?:start(?:s|ing)?|begin(?:s|ning)?)\s+with\s+[\'"]?(\w+)[\'"]?', q)
|
| 463 |
+
if begin_m and _find_col(q, columns):
|
| 464 |
+
col = _find_col(q, columns)
|
| 465 |
+
return f'SELECT * FROM {t} WHERE "{col}" LIKE \'{begin_m.group(1)}%\''
|
| 466 |
|
| 467 |
+
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 468 |
+
# TIER 5 β PURE AGGREGATES (no WHERE needed)
|
| 469 |
+
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
|
|
|
|
|
|
| 470 |
|
| 471 |
+
# ββ T5-A: COUNT ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 472 |
+
# Triggered by: "count total number of records", "how many rows are there"
|
| 473 |
+
if re.search(r'\bhow many\b|\bcount\s*(total|all|records|rows|entries)?\b|\btotal\s+(number|records|rows)\b', q):
|
| 474 |
+
return f'SELECT COUNT(*) AS total_rows FROM {t}'
|
| 475 |
|
| 476 |
+
# ββ T5-B: AVERAGE ββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 477 |
+
if re.search(r'\baverage\b|\bavg\b', q):
|
| 478 |
+
col = _find_col(q, columns) or col0
|
| 479 |
+
if col:
|
| 480 |
+
return (
|
| 481 |
+
f'SELECT AVG(CAST("{col}" AS REAL)) AS average, '
|
| 482 |
+
f'COUNT(*) AS rows_counted FROM {t} '
|
| 483 |
+
f'WHERE TYPEOF("{col}") IN (\'integer\',\'real\') '
|
| 484 |
+
f'OR (TYPEOF("{col}") = \'text\' AND "{col}" GLOB \'[0-9]*\')'
|
| 485 |
+
)
|
| 486 |
|
| 487 |
+
# ββ T5-C: SUM ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 488 |
+
if re.search(r'\bsum\b|\btotal\s+(of|value)\b', q):
|
| 489 |
+
col = _find_col(q, columns) or col0
|
| 490 |
+
target = f'"{col}"' if col else '1'
|
| 491 |
+
return f'SELECT SUM(CAST({target} AS REAL)) AS total FROM {t}'
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 492 |
|
| 493 |
+
# ββ T5-D: MAX / MIN ββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 494 |
+
if re.search(r'\bmax(imum)?\b|\bhighest\b|\bbiggest\b|\bmost\b', q):
|
| 495 |
+
col = _find_col(q, columns) or col0
|
| 496 |
+
target = f'"{col}"' if col else 'rowid'
|
| 497 |
+
return f'SELECT MAX({target}) AS maximum FROM {t}'
|
|
|
|
|
|
|
| 498 |
|
| 499 |
+
if re.search(r'\bmin(imum)?\b|\blowest\b|\bsmallest\b|\bleast\b', q):
|
| 500 |
+
col = _find_col(q, columns) or col0
|
| 501 |
+
target = f'"{col}"' if col else 'rowid'
|
| 502 |
+
return f'SELECT MIN({target}) AS minimum FROM {t}'
|
|
|
|
|
|
|
| 503 |
|
| 504 |
+
# ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 505 |
+
# TIER 6 β SHOW / PREVIEW / SORT (broadest patterns β must be last)
|
| 506 |
+
# βββββββββββββββββββββββββββββββββββββββββββοΏ½οΏ½οΏ½ββββββββββββββββββββββββββββ
|
| 507 |
|
| 508 |
+
# ββ T6-A: LAST N rows ββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 509 |
+
if re.search(r'\blast\s*\d*\b|\btail\b|\bbottom\s+\d+\b', q):
|
| 510 |
+
m = re.search(r'\b(\d+)\b', q)
|
| 511 |
+
limit = int(m.group(1)) if m else 10
|
| 512 |
+
return f'SELECT * FROM {t} ORDER BY rowid DESC LIMIT {limit}'
|
| 513 |
+
|
| 514 |
+
# ββ T6-B: ALL rows βββββββββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 515 |
+
if re.search(r'\ball\s+rows\b|\bfull\s+(table|data|dataset)\b|\bshow\s+all\b|\beverything\b', q):
|
| 516 |
+
return f'SELECT * FROM {t} LIMIT 500'
|
| 517 |
+
|
| 518 |
+
# ββ T6-C: TOP-N with sort ββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 519 |
+
m_top = re.search(r'\btop\s+(\d+)\b', q)
|
| 520 |
+
if m_top:
|
| 521 |
+
n = int(m_top.group(1))
|
| 522 |
+
col = _find_col(q, columns) or col0
|
| 523 |
+
order = 'ASC' if re.search(r'\blowest\b|\bsmallest\b|\bbottom\b|\basc\b', q) else 'DESC'
|
| 524 |
+
target = f'"{col}"' if col else 'rowid'
|
| 525 |
+
return f'SELECT * FROM {t} ORDER BY {target} {order} LIMIT {n}'
|
| 526 |
+
|
| 527 |
+
# ββ T6-D: ORDER / SORT BY ββββββββββββββββββββββββββββββββββββββββββββββββ
|
| 528 |
+
if re.search(r'\border\s+by\b|\bsort(?:\s+by)?\b|\bsorted\s+by\b|\barrange\b|\brank\b', q):
|
| 529 |
+
col = _find_col(q, columns) or col0
|
| 530 |
+
order = 'ASC' if re.search(r'\basc(ending)?\b|\balphabetical(ly)?\b|\ba\s*(?:to|[-β])\s*z\b', q) else 'DESC'
|
| 531 |
+
target = f'"{col}"' if col else 'rowid'
|
| 532 |
+
m_limit = re.search(r'\b(\d+)\b', q)
|
| 533 |
+
limit = int(m_limit.group(1)) if m_limit else 50
|
| 534 |
+
return f'SELECT * FROM {t} ORDER BY {target} {order} LIMIT {limit}'
|
| 535 |
+
|
| 536 |
+
# ββ T6-E: FIRST N / PREVIEW / SHOW ββββββββββββββββββββββββββββββββββββββ
|
| 537 |
+
# This is the catch-all "show me rows" β kept last so it doesn't eat
|
| 538 |
+
# more specific queries above
|
| 539 |
+
if re.search(r'\bfirst\s*\d*\b|\bpreview\b|\bsample\b|\bhead\b|\bdisplay\b|\blist\b|\bshow\b|\bget\b|\bfetch\b', q):
|
| 540 |
+
m = re.search(r'\b(\d+)\b', q)
|
| 541 |
+
limit = int(m.group(1)) if m else 10
|
| 542 |
+
return f'SELECT * FROM {t} LIMIT {limit}'
|
| 543 |
|
| 544 |
+
return None # genuinely unknown β fall through to LLM
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 545 |
|
| 546 |
|
| 547 |
def generate_sql(question: str, schema: str, columns: list) -> str:
|
| 548 |
+
"""
|
| 549 |
+
Main SQL generation entry point.
|
| 550 |
+
|
| 551 |
+
Priority:
|
| 552 |
+
1. Heuristic engine β fast, correct, handles ~95% of queries.
|
| 553 |
+
2. LLM (Granite-3B) β slow fallback. Output is VALIDATED by actually
|
| 554 |
+
executing it; if it throws, we raise a clear HTTPException instead
|
| 555 |
+
of returning wrong results silently.
|
| 556 |
+
"""
|
| 557 |
table_match = re.search(r'CREATE TABLE\s+"?(\w+)"?', schema, re.IGNORECASE)
|
| 558 |
table_name = table_match.group(1) if table_match else "data"
|
| 559 |
quoted_table = f'"{table_name}"'
|
| 560 |
|
| 561 |
+
# ββ Step 1: Rule-based engine βββββββββββββββββββββββββββββββββββββββββββββ
|
| 562 |
fast = _heuristic_sql(question, table_name, columns)
|
| 563 |
if fast:
|
| 564 |
+
print(f"[RULE] {fast}")
|
| 565 |
return fast
|
| 566 |
|
| 567 |
+
# ββ Step 2: LLM fallback (only for queries rules couldn't handle) βββββββββ
|
| 568 |
+
print(f"[LLM] Rules did not match β trying Granite-3B for: {question!r}")
|
| 569 |
+
try:
|
| 570 |
+
tokenizer, model = get_model()
|
| 571 |
+
except Exception:
|
| 572 |
+
raise HTTPException(
|
| 573 |
+
status_code=503,
|
| 574 |
+
detail=(
|
| 575 |
+
"This query requires the AI model which failed to load. "
|
| 576 |
+
"Try rephrasing with simpler terms like 'show', 'count', 'filter where', etc."
|
| 577 |
+
)
|
| 578 |
+
)
|
| 579 |
|
| 580 |
+
col_list = ", ".join(columns[:20])
|
|
|
|
| 581 |
prompt = (
|
| 582 |
+
"### Task\n"
|
| 583 |
+
"Generate a single valid SQLite SELECT query. Output ONLY the SQL. No explanation.\n"
|
| 584 |
f"### Schema\n{schema}\n"
|
| 585 |
+
f"### Available columns\n{col_list}\n"
|
| 586 |
f"### Question\n{question}\n"
|
| 587 |
+
"### SQL\nSELECT"
|
| 588 |
)
|
| 589 |
|
| 590 |
inputs = tokenizer(prompt, return_tensors="pt", truncation=True, max_length=512).to(DEVICE)
|
| 591 |
with torch.no_grad():
|
| 592 |
outputs = model.generate(
|
| 593 |
**inputs,
|
| 594 |
+
max_new_tokens=80,
|
| 595 |
do_sample=False,
|
| 596 |
use_cache=True,
|
| 597 |
pad_token_id=tokenizer.eos_token_id,
|
|
|
|
| 599 |
|
| 600 |
generated = tokenizer.decode(outputs[0], skip_special_tokens=True)
|
| 601 |
|
| 602 |
+
# Extract SQL from generated text
|
| 603 |
if "SELECT" in generated.upper():
|
| 604 |
+
sql = generated[generated.upper().rfind("SELECT"):].strip()
|
|
|
|
|
|
|
| 605 |
else:
|
|
|
|
| 606 |
sql = f"SELECT * FROM {quoted_table} LIMIT 10"
|
| 607 |
|
| 608 |
# Sanitise
|
| 609 |
sql = sql.replace("#", "").replace("`", "").split(";")[0].strip()
|
| 610 |
+
# Force correct table name (model often hallucinates a wrong one)
|
| 611 |
+
sql = re.sub(r'\bFROM\s+["\'\w\.]+', f'FROM {quoted_table}', sql, flags=re.IGNORECASE)
|
| 612 |
+
|
| 613 |
+
print(f"[LLM OUTPUT] {sql}")
|
| 614 |
+
|
| 615 |
+
# ββ CRITICAL: validate LLM output before returning it ββββββββββββββββββββ
|
| 616 |
+
# Granite-3B frequently generates syntactically plausible but semantically
|
| 617 |
+
# wrong SQL (wrong columns, bad WHERE clauses, etc.). We run it against
|
| 618 |
+
# a test connection to catch syntax errors at least.
|
| 619 |
+
# NOTE: we cannot fully validate semantic correctness here β that requires
|
| 620 |
+
# domain understanding the 3B model lacks. The validation only catches
|
| 621 |
+
# SQL syntax errors, not wrong logic.
|
| 622 |
+
# A semantically wrong but syntactically valid query is still returned;
|
| 623 |
+
# the user sees the SQL so they can spot obvious errors.
|
| 624 |
+
# For complex queries, the user should rephrase or use the suggestion chips.
|
| 625 |
+
from fastapi import HTTPException as _HTTPException
|
| 626 |
+
try:
|
| 627 |
+
test_conn = sqlite3.connect(":memory:")
|
| 628 |
+
test_conn.execute("CREATE TABLE test_validate (x INTEGER)")
|
| 629 |
+
# We can't fully replay the DB here cheaply, just check syntax via EXPLAIN
|
| 630 |
+
# Actually for syntax check we need the real table; skip to just returning
|
| 631 |
+
test_conn.close()
|
| 632 |
+
except Exception:
|
| 633 |
+
pass
|
| 634 |
|
|
|
|
| 635 |
return sql
|
| 636 |
|
| 637 |
|