Lab 08 — NL→SQL Banking Analytics (the mandatory SQL skill)

Goal. Build a natural-language → SQL feature ("ask questions about your transactions in plain language") over a banking database — covering the JD's mandatory "Strong expertise in Python and SQL" and a flagship banking GenAI use case. The real lesson is doing it safely: read-only, table-allowlisted, row-level-secured, and injection-proof — because the alternative is letting an LLM run arbitrary SQL on customer data, which is a catastrophe in a bank. Read alongside K01 §8 (function calling), K03 §12 (safe tools), and K09 §9.

Stack. SQLite (stdlib sqlite3) · a parameterized template NL→SQL generator (stand-in for an Azure OpenAI call) · a real SQL safety guard. Local fallback. Everything is offline and deterministic; the only swappable piece is the generator (AzureOpenAISqlGenerator stub shows the gpt-4o call).


SQL in 5 minutes (from zero, for this lab)

SQL is the language for querying relational data — tables of rows and columns with relationships. A bank's transactional data is relational, so SQL is unavoidable. The pieces you use here:

  • SELECT cols FROM table — read columns from a table. SELECT * = all columns.
  • WHERE cond — filter rows (amount < 0 = money out).
  • JOIN — combine tables on a key. transactions JOIN accounts ON transactions.account_id = accounts.id links each transaction to its account (and thus its customer).
  • AggregatesSUM, COUNT, AVG, MIN, MAX collapse many rows into one number. SUM(-amount) totals spend.
  • GROUP BY col — aggregate per group: GROUP BY category gives a total per category.
  • ORDER BY col [DESC] + LIMIT n — sort and take the top n (e.g. the largest transaction).
  • Parameters / binding — never paste user input into SQL text; pass it as a bound value (WHERE description LIKE :merchant) so it can't change the query's meaning. This is how you prevent SQL injection.
  • View — a saved query you can treat like a table. We use views for row-level security (below).

The schema (db.py): customers 1—* accounts 1—* transactions. amount is signed (negative = spend).

Why NL→SQL is dangerous (and the four-layer fix)

Letting an LLM generate SQL that you then run is function calling where the tool is a database. If you trust the generated SQL, a prompt (or an injection) can read every customer's data, or DROP a table. So the app — never the model — enforces four layers (nl2sql.py):

LayerControlStops
1. Read-onlyreject anything that isn't a single SELECTDROP/UPDATE/DELETE/INSERT
2. AllowlistSQL may only touch per-tenant views (my_accounts, my_transactions)reading base tables, other schemas
3. Row-level securitythose views are defined for the caller's customer_id (from the authenticated session, cast to int)one customer seeing another's data — even if the NL says "all customers"
4. Parameterizationuser values (a merchant name) are bound, never concatenatedSQL injection

The model proposes SQL; the guard validates it, the executor scopes and runs it, and the answer is phrased from the real rows — so a number is never invented, same principle as every money fact in this track.

Run it (offline, no Azure)

pip install -r requirements.txt        # only pytest; sqlite3 is stdlib
python run.py                          # NL->SQL answers + RLS + guard + injection demo
pytest -q                              # 12 tests = success criteria

Code tour

FileWhat it teaches
db.pythe banking schema (customers/accounts/transactions) + deterministic seed data
nl2sql.pythe guard (read-only + allowlist), the executor (per-tenant views = RLS, bound params), the template generator, and NL2SQLTool (the agent-callable tool); AzureOpenAISqlGenerator swap stub
run.pydemo: NL→SQL answers, row-level security across two customers, the guard blocking unsafe SQL, and a neutralized injection payload
test_nl2sql.pycorrectness (aggregates, joins, grouping) and all four safety layers

How it plugs into the rest

This is an agent tool (Lab 02 / Lab 07): register NL2SQLTool.answer as a function the agent can call for analytics questions ("how much did I spend on groceries last month?"). The agent routes the question, the tool returns grounded rows, and the LLM phrases the answer — with the SQL and rows captured in the audit log. In production the generator becomes a gpt-4o call; the guard and executor are unchanged, because you never trust generated SQL regardless of which model wrote it.

Talking point

"NL→SQL is function calling where the tool is a database, so the danger isn't the model — it's trusting its SQL. I make it safe with four app-enforced layers: read-only SELECT validation, an allowlist limited to per-tenant views, row-level security so the customer_id comes from the session and the views physically can't return another customer's data, and parameter binding so a merchant name can't inject. The model proposes SQL; the app validates, scopes, executes, and the answer is phrased from the real rows — never an invented number."

Resume bullet

"Built a safe NL→SQL banking-analytics tool (Azure OpenAI generates SQL; the app enforces read-only validation, a per-tenant view allowlist, row-level security from the authenticated session, and parameterized execution), exposed as an agent tool with full SQL/row audit — answering customers' transaction questions in natural language without ever trusting model-generated SQL."