AI Data Agent Workshop
Hands-on workshop · Text-to-SQL agent · 100% free and local

Build a "talk to your data" AI agent

Ask "total revenue by region" in plain English. A local AI model writes the SQL, a guard checks that it only reads data, DuckDB runs it, and you get a table and a chart. No API keys, no cloud, no cost. The data never leaves your laptop.

Time: about 4 hours, 8 labs Level: knows basic Python and SQL Stack: Ollama · Qwen2.5-Coder · DuckDB · pandas · Streamlit · pytest Laptop: 8 GB RAM minimum

Workshop map

  1. 1What you will build
  2. 2Design choices and industry practice
  3. 3What we improved in the original project
  4. 4Prerequisites
  5. 5Labs 0–7, step by step
  6. 6Answer key
  7. 7Troubleshooting
  8. 8Your POC: deliverables and checklist
  9. 9Extension ideas

1. What you will build

One question goes through six steps. Each step lives in its own file, so you can learn, test and change it separately.

  1. 1
    UserPlain-English question"total revenue by region"cli.py · app.py
  2. 2
    Prompt builderRules + schema + relationships + business terms + examplesEverything the model needs to know about your data, and nothing it should guess.prompts.py · knowledge.py · database.describe()
  3. 3
    Local LLMQwen2.5-Coder writes one SQL queryRuns inside Ollama on your laptop. Free and offline.llm.py → POST localhost:11434/api/chat
  4. 4
    GuardOnly one read-only SELECT may passWrites, multiple statements and file access are refused.sql_guard.py
  5. 5
    DuckDBRuns the query on locked-down, in-memory tablesIf the SQL has an error, the error goes back to the model, which tries again (at most 2 retries).database.py · agent.py
  6. ✓
    UserSQL, table, chart, CSV downloadThe SQL is always shown so the answer can be checked.Answer(sql, data, attempts, seconds)
AI modelDeterministic codeInterface / prompt

The data: 10 tables from a small business

Revenue is not stored anywhere. It must be calculated as order_items.quantity × products.price. Small details like this are why the model needs business terms (Lab 3).

2. Design choices and industry practice

ChoiceWhyIndustry practice it follows
Local model via OllamaFree, works offline, and company data never leaves the laptopData residency and privacy by design. The same code can later point at a hosted model.
Generate SQL, not pandas codeSmall models invent column names and break on method chains. SQL is simpler and can be checked before it runs.Text-to-SQL is the standard pattern for natural-language analytics.
DuckDB in memoryNo server, fast on CSVs, strong date functionsAn embedded analytical database, as used for local analytics and data apps
Schema with types and allowed valuesThe model sees status (values: completed, pending) instead of guessingGrounding the model with schema context
knowledge.py business termsDefines revenue, order value and joins onceA lightweight semantic layer (the idea behind dbt metrics and BI semantic models)
Two layers of guardrailsThe code guard refuses writes and file access; DuckDB itself has file access turned off and its settings lockedOWASP Top 10 for LLM Apps 2025: LLM05 Improper Output Handling, LLM06 Excessive Agency, LLM10 Unbounded Consumption (row limit)
Self-correction loopDuckDB's error message tells the model exactly which column was wrongStandard agent pattern: act, observe the result, retry with a limit
Golden question setMeasures accuracy, so a model or prompt change is a decision based on numbersExecution accuracy, the metric used by the Spider and BIRD text-to-SQL benchmarks
Tests with a fake modelFast and repeatable, with no AI needed to test the plumbingDependency injection and unit testing

3. What we improved in the original project

The original ai-data-agent already works and has a strong design (SQL instead of pandas, few-shot examples, auto-retry). Version 2 keeps that design and fixes these gaps. Use this table as a code-review exercise: find each issue in the original before reading the fix.

Finding in the originalRiskFix in v2
AI-written SQL runs with full DuckDB powerA query could read any file on the laptop (read_csv('C:/...')), write files with COPY, or install extensionssql_guard.py + locked connection in database.py
No row limitA cross join can return millions of rows and freeze the appAt most 1,000 rows (MAX_ROWS)
Prompt and retry logic copied into both main.py and app.py, and already different (7 examples vs 3)Fixes in one place don't reach the otherOne agent.py used by both interfaces
The terminal version reloads all 10 CSVs for every questionSlowLoad once into DuckDB
Schema shows column names onlyThe model guesses status values and typesTypes, row counts and allowed text values
"Revenue" is not defined, and employees is missing from the relationshipsWrong numbers for revenue questions; employee questions failknowledge.py glossary and relationships
Few-shot examples equal the sample questionsTesting on the examples only measures copyingSeparate eval/golden.json with different questions
No tests, no accuracy measureNo way to prove a change helpstests/ + evaluate.py
If Ollama is off, you get a long stack traceConfusing for new usersClear messages: "Open the Ollama app", "Run: ollama pull ..."
streamlit missing from the install line; unused legacy filesSetup fails; confusing coderequirements.txt; legacy files removed

4. Prerequisites

ItemVersion / sizeCost
Python3.11 or 3.12 (the original ran on 3.12)Free
OllamaLatest, from ollama.com/download (Windows, macOS, Linux)Free
qwen2.5-coder:1.5b986 MB download. Fine on 8 GB RAM.Free
qwen2.5-coder:7b (optional, Lab 7)4.7 GB download. 16 GB RAM recommended.Free
VS Code + Python extensionAny recent versionFree
Starter projectai-data-agent-v2 folder (with the same 10 CSV files)Provided

