Text-to-SQL Agent: Answer Questions From a Database Without Letting the Model Touch the Data
Hands-on lab · IDE in your browser

Text-to-SQL Agent: Answer Questions From a Database Without Letting the Model Touch the Data

Build a text-to-SQL assistant on SQLite that is safe to point at real data.

Time
50 min
Checked steps
5
Level
Intermediate
Setup
None
Read step 1

Hands-on labs require Pro · $29.99/mo · cancel anytime

Lab cockpit50 min · 5 stepsSession running
4 / 5 steps passingExamples that teach the rules · step 5 of 5
sqlagent.py▶ Run✓ Check
def build_prompt(question, schema):    prompt = ("You write SQLite queries for Tidewell Gym's database. Read the comments in the schema: they hold "              "the business rules.\n\n" + schema)   
TerminalOutput

The job

Tidewell Gym's managers want answers from the member database without waiting for someone who knows SQL. You build the assistant that writes the queries, and make sure no question, typo or hostile request can change a single row.

5 steps, each checked when you finish it

A check runs your work at the end of every step. Hints and the full solution are there if you get stuck.

  1. 1

    Describe the database

    Tidewell Gym's managers want answers from the member database without writing SQL.

    You writeschema_text()
  2. 2

    A connection that can only read

    The model's SQL runs against the real database.

    You writeauthorize()connect_readonly()
  3. 3

    Check and repair

    Models write SQL with typos, misspelt columns, a second statement tacked on, or a DELETE when the question asked for one.

    You writecheck_sql()
  4. 4

    Grade by results

    To improve the agent you need a score.

    You writesame_result()
  5. 5

    Examples that teach the rules

    The misses in step 4 break rules that the schema comments only hint at: - revenue is net of refunds; - erased members don't count, even when their status still says active; - an average per member divides by every member, including those with no visits.

    You writepick_examples()

Step 1 as it appears in the lab

The lab’s own text. The hint and the solution stay inside the lab.

Step 1: Describe the database

Tidewell Gym's managers want answers from the member database without writing SQL. The model can write the SQL, but only for tables and columns it knows about. Whatever you put in the prompt is all it knows.

The schema has more than names in it. The comments in the CREATE statements carry the rules: money is in pence, refunds are negative, and erased members are kept for accounting only. sqlite_master stores those statements exactly as they were written, comments included.

Do this

1. Write schema_text(conn): for each table, give its CREATE statement from sqlite_master, its row count and its first 2 rows.

2. Run to see the schema the model gets and the SQL it writes for four questions. This step's run_query() is a plain connection that can do anything. Step 2 fixes that.

sqlagent.py, the file you edit118 lines
"""Ask Tidewell Gym's database questions in plain English: the model writes SQL, the code checks and runs it."""
import json
import os
import re
import sqlite3
import time

import build_db
import harness

DB = "gym.db"
MAX_ROWS = 50
TIME_LIMIT = 2.0   # seconds per query


def ensure_db():
    if not os.path.exists(DB):
        build_db.build(DB)


# ---------- Step 1: describe the database ----------
def schema_text(conn):
    """For every table (in sqlite_master order): its CREATE statement exactly as stored (with its comments), its
    row count, and its first 2 rows as tuples."""
    # TODO (Step 1): loop over conn.execute("SELECT name, sql FROM sqlite_master WHERE type = 'table' ORDER BY rowid");
    # for each table give its CREATE sql, its COUNT(*) and its first 2 rows (LIMIT 2). Join the tables with blank lines.
    raise NotImplementedError("Step 1: write schema_text()")


# ---------- Step 2: a connection that can only read ----------
ALLOWED_ACTIONS = {sqlite3.SQLITE_SELECT, sqlite3.SQLITE_READ, sqlite3.SQLITE_FUNCTION, sqlite3.SQLITE_RECURSIVE}


def authorize(action, arg1, arg2, db_name, trigger):
    """sqlite3 calls this for every action a statement needs while it is compiled."""
    return sqlite3.SQLITE_OK


def connect_readonly():
    """A connection that opens gym.db read-only and denies every action outside ALLOWED_ACTIONS."""
    return sqlite3.connect(DB, check_same_thread=False)


