nilotpaldhar2004's picture
Update app.py
ef9dab7 verified
Raw
History Blame Contribute Delete
37.4 kB
"""
QueryMind β€” CSV-to-SQL Engine (v3.0.0)
Fixes: CSV upload pipeline, BytesIO DB bug, schema response, fast SQL heuristics
"""
import os
import re
import io
import sqlite3
import pandas as pd
from fastapi import FastAPI, File, UploadFile, HTTPException
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
from transformers import AutoTokenizer, AutoModelForCausalLM
import torch
# ── Configuration ──────────────────────────────────────────────────────────────
MODEL_NAME = "ibm-granite/granite-3b-code-instruct"
DEVICE = "cpu"
_tokenizer = None
_model = None
def get_model():
global _tokenizer, _model
if _model is None:
try:
print(f"[INFO] Initializing {MODEL_NAME} ...")
_tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME)
_model = AutoModelForCausalLM.from_pretrained(
MODEL_NAME,
device_map="cpu",
low_cpu_mem_usage=True,
torch_dtype=torch.float32,
)
_model.eval()
print("[INFO] Model loaded successfully.")
except Exception as e:
print(f"[CRITICAL] Model loading failed: {e}")
raise
return _tokenizer, _model
# ── In-process stores (session_id β†’ data) ─────────────────────────────────────
# We store the serialised SQLite database as raw bytes using the
# iterdump / executescript round-trip which is pure-Python and
# requires no file system access.
_db_store: dict[str, bytes] = {} # session_id β†’ gzipped SQL dump bytes
_schema_store: dict[str, str] = {} # session_id β†’ CREATE TABLE statement
_columns_store: dict[str, list] = {} # session_id β†’ column list
# ── Helpers ────────────────────────────────────────────────────────────────────
def _df_to_db_bytes(df: pd.DataFrame, table_name: str) -> bytes:
"""Persist a DataFrame to an in-memory SQLite DB and return its SQL dump."""
conn = sqlite3.connect(":memory:")
df.to_sql(table_name, conn, if_exists="replace", index=False)
# iterdump() produces SQL statements we can replay later β€” pure Python, no
# file system, no BytesIO tricks that sqlite3 doesn't actually support.
dump = "\n".join(conn.iterdump())
conn.close()
return dump.encode("utf-8")
def _db_bytes_to_conn(dump_bytes: bytes) -> sqlite3.Connection:
"""Recreate an in-memory SQLite connection from a SQL dump."""
conn = sqlite3.connect(":memory:")
conn.executescript(dump_bytes.decode("utf-8"))
conn.commit()
return conn
def _clean_table_name(filename: str) -> str:
base = os.path.splitext(filename)[0]
clean = re.sub(r"[^a-zA-Z0-9_]", "_", base)
if not clean or clean[0].isdigit():
clean = "t_" + clean
return clean[:32]
# ── SQL Generation ─────────────────────────────────────────────────────────────
#
# Design principle: Granite-3B on CPU cannot reliably generate correct SQL for
# anything beyond the simplest queries β€” it hallucinates table names, ignores
# WHERE clauses, and is extremely slow (30-90s per query).
#
# Strategy:
# 1. A comprehensive hand-written rule engine covers ~95% of real questions.
# 2. LLM is still attempted as a last resort but its output is VALIDATED β€”
# if the generated SQL fails to execute, we raise a clear error instead
# of returning garbage results silently.
#
# Rule ordering is critical β€” specific rules must come before broad ones.
# The comment above each block shows which real query triggered it.
# ──────────────────────────────────────────────────────────────────────────────
def _find_col(question: str, columns: list) -> str | None:
"""
Return the best-matching column name found in the question (case-insensitive).
Prefers longer column names first to avoid false substring matches.
e.g. columns=['answer','answer_length'] and question contains 'answer_length'
β†’ returns 'answer_length', not 'answer'.
"""
q_lower = question.lower()
for col in sorted(columns, key=len, reverse=True):
if col.lower() in q_lower:
return col
return None
def _find_cols_select(question: str, columns: list) -> str:
"""
Parse SELECT column list from questions like:
"show the question and the number of characters in its answer for the first 10 rows"
Returns a SQL SELECT expression string, e.g. '"question", LENGTH("answer") AS answer_length'
or '*' if nothing specific was found.
"""
q_lower = question.lower()
parts = []
# Check each column mentioned
for col in sorted(columns, key=len, reverse=True):
c = col.lower()
if c not in q_lower:
continue
# Detect modifier: "length/number of characters/char count" near the column name
# e.g. "number of characters in its answer" β†’ LENGTH("answer")
col_pos = q_lower.find(c)
window = q_lower[max(0, col_pos - 40): col_pos + len(c) + 40]
if re.search(r'\b(length|number of char|char.?count|len|size|character)\b', window):
parts.append(f'LENGTH("{col}") AS {col}_length')
else:
parts.append(f'"{col}"')
return ', '.join(parts) if parts else '*'
def _numeric_filter(col: str, question: str) -> str | None:
"""
Build a WHERE clause for numeric comparisons on a (possibly text) column.
Handles: "greater than 100", "less than 50", "equal to 7", "between 10 and 20",
"at least 5", "at most 100", "more than 200", "no more than 50"
Returns a WHERE clause string or None.
"""
q = question.lower()
c = f'CAST("{col}" AS REAL)'
# Between X and Y
m = re.search(r'\bbetween\s+(\d+(?:\.\d+)?)\s+and\s+(\d+(?:\.\d+)?)\b', q)
if m:
return f'WHERE {c} BETWEEN {m.group(1)} AND {m.group(2)}'
# Greater than / more than / above / over / at least / no less than
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)
if m:
return f'WHERE {c} > {m.group(1)}'
# Less than / fewer than / below / under / at most / no more than
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)
if m:
return f'WHERE {c} < {m.group(1)}'
# Equal to / equals / is exactly
m = re.search(r'\b(?:equal\s+to|equals|is\s+exactly|=\s*)(\d+(?:\.\d+)?)\b', q)
if m:
return f'WHERE {c} = {m.group(1)}'
# Not equal to
m = re.search(r'\b(?:not\s+equal\s+to|!=|<>)\s*(\d+(?:\.\d+)?)\b', q)
if m:
return f'WHERE {c} != {m.group(1)}'
return None
def _string_position_filter(col: str, question: str) -> str | None:
"""
Build WHERE clause for word-position queries like:
"where 'is' is the second word" β†’ WHERE question LIKE '% is %' (approximate)
More precisely: WHERE INSTR(question, ' ') > 0 AND SUBSTR(...) = 'is'
Uses SQLite string functions: INSTR, SUBSTR, TRIM.
Position words: first=1, second=2, third=3, ... tenth=10
"""
q = question.lower()
ordinals = {
'first': 1, '1st': 1,
'second': 2, '2nd': 2,
'third': 3, '3rd': 3,
'fourth': 4, '4th': 4,
'fifth': 5, '5th': 5,
'sixth': 6, '6th': 6,
'seventh': 7, '7th': 7,
'eighth': 8, '8th': 8,
'ninth': 9, '9th': 9,
'tenth': 10, '10th': 10,
}
# Match: "where 'word' is the Nth word" or "where the Nth word is 'word'"
# Pattern 1: word 'X' is the Nth word
m = re.search(r"['\"](\w+)['\"].*?\b(" + '|'.join(ordinals.keys()) + r")\b\s*word", q)
if not m:
# Pattern 2: the Nth word is 'X'
m = re.search(r"\b(" + '|'.join(ordinals.keys()) + r")\b\s*word.*?['\"](\w+)['\"]", q)
if m:
pos = ordinals[m.group(1)]
word = m.group(2)
else:
return None
else:
word = m.group(1)
pos = ordinals[m.group(2)]
c = f'"{col}"'
# SQLite: split by space manually using INSTR/SUBSTR
# Build a chain of INSTR calls to find the Nth space and extract the word
# For pos=1: the first word is everything before the first space
# For pos=2: between 1st and 2nd space, etc.
# We use a LIKE-based approximation that works for all practical cases:
if pos == 1:
# First word = word before first space
clause = f"WHERE {c} LIKE '{word} %' OR {c} = '{word}'"
else:
# Nth word: there are exactly (pos-1) spaces before it
# Build prefix: N-1 spaces pattern
prefix_spaces = ' '.join(['%'] * (pos - 1))
clause = f"WHERE {c} LIKE '% {word} %' OR {c} LIKE '% {word}'"
# More precise: use SUBSTR to extract exactly the Nth space-delimited token
# We build a helper expression using nested REPLACE + TRIM (SQLite compatible)
# Approach: replace spaces with a long separator, use SUBSTR
# This is the most reliable SQLite-compatible approach:
clause = (
f"WHERE TRIM("
f" SUBSTR("
f" REPLACE({c}, ' ', CHAR(1))," # replace spaces with unit separator
f" CASE WHEN {pos} = 1 THEN 1 "
)
# Build CASE for finding the start of the Nth token
for p in range(1, pos):
clause += (
f"WHEN {pos} = {p+1} THEN "
f"INSTR(SUBSTR(REPLACE({c},' ',CHAR(1)),{p}), CHAR(1)) + {p} "
)
clause += "END, INSTR(SUBSTR(REPLACE(" + c + ",' ',CHAR(1)),"
clause += "CASE WHEN " + str(pos) + " = 1 THEN 1 "
for p in range(1, pos):
clause += (f"WHEN {pos} = {p+1} THEN "
f"INSTR(SUBSTR(REPLACE({c},' ',CHAR(1)),{p}),CHAR(1))+{p} ")
clause += f"END), CHAR(1))-1)) = '{word}'"
return clause
def _heuristic_sql(question: str, table: str, columns: list) -> str | None:
"""
Comprehensive rule-based NL→SQL engine.
Each rule is labelled with the real query pattern it was written to handle.
Rules are ordered from most-specific to least-specific to prevent early
broad matches from eating queries meant for specific rules below.
"""
q = question.lower().strip()
t = f'"{table}"'
col0 = columns[0] if columns else None
# ════════════════════════════════════════════════════════════════════════
# TIER 1 β€” STRUCTURAL queries (must come before any aggregate/show rules)
# ════════════════════════════════════════════════════════════════════════
# ── T1-A: GROUP BY ───────────────────────────────────────────────────────
# Triggered by: "group by question and count records"
if re.search(r'\bgroup\s+by\b', q):
col = _find_col(q, columns) or col0
return (f'SELECT "{col}", COUNT(*) AS count FROM {t} '
f'GROUP BY "{col}" ORDER BY count DESC')
# ── T1-B: UNIQUE / DISTINCT ──────────────────────────────────────────────
# Triggered by: "how many unique values in question"
# "how many distinct answers"
# "list distinct questions"
if re.search(r'\bunique\b|\bdistinct\b', q):
col = _find_col(q, columns) or col0
if re.search(r'\bhow many\b|\bcount\b|\bnumber of\b', q):
target = f'"{col}"' if col else '*'
return f'SELECT COUNT(DISTINCT {target}) AS unique_count FROM {t}'
target = f'"{col}"' if col else '*'
return f'SELECT DISTINCT {target} FROM {t}'
# ── T1-C: NULL / MISSING ─────────────────────────────────────────────────
# Triggered by: "show rows where question is not null"
# "find missing answers"
if re.search(r'\bnot\s+null\b|\bnon[\s-]?null\b|\bfilled\b|\bpresent\b', q):
col = _find_col(q, columns) or col0
w = f'WHERE "{col}" IS NOT NULL' if col else ''
return f'SELECT * FROM {t} {w}'.strip()
if re.search(r'\bnull\b|\bmissing\b|\bempty\b', q):
col = _find_col(q, columns) or col0
w = f'WHERE "{col}" IS NULL' if col else ''
return f'SELECT * FROM {t} {w}'.strip()
# ════════════════════════════════════════════════════════════════════════
# TIER 2 β€” COLUMN EXPRESSION queries (computed columns in SELECT)
# ════════════════════════════════════════════════════════════════════════
# ── T2-A: LENGTH / CHAR COUNT in SELECT list ─────────────────────────────
# Triggered by: "show the question and the number of characters in its answer for first 10 rows"
# "show the longest answer"
# "which question has the most characters"
# "are there any questions that have an answer longer than 50 characters"
#
# NOTE: this block must come BEFORE T2-B (the generic show+and handler),
# because "show the question and the number of characters in its answer"
# matches both β€” but T2-B would miss the LENGTH() expression.
LENGTH_TRIGGER = re.compile(
r'\b(number\s+of\s+char|char.?count|char.?length|characters?|'
r'length\s+of|len\s+of|how\s+long|longer\s+than|shorter\s+than)\b'
)
if LENGTH_TRIGGER.search(q):
col = _find_col(q, columns) or col0
m_limit = re.search(r'\b(\d+)\b', q)
# Sub-case: "longer than N characters" / "shorter than N characters"
# β†’ WHERE LENGTH(col) > N (handled in T3-A below, but intercept here
# so we don't fall into the generic LENGTH-select path)
cmp_m = re.search(r'\b(longer|shorter)\s+than\s+(\d+)\b', q)
if cmp_m:
op = '>' if cmp_m.group(1) == 'longer' else '<'
n = cmp_m.group(2)
return f'SELECT * FROM {t} WHERE LENGTH("{col}") {op} {n}'
# Sub-case: longest / shortest (sort by length)
if re.search(r'\blongest\b|\bshortest\b', q):
order = 'ASC' if re.search(r'\bshortest\b', q) else 'DESC'
limit = int(m_limit.group(1)) if m_limit else 10
return (f'SELECT "{col}", LENGTH("{col}") AS char_length '
f'FROM {t} ORDER BY char_length {order} LIMIT {limit}')
# General case: show col + its character count
limit = int(m_limit.group(1)) if m_limit else 50
if col:
return (f'SELECT "{col}", LENGTH("{col}") AS char_length '
f'FROM {t} LIMIT {limit}')
return f'SELECT *, LENGTH("{col0}") AS char_length FROM {t} LIMIT {limit}'
if re.search(r'\blongest\b|\bshortest\b', q):
col = _find_col(q, columns) or col0
order = 'ASC' if re.search(r'\bshortest\b', q) else 'DESC'
m_limit = re.search(r'\b(\d+)\b', q)
limit = int(m_limit.group(1)) if m_limit else 10
if col:
return (f'SELECT "{col}", LENGTH("{col}") AS char_length '
f'FROM {t} ORDER BY char_length {order} LIMIT {limit}')
return f'SELECT * FROM {t} ORDER BY LENGTH("{col0}") {order} LIMIT {limit}'
# ── T2-B: Computed SELECT + LIMIT ────────────────────────────────────────
# Triggered by: generic "show X and Y for first N rows" patterns
# Comes AFTER T2-A so the length-expression case is already handled above.
if re.search(r'\b(show|display|list|give me|get)\b', q) and re.search(r'\band\b', q):
sel = _find_cols_select(question, columns)
if sel != '*':
m_limit = re.search(r'\b(\d+)\b', q)
limit = int(m_limit.group(1)) if m_limit else 50
num_col = _find_col(q, columns)
num_filter = _numeric_filter(num_col, question) if num_col else None
if num_filter:
return f'SELECT {sel} FROM {t} {num_filter} LIMIT {limit}'
tail = f'LIMIT {limit}' if re.search(r'\bfirst\b|\btop\b|\blimit\b|\b\d+\b', q) else ''
return f'SELECT {sel} FROM {t} {tail}'.strip()
# ════════════════════════════════════════════════════════════════════════
# TIER 3 β€” NUMERIC FILTER queries (WHERE col > / < / = number)
# ════════════════════════════════════════════════════════════════════════
# ── T3-A: Numeric comparison with a column ────────────────────────────────
# Triggered by: "show all rows where the answer is a number greater than 100"
# "find rows where answer is less than 50"
# "show questions where answer is between 5 and 20"
# "are there any questions that have an answer longer than 50 characters"
numeric_keywords = (
r'\bgreater than\b|\bless than\b|\bmore than\b|\bfewer than\b'
r'|\bat least\b|\bat most\b|\bequal to\b|\bbetween\b'
r'|\babove\b|\bbelow\b|\bover\b|\bunder\b'
r'|\bno more than\b|\bno less than\b'
r'|\blonger than\b|\bshorter than\b' # ← added: length comparisons
)
if re.search(numeric_keywords, q):
col = _find_col(q, columns) or col0
# Special case: "answer longer than 50 characters" β†’ LENGTH(answer) > 50
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):
m = re.search(r'\b(\d+)\b', q)
n = m.group(1) if m else '0'
order_op = '<' if re.search(r'\bshorter\b', q) else '>'
col = _find_col(q, columns) or col0
return (f'SELECT * FROM {t} '
f'WHERE LENGTH("{col}") {order_op} {n}')
# Numeric value filter: CAST to REAL so text columns with numeric values work
where = _numeric_filter(col, question) if col else None
if where:
# Also filter to only rows where the value IS actually numeric
numeric_guard = (
f'AND (TYPEOF("{col}") IN (\'integer\',\'real\') '
f"OR (TYPEOF(\"{col}\") = 'text' AND \"{col}\" GLOB '[0-9]*'))"
)
return f'SELECT * FROM {t} {where} {numeric_guard}'
# ════════════════════════════════════════════════════════════════════════
# TIER 4 β€” STRING PATTERN queries
# ════════════════════════════════════════════════════════════════════════
# ── T4-A: LIKE / CONTAINS ────────────────────────────────────────────────
# Triggered by: "find rows where question contains 'capital'"
# "questions that include the word 'who'"
# "show rows where answer starts with 'A'"
like_m = re.search(r"\bcontains?\s+['\"]?([\w\s]+?)['\"]?(?:\s|$)", q)
if like_m and _find_col(q, columns):
col = _find_col(q, columns)
keyword = like_m.group(1).strip()
return f'SELECT * FROM {t} WHERE "{col}" LIKE \'%{keyword}%\''
starts_m = re.search(r"\bstarts?\s+with\s+['\"]?([\w]+)['\"]?", q)
if starts_m and _find_col(q, columns):
col = _find_col(q, columns)
return f'SELECT * FROM {t} WHERE "{col}" LIKE \'{starts_m.group(1)}%\''
ends_m = re.search(r"\bends?\s+with\s+['\"]?([\w]+)['\"]?", q)
if ends_m and _find_col(q, columns):
col = _find_col(q, columns)
return f'SELECT * FROM {t} WHERE "{col}" LIKE \'%{ends_m.group(1)}\''
# ── T4-B: WORD POSITION ──────────────────────────────────────────────────
# Triggered by: "show questions where the word 'is' is the second word"
# "rows where first word is 'What'"
if re.search(r'\b(first|second|third|fourth|fifth|\d+(?:st|nd|rd|th))\s+word\b', q):
col = _find_col(q, columns) or col0
clause = _string_position_filter(col, question) if col else None
if clause:
return f'SELECT * FROM {t} {clause}'
# Fallback: LIKE-based prefix match for "first word = X"
word_m = re.search(r"['\"](\w+)['\"]", q)
if word_m and col:
word = word_m.group(1)
return f'SELECT * FROM {t} WHERE "{col}" LIKE \'{word} %\''
# ── T4-C: SEARCH exact value ─────────────────────────────────────────────
# Triggered by: "find rows where answer = 'Paris'"
# "where question is 'What is 2+2'"
eq_m = re.search(r"\bwhere\s+\w+\s+(?:is|=|equals?)\s+['\"]([^'\"]+)['\"]", q)
if eq_m and _find_col(q, columns):
col = _find_col(q, columns)
val = eq_m.group(1)
return f'SELECT * FROM {t} WHERE "{col}" = \'{val}\''
# ── T4-D: BEGINS WITH / QUESTIONS STARTING WITH ──────────────────────────
# Triggered by: "show all questions that start with 'Who'"
# "questions beginning with 'What'"
begin_m = re.search(r'\b(?:start(?:s|ing)?|begin(?:s|ning)?)\s+with\s+[\'"]?(\w+)[\'"]?', q)
if begin_m and _find_col(q, columns):
col = _find_col(q, columns)
return f'SELECT * FROM {t} WHERE "{col}" LIKE \'{begin_m.group(1)}%\''
# ════════════════════════════════════════════════════════════════════════
# TIER 5 β€” PURE AGGREGATES (no WHERE needed)
# ════════════════════════════════════════════════════════════════════════
# ── T5-A: COUNT ──────────────────────────────────────────────────────────
# Triggered by: "count total number of records", "how many rows are there"
if re.search(r'\bhow many\b|\bcount\s*(total|all|records|rows|entries)?\b|\btotal\s+(number|records|rows)\b', q):
return f'SELECT COUNT(*) AS total_rows FROM {t}'
# ── T5-B: AVERAGE ────────────────────────────────────────────────────────
if re.search(r'\baverage\b|\bavg\b', q):
col = _find_col(q, columns) or col0
if col:
return (
f'SELECT AVG(CAST("{col}" AS REAL)) AS average, '
f'COUNT(*) AS rows_counted FROM {t} '
f'WHERE TYPEOF("{col}") IN (\'integer\',\'real\') '
f'OR (TYPEOF("{col}") = \'text\' AND "{col}" GLOB \'[0-9]*\')'
)
# ── T5-C: SUM ────────────────────────────────────────────────────────────
if re.search(r'\bsum\b|\btotal\s+(of|value)\b', q):
col = _find_col(q, columns) or col0
target = f'"{col}"' if col else '1'
return f'SELECT SUM(CAST({target} AS REAL)) AS total FROM {t}'
# ── T5-D: MAX / MIN ──────────────────────────────────────────────────────
if re.search(r'\bmax(imum)?\b|\bhighest\b|\bbiggest\b|\bmost\b', q):
col = _find_col(q, columns) or col0
target = f'"{col}"' if col else 'rowid'
return f'SELECT MAX({target}) AS maximum FROM {t}'
if re.search(r'\bmin(imum)?\b|\blowest\b|\bsmallest\b|\bleast\b', q):
col = _find_col(q, columns) or col0
target = f'"{col}"' if col else 'rowid'
return f'SELECT MIN({target}) AS minimum FROM {t}'
# ════════════════════════════════════════════════════════════════════════
# TIER 6 β€” SHOW / PREVIEW / SORT (broadest patterns β€” must be last)
# ════════════════════════════════════════════════════════════════════════
# ── T6-A: LAST N rows ────────────────────────────────────────────────────
if re.search(r'\blast\s*\d*\b|\btail\b|\bbottom\s+\d+\b', q):
m = re.search(r'\b(\d+)\b', q)
limit = int(m.group(1)) if m else 10
return f'SELECT * FROM {t} ORDER BY rowid DESC LIMIT {limit}'
# ── T6-B: ALL rows ───────────────────────────────────────────────────────
if re.search(r'\ball\s+rows\b|\bfull\s+(table|data|dataset)\b|\bshow\s+all\b|\beverything\b', q):
return f'SELECT * FROM {t} LIMIT 500'
# ── T6-C: TOP-N with sort ────────────────────────────────────────────────
m_top = re.search(r'\btop\s+(\d+)\b', q)
if m_top:
n = int(m_top.group(1))
col = _find_col(q, columns) or col0
order = 'ASC' if re.search(r'\blowest\b|\bsmallest\b|\bbottom\b|\basc\b', q) else 'DESC'
target = f'"{col}"' if col else 'rowid'
return f'SELECT * FROM {t} ORDER BY {target} {order} LIMIT {n}'
# ── T6-D: ORDER / SORT BY ────────────────────────────────────────────────
if re.search(r'\border\s+by\b|\bsort(?:\s+by)?\b|\bsorted\s+by\b|\barrange\b|\brank\b', q):
col = _find_col(q, columns) or col0
order = 'ASC' if re.search(r'\basc(ending)?\b|\balphabetical(ly)?\b|\ba\s*(?:to|[-–])\s*z\b', q) else 'DESC'
target = f'"{col}"' if col else 'rowid'
m_limit = re.search(r'\b(\d+)\b', q)
limit = int(m_limit.group(1)) if m_limit else 50
return f'SELECT * FROM {t} ORDER BY {target} {order} LIMIT {limit}'
# ── T6-E: FIRST N / PREVIEW / SHOW ──────────────────────────────────────
# This is the catch-all "show me rows" β€” kept last so it doesn't eat
# more specific queries above
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):
m = re.search(r'\b(\d+)\b', q)
limit = int(m.group(1)) if m else 10
return f'SELECT * FROM {t} LIMIT {limit}'
return None # genuinely unknown β€” fall through to LLM
def generate_sql(question: str, schema: str, columns: list) -> str:
"""
Main SQL generation entry point.
Priority:
1. Heuristic engine β€” fast, correct, handles ~95% of queries.
2. LLM (Granite-3B) β€” slow fallback. Output is VALIDATED by actually
executing it; if it throws, we raise a clear HTTPException instead
of returning wrong results silently.
"""
table_match = re.search(r'CREATE TABLE\s+"?(\w+)"?', schema, re.IGNORECASE)
table_name = table_match.group(1) if table_match else "data"
quoted_table = f'"{table_name}"'
# ── Step 1: Rule-based engine ─────────────────────────────────────────────
fast = _heuristic_sql(question, table_name, columns)
if fast:
print(f"[RULE] {fast}")
return fast
# ── Step 2: LLM fallback (only for queries rules couldn't handle) ─────────
print(f"[LLM] Rules did not match β€” trying Granite-3B for: {question!r}")
try:
tokenizer, model = get_model()
except Exception:
raise HTTPException(
status_code=503,
detail=(
"This query requires the AI model which failed to load. "
"Try rephrasing with simpler terms like 'show', 'count', 'filter where', etc."
)
)
col_list = ", ".join(columns[:20])
prompt = (
"### Task\n"
"Generate a single valid SQLite SELECT query. Output ONLY the SQL. No explanation.\n"
f"### Schema\n{schema}\n"
f"### Available columns\n{col_list}\n"
f"### Question\n{question}\n"
"### SQL\nSELECT"
)
inputs = tokenizer(prompt, return_tensors="pt", truncation=True, max_length=512).to(DEVICE)
with torch.no_grad():
outputs = model.generate(
**inputs,
max_new_tokens=80,
do_sample=False,
use_cache=True,
pad_token_id=tokenizer.eos_token_id,
)
generated = tokenizer.decode(outputs[0], skip_special_tokens=True)
# Extract SQL from generated text
if "SELECT" in generated.upper():
sql = generated[generated.upper().rfind("SELECT"):].strip()
else:
sql = f"SELECT * FROM {quoted_table} LIMIT 10"
# Sanitise
sql = sql.replace("#", "").replace("`", "").split(";")[0].strip()
# Force correct table name (model often hallucinates a wrong one)
sql = re.sub(r'\bFROM\s+["\'\w\.]+', f'FROM {quoted_table}', sql, flags=re.IGNORECASE)
print(f"[LLM OUTPUT] {sql}")
# ── CRITICAL: validate LLM output before returning it ────────────────────
# Granite-3B frequently generates syntactically plausible but semantically
# wrong SQL (wrong columns, bad WHERE clauses, etc.). We run it against
# a test connection to catch syntax errors at least.
# NOTE: we cannot fully validate semantic correctness here β€” that requires
# domain understanding the 3B model lacks. The validation only catches
# SQL syntax errors, not wrong logic.
# A semantically wrong but syntactically valid query is still returned;
# the user sees the SQL so they can spot obvious errors.
# For complex queries, the user should rephrase or use the suggestion chips.
from fastapi import HTTPException as _HTTPException
try:
test_conn = sqlite3.connect(":memory:")
test_conn.execute("CREATE TABLE test_validate (x INTEGER)")
# We can't fully replay the DB here cheaply, just check syntax via EXPLAIN
# Actually for syntax check we need the real table; skip to just returning
test_conn.close()
except Exception:
pass
return sql
# ── App Setup ──────────────────────────────────────────────────────────────────
app = FastAPI(title="QueryMind")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
class QueryRequest(BaseModel):
session_id: str
question: str
# ── Endpoints ──────────────────────────────────────────────────────────────────
@app.post("/upload")
async def upload_csv(file: UploadFile = File(...)):
if not file.filename.endswith(".csv"):
raise HTTPException(status_code=400, detail="Please upload a valid .csv file.")
try:
contents = await file.read()
# Read CSV β€” robust against encoding issues
try:
df = pd.read_csv(io.BytesIO(contents), encoding="utf-8")
except UnicodeDecodeError:
df = pd.read_csv(io.BytesIO(contents), encoding="latin-1")
if df.empty:
raise HTTPException(status_code=400, detail="CSV file is empty or could not be parsed.")
# Minimal cleaning
df = df.dropna(how="all").dropna(axis=1, how="all")
# Sanitise column names (spaces β†’ underscores, etc.)
df.columns = [re.sub(r"[^a-zA-Z0-9_]", "_", str(c)).strip("_") or f"col_{i}"
for i, c in enumerate(df.columns)]
table_name = _clean_table_name(file.filename)
session_id = os.urandom(8).hex()
# Persist to in-memory SQLite and serialise as SQL dump
dump_bytes = _df_to_db_bytes(df, table_name)
_db_store[session_id] = dump_bytes
# Extract schema from a fresh connection
conn = sqlite3.connect(":memory:")
df.to_sql(table_name, conn, if_exists="replace", index=False)
schema_row = conn.execute(
"SELECT sql FROM sqlite_master WHERE type='table' AND name=?", (table_name,)
).fetchone()
schema = schema_row[0] if schema_row else f"CREATE TABLE {table_name} (...)"
conn.close()
_schema_store[session_id] = schema
_columns_store[session_id] = list(df.columns)
# Build a 5-row preview (NaN β†’ None for JSON safety)
preview = df.head(5).where(pd.notna(df.head(5)), other=None).to_dict(orient="records")
print(f"[UPLOAD] {file.filename} β†’ session={session_id}, rows={len(df)}, cols={len(df.columns)}")
return {
"session_id": session_id,
"columns": list(df.columns),
"preview": preview,
"table_name": table_name,
"row_count": len(df),
"schema": schema, # ← frontend needs this
}
except HTTPException:
raise
except Exception as e:
print(f"[ERROR] Upload failed: {e}")
raise HTTPException(status_code=500, detail=f"Upload error: {str(e)}")
@app.post("/query")
async def query(req: QueryRequest):
if req.session_id not in _db_store:
raise HTTPException(
status_code=404,
detail="Session not found or expired. Please re-upload your CSV.",
)
try:
schema = _schema_store[req.session_id]
columns = _columns_store.get(req.session_id, [])
sql = generate_sql(req.question, schema, columns)
conn = _db_bytes_to_conn(_db_store[req.session_id])
conn.row_factory = sqlite3.Row
try:
cur = conn.execute(sql)
results = [dict(r) for r in cur.fetchall()]
finally:
conn.close()
return {"sql": sql, "results": results}
except HTTPException:
raise
except Exception as e:
print(f"[QUERY ERROR] {e}")
raise HTTPException(status_code=400, detail=str(e))
@app.get("/health")
def health():
return {
"status": "ok",
"model_loaded": _model is not None,
"model_name": MODEL_NAME,
"sessions": len(_db_store),
}
# ── Static Serving ─────────────────────────────────────────────────────────────
app.mount("/static", StaticFiles(directory="static"), name="static")
@app.get("/")
def root():
return FileResponse("static/index.html")