5. Labs, step by step

Each lab has a goal, the commands, a checkpoint to confirm you are on track, and the reason it matters. Don't skip the checkpoints.

Lab 0 · Set up the laptop

Time 30 minGoal model running, tests green
PowerShell
# 1) Install Ollama (free) from https://ollama.com/download, then open a new terminal:
ollama --version
ollama pull qwen2.5-coder:1.5b
ollama run qwen2.5-coder:1.5b "Write SQL to count rows in a table called orders"
# type /bye to leave the chat

# 2) Project
cd ai-data-agent-v2
python -m venv .venv
.venv\Scripts\activate            # macOS/Linux: source .venv/bin/activate
pip install -r requirements.txt

# 3) Safety net: tests use a fake model, so they pass even before the AI is involved
pytest -q
Checkpoint

The model replies with a SQL statement, and pytest reports all tests passing:

Expected
22 passed

The tests use a fake model, so a green result proves the database, guard and retry loop work before you add AI. When something breaks later, you'll know it's the model or the prompt, not the plumbing.

Lab 1 · Know your data (no AI yet)

Time 20 minFiles database.py
database.py
"""Load the CSV files into an in-memory DuckDB database that is locked down for AI-written SQL."""
from pathlib import Path

import duckdb
import pandas as pd

DATE_SUFFIXES = ("_date", "_time", "_timestamp")


def load_tables(data_dir: Path) -> dict[str, pd.DataFrame]:
    """Every CSV becomes one table named after the file. Date-like columns are parsed as dates."""
    tables = {}
    for path in sorted(Path(data_dir).glob("*.csv")):
        df = pd.read_csv(path)
        for col in df.columns:
            if col.endswith(DATE_SUFFIXES):
                df[col] = pd.to_datetime(df[col], errors="coerce")
        tables[path.stem] = df
    if not tables:
        raise FileNotFoundError(f"No CSV files found in {data_dir}")
    return tables


def connect(tables: dict[str, pd.DataFrame]) -> duckdb.DuckDBPyConnection:
    """Read-only style connection: no file, network or extension access, settings locked."""
    con = duckdb.connect(config={"enable_external_access": False})
    for name, df in tables.items():
        columns = ", ".join(
            f'CAST("{c}" AS TIMESTAMP) AS "{c}"' if pd.api.types.is_datetime64_any_dtype(df[c]) else f'"{c}"'
            for c in df.columns
        )
        con.register("_staging", df)                               # in-memory pandas data, not a file
        con.execute(f'CREATE TABLE "{name}" AS SELECT {columns} FROM _staging')
        con.unregister("_staging")
    con.execute("SET lock_configuration = true")                   # SQL can no longer change settings
    return con


def describe(con: duckdb.DuckDBPyConnection, max_values: int = 10) -> str:
    """Schema text for the prompt: columns, types, and the allowed values of small text columns."""
    lines = []
    for (table,) in con.execute(
        "SELECT table_name FROM information_schema.tables ORDER BY table_name"
    ).fetchall():
        rows = con.execute(f'SELECT COUNT(*) FROM "{table}"').fetchone()[0]
        columns = []
        for col, col_type, *_ in con.execute(f'DESCRIBE "{table}"').fetchall():
            text = f"{col} {col_type}"
            if col_type == "VARCHAR":
                values = [v for (v,) in con.execute(
                    f'SELECT DISTINCT "{col}" FROM "{table}" WHERE "{col}" IS NOT NULL LIMIT {max_values + 1}'
                ).fetchall()]
                if len(values) <= max_values:
                    text += f" (values: {', '.join(sorted(map(str, values)))})"
            columns.append(text)
        lines.append(f"{table} ({rows:,} rows): " + "; ".join(columns))
    return "\n".join(lines)


def run_query(con: duckdb.DuckDBPyConnection, sql: str, max_rows: int) -> pd.DataFrame:
    """Run SQL and return at most max_rows rows, keeping the query's own ORDER BY."""
    cursor = con.execute(sql)
    names = [d[0] for d in cursor.description]
    return pd.DataFrame(cursor.fetchmany(max_rows), columns=names)

Create explore.py in the project folder and run it:

explore.py
# explore.py - look at the data before involving any AI
from config import DATA_DIR
from database import connect, describe, load_tables, run_query

con = connect(load_tables(DATA_DIR))
print(describe(con))
print(run_query(con, "SELECT status, COUNT(*) AS total FROM filings GROUP BY 1 ORDER BY 1", 100))
Checkpoint

You see each table with types, and the status values for filings and tickets. Filings: 1,015 completed, 985 pending.

Expected
customers (2,000 rows): customer_id BIGINT; name VARCHAR; region_id BIGINT
employees (200 rows): employee_id BIGINT; name VARCHAR; region_id BIGINT
filings (2,000 rows): filing_id BIGINT; customer_id BIGINT; filing_date TIMESTAMP; status VARCHAR (values: completed, pending)
...
      status  total
0  completed   1015
1    pending    985