def run_query(sql, max_rows=MAX_ROWS):
    """Run one query on a read-only connection. Returns {"columns", "rows" (lists), "truncated"} or {"error": text}.
    Stop queries that run longer than TIME_LIMIT seconds; return at most max_rows rows."""
    conn = sqlite3.connect(DB)
    try:
        cur = conn.execute(sql)
        rows = cur.fetchall()
        conn.commit()
        return {"columns": [d[0] for d in cur.description or []], "rows": [list(r) for r in rows], "truncated": False}
    except sqlite3.Error as e:
        return {"error": str(e)}
    finally:
        conn.close()


# ---------- Step 3: check, run, repair ----------
def extract_sql(reply):
    """The SQL inside the reply's ```sql block (or the whole reply if there is none), stripped."""
    m = re.search(r"```(?:sql)?\s*(.*?)```", reply or "", re.S | re.I)
    return (m.group(1) if m else reply or "").strip()


def check_sql(sql):
    """None if the SQL is one read-only statement that compiles; else the reason it is not."""
    return None


def build_prompt(question, schema):
    prompt = ("You write SQLite queries for Tidewell Gym's database. Read the comments in the schema: they hold "
              "the business rules.\n\n" + schema)
    return prompt + ("\n\nAnswer with one SQLite SELECT query in a ```sql block and nothing else. If the request "
                     "asks to change data, still answer with a SELECT query that reads what is relevant.")


def ask(question, max_repairs=2):
    """Returns {"sql", "columns", "rows", "error", "attempts"}: the last SQL tried, its result (or error)."""
    conn = sqlite3.connect(f"file:{DB}?mode=ro", uri=True)
    schema = schema_text(conn)
    conn.close()
    messages = [{"role": "system", "content": build_prompt(question, schema)}, {"role": "user", "content": question}]
    for attempt in range(1, max_repairs + 2):
        reply = harness.complete(messages, max_tokens=500)["content"] or ""
        sql = extract_sql(reply)
        problem = check_sql(sql)
        result = run_query(sql) if problem is None else {"error": problem}
        return {"sql": sql, "columns": result.get("columns", []), "rows": result.get("rows", []),
                "error": result.get("error"), "attempts": attempt}


# ---------- Step 4: grade by results ----------
def norm(v):
    """Numbers rounded to 2 decimal places (as floats), everything else unchanged."""
    return round(float(v), 2) if isinstance(v, (int, float)) and not isinstance(v, bool) else v


def same_result(expected, got):
    """True if got answers the question as well as expected: the same number of rows, and every expected row matches
    a different got row that contains all its values (compared with norm()). Row order, column order, column names
    and extra columns in got do not matter."""
    return expected == got


# ---------- Step 5: examples that teach the rules ----------
EXAMPLES = json.load(open("examples.json", encoding="utf-8"))


def words(text):
    return set(re.findall(r"[a-z0-9]+", text.lower())) - {"the", "a", "an", "in", "of", "how", "what", "is", "are",
                                                          "was", "were", "many", "much", "which", "do", "does", "did"}


def pick_examples(question, k=4):
    """The k examples whose questions share the most words with this question (Jaccard similarity of words()),
    most similar first; ties keep the library order."""
    return []
Provided for you:build_db.pyexamples.jsonharness.pyquestions.jsontry_it.py

Frequently asked questions

How do you stop a text-to-SQL model from changing data?

Enforce it in the database layer, not the prompt: open a read-only connection, install an authorizer that allows only reads, and reject any SQL that is not a single SELECT before it runs.

How do you evaluate text-to-SQL?

By execution: run the model's query and a verified query, and compare the rows they return, ignoring row order, column names and extra columns. Comparing SQL text fails because many different queries are correct.

How do few-shot examples help text-to-SQL?

Examples of verified queries for similar questions show the model the database's conventions, such as money in pence, net revenue after refunds or excluding erased records, which it often misses from the schema alone.

Building a safe text-to-SQL assistant

Text-to-SQL is one of the most useful things to build with an LLM, and one of the easiest to get wrong. The model needs the schema to write a query, the database needs protecting from the query, and the answers need checking against more than "it ran without an error". In this lab you build the whole assistant in Python on SQLite: a schema description with its business-rule comments, a read-only connection with an authorizer, a time limit and a row cap, a validator that sends errors back for repair, execution-based grading, and example queries retrieved by similarity.