LLMSDE
Plain-English questions to safe SQL, with two independent layers between the model and the data
Ask a database a question in plain English and get safe SQL, the right chart and a live dashboard
Sole developer
The problem
Teams have the numbers they need in a database, but getting them out means waiting for someone who writes SQL. Letting a model write SQL directly risks queries that change data or read what they shouldn't.
Team: Personal project. Timeline: September 2026.
Status
No measured results yet. Measure accuracy on a held-out question set.
Architecture
Request flows
- A question becomes a chart: React app → FastAPI query service → Gemini (JSON schema output)
- Generated SQL is checked before it runs: FastAPI query service → SQL guard (sqlglot AST) → Read-only executor (authorizer, time budget) → Dataset SQLite files
Decision 1 of 3
How should the model return its answer?
- Options
- Free text parsed with regexes · A JSON schema response
- Chose
- A JSON schema response of {answerable, sql, explanation}
- Trade-off
- The output is tied to one provider's structured-output support.
- Evidence
- The model can say a question is unanswerable instead of producing a guess, and there is no free text to parse.
Decision 2 of 3
How is generated SQL kept from changing or leaking data?
- Options
- Trust the prompt · A static check alone · A static AST guard and a runtime authorizer
- Chose
- Two independent layers, a sqlglot AST allow-list and the SQLite authorizer on a read-only connection
- Trade-off
- Some valid analytical SQL is refused, such as table-valued functions and schema-qualified names.
- Evidence
- The static guard can be wrong without data being exposed, because the authorizer separately allows only reads of the dataset's own tables.
Decision 3 of 3
What stops a runaway query?
- Options
- A database timeout · A progress-handler time budget and a row cap
- Chose
- A progress-handler time budget (3 s by default) and a row cap with a truncated flag
- Trade-off
- Long legitimate queries are cut off.
- Evidence
- Recursive CTEs and other runaway queries are interrupted inside SQLite rather than holding a worker.
The static guard on generated SQL
def check(sql: str, allowed_tables: set[str]) -> CheckedSQL:
sql = strip_fences(sql)
if not sql:
raise UnsafeSQLError("The generated query is empty.")
try:
statements = [s for s in sqlglot.parse(sql, read="sqlite") if s is not None]
except ParseError as exc:
raise UnsafeSQLError(f"The generated SQL could not be parsed: {_first_line(exc)}") from exc
if len(statements) != 1:
raise UnsafeSQLError("Only a single statement is allowed.")
tree = statements[0]
if not isinstance(tree, exp.Query):
raise UnsafeSQLError("Only read-only SELECT queries are allowed.")
if any(isinstance(node, WRITE_NODES) for node in tree.walk()):
raise UnsafeSQLError("Only read-only SELECT queries are allowed.")
for fn in tree.find_all(exp.Anonymous, exp.Func):
name = (fn.name if isinstance(fn, exp.Anonymous) else fn.sql_name()).lower()
if name in BLOCKED_FUNCTIONS:
raise UnsafeSQLError(f"The function {name}() is not allowed.")The query is checked as a syntax tree, not as text, so a write hidden inside a CTE or a subquery is found wherever it sits.