This schema text is exactly what the model receives. If a human can't answer a question from it, the model can't either. Two security settings are applied here: enable_external_access = false when the connection opens, and lock_configuration = true after the tables load. These follow DuckDB's own "Securing DuckDB" guide.

Lab 2 · Talk to the local model

Time 15 minFiles llm.py
PowerShell: raw REST call
# Windows PowerShell: call the local model's REST API directly
$body = @{
  model = "qwen2.5-coder:1.5b"; stream = $false
  messages = @(@{ role = "user"; content = "SQL to count rows in table orders. Only SQL." })
} | ConvertTo-Json -Depth 4
(Invoke-RestMethod -Uri http://localhost:11434/api/chat -Method Post -Body $body -ContentType "application/json").message.content
llm.py
"""Talk to a local model through Ollama's REST API. Free, offline, data never leaves the laptop."""
import httpx

from config import LLM_TIMEOUT_SECONDS, MODEL, OLLAMA_URL

SYSTEM_MESSAGE = "You are a DuckDB SQL expert. Reply with one SQL query only."


class LLMError(RuntimeError):
    """Ollama is not reachable or the model is missing."""


def ask_ollama(prompt: str, model: str = MODEL) -> str:
    try:
        response = httpx.post(
            f"{OLLAMA_URL}/api/chat",
            json={
                "model": model,
                "messages": [
                    {"role": "system", "content": SYSTEM_MESSAGE},
                    {"role": "user", "content": prompt},
                ],
                "stream": False,
                "options": {"temperature": 0, "num_predict": 512},
            },
            timeout=LLM_TIMEOUT_SECONDS,
        )
        response.raise_for_status()
    except httpx.ConnectError as err:
        raise LLMError(f"Cannot reach Ollama at {OLLAMA_URL}. Open the Ollama app or run: ollama serve") from err
    except httpx.TimeoutException as err:
        raise LLMError(f"The model took longer than {LLM_TIMEOUT_SECONDS}s. Try a smaller model.") from err
    except httpx.HTTPStatusError as err:
        if err.response.status_code == 404:
            raise LLMError(f"Model '{model}' is not installed. Run: ollama pull {model}") from err
        raise LLMError(f"Ollama error {err.response.status_code}: {err.response.text}") from err
    return response.json()["message"]["content"]
Try it from Python
from llm import ask_ollama
print(ask_ollama("SQL to count rows in table orders"))
Checkpoint

You get SQL back. Notice whether it arrives wrapped in ```sql fences or with extra text. Small models often add these even when told not to, which is why Lab 4 cleans the reply.

temperature: 0 makes answers repeatable, which you need for testing. Stop Ollama and run it again to see the friendly "Cannot reach Ollama" message instead of a stack trace.

Lab 3 · Build the prompt: give the model context

Time 30 minFiles knowledge.py, prompts.py
knowledge.py
"""Business knowledge the model cannot guess from column names (a small 'semantic layer').

Edit this file, not the code, when the data model or business rules change.
"""

RELATIONSHIPS = [
    "customers.region_id = regions.region_id",
    "employees.region_id = regions.region_id",
    "orders.customer_id = customers.customer_id",
    "order_items.order_id = orders.order_id",
    "order_items.product_id = products.product_id",
    "payments.customer_id = customers.customer_id",
    "filings.customer_id = customers.customer_id",
    "visits.customer_id = customers.customer_id",
    "support_tickets.customer_id = customers.customer_id",
]

GLOSSARY = {
    "revenue / sales": "SUM(order_items.quantity * products.price). There is no revenue column.",
    "order value": "SUM(order_items.quantity * products.price) for one order_id.",
    "customer name": "customers.name (there is no customer_name column).",
    "visitors / visits / traffic": "rows in visits, dated by visits.visit_timestamp.",
    "payments": "payments.amount. Payments have no date column.",
}

# Worked examples (few-shot). Keep them DIFFERENT from the questions in eval/golden.json,
# otherwise the evaluation only measures copying.
EXAMPLES = [
    ("total filings per day",
     "SELECT CAST(filing_date AS DATE) AS day, COUNT(*) AS total_filings FROM filings GROUP BY 1 ORDER BY 1"),
    ("total payments by customer",
     "SELECT c.customer_id, c.name, SUM(p.amount) AS total_amount FROM customers c "
     "JOIN payments p ON c.customer_id = p.customer_id GROUP BY 1, 2 ORDER BY 3 DESC"),
    ("filings per month",
     "SELECT DATE_TRUNC('month', filing_date) AS month, COUNT(*) AS total FROM filings GROUP BY 1 ORDER BY 1"),
    ("support tickets by status",
     "SELECT status, COUNT(*) AS total FROM support_tickets GROUP BY 1 ORDER BY 2 DESC"),
    ("revenue by product",
     "SELECT p.product_name, SUM(oi.quantity * p.price) AS revenue FROM order_items oi "
     "JOIN products p ON oi.product_id = p.product_id GROUP BY 1 ORDER BY 2 DESC"),
]
prompts.py
"""Prompt templates. The model sees: rules, schema, relationships, glossary, examples, question."""
from knowledge import EXAMPLES, GLOSSARY, RELATIONSHIPS

RULES = """Rules:
1. Write ONE DuckDB SELECT query. Return only the SQL: no markdown, no explanation.
2. Use only the tables and columns listed in the schema. Never invent columns.
3. Use the exact text values listed in the schema (for example status = 'pending').
4. Give every calculated column a clear alias, e.g. COUNT(*) AS total.
5. Add ORDER BY for rankings and time series."""


def _context(schema: str) -> str:
    relationships = "\n".join(f"- {r}" for r in RELATIONSHIPS)
    glossary = "\n".join(f"- {term}: {meaning}" for term, meaning in GLOSSARY.items())
    return f"Schema:\n{schema}\n\nRelationships:\n{relationships}\n\nBusiness terms:\n{glossary}"


def build_prompt(schema: str, question: str) -> str:
    examples = "\n\n".join(f"Q: {q}\nSQL: {sql}" for q, sql in EXAMPLES)
    return f"{RULES}\n\n{_context(schema)}\n\nExamples:\n\n{examples}\n\nQ: {question}\nSQL:"


def build_retry_prompt(schema: str, question: str, failed_sql: str, error: str) -> str:
    return (
        f"Your previous SQL failed. Fix it.\n\n{RULES}\n\n{_context(schema)}\n\n"
        f"Question: {question}\nFailed SQL: {failed_sql}\nError: {error}\n\nCorrected SQL:"
    )
Exercise
Exercise: prove that business knowledge matters
1. python cli.py  ->  ask: total revenue by region      (note the SQL and the numbers)
2. In knowledge.py, comment out the "revenue / sales" line in GLOSSARY. Restart cli.py.
3. Ask the same question again. Does the model still multiply quantity * price?
4. Put the line back.
Checkpoint

With the glossary, revenue by region puts South first at 560,792. Without it, watch whether the model still multiplies quantity by price. Small models may sum price alone or use payments.amount.

This is the cheapest way to improve accuracy. Before you change models or write code, add a relationship, a business term or an example. Keep examples different from your test questions (Lab 7).

Lab 4 · Guardrails: the agent can only read

Time 30 minFiles sql_guard.py, tests/test_sql_guard.py
sql_guard.py
"""Turn the model's reply into one safe, read-only SQL query - or refuse it.

Layer 1 of 2. Layer 2 is the locked DuckDB connection in database.py.
"""
import re

BLOCKED_KEYWORDS = {
    "insert", "update", "delete", "drop", "create", "alter", "truncate",
    "copy", "attach", "detach", "install", "load", "pragma", "set", "reset",
    "export", "import", "call", "checkpoint", "vacuum", "grant", "revoke",
}
BLOCKED_FUNCTIONS = {
    "read_csv", "read_csv_auto", "read_parquet", "parquet_scan", "read_json",
    "read_json_auto", "read_ndjson", "read_text", "read_blob", "glob", "sniff_csv",
}


class UnsafeSQLError(ValueError):
    """The query is not a single read-only SELECT."""


def extract_sql(reply: str) -> str:
    """Remove markdown fences and chatter such as 'Here is the query:'."""
    text = re.sub(r"```(?:sql)?", "", reply, flags=re.IGNORECASE).replace("`", '"').strip()
    match = re.search(r"\b(with|select)\b", text, flags=re.IGNORECASE)
    if match:
        text = text[match.start():]
    return text.strip().rstrip(";").strip()


def _code_only(sql: str) -> str:
    """Blank out comments and quoted text so words inside them are not mistaken for commands."""
    sql = re.sub(r"--[^\n]*", " ", sql)
    sql = re.sub(r"/\*.*?\*/", " ", sql, flags=re.DOTALL)
    sql = re.sub(r"'(?:[^']|'')*'", "''", sql)
    sql = re.sub(r'"(?:[^"]|"")*"', '""', sql)
    return sql.lower()


def validate(sql: str) -> str:
    if not sql:
        raise UnsafeSQLError("The model did not return a SQL query.")
    code = _code_only(sql)
    if ";" in code:
        raise UnsafeSQLError("Only one SQL statement is allowed.")
    first_word = re.match(r"\s*\(*\s*(\w+)", code)
    if not first_word or first_word.group(1) not in {"select", "with"}:
        raise UnsafeSQLError("Only SELECT queries are allowed.")
    words = set(re.findall(r"[a-z_]+", code))
    if words & BLOCKED_KEYWORDS:
        raise UnsafeSQLError(f"Blocked keyword: {', '.join(sorted(words & BLOCKED_KEYWORDS))}")
    called = set(re.findall(r"([a-z_]+)\s*\(", code))
    if called & BLOCKED_FUNCTIONS:
        raise UnsafeSQLError(f"File access is not allowed: {', '.join(sorted(called & BLOCKED_FUNCTIONS))}")
    return sql
tests/test_sql_guard.py
"""pytest -q   (no Ollama and no DuckDB needed for this file)"""
import sys
from pathlib import Path

import pytest

sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from sql_guard import UnsafeSQLError, extract_sql, validate  # noqa: E402


@pytest.mark.parametrize("reply, expected", [
    ("```sql\nSELECT 1;\n```", "SELECT 1"),
    ("Here is the query:\nSELECT name FROM customers", "SELECT name FROM customers"),
    ("SELECT `name` FROM customers", 'SELECT "name" FROM customers'),
])
def test_extract_sql(reply, expected):
    assert extract_sql(reply) == expected


@pytest.mark.parametrize("sql", [
    "SELECT status, COUNT(*) AS total FROM filings GROUP BY 1",
    "WITH t AS (SELECT * FROM orders) SELECT COUNT(*) FROM t",
    "SELECT REPLACE(name, '_', ' ') AS clean_name FROM customers",
    "SELECT * FROM support_tickets WHERE status = 'delete me'",
    "SELECT order_id FROM orders LIMIT 10 OFFSET 5",
])
def test_allows_read_only_queries(sql):
    assert validate(sql) == sql


@pytest.mark.parametrize("sql", [
    "DELETE FROM customers",
    "DROP TABLE customers",
    "SELECT 1; DROP TABLE customers",
    "COPY customers TO 'stolen.csv'",
    "SELECT * FROM read_csv('C:/Users/me/secrets.csv')",
    "INSTALL httpfs",
    "SET enable_external_access = true",
    "ATTACH 'other.db'",
    "",
])
def test_blocks_unsafe_queries(sql):
    with pytest.raises(UnsafeSQLError):
        validate(sql)
Try to break it (after Lab 5)
python cli.py
Ask: delete all customers
Ask: show me the file C:/Windows/win.ini using read_csv
Ask: how many filings are pending          <- still works
Expected for the first two
No answer: Refused: Only SELECT queries are allowed. This assistant is read-only.

Why two layers? Layer 1, the keyword guard, can be fooled by clever SQL. Layer 2, DuckDB with external access off and settings locked, still blocks file access if layer 1 misses something. tests/test_agent.py proves layer 2 on its own. This is defense in depth.

Notice what is allowed in the tests: REPLACE() as a text function, the word "delete" inside quotes, and OFFSET. A guard that blocks normal questions is as bad as one that lets attacks through, so test both sides.

Lab 5 · The agent loop with self-correction

Time 30 minFiles agent.py, cli.py, tests/test_agent.py
agent.py
"""The agent loop: question -> prompt -> model -> guard -> DuckDB -> answer, with self-correction."""
import time
from dataclasses import dataclass, field
from pathlib import Path
from typing import Callable

import duckdb
import pandas as pd

from config import DATA_DIR, MAX_RETRIES, MAX_ROWS, MODEL
from database import connect, describe, load_tables, run_query
from llm import LLMError, ask_ollama
from prompts import build_prompt, build_retry_prompt
from sql_guard import UnsafeSQLError, extract_sql, validate


@dataclass
class Attempt:
    sql: str
    error: str | None = None


@dataclass
class Answer:
    question: str
    sql: str | None = None
    data: pd.DataFrame | None = None
    error: str | None = None
    attempts: list[Attempt] = field(default_factory=list)
    seconds: float = 0.0

    @property
    def ok(self) -> bool:
        return self.data is not None


class DataAgent:
    def __init__(self, data_dir: Path = DATA_DIR, model: str = MODEL,
                 llm: Callable[[str, str], str] = ask_ollama):
        self.tables = load_tables(data_dir)
        self.con = connect(self.tables)
        self.schema = describe(self.con)
        self.model = model
        self.llm = llm                       # swap in a fake for tests

    def ask(self, question: str) -> Answer:
        answer = Answer(question=question)
        start = time.perf_counter()
        prompt = build_prompt(self.schema, question)
        try:
            for _ in range(MAX_RETRIES + 1):
                sql = extract_sql(self.llm(prompt, self.model))
                try:
                    validate(sql)
                except UnsafeSQLError as err:          # never retry a write or file-access attempt
                    answer.attempts.append(Attempt(sql, str(err)))
                    answer.error = f"Refused: {err} This assistant is read-only."
                    break
                try:
                    answer.data = run_query(self.con, sql, MAX_ROWS)
                    answer.sql = sql
                    answer.attempts.append(Attempt(sql))
                    break
                except duckdb.Error as err:            # wrong column, bad syntax: let the model fix it
                    answer.attempts.append(Attempt(sql, str(err)))
                    answer.error = str(err)
                    prompt = build_retry_prompt(self.schema, question, sql, str(err))
            if answer.ok:
                answer.error = None
        except LLMError as err:
            answer.error = str(err)
        answer.seconds = round(time.perf_counter() - start, 2)
        return answer
Show cli.py and tests/test_agent.py
cli.py
"""Terminal version. Type a question, 'schema' to see the tables, or 'exit'."""
import pandas as pd

from agent import DataAgent
from config import MODEL

pd.set_option("display.width", 120)


def main() -> None:
    agent = DataAgent()
    print(f"Loaded {len(agent.tables)} tables. Model: {MODEL}. Type 'schema' or 'exit'.")
    while True:
        question = input("\nAsk: ").strip()
        if question.lower() in {"exit", "quit"}:
            break
        if question.lower() == "schema":
            print(agent.schema)
            continue
        if not question:
            continue
        answer = agent.ask(question)
        for number, attempt in enumerate(answer.attempts, start=1):
            status = "ok" if attempt.error is None else f"failed: {attempt.error.splitlines()[0]}"
            print(f"\nAttempt {number} ({status})\n  {attempt.sql}")
        if answer.ok:
            print(f"\n{answer.data.head(20).to_string(index=False)}")
            print(f"\n{len(answer.data)} rows in {answer.seconds}s")
        else:
            print(f"\nNo answer: {answer.error}")


if __name__ == "__main__":
    main()
tests/test_agent.py
"""Agent tests with a fake model: checks the loop, retries and safety without Ollama. Needs duckdb + pandas."""
import sys
from pathlib import Path

import pytest

sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from agent import DataAgent  # noqa: E402

DATA = Path(__file__).resolve().parents[1] / "data"


def fake_llm(replies):
    queue = list(replies)
    return lambda prompt, model: queue.pop(0)


def test_answers_with_valid_sql():
    agent = DataAgent(DATA, llm=fake_llm(["SELECT COUNT(*) AS n FROM filings WHERE status = 'pending'"]))
    answer = agent.ask("pending filings")
    assert answer.ok and int(answer.data.iloc[0, 0]) == 985


def test_retries_after_wrong_column():
    agent = DataAgent(DATA, llm=fake_llm([
        "SELECT customer_name FROM customers",          # wrong column -> DuckDB error
        "SELECT name FROM customers LIMIT 3",           # fixed on retry
    ]))
    answer = agent.ask("customer names")
    assert answer.ok and len(answer.attempts) == 2 and answer.attempts[0].error


def test_refuses_writes_without_retry():
    agent = DataAgent(DATA, llm=fake_llm(["DROP TABLE customers"]))
    answer = agent.ask("delete everything")
    assert not answer.ok and "read-only" in answer.error and len(answer.attempts) == 1


def test_database_blocks_file_access_even_if_guard_is_bypassed():
    agent = DataAgent(DATA, llm=fake_llm([]))
    with pytest.raises(Exception):
        agent.con.execute("SELECT * FROM read_csv('data/customers.csv')")


def test_schema_lists_status_values():
    agent = DataAgent(DATA, llm=fake_llm([]))
    assert "status VARCHAR (values: completed, pending)" in agent.schema
    assert "filing_date TIMESTAMP" in agent.schema
Run
python cli.py

Ask: how many filings are pending
Ask: total revenue by region
Ask: orders per month
Ask: top 5 customers by total payments
Ask: schema          <- shows exactly what the model sees
Example output (timing varies by laptop)
Attempt 1 (ok)
  SELECT COUNT(*) AS pending_filings FROM filings WHERE status = 'pending'

 pending_filings
             985

1 rows in 2.1s
Checkpoint

Answers match the answer key. At least once, you should see "Attempt 1 (failed: ...)" followed by a successful attempt 2. That is self-correction working.

Three rules in the loop matter: retries are capped (cost and time), refused queries are never retried (a "delete" request should not get three tries), and every attempt is recorded so you can see what happened.

Lab 6 · Web interface

Time 20 minFiles app.py
Run
streamlit run app.py
# opens http://localhost:8501
Show app.py
app.py
"""Web version: streamlit run app.py"""
import pandas as pd
import streamlit as st

from agent import DataAgent
from config import MODEL

st.set_page_config(page_title="AI Data Agent", layout="wide")

SAMPLE_QUESTIONS = [
    "how many filings are pending",
    "total revenue by region",
    "orders per month",
    "average order value",
    "top 5 customers by total payments",
    "employees per region",
]


@st.cache_resource
def get_agent() -> DataAgent:
    return DataAgent()


agent = get_agent()

with st.sidebar:
    st.subheader("Tables")
    for name, df in agent.tables.items():
        with st.expander(f"{name} ({len(df):,} rows)"):
            st.dataframe(df.head(5), use_container_width=True)
    st.subheader("Try a question")
    for q in SAMPLE_QUESTIONS:
        if st.button(q, use_container_width=True):
            st.session_state["pending_question"] = q
    st.caption(f"Model: {MODEL} (local, via Ollama)")

st.title("AI Data Agent")
st.caption("Ask a question in plain English. A local AI writes read-only SQL, DuckDB runs it.")

if "history" not in st.session_state:
    st.session_state.history = []

question = st.chat_input("Ask about your data...") or st.session_state.pop("pending_question", None)
if question:
    with st.spinner("Thinking..."):
        st.session_state.history.append(agent.ask(question))

for answer in reversed(st.session_state.history):
    with st.chat_message("user"):
        st.write(answer.question)
    with st.chat_message("assistant"):
        with st.expander(f"SQL ({len(answer.attempts)} attempt(s), {answer.seconds}s)"):
            for attempt in answer.attempts:
                st.code(attempt.sql, language="sql")
                if attempt.error:
                    st.caption(f"Error: {attempt.error.splitlines()[0]}")
        if not answer.ok:
            st.error(answer.error)
            continue
        df = answer.data
        st.dataframe(df, use_container_width=True)
        numeric = df.select_dtypes("number").columns
        if len(df.columns) >= 2 and len(numeric) >= 1 and df.columns[0] not in numeric and 1 < len(df) <= 50:
            st.bar_chart(df.set_index(df.columns[0])[numeric[:1]])
        st.download_button("Download CSV", df.to_csv(index=False), f"result_{len(st.session_state.history)}.csv",
                           key=f"dl_{id(answer)}")
Checkpoint

Sidebar with 10 tables and sample-question buttons. Each answer shows the SQL and the number of attempts, a table, a bar chart when it makes sense, and a CSV download.

@st.cache_resource builds the agent once. Without it, Streamlit would reload all CSVs every time you click.

Lab 7 · Measure accuracy and compare models

Time 30 minFiles evaluate.py, eval/golden.json
eval/golden.json
[
  {"question": "how many filings are pending",
   "reference_sql": "SELECT COUNT(*) AS pending_filings FROM filings WHERE status = 'pending'"},
  {"question": "number of open support tickets",
   "reference_sql": "SELECT COUNT(*) AS open_tickets FROM support_tickets WHERE status = 'open'"},
  {"question": "what is the total revenue",
   "reference_sql": "SELECT SUM(oi.quantity * p.price) AS revenue FROM order_items oi JOIN products p ON oi.product_id = p.product_id"},
  {"question": "total revenue by region",
   "reference_sql": "SELECT r.region_name, SUM(oi.quantity * p.price) AS revenue FROM order_items oi JOIN products p ON oi.product_id = p.product_id JOIN orders o ON oi.order_id = o.order_id JOIN customers c ON o.customer_id = c.customer_id JOIN regions r ON c.region_id = r.region_id GROUP BY 1 ORDER BY 2 DESC"},
  {"question": "average payment amount",
   "reference_sql": "SELECT AVG(amount) AS avg_payment FROM payments"},
  {"question": "orders per month",
   "reference_sql": "SELECT DATE_TRUNC('month', order_date) AS month, COUNT(*) AS orders FROM orders GROUP BY 1 ORDER BY 1"},
  {"question": "number of customers in each region",
   "reference_sql": "SELECT r.region_name, COUNT(*) AS customers FROM customers c JOIN regions r ON c.region_id = r.region_id GROUP BY 1 ORDER BY 1"},
  {"question": "employees per region",
   "reference_sql": "SELECT r.region_name, COUNT(*) AS employees FROM employees e JOIN regions r ON e.region_id = r.region_id GROUP BY 1 ORDER BY 1"},
  {"question": "which customers have more than 4 filings",
   "reference_sql": "SELECT customer_id, COUNT(*) AS filings FROM filings GROUP BY 1 HAVING COUNT(*) > 4 ORDER BY 1"},
  {"question": "average order value",
   "reference_sql": "SELECT AVG(order_value) AS avg_order_value FROM (SELECT oi.order_id, SUM(oi.quantity * p.price) AS order_value FROM order_items oi JOIN products p ON oi.product_id = p.product_id GROUP BY 1)"}
]
Show evaluate.py
evaluate.py
"""Measure accuracy on a fixed set of questions (execution accuracy, as in the Spider/BIRD benchmarks).

    python evaluate.py                         # default model
    set MODEL=qwen2.5-coder:7b && python evaluate.py   (Windows)  |  MODEL=qwen2.5-coder:7b python evaluate.py

A question passes when the agent's result contains the same values as the reference query's result,
ignoring row order, column order and column names.
"""
import json
import math
from datetime import date, datetime
from pathlib import Path

import pandas as pd

from agent import DataAgent
from config import MODEL
from database import run_query

GOLDEN = Path(__file__).parent / "eval" / "golden.json"
RESULTS = Path(__file__).parent / "eval" / "results.csv"


def _norm(value) -> str:
    if value is None or (isinstance(value, float) and math.isnan(value)):
        return ""
    if isinstance(value, (pd.Timestamp, datetime)):
        return value.date().isoformat() if value.time() == datetime.min.time() else value.isoformat()
    if isinstance(value, date):
        return value.isoformat()
    if isinstance(value, (int, float)) and not isinstance(value, bool):
        return f"{float(value):.2f}"
    try:                                     # numpy / decimal / int128 numbers
        return f"{float(value):.2f}"
    except (TypeError, ValueError):
        return str(value)


def same_result(actual: pd.DataFrame, expected: pd.DataFrame) -> bool:
    def rows(df):
        return sorted(tuple(sorted(_norm(v) for v in row)) for row in df.itertuples(index=False))
    return rows(actual) == rows(expected)


def main() -> None:
    agent = DataAgent()
    cases = json.loads(GOLDEN.read_text())
    records = []
    for case in cases:
        expected = run_query(agent.con, case["reference_sql"], 10_000)
        answer = agent.ask(case["question"])
        passed = answer.ok and same_result(answer.data, expected)
        records.append({
            "question": case["question"], "passed": passed, "attempts": len(answer.attempts),
            "seconds": answer.seconds, "sql": answer.sql or "", "error": answer.error or "",
        })
        print(f"{'PASS' if passed else 'FAIL'}  {answer.seconds:>5}s  {case['question']}")
    df = pd.DataFrame(records)
    df.to_csv(RESULTS, index=False)
    print(f"\nModel {MODEL}: {df.passed.sum()}/{len(df)} correct ({df.passed.mean():.0%}), "
          f"average {df.seconds.mean():.1f}s per question. Details: {RESULTS}")


if __name__ == "__main__":
    main()
Run
python evaluate.py                       # default: qwen2.5-coder:1.5b

ollama pull qwen2.5-coder:7b             # about 4.7 GB
$env:MODEL = "qwen2.5-coder:7b"          # macOS/Linux: export MODEL=qwen2.5-coder:7b
python evaluate.py
Output format (illustrative; record your real numbers)
PASS    1.9s  how many filings are pending
PASS    2.4s  number of open support tickets
FAIL    6.8s  total revenue by region
...
Model qwen2.5-coder:1.5b: 8/10 correct (80%), average 3.1s per question. Details: eval/results.csv
Checkpoint

Fill in this table for your team and keep it with the POC.

ModelCorrect / 10Avg secondsQuestions that fail
qwen2.5-coder:1.5b………
qwen2.5-coder:7b………

The comparison ignores row order, column order and column names, and compares values. There are no "top 5" questions in the set on purpose: products with equal sales make top-N answers ambiguous, so a correct answer could fail. Choosing test questions with one clear answer is part of good evaluation.

6. Answer key

Computed directly from the CSV files, independently of the agent. Use these to check your results.

QuestionCorrect answer
How many filings are pending985 (completed: 1,015)
Number of open support tickets970 (closed: 1,030)
Total revenue2,539,983
Total revenue by regionSouth 560,792 · North 535,390 · West 509,348 · East 485,725 · Central 448,728
Total of all payments5,130,008 (2,000 payments)
Average payment amount2,565.00
Orders per monthMar 2026: 744 · Apr: 720 · May: 536
Customers per regionCentral 409 · East 379 · North 415 · South 401 · West 396
Employees per regionCentral 42 · East 35 · North 42 · South 44 · West 37
Customers with more than 4 filings11 customers, each with 5 filings
Average order value1,993.71 (over 1,274 orders that have items)
Top 5 customers by total payments1103 (17,587) · 820 (17,079) · 237 (15,097) · 1790 (13,850) · 1093 (13,636)

7. Troubleshooting

SymptomCauseFix
"Cannot reach Ollama at http://localhost:11434"Ollama not runningOpen the Ollama app, or run ollama serve in a separate terminal
"Model '...' is not installed"Model not downloaded, or a typoollama list, then ollama pull qwen2.5-coder:1.5b
"The model took longer than 120s"Laptop too slow for the model, or first loadRun it again (the first call loads the model), use the 1.5b model, close other apps
Binder Error: column "customer_name" not foundModel invented a columnNormally fixed by the retry. If it repeats, add a glossary line or an example
Correct-looking SQL, wrong numbersWrong business logic (e.g. revenue = price only)Compare with the answer key; add or fix the glossary entry
Refused: Only SELECT queries are allowedThe model replied with text but no SQL, or a write statementRephrase as a question about data; check that the prompt still includes the rules
activate blocked in PowerShellScript execution policySet-ExecutionPolicy -Scope CurrentUser RemoteSigned
pip install fails on duckdbVery new or old PythonUse Python 3.11 or 3.12

8. Your POC: deliverables and checklist

Each participant (or pair) runs the POC on the workshop data first, then on a dataset from their own area. Show it in a 10-minute demo.

Deliverables

  • Working app (CLI and Streamlit) on your laptop
  • Your own knowledge.py and at least 10 golden questions for your data
  • The Lab 7 table for two models
  • One honest failure: a question it gets wrong, and why
  • A one-page note: what you would need for production

Demo script (10 min)

  1. Architecture in one slide (section 1)
  2. 3 live questions, easy to hard
  3. One self-correction (show attempt 1 failing)
  4. One refused unsafe request
  5. Accuracy table and a model recommendation

Acceptance checklist

  • Runs on a fresh laptop by following the README only, with no paid service or API key
  • pytest -q passes
  • Write, multi-statement and file-access requests are refused
  • Every answer shows its SQL
  • Golden questions are different from the prompt examples
  • Accuracy is measured and recorded. The team agrees the target before the demo, not after.
  • No real customer or personal data is used unless approved; the data stays on the laptop

9. Extension ideas, all free

Your own CSVs

Starter

Swap the data/ folder and rewrite knowledge.py. This is the real test of the design.

data/ · knowledge.py · golden.json

Explain the answer

Starter

A second model call turns the result table into two plain-English sentences for business users.

+ summary prompt in agent.py

Query log

Starter

Save question, SQL, attempts, time and errors to a CSV. Failed questions become new golden questions.

+ append to logs/queries.csv

Proper SQL parsing

Intermediate

Replace the keyword guard with an AST check using sqlglot (open source): only SELECT, only known tables.

pip install sqlglot

Connect a real database

Intermediate

Point it at PostgreSQL with a read-only database user, so the database enforces safety too.

+ psycopg, read-only role

Follow-up questions

Intermediate

"And only for South?" Pass the previous question and SQL into the next prompt.

+ history in prompts.py

Large schemas

Advanced

With 200 tables the schema won't fit in the prompt. Retrieve only the relevant tables first, using local embeddings (for example Ollama's nomic-embed-text).

+ schema retrieval step

Row-level security

Advanced

A regional manager sees only their region: create filtered views per user and give the agent only those views.

+ per-user views in database.py

Summary

A useful data agent is mostly careful engineering around a small model: give it the schema, relationships and business terms; accept only read-only SQL; run it on a locked database; let it fix its own errors a limited number of times; and measure accuracy on questions it hasn't seen. Everything here runs free on a laptop, and the same structure scales to a real database and a larger model later.